feat: 插件性能护栏 + 公共组件库——激活/装载限时兜底、renderer 装载三态呈现、@aso/plugin-sdk/ui、withTimeout/withRetry

This commit is contained in:
Rick Ye
2026-09-11 09:56:26 +08:00
parent 4ed31a34fc
commit f77942ee2b
7 changed files with 473 additions and 12 deletions
+2 -1
View File
@@ -7,7 +7,8 @@
"exports": {
".": "./src/index.ts",
"./client": "./src/page.ts",
"./renderer": "./src/renderer.ts"
"./renderer": "./src/renderer.ts",
"./ui": "./src/ui.tsx"
},
"scripts": {
"typecheck": "tsc --noEmit"
+68
View File
@@ -0,0 +1,68 @@
/**
* 插件异步工具:超时与重试(零依赖,主进程 main.mjs 与 renderer 通用)。
*
* 性能铁律(docs/plugins/08-performance.md):插件内一切可能挂起/失败的异步操作
* (网络请求、子进程、文件轮询…)都应经 withTimeout / withRetry 包裹。宿主对插件
* 激活(10s)与 renderer 装载(8s)有兜底超时——超时即判失败,不会无限等;
* 插件自己的异步调用应先于宿主超时并自行恢复,而不是被宿主判死。
*/
/** 超时错误(err.name === 'TimeoutError',可据此与真实业务失败区分) */
export class TimeoutError extends Error {
constructor(message: string) {
super(message)
this.name = 'TimeoutError'
}
}
/**
* 给 Promise 加超时;超时后以 TimeoutError 拒绝。
* 注意:底层操作不会被取消(Promise 无 cancel 语义),有 AbortController 的调用方
* 应自行把 signal 接到底层,再用本函数兜底计时。
*/
export function withTimeout<T>(promise: Promise<T>, ms: number, label?: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new TimeoutError(`${label ?? '操作'} 超时(${ms}ms)`))
}, ms)
})
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export interface RetryOptions {
/** 失败后的额外重试次数(总尝试 = retries + 1),默认 2 */
retries?: number
/** 首次重试前的等待,默认 300ms;之后指数退避(×2) */
delayMs?: number
/** 单次尝试的超时上限(ms),经 withTimeout 计时 */
timeoutMs?: number
/** 每次重试前的回调(记日志/上报用) */
onRetry?: (err: unknown, attempt: number, nextDelayMs: number) => void
}
/**
* 带超时与指数退避的重试:fn 返回 reject 或超时都算失败,重试耗尽后抛最后一次的错误。
*/
export async function withRetry<T>(fn: (attempt: number) => Promise<T>, options: RetryOptions = {}): Promise<T> {
const retries = options.retries ?? 2
const delayMs = options.delayMs ?? 300
let lastErr: unknown
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const run = fn(attempt)
return options.timeoutMs !== undefined ? await withTimeout(run, options.timeoutMs, `第 ${attempt + 1} 次尝试`) : await run
} catch (err) {
lastErr = err
if (attempt === retries) break
const nextDelay = delayMs * 2 ** attempt
options.onRetry?.(err, attempt + 1, nextDelay)
await sleep(nextDelay)
}
}
throw lastErr
}
+1
View File
@@ -4,5 +4,6 @@
*/
export * from './manifest'
export * from './settings'
export * from './async'
export * from './status'
export * from './plugin'
+288
View File
@@ -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>
}