feat: 插件检验器 plugin:check + L6/L7 教程 + 模板接入组件库——性能与样式标准成为机器关卡
This commit is contained in:
@@ -7,7 +7,8 @@
|
||||
```
|
||||
manifest.json 清单(id/name/version/minContract 必改)
|
||||
main.mjs 主进程侧:Agent 能力 + 设置声明 + 日志埋点(范例齐备)
|
||||
src/renderer.tsx 宿主内 React 组件源码(JSX)
|
||||
src/renderer.tsx 宿主内 React 组件源码(JSX;公共组件库 + 三态示范)
|
||||
renderer.mjs 预构建产物(开箱即用;改了源码需重新构建)
|
||||
shims/ 构建期把 react 别名到宿主单例(勿改)
|
||||
```
|
||||
|
||||
@@ -16,11 +17,14 @@ shims/ 构建期把 react 别名到宿主单例(勿改)
|
||||
```sh
|
||||
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
|
||||
--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。)
|
||||
不需要 UI 时:删除 `entry.renderer` 与 `src/`、`shims/`,只保留 main.mjs。
|
||||
(esbuild 可用 `pnpm dlx esbuild@<版本>` 或任意 ≥0.17 版本;`--alias` 需 ≥0.17。
|
||||
两条 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. 重启或开关重载 → 设置 → 插件 → 对应作用域出现插件
|
||||
2. 日志 `userData/logs/main-*.log` 有 `plugin activated pluginId=my-plugin`
|
||||
3. 对 Agent 说"ping 一下我的插件" → 触发 `my_plugin_ping`
|
||||
4. 面板/侧栏出现 React 组件,点击 +1 正常(hooks 生效)
|
||||
1. `pnpm plugin:check <本目录>` → 0 error(性能与样式机器关卡,见 L7)
|
||||
2. 重启或开关重载 → 设置 → 插件 → 对应作用域出现插件
|
||||
3. 日志 `userData/logs/main-*.log` 有 `plugin activated pluginId=my-plugin`
|
||||
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.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 \
|
||||
* --jsx=automatic --alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js
|
||||
* 产物 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) {
|
||||
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 (
|
||||
<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-[12px] text-ink-3">计数:{count}</div>
|
||||
<button
|
||||
className="self-start rounded-control border border-line-strong px-2.5 py-1 text-[11.5px] text-ink hover:bg-hover"
|
||||
onClick={() => setCount((c) => c + 1)}
|
||||
>
|
||||
+1
|
||||
</button>
|
||||
|
||||
{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} 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user