Files
inkreach-agent/packages/plugin-sdk/src/ui.tsx
T

289 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 插件公共 UI 组件库(@aso/plugin-sdk/ui)——宿主视觉规范的代码化。
*
* 仅限 renderer 组件槽位使用(组件渲染在宿主 DOM 里,直接复用宿主样式表);
* iframe 插件页是物理隔离文档,请参考 DESIGN.md §4 的 HTML 模式自行实现。
*
* 样式来源只有两类:
* - 宿主已生成的 Tailwind 类(DESIGN.md §2 类名词汇,全部经过存在性核验)
* - 语义 token 的内联变量(var(--accent) 等,用于宿主类词汇未覆盖的场合)
* 因此深浅主题自动跟随宿主,禁止出现硬编码色值。
*
* 等待/失败/空态铁律(docs/plugins/08-performance.md §4):一切异步区块必须呈现
* LoadingState / ErrorState / EmptyState 三态之一,禁止裸文本或无反馈。
*/
import { useState } from 'react'
import type { ButtonHTMLAttributes, ComponentProps, InputHTMLAttributes, ReactElement, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react'
/** 拼接类名(过滤 falsy;轻量版 cn,不引依赖) */
export function cn(...classes: Array<string | false | null | undefined>): string {
return classes.filter(Boolean).join(' ')
}
// ---- 等待态 ----
export interface SpinnerProps {
/** 直径 px,默认 14 */
size?: number
className?: string
}
/** 旋转指示器:强调色弧 + 弱色圆环 */
export function Spinner({ size = 14, className }: SpinnerProps): ReactElement {
return (
<span
role="status"
aria-label="加载中"
className={cn('inline-block shrink-0 animate-spin rounded-full border-line-strong', className)}
style={{ width: size, height: size, borderWidth: 2, borderTopColor: 'var(--accent)' }}
/>
)
}
export interface LoadingStateProps {
/** 默认「加载中…」 */
label?: ReactNode
className?: string
}
/** 区块级等待态:任何异步内容就绪前的统一占位 */
export function LoadingState({ label = '加载中…', className }: LoadingStateProps): ReactElement {
return (
<div className={cn('flex flex-col items-center justify-center gap-2 py-10 text-center', className)}>
<Spinner />
<div className="text-[11.5px] text-ink-3">{label}</div>
</div>
)
}
// ---- 失败态 ----
export interface ErrorStateProps {
/** 默认「出错了」 */
title?: ReactNode
/** 错误详情(错误消息、排查建议),保留换行 */
description?: ReactNode
/** 提供则显示重试按钮 */
onRetry?: () => void
retryLabel?: string
className?: string
}
/** 区块级失败态:错误必须可见、可重试,禁止静默吞掉 */
export function ErrorState({ title = '出错了', description, onRetry, retryLabel = '重试', className }: ErrorStateProps): ReactElement {
return (
<div
className={cn('flex flex-col items-center justify-center gap-1.5 rounded-card border border-line bg-red/10 px-6 py-10 text-center', className)}
role="alert"
>
<div className="text-[12.5px] font-medium text-ink">{title}</div>
{description !== undefined && description !== null && description !== '' && (
<div className="max-w-full whitespace-pre-wrap break-words text-[11.5px] leading-4 text-ink-3">{description}</div>
)}
{onRetry && (
<Button variant="ghost" size="sm" className="mt-2" onClick={onRetry}>
{retryLabel}
</Button>
)}
</div>
)
}
// ---- 空态 ----
export interface EmptyStateProps {
title: ReactNode
/** 怎么产生的提示(如何让它不再为空) */
description?: ReactNode
/** 可选动作(如「新建」「去设置」) */
action?: ReactNode
className?: string
}
/** 区块级空态(DESIGN.md §4 标准模式的组件化) */
export function EmptyState({ title, description, action, className }: EmptyStateProps): ReactElement {
return (
<div className={cn('flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center', className)}>
<div className="text-[13px] font-medium text-ink-2">{title}</div>
{description !== undefined && description !== null && description !== '' && (
<div className="max-w-full break-words text-[11.5px] text-ink-3">{description}</div>
)}
{action && <div className="mt-2">{action}</div>}
</div>
)
}
// ---- 按钮 ----
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** primary=强调实心 / ghost=描边(默认)/ danger=危险描边 */
variant?: 'primary' | 'ghost' | 'danger'
/** sm=辅助按钮 11px / md=标准 11.5px(默认) */
size?: 'sm' | 'md'
/** true 时禁用并显示旋转指示器(异步提交防重复点击) */
loading?: boolean
}
export function Button({ variant = 'ghost', size = 'md', loading = false, disabled, className, children, ...rest }: ButtonProps): ReactElement {
const variantCls =
variant === 'primary'
? 'bg-accent text-white hover:opacity-90'
: variant === 'danger'
? 'border border-line-strong text-red hover:bg-red/10'
: 'border border-line-strong text-ink hover:bg-hover'
const sizeCls = size === 'sm' ? 'px-2 py-1 text-[11px]' : 'px-3 py-1.5 text-[11.5px]'
return (
<button
type="button"
disabled={disabled || loading}
className={cn(
'inline-flex items-center justify-center gap-1.5 rounded-control font-medium transition-colors disabled:pointer-events-none disabled:opacity-50',
variantCls,
sizeCls,
className
)}
{...rest}
>
{loading && <Spinner size={size === 'sm' ? 10 : 12} />}
{children}
</button>
)
}
// ---- 输入控件 ----
/** 单行输入(设置行/表单通用;与宿主设置页同款) */
export function Input({ className, ...rest }: InputHTMLAttributes<HTMLInputElement>): ReactElement {
return (
<input
className={cn(
'w-full rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 disabled:opacity-50',
className
)}
{...rest}
/>
)
}
export function Textarea({ className, ...rest }: TextareaHTMLAttributes<HTMLTextAreaElement>): ReactElement {
return (
<textarea
className={cn(
'w-full resize-y rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] leading-4 text-ink outline-none placeholder:text-ink-3 disabled:opacity-50',
className
)}
{...rest}
/>
)
}
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
options: Array<{ value: string; label: string }>
}
/** 下拉选择(原生 select,宿主输入框同款外观) */
export function Select({ options, className, ...rest }: SelectProps): ReactElement {
return (
<select
className={cn(
'w-full rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none disabled:opacity-50',
className
)}
{...rest}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
)
}
export interface SwitchProps {
/** 受控值;不传则内部自持(非受控) */
checked?: boolean
defaultChecked?: boolean
onCheckedChange?: (checked: boolean) => void
disabled?: boolean
'aria-label'?: string
}
/** 开关(DESIGN.md §4 规格:h-5 w-9,滑块 size-4,开 accent / 关 line-strong) */
export function Switch({ checked, defaultChecked, onCheckedChange, disabled, 'aria-label': ariaLabel }: SwitchProps): ReactElement {
const [inner, setInner] = useState(defaultChecked ?? false)
const isControlled = checked !== undefined
const value = isControlled ? checked : inner
return (
<button
type="button"
role="switch"
aria-checked={value}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => {
const next = !value
if (!isControlled) setInner(next)
onCheckedChange?.(next)
}}
className={cn(
'relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:pointer-events-none disabled:opacity-50',
value ? 'bg-accent' : 'bg-line-strong'
)}
>
<span
className="absolute top-0.5 size-4 rounded-full bg-white shadow transition-transform"
style={{ transform: `translateX(${value ? 16 : 2}px)` }}
/>
</button>
)
}
// ---- 布局与展示 ----
/** 卡片容器(rounded-card + line 边框;内边距由内容自理) */
export function Card({ className, children, ...rest }: ComponentProps<'div'>): ReactElement {
return (
<div className={cn('rounded-card border border-line bg-surface', className)} {...rest}>
{children}
</div>
)
}
export interface SettingRowProps {
label: ReactNode
/** 次级说明(控件左侧标题下方) */
description?: ReactNode
/** 右侧控件(Switch / Input / Button / Select…) */
children?: ReactNode
className?: string
}
/** 设置行(DESIGN.md §4 标准模式:label 左 / 控件右) */
export function SettingRow({ label, description, children, className }: SettingRowProps): ReactElement {
return (
<div className={cn('flex items-center justify-between gap-3 rounded-card border border-line px-3 py-2.5', className)}>
<div className="min-w-0">
<div className="text-[12.5px] text-ink">{label}</div>
{description !== undefined && description !== null && description !== '' && (
<div className="mt-0.5 break-words text-[11px] leading-4 text-ink-3">{description}</div>
)}
</div>
{children}
</div>
)
}
export interface BadgeProps {
/** neutral=灰 / accent=强调 / danger=危险 */
tone?: 'neutral' | 'accent' | 'danger'
className?: string
children: ReactNode
}
/** 徽标(版本、来源、状态等元信息) */
export function Badge({ tone = 'neutral', className, children }: BadgeProps): ReactElement {
const toneCls = tone === 'accent' ? 'bg-accent/10 text-accent' : tone === 'danger' ? 'bg-red/10 text-red' : 'bg-hover text-ink-3'
return <span className={cn('rounded-control px-1.5 py-0.5 text-[10px]', toneCls, className)}>{children}</span>
}