feat: 插件检验器 plugin:check + L6/L7 教程 + 模板接入组件库——性能与样式标准成为机器关卡
This commit is contained in:
@@ -74,6 +74,10 @@ export default defineConfig({
|
|||||||
// 允许 dev server 读取工作区源码包(@aso/plugin-sdk 从 packages/ 链接进来)
|
// 允许 dev server 读取工作区源码包(@aso/plugin-sdk 从 packages/ 链接进来)
|
||||||
fs: { allow: [resolve(__dirname, '../..')] }
|
fs: { allow: [resolve(__dirname, '../..')] }
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
// sdk 是源码形态(exports 指向 .ts/.tsx),必须走源码 transform 而非预打包
|
||||||
|
exclude: ['@aso/plugin-sdk']
|
||||||
|
},
|
||||||
plugins: [react(), tailwindcss(), copyTokensCss()],
|
plugins: [react(), tailwindcss(), copyTokensCss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
+14
-1
@@ -120,7 +120,20 @@ export const { jsx, jsxs, Fragment } = J
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 装载时机与热重载:插件列表变化(扫描/启停/`plugins:reload`)即按 `?t=` cache-bust 重装;被禁用/移除的插件注册随之清除
|
- 装载时机与热重载:插件列表变化(扫描/启停/`plugins:reload`)即按 `?t=` cache-bust 重装;被禁用/移除的插件注册随之清除
|
||||||
- 门控:仅开发版装载(`import.meta.env.DEV` + 主进程 `EXTERNAL_PLUGINS_ENABLED`);错误隔离到单插件(装载失败仅 console.error,渲染崩溃由 PluginErrorBoundary 兜底)
|
- 门控:仅开发版装载(`import.meta.env.DEV` + 主进程 `EXTERNAL_PLUGINS_ENABLED`)
|
||||||
|
- 性能护栏:读源与 import 各限时 8s,超时/失败由宿主槽位呈现统一等待/失败态(ErrorState + 重试,见 `plugins/renderer-host`);渲染期崩溃由 PluginErrorBoundary 兜底
|
||||||
|
|
||||||
|
## 公共 UI 组件库与异步工具(`@aso/plugin-sdk/ui` · `withTimeout`/`withRetry`)
|
||||||
|
|
||||||
|
- **`./ui` 子入口**:宿主视觉规范的代码化——`Button`/`Input`/`Select`/`Switch`/`Card`/`SettingRow`/`Badge`
|
||||||
|
+ 三态组件 `LoadingState`/`ErrorState`/`EmptyState`/`Spinner`。仅 renderer 组件槽位可用
|
||||||
|
(渲染在宿主 DOM,复用宿主样式表);组件目录与三态铁律见 [docs/plugins/07-ui-components.md](./plugins/07-ui-components.md)
|
||||||
|
- **异步工具(主入口导出)**:`withTimeout(promise, ms, label?)`、`withRetry(fn, { retries, delayMs, timeoutMs, onRetry })`、
|
||||||
|
`TimeoutError`——插件内一切可能挂起的异步(网络/子进程/文件轮询)必须自带超时与重试
|
||||||
|
- **宿主兜底**:插件激活限时 10s(异步工厂超时/reject 即标记插件失败,不拖垮 runtime 重建);
|
||||||
|
renderer 装载限时 8s
|
||||||
|
- **机器关卡**:`pnpm plugin:check <插件目录>` 校验契约完整性、同步 IO、console.*、
|
||||||
|
React 单实例、hex 色值、三态使用等(error 级不通过即 exit 1),规则见 [docs/plugins/08-performance.md](./plugins/08-performance.md)
|
||||||
|
|
||||||
## 插件设置(声明式 schema,`api.ui.registerSettings`)
|
## 插件设置(声明式 schema,`api.ui.registerSettings`)
|
||||||
|
|
||||||
|
|||||||
@@ -42,9 +42,13 @@ export default function mount(ctx) {
|
|||||||
```sh
|
```sh
|
||||||
esbuild renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
|
esbuild renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
|
||||||
--jsx=automatic \
|
--jsx=automatic \
|
||||||
--alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js
|
--alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js \
|
||||||
|
--alias:@aso/plugin-sdk=<sdk源码路径>/index.ts --alias:@aso/plugin-sdk/ui=<sdk源码路径>/ui.tsx
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 最后两条 alias 只在用到 `@aso/plugin-sdk`(withTimeout 等)或 `./ui` 组件库时需要;
|
||||||
|
> sdk 以源码形态发布(exports 指向 .ts/.tsx),esbuild 无法按包名解析,指路径即可。
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// shims/react.js —— 从宿主全局取单例
|
// shims/react.js —— 从宿主全局取单例
|
||||||
const R = window.__ASO_PLUGIN_HOST__.react
|
const R = window.__ASO_PLUGIN_HOST__.react
|
||||||
@@ -72,13 +76,14 @@ export const { jsx, jsxs, Fragment } = window.__ASO_PLUGIN_HOST__.jsxRuntime
|
|||||||
|
|
||||||
## 5. 样式
|
## 5. 样式
|
||||||
|
|
||||||
组件直接跑在宿主树里——**宿主的 Tailwind 类与 token 全部可用**(`text-ink`、`border-line`、`rounded-card`、`bg-accent` …)。必须遵守 [DESIGN.md](./DESIGN.md) 的类名词汇与布局模式,禁止硬编码色值/阴影。
|
组件直接跑在宿主树里——**宿主的 Tailwind 类与 token 全部可用**。标准模式已代码化:优先 `import { Button, SettingRow, LoadingState, ErrorState, EmptyState, Switch } from '@aso/plugin-sdk/ui'`(组件目录与三态铁律见 [L6](./07-ui-components.md)),不要手写;遵守 [DESIGN.md](./DESIGN.md) 的类名词汇,禁止硬编码色值/阴影。
|
||||||
|
|
||||||
## 6. 生命周期
|
## 6. 生命周期
|
||||||
|
|
||||||
- 装载:插件列表变化(启停/重载/启动)即重装;`mount` 同步执行注册
|
- 装载:插件列表变化(启停/重载/启动)即重装;`mount` 同步执行注册
|
||||||
- 卸载:插件禁用/移除 → 注册的组件自动从宿主卸载
|
- 卸载:插件禁用/移除 → 注册的组件自动从宿主卸载
|
||||||
- 崩溃隔离:渲染错误被 `PluginErrorBoundary` 捕获,不影响宿主;装载失败仅 console.error + 日志
|
- 性能护栏:读源与 import 各限时 8s——超时/失败时槽位呈现宿主统一「装载失败」ErrorState(带重试),见 [L7](./08-performance.md)
|
||||||
|
- 崩溃隔离:渲染错误被 `PluginErrorBoundary` 捕获,不影响宿主
|
||||||
|
|
||||||
## 7. 验证
|
## 7. 验证
|
||||||
|
|
||||||
@@ -88,4 +93,4 @@ export const { jsx, jsxs, Fragment } = window.__ASO_PLUGIN_HOST__.jsxRuntime
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**上一篇** [L3](./04-ui-slots.md) · **下一篇**:[L5 自诊断](./06-self-diagnosis.md)——让 Agent 能发现并修复你的插件问题。
|
**上一篇** [L3](./04-ui-slots.md) · **下一篇**:[L6 公共组件库](./07-ui-components.md)——别手写样式,宿主把标准模式做成了组件。
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# L6 · 公共 UI 组件库(@aso/plugin-sdk/ui)
|
||||||
|
|
||||||
|
> 前置:[L4 React 组件](./05-renderer-components.md)。约 15 分钟。
|
||||||
|
> 目标:不写一行样式代码,做出与宿主完全一致的界面;等待/失败/空态不再自造。
|
||||||
|
|
||||||
|
## 为什么必须用它
|
||||||
|
|
||||||
|
[DESIGN.md](./DESIGN.md) 定了「长什么样」,组件库把它变成「直接拿来的代码」:
|
||||||
|
|
||||||
|
- **token 同源**:组件内部只用宿主已核验的类词汇(`text-ink-3`、`rounded-control`…)
|
||||||
|
与 `var(--token)` 内联变量,深浅色自动跟随宿主主题
|
||||||
|
- **三态统一**:等待 `LoadingState` / 失败 `ErrorState`(可重试)/ 空 `EmptyState`,
|
||||||
|
任何插件的加载体验与宿主一致
|
||||||
|
- **零依赖**:`./ui` 是 sdk 源码子路径,随 esbuild 一起打进 renderer.mjs,
|
||||||
|
不引任何第三方 UI 库(shadcn/ui 的思路——无运行时、复制即用——由宿主承担)
|
||||||
|
|
||||||
|
## 导入与构建
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button, LoadingState, ErrorState, EmptyState, SettingRow, Switch } from '@aso/plugin-sdk/ui'
|
||||||
|
```
|
||||||
|
|
||||||
|
构建命令与 L4 完全相同——`ui` 随 sdk 源码一起被打包,react 别名照旧:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
esbuild src/renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
|
||||||
|
--jsx=automatic --alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 组件目录
|
||||||
|
|
||||||
|
| 组件 | 用途 | 要点 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Spinner` | 行内旋转指示 | `size` 默认 14px |
|
||||||
|
| `LoadingState` | 区块等待态 | `label` 可换文案,默认「加载中…」 |
|
||||||
|
| `ErrorState` | 区块失败态 | `title`/`description`(错误消息)、`onRetry` 提供即显示重试按钮 |
|
||||||
|
| `EmptyState` | 区块空态 | `title` + `description` + `action`(如「去设置」按钮) |
|
||||||
|
| `Button` | 按钮 | `variant: 'primary' \| 'ghost' \| 'danger'`;`loading` 置灰并显示 Spinner,防重复提交 |
|
||||||
|
| `Input` / `Textarea` | 输入 | 与宿主设置页同款外观 |
|
||||||
|
| `Select` | 下拉 | `options: { value, label }[]`,原生 select |
|
||||||
|
| `Switch` | 开关 | 受控 `checked`/`onCheckedChange`,或非受控 `defaultChecked` |
|
||||||
|
| `Card` | 卡片容器 | `rounded-card` + line 边框 |
|
||||||
|
| `SettingRow` | 设置行 | label 左 / 控件右(DESIGN §4 标准模式) |
|
||||||
|
| `Badge` | 徽标 | `tone: 'neutral' \| 'accent' \| 'danger'`,版本/来源/状态元信息 |
|
||||||
|
|
||||||
|
全部组件接受 `className` 追加布局类(flex/间距/尺寸),**不接受**颜色/字号覆写——
|
||||||
|
那两条路(hex、新字号)被 [L7 检验器](./08-performance.md)拦截。
|
||||||
|
|
||||||
|
## 三态铁律
|
||||||
|
|
||||||
|
任何异步区块的状态机:**loading → data | error | empty**,四选一,不允许第五种
|
||||||
|
(白屏、裸文本「加载中」、无反馈的失败)。模板(plugin-template)是完整示范:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{error !== null ? (
|
||||||
|
<ErrorState title="数据加载失败" description={error} onRetry={() => setTick((t) => t + 1)} />
|
||||||
|
) : items === null ? (
|
||||||
|
<LoadingState label="正在拉取数据…" />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<EmptyState title="暂无数据" description="检查数据源后点击重新加载" />
|
||||||
|
) : (
|
||||||
|
items.map((it) => <SettingRow key={it} label={it} />)
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
配套铁律:异步调用必须 `withTimeout`/`withRetry`(见 L7),否则 loading 态可能永远挂着。
|
||||||
|
|
||||||
|
## 宿主兜底
|
||||||
|
|
||||||
|
renderer 模块的读源与 import 宿主各限时 8 秒:超时或失败时,插件槽位自动呈现宿主侧
|
||||||
|
「插件界面装载失败」ErrorState(带重试按钮);组件渲染期崩溃由 PluginErrorBoundary
|
||||||
|
兜底。也就是说**装载失败不需要插件自己处理**,插件只需管好自己组件内部的异步。
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 仅 renderer 组件槽位可用(渲染在宿主 DOM,复用宿主样式表);iframe 插件页是物理
|
||||||
|
隔离文档,请按 [DESIGN.md](./DESIGN.md) §4 的 HTML 模式自行实现
|
||||||
|
- 需要新形态时:先用现有组件拼;确实不够,在 DESIGN.md 约束内组合 token,
|
||||||
|
并把新形态沉淀回团队公共代码,而不是留在单个插件里
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**上一篇** [L5 自诊断](./06-self-diagnosis.md) · **下一篇**:[L7 性能与检验](./08-performance.md)——超时、重试与机器关卡。
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# L7 · 性能与检验(plugin:check)
|
||||||
|
|
||||||
|
> 前置:L0–L6。约 20 分钟。
|
||||||
|
> 目标:插件百花齐放,但**绝不卡宿主、绝不风格跑偏**;发布前有机器关卡把关。
|
||||||
|
|
||||||
|
## 性能铁律
|
||||||
|
|
||||||
|
### 1. 主进程不阻塞(检验器:error)
|
||||||
|
|
||||||
|
`main.mjs` 运行在宿主主进程。任何同步 IO——`fs` 的 `*Sync`、`execSync`、
|
||||||
|
`spawnSync`——都会卡住**整个应用**的事件循环(聊天流、其他插件全部冻结)。
|
||||||
|
一律用 `node:fs/promises` 与异步子进程:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ❌ const text = fs.readFileSync(path, 'utf8')
|
||||||
|
// ✅
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
const text = await readFile(path, 'utf8')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 激活限时 10 秒(宿主强制)
|
||||||
|
|
||||||
|
插件工厂若返回 Promise,宿主限时 10s;超时或 reject 即把插件标记为失败
|
||||||
|
(插件管理页可见红字),绝不无限等待拖垮 runtime 重建。因此:
|
||||||
|
|
||||||
|
- 激活路径只做「注册」级别的工作,重活(预取数据、预热连接)放到工具首次调用
|
||||||
|
- 自己 `catch` 并 `api.log.error`——被宿主兜住意味着插件没跑起来
|
||||||
|
|
||||||
|
### 3. renderer 装载限时 8 秒(宿主强制)
|
||||||
|
|
||||||
|
读源与 import 各限时 8s,超时/失败由宿主呈现统一失败态(重试按钮);
|
||||||
|
组件渲染期崩溃由 PluginErrorBoundary 兜底。插件只需管好组件内部的异步。
|
||||||
|
|
||||||
|
### 4. 自己的异步自己兜(sdk 提供 `withTimeout` / `withRetry`)
|
||||||
|
|
||||||
|
先于宿主超时并自行恢复,而不是等宿主判死:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { withTimeout, withRetry, TimeoutError } from '@aso/plugin-sdk'
|
||||||
|
|
||||||
|
// 网络请求:3s 超时,失败退避重试 2 次(300ms → 600ms)
|
||||||
|
const data = await withRetry(() => callApi(), { retries: 2, delayMs: 300, timeoutMs: 3000 })
|
||||||
|
|
||||||
|
// 单次限时
|
||||||
|
const cfg = await withTimeout(loadConfig(), 2000, '加载配置')
|
||||||
|
try {
|
||||||
|
await risky()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TimeoutError) { /* 降级路径 */ }
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:`withTimeout` 只是竞速,底层操作不会被取消——有 `AbortController` 的
|
||||||
|
调用方请把 signal 接到底层,再用它兜底计时。
|
||||||
|
|
||||||
|
### 5. 渲染不卡顿
|
||||||
|
|
||||||
|
长列表分页/虚拟化,不要一次渲染数万节点;重计算放进工具(Agent 侧)或
|
||||||
|
`setTimeout(fn, 0)` 分帧,不占渲染主线程。
|
||||||
|
|
||||||
|
## 三态呈现(检验器:warn)
|
||||||
|
|
||||||
|
任何异步 UI 必须是四态之一:`LoadingState` / `ErrorState` / `EmptyState` / 数据。
|
||||||
|
`alert`/`confirm`/`prompt` 是阻塞式同步对话框,禁止(用 `Button` + 状态组件替代)。
|
||||||
|
详见 [L6](./07-ui-components.md)。
|
||||||
|
|
||||||
|
## 样式一致性(检验器:error)
|
||||||
|
|
||||||
|
- 只用公共组件库 + [DESIGN.md](./DESIGN.md) token;硬编码 hex 色值是 error
|
||||||
|
(`#fff`/`#ffffff` 白字除外)
|
||||||
|
- renderer.mjs 必须经宿主 shim 使用单实例 React(`__ASO_PLUGIN_HOST__`)——
|
||||||
|
自带 React 副本 = hooks 全部失效
|
||||||
|
|
||||||
|
## 日志(检验器:error)
|
||||||
|
|
||||||
|
`main.mjs` 禁 `console.*`,统一 `api.log.*`——Agent 自诊断闭环见 [L5](./06-self-diagnosis.md)。
|
||||||
|
|
||||||
|
## 检验器:`pnpm plugin:check <插件目录>`
|
||||||
|
|
||||||
|
发布前必跑;Agent 自迭代时,**通过检验器是完成定义(DoD)的一部分**。
|
||||||
|
|
||||||
|
| 级别 | 检查项 |
|
||||||
|
|---|---|
|
||||||
|
| error | manifest 缺失/不可解析;id/name/version(semver) 缺失;minContract 高于宿主契约;entry 指向的文件不存在;renderer.mjs > 8MB / main.mjs > 2MB;main.mjs 同步 IO;main.mjs `console.*`;renderer.mjs 未引用 `__ASO_PLUGIN_HOST__`;任何源码硬编码 hex 色值 |
|
||||||
|
| warn | 缺 minContract;renderer.mjs > 300KB(疑似打包自带 React);注册 view/panel 但未用三态组件;`alert/confirm/prompt`;entry.renderer 产物缺失但有 src/renderer.tsx(未构建) |
|
||||||
|
|
||||||
|
宿主契约版本从 `plugin-host.ts` 源码提取(单一事实源),检验器与宿主永不会各执一词。
|
||||||
|
|
||||||
|
## 一张自查表
|
||||||
|
|
||||||
|
- [ ] `pnpm plugin:check <目录>` 0 error
|
||||||
|
- [ ] main.mjs 无同步 IO,激活 10s 内完成
|
||||||
|
- [ ] 一切异步有超时与重试,catch 后 `api.log` + `ErrorState`
|
||||||
|
- [ ] 长列表不整批渲染
|
||||||
|
- [ ] 深浅色各看一眼(token 正确时自动成立)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**上一篇** [L6 公共组件库](./07-ui-components.md) · 返回 [教程索引](./README.md)
|
||||||
@@ -68,6 +68,11 @@
|
|||||||
|
|
||||||
## 4. 标准模式(复制即用)
|
## 4. 标准模式(复制即用)
|
||||||
|
|
||||||
|
> **renderer 组件优先用组件库**:本节模式已在 `@aso/plugin-sdk/ui` 代码化
|
||||||
|
> (`Button`/`SettingRow`/`EmptyState`/`LoadingState`/`ErrorState`/`Badge`…,
|
||||||
|
> 见 [L6](./07-ui-components.md)),直接 import,不要手写。
|
||||||
|
> 下面的 HTML 形态留给 iframe 插件页作者。
|
||||||
|
|
||||||
### 设置行(label 左 / 控件右)
|
### 设置行(label 左 / 控件右)
|
||||||
|
|
||||||
```html
|
```html
|
||||||
@@ -116,14 +121,16 @@
|
|||||||
|
|
||||||
## 6. renderer 组件附加规则
|
## 6. renderer 组件附加规则
|
||||||
|
|
||||||
- 类名直接用宿主词汇(§2),无需引样式
|
- 类名直接用宿主词汇(§2),无需引样式;**优先 import 公共组件库**([L6](./07-ui-components.md)),不要手写标准模式
|
||||||
- React 必须来自 `ctx.react`(见 [L4](./05-renderer-components.md)),否则 hooks 失效
|
- React 必须来自 `ctx.react`(见 [L4](./05-renderer-components.md)),否则 hooks 失效
|
||||||
- 禁止操作宿主 DOM(document 全局查询/改样式),一切交互限制在自己的组件子树
|
- 禁止操作宿主 DOM(document 全局查询/改样式),一切交互限制在自己的组件子树
|
||||||
|
- 机器关卡:`pnpm plugin:check <插件目录>`(hex 色值 / React 副本 / 三态缺失等,见 [L7](./08-performance.md))
|
||||||
|
|
||||||
## 7. 自检清单(提发布前)
|
## 7. 自检清单(提发布前)
|
||||||
|
|
||||||
|
- [ ] `pnpm plugin:check <插件目录>` 0 error(机器关卡,见 [L7](./08-performance.md))
|
||||||
- [ ] 无硬编码色值/阴影/圆角(铁律 1)
|
- [ ] 无硬编码色值/阴影/圆角(铁律 1)
|
||||||
- [ ] 字号都在 §3 的阶上
|
- [ ] 字号都在 §3 的阶上
|
||||||
- [ ] 深浅色都检查过(切主题看一眼;token 正确时自动成立)
|
- [ ] 深浅色都检查过(切主题看一眼;token 正确时自动成立)
|
||||||
- [ ] 复用了 §4 模式而非新造形态
|
- [ ] 复用了 §4 模式(renderer 直接用组件库)而非新造形态
|
||||||
- [ ] iframe 页引了 tokens.css 且无 `prefers-color-scheme`
|
- [ ] iframe 页引了 tokens.css 且无 `prefers-color-scheme`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 插件开发教程(渐进式)
|
# 插件开发教程(渐进式)
|
||||||
|
|
||||||
> 面向「写插件的人(含 Agent)」。从零到高级共 6 篇,每篇 10-30 分钟,逐篇递进、可独立验证。
|
> 面向「写插件的人(含 Agent)」。从零到高级共 8 篇,每篇 10-30 分钟,逐篇递进、可独立验证。
|
||||||
> 契约细节(类型/字段全集)见 [../plugins.md](../plugins.md);UI 规范见 [DESIGN.md](./DESIGN.md)。
|
> 契约细节(类型/字段全集)见 [../plugins.md](../plugins.md);UI 规范见 [DESIGN.md](./DESIGN.md)。
|
||||||
|
|
||||||
## 学习路径
|
## 学习路径
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
| [L3 UI 槽位](./04-ui-slots.md) | 状态胶囊/工具栏按钮/视图/面板 tab/iframe 页 | 界面存在感 |
|
| [L3 UI 槽位](./04-ui-slots.md) | 状态胶囊/工具栏按钮/视图/面板 tab/iframe 页 | 界面存在感 |
|
||||||
| [L4 React 组件](./05-renderer-components.md) | entry.renderer:宿主内 React 组件 + JSX 构建配方 | 一等公民级 UI |
|
| [L4 React 组件](./05-renderer-components.md) | entry.renderer:宿主内 React 组件 + JSX 构建配方 | 一等公民级 UI |
|
||||||
| [L5 自诊断](./06-self-diagnosis.md) | 日志规范、排障闭环、Agent 自迭代约定 | 可被 Agent 维护的插件 |
|
| [L5 自诊断](./06-self-diagnosis.md) | 日志规范、排障闭环、Agent 自迭代约定 | 可被 Agent 维护的插件 |
|
||||||
|
| [L6 公共组件库](./07-ui-components.md) | @aso/plugin-sdk/ui:统一三态(等待/失败/空)与基础控件 | 与宿主零差异的 UI |
|
||||||
|
| [L7 性能与检验](./08-performance.md) | 主线程不阻塞、超时重试、plugin:check 机器关卡 | 可发布、不卡宿主的插件 |
|
||||||
|
|
||||||
## 30 秒总览
|
## 30 秒总览
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
| v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) |
|
| v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) |
|
||||||
| v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 |
|
| v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 |
|
||||||
| v0.8.0 | 动态插件系统(M0–M3) | 两层能力模型(内置组件级/外置 iframe)、pi 原生契约聚合 extension、@aso/plugin-sdk、plugin://peer:// 协议、视图切换+聊天保活+徽标、侧栏/面板/胶囊/深链槽位、浏览器面板插件化、插件重载 |
|
| v0.8.0 | 动态插件系统(M0–M3) | 两层能力模型(内置组件级/外置 iframe)、pi 原生契约聚合 extension、@aso/plugin-sdk、plugin://peer:// 协议、视图切换+聊天保活+徽标、侧栏/面板/胶囊/深链槽位、浏览器面板插件化、插件重载 |
|
||||||
| v0.9.0 | 插件一等公民 + 可观测 | entry.renderer 宿主内 React 组件(Blob 装载+单实例 React)、.pi/extensions 一等插件源(build 开关门控)、声明式设置体系(13 控件+一级导航入口)、统一日志系统(滚动文件/api.log/IPC 追踪/渲染层错误归档)、渐进式教程 docs/plugins/ + DESIGN.md + plugin-template |
|
| v0.9.0 | 插件一等公民 + 可观测 | entry.renderer 宿主内 React 组件(Blob 装载+单实例 React)、.pi/extensions 一等插件源(build 开关门控)、声明式设置体系(13 控件+一级导航入口)、统一日志系统(滚动文件/api.log/IPC 追踪/渲染层错误归档)、渐进式教程 docs/plugins/ + DESIGN.md + plugin-template、性能/样式标准与检验器(激活 10s/装载 8s 护栏、@aso/plugin-sdk/ui 组件库+三态、withTimeout/withRetry、plugin:check) |
|
||||||
| 未来 | | 插件管理与 zip 导入(M4)、ed25519 签名在线更新(M5)、外置插件模板与开发工作流(M6)、pi 原生扩展目录管控(M7)、B3/B4/B5 槽位、electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 |
|
| 未来 | | 插件管理与 zip 导入(M4)、ed25519 签名在线更新(M5)、外置插件模板与开发工作流(M6)、pi 原生扩展目录管控(M7)、B3/B4/B5 槽位、electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+2
-1
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "pnpm -r --if-present typecheck",
|
"typecheck": "pnpm -r --if-present typecheck",
|
||||||
"build": "pnpm -r --if-present build",
|
"build": "pnpm -r --if-present build",
|
||||||
"dev": "pnpm --dir apps/desktop dev"
|
"dev": "pnpm --dir apps/desktop dev",
|
||||||
|
"plugin:check": "node scripts/check-plugin.mjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
```
|
```
|
||||||
manifest.json 清单(id/name/version/minContract 必改)
|
manifest.json 清单(id/name/version/minContract 必改)
|
||||||
main.mjs 主进程侧:Agent 能力 + 设置声明 + 日志埋点(范例齐备)
|
main.mjs 主进程侧:Agent 能力 + 设置声明 + 日志埋点(范例齐备)
|
||||||
src/renderer.tsx 宿主内 React 组件源码(JSX)
|
src/renderer.tsx 宿主内 React 组件源码(JSX;公共组件库 + 三态示范)
|
||||||
|
renderer.mjs 预构建产物(开箱即用;改了源码需重新构建)
|
||||||
shims/ 构建期把 react 别名到宿主单例(勿改)
|
shims/ 构建期把 react 别名到宿主单例(勿改)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -16,11 +17,14 @@ shims/ 构建期把 react 别名到宿主单例(勿改)
|
|||||||
```sh
|
```sh
|
||||||
esbuild src/renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
|
esbuild src/renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
|
||||||
--jsx=automatic \
|
--jsx=automatic \
|
||||||
--alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js
|
--alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js \
|
||||||
|
--alias:@aso/plugin-sdk=../../packages/plugin-sdk/src/index.ts \
|
||||||
|
--alias:@aso/plugin-sdk/ui=../../packages/plugin-sdk/src/ui.tsx
|
||||||
```
|
```
|
||||||
|
|
||||||
(esbuild 可用 `pnpm dlx esbuild@<版本>` 或任意 ≥0.17 版本;`--alias` 需 ≥0.17。)
|
(esbuild 可用 `pnpm dlx esbuild@<版本>` 或任意 ≥0.17 版本;`--alias` 需 ≥0.17。
|
||||||
不需要 UI 时:删除 `entry.renderer` 与 `src/`、`shims/`,只保留 main.mjs。
|
两条 sdk alias 指向仓库内源码——本目录复制到别处后,改成 sdk 源码的实际路径即可。)
|
||||||
|
不需要 UI 时:删除 `entry.renderer` 与 `src/`、`shims/`、`renderer.mjs`,只保留 main.mjs。
|
||||||
|
|
||||||
## 放置位置(三选一,按优先级)
|
## 放置位置(三选一,按优先级)
|
||||||
|
|
||||||
@@ -32,13 +36,16 @@ esbuild src/renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
|
|||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
1. 重启或开关重载 → 设置 → 插件 → 对应作用域出现插件
|
1. `pnpm plugin:check <本目录>` → 0 error(性能与样式机器关卡,见 L7)
|
||||||
2. 日志 `userData/logs/main-*.log` 有 `plugin activated pluginId=my-plugin`
|
2. 重启或开关重载 → 设置 → 插件 → 对应作用域出现插件
|
||||||
3. 对 Agent 说"ping 一下我的插件" → 触发 `my_plugin_ping`
|
3. 日志 `userData/logs/main-*.log` 有 `plugin activated pluginId=my-plugin`
|
||||||
4. 面板/侧栏出现 React 组件,点击 +1 正常(hooks 生效)
|
4. 对 Agent 说"ping 一下我的插件" → 触发 `my_plugin_ping`
|
||||||
|
5. 面板/侧栏出现 React 组件:等待/数据/开关/重载齐全(三态示范,hooks 生效)
|
||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
|
|
||||||
- 教程索引:`docs/plugins/README.md`(L0→L5)
|
- 教程索引:`docs/plugins/README.md`(L0→L7)
|
||||||
|
- 公共组件库与三态铁律:`docs/plugins/07-ui-components.md`(renderer.tsx 即示范)
|
||||||
|
- 性能与检验器:`docs/plugins/08-performance.md`
|
||||||
- 视觉规范:`docs/plugins/DESIGN.md`(提发布前过一遍自检清单)
|
- 视觉规范:`docs/plugins/DESIGN.md`(提发布前过一遍自检清单)
|
||||||
- 契约全集:`docs/plugins.md`
|
- 契约全集:`docs/plugins.md`
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// shims/react.js
|
||||||
|
var R = window.__ASO_PLUGIN_HOST__.react;
|
||||||
|
var {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
Fragment,
|
||||||
|
memo,
|
||||||
|
forwardRef,
|
||||||
|
createElement
|
||||||
|
} = R;
|
||||||
|
|
||||||
|
// ../plugin-sdk/src/async.ts
|
||||||
|
var TimeoutError = class extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.name = "TimeoutError";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function withTimeout(promise, ms, label) {
|
||||||
|
let timer;
|
||||||
|
const timeout = new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
reject(new TimeoutError(`${label ?? "\u64CD\u4F5C"} \u8D85\u65F6\uFF08${ms}ms\uFF09`));
|
||||||
|
}, ms);
|
||||||
|
});
|
||||||
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||||
|
}
|
||||||
|
|
||||||
|
// shims/jsx-runtime.js
|
||||||
|
var { jsx, jsxs, Fragment: Fragment2 } = window.__ASO_PLUGIN_HOST__.jsxRuntime;
|
||||||
|
|
||||||
|
// ../plugin-sdk/src/ui.tsx
|
||||||
|
function cn(...classes) {
|
||||||
|
return classes.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
function Spinner({ size = 14, className }) {
|
||||||
|
return /* @__PURE__ */ jsx(
|
||||||
|
"span",
|
||||||
|
{
|
||||||
|
role: "status",
|
||||||
|
"aria-label": "\u52A0\u8F7D\u4E2D",
|
||||||
|
className: cn("inline-block shrink-0 animate-spin rounded-full border-line-strong", className),
|
||||||
|
style: { width: size, height: size, borderWidth: 2, borderTopColor: "var(--accent)" }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function LoadingState({ label = "\u52A0\u8F7D\u4E2D\u2026", className }) {
|
||||||
|
return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col items-center justify-center gap-2 py-10 text-center", className), children: [
|
||||||
|
/* @__PURE__ */ jsx(Spinner, {}),
|
||||||
|
/* @__PURE__ */ jsx("div", { className: "text-[11.5px] text-ink-3", children: label })
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
function ErrorState({ title = "\u51FA\u9519\u4E86", description, onRetry, retryLabel = "\u91CD\u8BD5", className }) {
|
||||||
|
return /* @__PURE__ */ jsxs(
|
||||||
|
"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",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx("div", { className: "text-[12.5px] font-medium text-ink", children: title }),
|
||||||
|
description !== void 0 && description !== null && description !== "" && /* @__PURE__ */ jsx("div", { className: "max-w-full whitespace-pre-wrap break-words text-[11.5px] leading-4 text-ink-3", children: description }),
|
||||||
|
onRetry && /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "sm", className: "mt-2", onClick: onRetry, children: retryLabel })
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function EmptyState({ title, description, action, className }) {
|
||||||
|
return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center", className), children: [
|
||||||
|
/* @__PURE__ */ jsx("div", { className: "text-[13px] font-medium text-ink-2", children: title }),
|
||||||
|
description !== void 0 && description !== null && description !== "" && /* @__PURE__ */ jsx("div", { className: "max-w-full break-words text-[11.5px] text-ink-3", children: description }),
|
||||||
|
action && /* @__PURE__ */ jsx("div", { className: "mt-2", children: action })
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
function Button({ variant = "ghost", size = "md", loading = false, disabled, className, children, ...rest }) {
|
||||||
|
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 /* @__PURE__ */ jsxs(
|
||||||
|
"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,
|
||||||
|
children: [
|
||||||
|
loading && /* @__PURE__ */ jsx(Spinner, { size: size === "sm" ? 10 : 12 }),
|
||||||
|
children
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function Switch({ checked, defaultChecked, onCheckedChange, disabled, "aria-label": ariaLabel }) {
|
||||||
|
const [inner, setInner] = useState(defaultChecked ?? false);
|
||||||
|
const isControlled = checked !== void 0;
|
||||||
|
const value = isControlled ? checked : inner;
|
||||||
|
return /* @__PURE__ */ jsx(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
role: "switch",
|
||||||
|
"aria-checked": value,
|
||||||
|
"aria-label": ariaLabel,
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
children: /* @__PURE__ */ jsx(
|
||||||
|
"span",
|
||||||
|
{
|
||||||
|
className: "absolute top-0.5 size-4 rounded-full bg-white shadow transition-transform",
|
||||||
|
style: { transform: `translateX(${value ? 16 : 2}px)` }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function SettingRow({ label, description, children, className }) {
|
||||||
|
return /* @__PURE__ */ jsxs("div", { className: cn("flex items-center justify-between gap-3 rounded-card border border-line px-3 py-2.5", className), children: [
|
||||||
|
/* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
|
||||||
|
/* @__PURE__ */ jsx("div", { className: "text-[12.5px] text-ink", children: label }),
|
||||||
|
description !== void 0 && description !== null && description !== "" && /* @__PURE__ */ jsx("div", { className: "mt-0.5 break-words text-[11px] leading-4 text-ink-3", children: description })
|
||||||
|
] }),
|
||||||
|
children
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/renderer.tsx
|
||||||
|
function fetchItems() {
|
||||||
|
return withTimeout(Promise.resolve(["\u6761\u76EE\u4E00", "\u6761\u76EE\u4E8C", "\u6761\u76EE\u4E09"]), 3e3, "\u62C9\u53D6\u6570\u636E");
|
||||||
|
}
|
||||||
|
function mount(ctx) {
|
||||||
|
function Panel() {
|
||||||
|
const [items, setItems] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [verbose, setVerbose] = useState(false);
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setItems(null);
|
||||||
|
setError(null);
|
||||||
|
fetchItems().then((data) => {
|
||||||
|
if (alive) setItems(data);
|
||||||
|
}).catch((err) => {
|
||||||
|
if (alive) setError(err instanceof Error ? err.message : String(err));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [tick]);
|
||||||
|
return /* @__PURE__ */ jsxs("div", { className: "flex h-full flex-col gap-2 overflow-y-auto p-3", children: [
|
||||||
|
/* @__PURE__ */ jsx("div", { className: "text-[12.5px] text-ink", children: "\u6211\u7684\u63D2\u4EF6\u9762\u677F" }),
|
||||||
|
error !== null ? /* @__PURE__ */ jsx(ErrorState, { title: "\u6570\u636E\u52A0\u8F7D\u5931\u8D25", description: error, onRetry: () => setTick((t) => t + 1) }) : items === null ? /* @__PURE__ */ jsx(LoadingState, { label: "\u6B63\u5728\u62C9\u53D6\u6570\u636E\u2026" }) : items.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { title: "\u6682\u65E0\u6570\u636E", description: "\u68C0\u67E5\u6570\u636E\u6E90\u540E\u70B9\u51FB\u91CD\u65B0\u52A0\u8F7D" }) : items.map((it) => /* @__PURE__ */ jsx(SettingRow, { label: it, description: "\u793A\u4F8B\u6761\u76EE" }, it)),
|
||||||
|
/* @__PURE__ */ jsx(SettingRow, { label: "\u8BE6\u7EC6\u6A21\u5F0F", description: "\u5F00\u542F\u540E main \u4FA7\u8F93\u51FA\u66F4\u8BE6\u7EC6\u7684\u65E5\u5FD7", children: /* @__PURE__ */ jsx(Switch, { checked: verbose, onCheckedChange: setVerbose, "aria-label": "\u8BE6\u7EC6\u6A21\u5F0F" }) }),
|
||||||
|
/* @__PURE__ */ jsx(Button, { variant: "ghost", size: "sm", className: "self-start", onClick: () => setTick((t) => t + 1), children: "\u91CD\u65B0\u52A0\u8F7D" })
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
ctx.registerPanel(Panel);
|
||||||
|
ctx.registerView(Panel);
|
||||||
|
}
|
||||||
|
export {
|
||||||
|
mount as default
|
||||||
|
};
|
||||||
@@ -3,23 +3,65 @@
|
|||||||
* esbuild src/renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
|
* esbuild src/renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
|
||||||
* --jsx=automatic --alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js
|
* --jsx=automatic --alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js
|
||||||
* 产物 renderer.mjs 是自包含单文件,放回插件目录即被宿主装载。
|
* 产物 renderer.mjs 是自包含单文件,放回插件目录即被宿主装载。
|
||||||
* 样式类名只用 DESIGN.md 里的宿主词汇。
|
*
|
||||||
|
* UI 一律用 @aso/plugin-sdk/ui 公共组件:token 与宿主同源、深浅色自动跟随,
|
||||||
|
* 等待/失败/空态统一呈现(规范见 docs/plugins/07-ui-components.md 与 DESIGN.md)。
|
||||||
|
* 一切异步操作都要有超时(withTimeout/withRetry 见 '@aso/plugin-sdk'),
|
||||||
|
* 区块三态:LoadingState → ErrorState(可重试) / EmptyState / 数据。
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { withTimeout } from '@aso/plugin-sdk'
|
||||||
|
import { Button, EmptyState, ErrorState, LoadingState, SettingRow, Switch } from '@aso/plugin-sdk/ui'
|
||||||
|
|
||||||
|
/** 模拟异步数据源:换成真实 IPC / 请求时保持「必超时、必 catch」的形状 */
|
||||||
|
function fetchItems(): Promise<string[]> {
|
||||||
|
return withTimeout(Promise.resolve(['条目一', '条目二', '条目三']), 3000, '拉取数据')
|
||||||
|
}
|
||||||
|
|
||||||
export default function mount(ctx) {
|
export default function mount(ctx) {
|
||||||
function Panel() {
|
function Panel() {
|
||||||
const [count, setCount] = useState(0)
|
const [items, setItems] = useState(null) // null=加载中
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [verbose, setVerbose] = useState(false)
|
||||||
|
const [tick, setTick] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
setItems(null)
|
||||||
|
setError(null)
|
||||||
|
fetchItems()
|
||||||
|
.then((data) => {
|
||||||
|
if (alive) setItems(data)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (alive) setError(err instanceof Error ? err.message : String(err))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
}
|
||||||
|
}, [tick])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-2 p-3">
|
<div className="flex h-full flex-col gap-2 overflow-y-auto p-3">
|
||||||
<div className="text-[12.5px] text-ink">我的插件面板</div>
|
<div className="text-[12.5px] text-ink">我的插件面板</div>
|
||||||
<div className="text-[12px] text-ink-3">计数:{count}</div>
|
|
||||||
<button
|
{error !== null ? (
|
||||||
className="self-start rounded-control border border-line-strong px-2.5 py-1 text-[11.5px] text-ink hover:bg-hover"
|
<ErrorState title="数据加载失败" description={error} onRetry={() => setTick((t) => t + 1)} />
|
||||||
onClick={() => setCount((c) => c + 1)}
|
) : items === null ? (
|
||||||
>
|
<LoadingState label="正在拉取数据…" />
|
||||||
+1
|
) : items.length === 0 ? (
|
||||||
</button>
|
<EmptyState title="暂无数据" description="检查数据源后点击重新加载" />
|
||||||
|
) : (
|
||||||
|
items.map((it) => <SettingRow key={it} label={it} description="示例条目" />)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SettingRow label="详细模式" description="开启后 main 侧输出更详细的日志">
|
||||||
|
<Switch checked={verbose} onCheckedChange={setVerbose} aria-label="详细模式" />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<Button variant="ghost" size="sm" className="self-start" onClick={() => setTick((t) => t + 1)}>
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user