feat: 插件性能护栏 + 公共组件库——激活/装载限时兜底、renderer 装载三态呈现、@aso/plugin-sdk/ui、withTimeout/withRetry
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 插件公共 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>
|
||||
}
|
||||
Reference in New Issue
Block a user