feat: 插件性能护栏 + 公共组件库——激活/装载限时兜底、renderer 装载三态呈现、@aso/plugin-sdk/ui、withTimeout/withRetry
This commit is contained in:
@@ -34,6 +34,7 @@ import type {
|
|||||||
StatusItem,
|
StatusItem,
|
||||||
ToolbarButtonDef
|
ToolbarButtonDef
|
||||||
} from '@aso/plugin-sdk'
|
} from '@aso/plugin-sdk'
|
||||||
|
import { withTimeout } from '@aso/plugin-sdk'
|
||||||
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
|
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
|
||||||
import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin'
|
import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin'
|
||||||
import { getLogger } from '../logger'
|
import { getLogger } from '../logger'
|
||||||
@@ -44,6 +45,9 @@ const pluginLog = getLogger('plugin')
|
|||||||
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
|
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
|
||||||
export const HARNESS_CONTRACT_VERSION = '0.2.0'
|
export const HARNESS_CONTRACT_VERSION = '0.2.0'
|
||||||
|
|
||||||
|
/** 插件激活限时(异步工厂的兜底超时):挂死的插件不得阻塞 runtime 重建主流程 */
|
||||||
|
export const PLUGIN_ACTIVATION_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
/** 外置插件装载开关(build 级):开发版开启、用户版关闭;ASO_EXTERNAL_PLUGINS=1 强制开启 */
|
/** 外置插件装载开关(build 级):开发版开启、用户版关闭;ASO_EXTERNAL_PLUGINS=1 强制开启 */
|
||||||
export const EXTERNAL_PLUGINS_ENABLED = !app.isPackaged || process.env.ASO_EXTERNAL_PLUGINS === '1'
|
export const EXTERNAL_PLUGINS_ENABLED = !app.isPackaged || process.env.ASO_EXTERNAL_PLUGINS === '1'
|
||||||
|
|
||||||
@@ -597,13 +601,25 @@ class PluginHost {
|
|||||||
this.resetSettingsCallbacks(plugin.manifest.id)
|
this.resetSettingsCallbacks(plugin.manifest.id)
|
||||||
try {
|
try {
|
||||||
const result = factory(this.buildApi(plugin, pi))
|
const result = factory(this.buildApi(plugin, pi))
|
||||||
log.info('plugin activated', { pluginId: plugin.manifest.id, source: plugin.source })
|
const pluginId = plugin.manifest.id
|
||||||
if (typeof result === 'function') {
|
if (typeof result === 'function') {
|
||||||
disposers.push(result)
|
disposers.push(result)
|
||||||
|
log.info('plugin activated', { pluginId, source: plugin.source })
|
||||||
} else if (result && typeof (result as Promise<unknown>).then === 'function') {
|
} else if (result && typeof (result as Promise<unknown>).then === 'function') {
|
||||||
void (result as Promise<void | (() => void)>).then((d) => {
|
// 异步工厂:限时兜底(挂死不拖垮 runtime 重建),reject 必须接住(不得成为 unhandled rejection)
|
||||||
if (typeof d === 'function') disposers.push(d)
|
void withTimeout(result as Promise<void | (() => void)>, PLUGIN_ACTIVATION_TIMEOUT_MS, `插件 ${pluginId} 激活`)
|
||||||
})
|
.then((d) => {
|
||||||
|
if (typeof d === 'function') disposers.push(d)
|
||||||
|
log.info('plugin activated', { pluginId, source: plugin.source, async: true })
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
plugin.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
log.error('plugin activation failed', { pluginId, err })
|
||||||
|
this.clearPluginUi(pluginId)
|
||||||
|
this.send('plugins:listChanged', null)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
log.info('plugin activated', { pluginId, source: plugin.source })
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
plugin.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
|
plugin.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ import { Search } from './components/Search'
|
|||||||
import { BrowserPanel } from './components/browser/BrowserPanel'
|
import { BrowserPanel } from './components/browser/BrowserPanel'
|
||||||
import { SidePanel, type PanelTabIcon } from './components/SidePanel'
|
import { SidePanel, type PanelTabIcon } from './components/SidePanel'
|
||||||
import { PluginPageFrame, PluginIcon } from './plugins/PluginPageFrame'
|
import { PluginPageFrame, PluginIcon } from './plugins/PluginPageFrame'
|
||||||
import { useExternalPluginComponents, usePluginRendererLoader } from './plugins/renderer-host'
|
import {
|
||||||
|
useExternalPluginComponents,
|
||||||
|
usePluginRendererLoader,
|
||||||
|
usePluginRenderStatus,
|
||||||
|
retryPluginRenderer
|
||||||
|
} from './plugins/renderer-host'
|
||||||
|
import { LoadingState, ErrorState } from '@aso/plugin-sdk/ui'
|
||||||
import { PluginErrorBoundary } from './plugins/PluginErrorBoundary'
|
import { PluginErrorBoundary } from './plugins/PluginErrorBoundary'
|
||||||
import { PluginStatusCapsules } from './plugins/PluginStatusCapsules'
|
import { PluginStatusCapsules } from './plugins/PluginStatusCapsules'
|
||||||
import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store'
|
import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store'
|
||||||
@@ -60,6 +66,20 @@ function loadSideState(): SideState {
|
|||||||
return { open: false, width: SIDE_DEFAULT_WIDTH, tabs: [], active: null }
|
return { open: false, width: SIDE_DEFAULT_WIDTH, tabs: [], active: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 外置 renderer 槽位的统一等待/失败态:装载中显示 LoadingState,失败显示 ErrorState + 重试 */
|
||||||
|
function PluginRendererSlot({ pluginId }: { pluginId: string }): JSX.Element {
|
||||||
|
const status = usePluginRenderStatus(pluginId)
|
||||||
|
if (!status || status.state !== 'error') return <LoadingState className="h-full" />
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
className="h-full"
|
||||||
|
title="插件界面装载失败"
|
||||||
|
description={status.message}
|
||||||
|
onRetry={() => retryPluginRenderer(pluginId)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
@@ -347,6 +367,8 @@ export default function App(): JSX.Element {
|
|||||||
icon: pluginTabIcons.get(id) ?? (LuGlobe as PanelTabIcon),
|
icon: pluginTabIcons.get(id) ?? (LuGlobe as PanelTabIcon),
|
||||||
content: PanelComponent ? (
|
content: PanelComponent ? (
|
||||||
<PanelComponent />
|
<PanelComponent />
|
||||||
|
) : plugin.hasRenderer ? (
|
||||||
|
<PluginRendererSlot pluginId={id} />
|
||||||
) : plugin.hasPage ? (
|
) : plugin.hasPage ? (
|
||||||
<PluginPageFrame plugin={plugin} onOpenView={handleOpenView} reloadToken={pluginReloadToken} />
|
<PluginPageFrame plugin={plugin} onOpenView={handleOpenView} reloadToken={pluginReloadToken} />
|
||||||
) : (
|
) : (
|
||||||
@@ -477,6 +499,8 @@ export default function App(): JSX.Element {
|
|||||||
>
|
>
|
||||||
{ActivePluginComponent ? (
|
{ActivePluginComponent ? (
|
||||||
<ActivePluginComponent />
|
<ActivePluginComponent />
|
||||||
|
) : activePlugin.hasRenderer ? (
|
||||||
|
<PluginRendererSlot pluginId={activeView.view} />
|
||||||
) : activePlugin.hasPage ? (
|
) : activePlugin.hasPage ? (
|
||||||
<PluginPageFrame
|
<PluginPageFrame
|
||||||
plugin={activePlugin}
|
plugin={activePlugin}
|
||||||
|
|||||||
@@ -6,16 +6,21 @@
|
|||||||
* (dev 页面源是 http://localhost,直接 import(plugin://) 会被 CORS 拦),
|
* (dev 页面源是 http://localhost,直接 import(plugin://) 会被 CORS 拦),
|
||||||
* 默认导出 mount(ctx) 注册 view/panel 组件;列表/热重载变化即整模块重装
|
* 默认导出 mount(ctx) 注册 view/panel 组件;列表/热重载变化即整模块重装
|
||||||
* - 仅开发版装载(与主进程 EXTERNAL_PLUGINS_ENABLED 同开关语义;用户版主进程不下发外置条目)
|
* - 仅开发版装载(与主进程 EXTERNAL_PLUGINS_ENABLED 同开关语义;用户版主进程不下发外置条目)
|
||||||
* - 错误隔离到单插件:装载/注册失败仅 console.error 并清空该插件注册,不影响宿主与其他插件;
|
* - 性能护栏:读源与 import 均限时 8s(挂死的装载不得无限占位),按插件记录
|
||||||
|
* loading/ready/error 状态供槽位呈现统一等待/失败态;错误隔离到单插件,
|
||||||
* 组件渲染期崩溃由 App 层 PluginErrorBoundary 兜底
|
* 组件渲染期崩溃由 App 层 PluginErrorBoundary 兜底
|
||||||
*/
|
*/
|
||||||
import { useEffect, useSyncExternalStore } from 'react'
|
import { useEffect, useSyncExternalStore } from 'react'
|
||||||
import type { ComponentType } from 'react'
|
import type { ComponentType } from 'react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import * as jsxRuntime from 'react/jsx-runtime'
|
import * as jsxRuntime from 'react/jsx-runtime'
|
||||||
|
import { withTimeout } from '@aso/plugin-sdk'
|
||||||
import type { PluginHostGlobal, PluginRendererModule } from '@aso/plugin-sdk/renderer'
|
import type { PluginHostGlobal, PluginRendererModule } from '@aso/plugin-sdk/renderer'
|
||||||
import type { PluginInfo } from '@shared/types'
|
import type { PluginInfo } from '@shared/types'
|
||||||
|
|
||||||
|
/** 单个 renderer 模块读源/装载限时 */
|
||||||
|
const RENDERER_LOAD_TIMEOUT_MS = 8_000
|
||||||
|
|
||||||
/** 插件已注册组件(按槽位;缺省 = 未注册该槽位) */
|
/** 插件已注册组件(按槽位;缺省 = 未注册该槽位) */
|
||||||
interface PluginComponents {
|
interface PluginComponents {
|
||||||
view?: ComponentType
|
view?: ComponentType
|
||||||
@@ -49,6 +54,52 @@ export function useExternalPluginComponents(): Map<string, PluginComponents> {
|
|||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 装载状态(loading/ready/error,按插件;槽位据此呈现统一等待/失败态) ----
|
||||||
|
|
||||||
|
export type PluginRenderStatus = { state: 'loading' } | { state: 'ready' } | { state: 'error'; message: string }
|
||||||
|
|
||||||
|
const renderStatus = new Map<string, PluginRenderStatus>()
|
||||||
|
const statusListeners = new Set<() => void>()
|
||||||
|
let statusVersion = 0
|
||||||
|
|
||||||
|
function setStatus(pluginId: string, status: PluginRenderStatus): void {
|
||||||
|
renderStatus.set(pluginId, status)
|
||||||
|
statusVersion++
|
||||||
|
for (const fn of statusListeners) fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteStatus(pluginId: string): void {
|
||||||
|
if (!renderStatus.delete(pluginId)) return
|
||||||
|
statusVersion++
|
||||||
|
for (const fn of statusListeners) fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeStatus(fn: () => void): () => void {
|
||||||
|
statusListeners.add(fn)
|
||||||
|
return () => statusListeners.delete(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 某插件 renderer 的当前装载状态(未跟踪 = undefined,视为 loading) */
|
||||||
|
export function usePluginRenderStatus(pluginId: string): PluginRenderStatus | undefined {
|
||||||
|
useSyncExternalStore(subscribeStatus, () => statusVersion)
|
||||||
|
return renderStatus.get(pluginId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 重试:槽位失败态的重试按钮触发整轮重装 ----
|
||||||
|
|
||||||
|
let retryTick = 0
|
||||||
|
|
||||||
|
/** 重试装载某插件的 renderer(清掉 error 态并重跑装载循环) */
|
||||||
|
export function retryPluginRenderer(pluginId: string): void {
|
||||||
|
setStatus(pluginId, { state: 'loading' })
|
||||||
|
retryTick++
|
||||||
|
for (const fn of statusListeners) fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePluginRendererRetryTick(): number {
|
||||||
|
return useSyncExternalStore(subscribeStatus, () => retryTick)
|
||||||
|
}
|
||||||
|
|
||||||
// 宿主全局:插件构建期把 react / react/jsx-runtime 别名到这里的实例(见 docs/plugins.md)
|
// 宿主全局:插件构建期把 react / react/jsx-runtime 别名到这里的实例(见 docs/plugins.md)
|
||||||
;(window as unknown as Record<'__ASO_PLUGIN_HOST__', PluginHostGlobal | undefined>).__ASO_PLUGIN_HOST__ = {
|
;(window as unknown as Record<'__ASO_PLUGIN_HOST__', PluginHostGlobal | undefined>).__ASO_PLUGIN_HOST__ = {
|
||||||
react: React,
|
react: React,
|
||||||
@@ -56,12 +107,13 @@ export function useExternalPluginComponents(): Map<string, PluginComponents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 外置插件 renderer 模块装载循环:插件列表 / 热重载令牌变化即重装。
|
* 外置插件 renderer 模块装载循环:插件列表 / 热重载令牌 / 重试信号变化即重装。
|
||||||
* 源码经 IPC 取回后以 Blob URL import——dev 下页面源是 http://localhost,
|
* 源码经 IPC 取回后以 Blob URL import——dev 下页面源是 http://localhost,
|
||||||
* 直接 import(plugin://) 会被 CORS 拦截;Blob 模块须自包含(相对导入无基路径)。
|
* 直接 import(plugin://) 会被 CORS 拦截;Blob 模块须自包含(相对导入无基路径)。
|
||||||
* 不在列表(禁用/卸载/关闭开关)或主进程侧报错的插件被移除,其组件随之卸载。
|
* 不在列表(禁用/卸载/关闭开关)或主进程侧报错的插件被移除,其组件随之卸载。
|
||||||
*/
|
*/
|
||||||
export function usePluginRendererLoader(plugins: PluginInfo[], reloadToken: number): void {
|
export function usePluginRendererLoader(plugins: PluginInfo[], reloadToken: number): void {
|
||||||
|
const retryToken = usePluginRendererRetryTick()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!import.meta.env.DEV) return
|
if (!import.meta.env.DEV) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -72,12 +124,17 @@ export function usePluginRendererLoader(plugins: PluginInfo[], reloadToken: numb
|
|||||||
if (!plugin.enabled || plugin.source !== 'external' || plugin.error || !file) continue
|
if (!plugin.enabled || plugin.source !== 'external' || plugin.error || !file) continue
|
||||||
const id = plugin.manifest.id
|
const id = plugin.manifest.id
|
||||||
keep.add(id)
|
keep.add(id)
|
||||||
|
setStatus(id, { state: 'loading' })
|
||||||
try {
|
try {
|
||||||
const code = await window.api.pluginsReadRenderer(id)
|
const code = await withTimeout(window.api.pluginsReadRenderer(id), RENDERER_LOAD_TIMEOUT_MS, `读取 ${id} renderer 源码`)
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' }))
|
const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' }))
|
||||||
try {
|
try {
|
||||||
const mod = (await import(/* @vite-ignore */ url)) as { default?: unknown }
|
const mod = (await withTimeout(
|
||||||
|
import(/* @vite-ignore */ url),
|
||||||
|
RENDERER_LOAD_TIMEOUT_MS,
|
||||||
|
`装载 ${id} renderer 模块`
|
||||||
|
)) as { default?: unknown }
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const mount = mod.default
|
const mount = mod.default
|
||||||
if (typeof mount !== 'function') throw new Error('renderer 模块默认导出必须是 mount(ctx) 函数')
|
if (typeof mount !== 'function') throw new Error('renderer 模块默认导出必须是 mount(ctx) 函数')
|
||||||
@@ -87,21 +144,27 @@ export function usePluginRendererLoader(plugins: PluginInfo[], reloadToken: numb
|
|||||||
registerView: (component) => upsert(id, { view: component }),
|
registerView: (component) => upsert(id, { view: component }),
|
||||||
registerPanel: (component) => upsert(id, { panel: component })
|
registerPanel: (component) => upsert(id, { panel: component })
|
||||||
})
|
})
|
||||||
|
setStatus(id, { state: 'ready' })
|
||||||
} finally {
|
} finally {
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
console.error(`[plugins] ${id} renderer 装载失败:`, err)
|
console.error(`[plugins] ${id} renderer 装载失败:`, err)
|
||||||
|
setStatus(id, { state: 'error', message })
|
||||||
remove(id)
|
remove(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const id of [...registry.keys()]) {
|
for (const id of [...registry.keys()]) {
|
||||||
if (!keep.has(id)) remove(id)
|
if (!keep.has(id)) {
|
||||||
|
remove(id)
|
||||||
|
deleteStatus(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void load()
|
void load()
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [plugins, reloadToken])
|
}, [plugins, reloadToken, retryToken])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./client": "./src/page.ts",
|
"./client": "./src/page.ts",
|
||||||
"./renderer": "./src/renderer.ts"
|
"./renderer": "./src/renderer.ts",
|
||||||
|
"./ui": "./src/ui.tsx"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -4,5 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
export * from './manifest'
|
export * from './manifest'
|
||||||
export * from './settings'
|
export * from './settings'
|
||||||
|
export * from './async'
|
||||||
export * from './status'
|
export * from './status'
|
||||||
export * from './plugin'
|
export * from './plugin'
|
||||||
|
|||||||
@@ -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