dsh-skill-tag 0.1.15 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -355,7 +355,7 @@ npm test # 194 项
355
355
  界面预览可以在没有浏览器扩展时生成静态 HTML:
356
356
 
357
357
  ```bash
358
- node docs/preview/render-preview.mjs # → docs/preview/标签组配置-preview.html
358
+ node dev/preview/render-preview.mjs # → dev/preview/标签组配置-preview.html
359
359
  ```
360
360
 
361
361
  (产物已在 `.gitignore` 里排除。)
@@ -472,17 +472,17 @@ ctx.on("domain/changed", (change) => {
472
472
 
473
473
  ## 7.5 界面预览
474
474
 
475
- `docs/preview/` 下的预览图由 `render-conv-preview.mjs` 用**真实组件**渲染生成
475
+ `dev/preview/` 下的预览图由 `render-conv-preview.mjs` 用**真实组件**渲染生成
476
476
  (迷你 React 运行时执行 `lib/client.js` 的 `ManageTagsDialog` / `TagSettingsDialog`,
477
477
  再序列化成 HTML,用 headless Edge 截图),与线上界面同源:
478
478
 
479
- - `docs/preview/对话管理标签-preview.html` — 完整预览页(3 个场景)
480
- - `docs/preview/conv-manage-shot.png` — 截图
479
+ - `dev/preview/对话管理标签-preview.html` — 完整预览页(3 个场景)
480
+ - `dev/preview/conv-manage-shot.png` — 截图
481
481
 
482
482
  重新生成:
483
483
 
484
484
  ```bash
485
- node docs/preview/render-conv-preview.mjs
485
+ node dev/preview/render-conv-preview.mjs
486
486
  ```
487
487
 
488
488
  场景:①已继承工作区 ②未继承(独立配置)③对话版「tag 设置」载入标签组后可继续编辑。
@@ -285,13 +285,13 @@ npm test
285
285
 
286
286
  ### 6.3 界面预览(无需浏览器扩展)
287
287
 
288
- `docs/preview/标签组配置-preview.html` 是用**真实组件代码**渲染出来的静态页面:
289
- `docs/preview/render-preview.mjs` 提供一个迷你 React 运行时(`useState`/`useEffect`/`useRef`/`useCallback`),
288
+ `dev/preview/标签组配置-preview.html` 是用**真实组件代码**渲染出来的静态页面:
289
+ `dev/preview/render-preview.mjs` 提供一个迷你 React 运行时(`useState`/`useEffect`/`useRef`/`useCallback`),
290
290
  真的执行 `lib/client.js` 里的 `Section` 与 `TemplateGroupPage`(含 250ms 防抖校验),再把渲染树序列化成 HTML。
291
291
 
292
292
  ```bash
293
293
  cd <插件目录>
294
- node docs/preview/render-preview.mjs # 重新生成 标签组配置-preview.html
294
+ node dev/preview/render-preview.mjs # 重新生成 标签组配置-preview.html
295
295
  ```
296
296
 
297
297
  覆盖场景:① 主页面入口 ② 未选中(引导文案、无输入框) ③ 回显 `A+B*C` + 校验通过 + 解析结果
@@ -255,17 +255,17 @@ NAME := 双引号字符串(名称含空格/运算符/括号时) | 裸标识
255
255
 
256
256
  ## 7.5 界面预览
257
257
 
258
- `docs/preview/` 下的预览图由 `render-manage-preview.mjs` 用**真实组件**渲染生成
258
+ `dev/preview/` 下的预览图由 `render-manage-preview.mjs` 用**真实组件**渲染生成
259
259
  (迷你 React 运行时执行 `lib/client.js` 里的 `ManageTagsDialog` / `TagSettingsDialog`,
260
260
  再序列化成 HTML,用 headless Edge 截图),因此与线上界面同源、不会图文不符:
261
261
 
262
- - `docs/preview/管理标签-preview.html` — 完整预览页
263
- - `docs/preview/manage-shot.png` — 截图
262
+ - `dev/preview/管理标签-preview.html` — 完整预览页
263
+ - `dev/preview/manage-shot.png` — 截图
264
264
 
265
265
  重新生成:
266
266
 
267
267
  ```bash
268
- node docs/preview/render-manage-preview.mjs
268
+ node dev/preview/render-manage-preview.mjs
269
269
  ```
270
270
 
271
271
  ---
package/lib/client.js CHANGED
@@ -1149,14 +1149,18 @@ window.__ModuleLoader__.load({
1149
1149
 
1150
1150
  /**
1151
1151
  * 判断一份菜单是不是宿主的工作区行菜单。
1152
- * 宿主源码里它就是 [{id:"rename"},{id:"delete",danger:true}] 两项;
1153
- * 会话行菜单项更多,且不含"删除工作区"。
1152
+ * 宿主源码里原本是 [{id:"rename"},{id:"delete",danger:true}] 两项,但项数会变:
1153
+ * 0.2.x 起中间插了「在文件夹显示」,于是 `labels.length !== 2` 把这份菜单整个漏掉,
1154
+ * 「管理标签」入口跟着消失(用户报告的 bug)。
1155
+ * 所以改成语义匹配,不绑项数:首项是「重命名」,且后面存在一项含「删除」。
1156
+ * 会话行菜单首项是「置顶」、且只有「归档」没有「删除」,仍会被排除。
1154
1157
  * @param {string[]} labels 各 menuitem 的可见文本
1155
1158
  */
1156
1159
  function isWorkspaceMenu(labels) {
1157
- if (!Array.isArray(labels) || labels.length !== 2) return false;
1158
- const [first, second] = labels.map((s) => String(s ?? "").trim());
1159
- return /^(rename|重命名)$/i.test(first) && /(delete|删除)/i.test(second);
1160
+ if (!Array.isArray(labels) || labels.length < 2) return false;
1161
+ const first = String(labels[0] ?? "").trim();
1162
+ if (!/^(rename|重命名)$/i.test(first)) return false;
1163
+ return labels.slice(1).some((s) => /(delete|删除)/i.test(String(s ?? "")));
1160
1164
  }
1161
1165
 
1162
1166
  /** 「管理标签」菜单项按钮(内联样式模拟宿主菜单项,不依赖 CSS Module 类名) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-tag",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "给 DSH 的技能(skill)打标签,用一条布尔表达式在「工作区 / 对话」两级选出要下发给模型的技能子集",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -29,11 +29,18 @@
29
29
  "lib",
30
30
  "src",
31
31
  "skills",
32
- "docs",
33
32
  "tools",
34
33
  "cordis.patch.yml",
35
34
  "README.md",
36
- "LICENSE"
35
+ "LICENSE",
36
+ "docs/给技能打标签.md",
37
+ "docs/标签组配置.md",
38
+ "docs/管理标签.md",
39
+ "docs/对话管理标签.md",
40
+ "docs/技能发现与宿主对齐.md",
41
+ "docs/deny-shadow-覆盖率.md",
42
+ "docs/修复指南.md",
43
+ "docs/skill联系tag与打开文件夹.md"
37
44
  ],
38
45
  "dsh": {
39
46
  "bundle": {
package/src/api.js CHANGED
@@ -574,16 +574,31 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
574
574
  if ((path === "/workspaces/push-preview" || path === "/workspaces/push") && (method === "GET" || method === "POST")) {
575
575
  const wid = q("workspaceId");
576
576
  if (!wid) return json(res, 400, { ok: false, error: "缺少 workspaceId" });
577
- // 该工作区下的对话:宿主 sessionIds 是权威来源。
578
- // resolveWorkspaceSessions 没注入时退回对话自己记录的 sourceWorkspaceId。
579
- let ids = [];
577
+ // 该工作区下的对话:**宿主 sessionIds 是唯一权威来源**。
578
+ //
579
+ // 这里曾经无条件并入「插件表里 own.sourceWorkspaceId === wid」的行,
580
+ // 本意是兜住宿主列表里没有的历史会话(CHANGELOG 0.1.13)。但那个字段是
581
+ // **继承时刻的快照**,会话被移出工作区后不会清空,于是每一条「曾经属于
582
+ // 本工作区、现在不属于」的会话都永久留在下发名单里:issue #5 里本工作区
583
+ // 多算 1 个、StudyMate 多算 72 个,弹窗总数因此虚高。
584
+ //
585
+ // 新口径(2026-11-11,用户确认):能拿到宿主列表就**只认宿主**;
586
+ // 拿不到(测试面 / 极简宿主 / 调用抛错)才退回插件自己的来源工作区记录。
587
+ // 注意「拿到了但是空数组」与「拿不到」必须分开:前者是「本工作区确实没有
588
+ // 对话」,此时退回快照会把孤儿又捞回来,等于没修。
589
+ let hostIds = null;
580
590
  if (typeof resolveWorkspaceSessions === "function") {
581
- try { ids = resolveWorkspaceSessions(wid) ?? []; } catch { ids = []; }
591
+ try {
592
+ const got = resolveWorkspaceSessions(wid);
593
+ if (Array.isArray(got)) hostIds = got;
594
+ } catch { hostIds = null; }
582
595
  }
583
- if (!Array.isArray(ids)) ids = [];
584
- // 并入已存在的 own.sourceWorkspaceId 指向本工作区的对话(宿主列表可能不含历史会话)
585
- for (const c of services._caches.conversations.values()) {
586
- if (c?.own?.sourceWorkspaceId === wid) ids.push(c.conversationId);
596
+ let ids = hostIds ?? [];
597
+ if (hostIds === null) {
598
+ // 兜底:宿主列表拿不到时,才用对话自己记录的来源工作区收拢。
599
+ for (const c of services._caches.conversations.values()) {
600
+ if (c?.own?.sourceWorkspaceId === wid) ids.push(c.conversationId);
601
+ }
587
602
  }
588
603
  ids = [...new Set(ids.filter(Boolean))];
589
604
 
@@ -1,327 +0,0 @@
1
- // 生成「对话 → 管理标签」界面的可视化预览(不依赖浏览器控制扩展)
2
- //
3
- // 做法同 render-manage-preview.mjs:用迷你 React 运行时真正执行 lib/client.js 里的
4
- // ManageTagsDialog(对话作用域)与 TagSettingsDialog,序列化成 HTML,再用 headless Edge 截图。
5
- //
6
- // 用法:node docs/preview/render-conv-preview.mjs
7
- // 产物:docs/preview/对话管理标签-preview.html
8
-
9
- import fs from "node:fs";
10
- import vm from "node:vm";
11
- import path from "node:path";
12
- import { fileURLToPath } from "node:url";
13
-
14
- const HERE = path.dirname(fileURLToPath(import.meta.url));
15
- const ROOT = path.resolve(HERE, "..", "..");
16
- const SOURCE = fs.readFileSync(path.join(ROOT, "lib", "client.js"), "utf8");
17
- const FRAGMENT = Symbol("Fragment");
18
-
19
- const TAGS = [
20
- { tagId: "t-a", name: "前端" },
21
- { tagId: "t-b", name: "测试" },
22
- { tagId: "t-c", name: "文档" },
23
- ];
24
- const TEMPLATES = [
25
- { templateId: "tpl-1", name: "前端组", expression: "前端+测试*文档" },
26
- { templateId: "tpl-2", name: "纯后端", expression: "(前端+测试)*文档" },
27
- ];
28
- const UNIVERSE = ["code-review", "tdd", "docx", "xlsx", "pdf", "webapp-testing"];
29
- const LINKS = [
30
- { skillId: "code-review", tagIds: ["t-a"] },
31
- { skillId: "tdd", tagIds: ["t-b"] },
32
- { skillId: "docx", tagIds: ["t-c"] },
33
- { skillId: "pdf", tagIds: ["t-c"] },
34
- ];
35
-
36
- function makeFetcher(state) {
37
- const hasFilter = () => state.expression.trim() !== "";
38
- const hit = (name) => {
39
- const ids = new Set((LINKS.find((l) => l.skillId === name)?.tagIds) ?? []);
40
- if (!hasFilter()) return true;
41
- return ids.has("t-a") || (ids.has("t-b") && ids.has("t-c"));
42
- };
43
- const view = () => {
44
- const f = hasFilter();
45
- const matched = f ? UNIVERSE.filter(hit) : UNIVERSE.slice();
46
- const visible = [];
47
- for (const n of matched) if (!state.disabledSkills.includes(n)) visible.push(n);
48
- for (const n of state.manualSkills) if (!state.disabledSkills.includes(n) && !visible.includes(n)) visible.push(n);
49
- return { hasFilter: f, matched, manual: state.manualSkills.slice(), disabled: state.disabledSkills.slice(), visible };
50
- };
51
- const payload = () => ({
52
- ok: true,
53
- conversationId: state.conversationId,
54
- workspaceId: state.sourceWorkspaceId,
55
- expression: state.expression,
56
- config: hasFilter() ? { orTags: ["t-a"], andGroups: [{ groupId: "g1", tagIds: ["t-b", "t-c"] }] } : { orTags: [], andGroups: [] },
57
- manualSkills: state.manualSkills.slice(),
58
- disabledSkills: state.disabledSkills.slice(),
59
- inheritedAt: state.inheritedAt,
60
- sourceWorkspaceId: state.sourceWorkspaceId,
61
- candidates: hasFilter() ? UNIVERSE.filter(hit) : [],
62
- universe: UNIVERSE,
63
- view: view(),
64
- });
65
- const respond = (p) => {
66
- if (p === "/state") return { ok: true, tags: TAGS, skillLinks: LINKS, templates: TEMPLATES, workspaces: [], conversations: [] };
67
- if (p === "/conversations/config") return payload();
68
- if (p === "/conversations/inherit") {
69
- // 假装工作区那份配置:前端+测试*文档 / 手动 xlsx / 禁用 pdf
70
- state.expression = "前端+测试*文档";
71
- state.manualSkills = ["xlsx"];
72
- state.disabledSkills = ["pdf"];
73
- state.sourceWorkspaceId = "ws-1";
74
- state.inheritedAt = new Date().toISOString();
75
- return payload();
76
- }
77
- if (p === "/conversations/expression") { state.expression = JSON.parse(state.lastBody ?? "{}").expression ?? ""; return { ok: true, conversation: {} }; }
78
- if (p === "/conversations/skills/disable") {
79
- const n = JSON.parse(state.lastBody ?? "{}").skillName;
80
- state.disabledSkills.push(n);
81
- state.manualSkills = state.manualSkills.filter((s) => s !== n);
82
- return { ok: true, conversation: {} };
83
- }
84
- if (p === "/conversations/skills/enable") {
85
- const n = JSON.parse(state.lastBody ?? "{}").skillName;
86
- state.disabledSkills = state.disabledSkills.filter((s) => s !== n);
87
- return { ok: true, conversation: {} };
88
- }
89
- if (p === "/conversations/skills/add") { state.manualSkills.push(JSON.parse(state.lastBody ?? "{}").skillName); return { ok: true, conversation: {} }; }
90
- if (p === "/templates/validate") {
91
- if (state.expression.trim() === "") {
92
- return { ok: true, validation: { ok: true, errors: [], warnings: [], normalized: "", config: { orTags: [], andGroups: [] } } };
93
- }
94
- return {
95
- ok: true,
96
- validation: {
97
- ok: true, errors: [], warnings: [], normalized: state.expression,
98
- config: { orTags: ["t-a"], andGroups: [{ groupId: "g1", tagIds: ["t-b", "t-c"] }] },
99
- },
100
- };
101
- }
102
- return { ok: true };
103
- };
104
- return async (url, init) => {
105
- const p = String(url).replace("/skill-tag/api", "");
106
- if (init?.body) state.lastBody = init.body;
107
- const out = respond(p);
108
- return { json: async () => out };
109
- };
110
- }
111
-
112
- function makeRuntime() {
113
- const stores = new Map();
114
- const pendingEffects = [];
115
- function storeOf(Comp) {
116
- let s = stores.get(Comp);
117
- if (!s) { s = { hooks: [], cursor: 0 }; stores.set(Comp, s); }
118
- return s;
119
- }
120
- const react = {
121
- createElement: (type, props, ...children) => ({
122
- type, props: props ?? {},
123
- children: children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false),
124
- }),
125
- Fragment: FRAGMENT,
126
- useState(init) {
127
- const s = storeOf(react.__cur);
128
- const i = s.cursor++;
129
- if (!(i in s.hooks)) s.hooks[i] = typeof init === "function" ? init() : init;
130
- return [s.hooks[i], (n) => { s.hooks[i] = typeof n === "function" ? n(s.hooks[i]) : n; }];
131
- },
132
- useRef(init) {
133
- const s = storeOf(react.__cur);
134
- const i = s.cursor++;
135
- if (!(i in s.hooks)) s.hooks[i] = { current: init ?? null };
136
- return s.hooks[i];
137
- },
138
- useEffect(fn) { storeOf(react.__cur).cursor++; pendingEffects.push(fn); },
139
- useMemo(fn) { storeOf(react.__cur).cursor++; return fn(); },
140
- useCallback(fn) { storeOf(react.__cur).cursor++; return fn; },
141
- };
142
- function renderComponent(Comp, props) {
143
- const s = storeOf(Comp);
144
- s.cursor = 0;
145
- const prev = react.__cur;
146
- react.__cur = Comp;
147
- try { return Comp(props); } finally { react.__cur = prev; }
148
- }
149
- function drain() {
150
- for (const fn of pendingEffects.splice(0)) { try { fn(); } catch { /* 忽略 */ } }
151
- }
152
- function expand(node, depth = 0) {
153
- if (node === null || node === undefined || node === false) return node;
154
- if (typeof node !== "object") return node;
155
- if (depth > 40) return node;
156
- if (typeof node.type === "function") return expand(renderComponent(node.type, node.props), depth + 1);
157
- return { ...node, children: (node.children ?? []).map((c) => expand(c, depth + 1)) };
158
- }
159
- /** 注意:必须 expand 之后才 drain,否则子组件(TagSettingsDialog)的 effect 不会入队 */
160
- async function settle(Comp, props, rounds = 10) {
161
- let tree = null;
162
- for (let r = 0; r < rounds; r += 1) {
163
- pendingEffects.length = 0;
164
- tree = expand(renderComponent(Comp, props));
165
- drain();
166
- await new Promise((res) => setTimeout(res, 0));
167
- await new Promise((res) => setTimeout(res, 0));
168
- }
169
- pendingEffects.length = 0;
170
- tree = expand(renderComponent(Comp, props));
171
- drain();
172
- return tree;
173
- }
174
- return { react, settle };
175
- }
176
-
177
- const ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
178
- const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ESC[c]);
179
-
180
- function toHtml(node) {
181
- if (node === null || node === undefined || node === false) return "";
182
- if (typeof node !== "object") return esc(node);
183
- const { type, props = {}, children = [] } = node;
184
- if (typeof type === "function") return "";
185
- const inner = children.map(toHtml).join("");
186
- const tag = typeof type === "string" ? type : "div";
187
- const attrs = [];
188
- const p = props ?? {};
189
- if (p.style && typeof p.style === "object") {
190
- attrs.push(`style="${esc(Object.entries(p.style).map(([k, v]) => `${k.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase())}:${v}`).join(";"))}"`);
191
- }
192
- if (p.className) attrs.push(`class="${esc(p.className)}"`);
193
- if (tag === "input") {
194
- if (p.value !== undefined) attrs.push(`value="${esc(p.value)}"`);
195
- if (p.placeholder) attrs.push(`placeholder="${esc(p.placeholder)}"`);
196
- if (p.type) attrs.push(`type="${esc(p.type)}"`);
197
- if (p.checked) attrs.push("checked");
198
- return `<input ${attrs.join(" ")}>`;
199
- }
200
- if (tag === "textarea") {
201
- if (p.value !== undefined) attrs.push(`value="${esc(p.value)}"`);
202
- return `<textarea ${attrs.join(" ")}>${esc(p.value ?? "")}</textarea>`;
203
- }
204
- if (tag === "select") {
205
- const opts = children.map((c) => {
206
- if (!c || typeof c !== "object" || c.type !== "option") return toHtml(c);
207
- const sel = c.props?.value === p.value ? " selected" : "";
208
- return `<option value="${esc(c.props?.value ?? "")}"${sel}>${esc((c.children ?? []).map(toHtml).join(""))}</option>`;
209
- }).join("");
210
- return `<select ${attrs.join(" ")}>${opts}</select>`;
211
- }
212
- if (tag === "option") return `<option>${inner}</option>`;
213
- if (tag === "button") {
214
- if (p.disabled) attrs.push("disabled");
215
- return `<button ${attrs.join(" ")}>${inner}</button>`;
216
- }
217
- return `<${tag} ${attrs.join(" ")}>${inner}</${tag}>`;
218
- }
219
-
220
- const walk = (n, f) => { if (!n || typeof n !== "object") return; f(n); for (const c of n.children ?? []) walk(c, f); };
221
- const findBtn = (tree, label) => {
222
- const out = [];
223
- walk(tree, (n) => {
224
- if (n.type === "button" && (n.children ?? []).map(String).join("").trim() === label) out.push(n);
225
- });
226
- return out;
227
- };
228
-
229
- /** 载入 client.js,取回 shell.overlay 组件与 __test 助手 */
230
- function boot(rt, state) {
231
- const sandbox = { console, setTimeout, clearTimeout, fetch: makeFetcher(state), window: { confirm: () => true, __ModuleLoader__: { load: () => {} } } };
232
- sandbox.globalThis = sandbox;
233
- vm.runInNewContext(SOURCE, sandbox, { filename: "lib/client.js" });
234
- const out = { Dialog: null, helpers: null };
235
- sandbox.window.__ModuleLoader__.load = ({ factory }) => {
236
- const exp = factory((n) => (n === "react" ? rt.react : {}));
237
- out.helpers = exp.__test;
238
- exp.apply({
239
- effect: (fn) => fn(),
240
- slots: { inject: (_n, fn) => fn(), register: (o, C) => { if (o.name === "shell.overlay") out.Dialog = C; } },
241
- });
242
- };
243
- // 再跑一次源码,这次 load 已就位,模块才会真正执行并注册槽位
244
- vm.runInNewContext(SOURCE, sandbox, { filename: "lib/client.js" });
245
- return out;
246
- }
247
-
248
- function scene(title, note, bodyHtml) {
249
- return `<div class="cap"><b>${esc(title)}</b> ${note}</div><div class="shot">${bodyHtml}</div>`;
250
- }
251
-
252
- const htmls = [];
253
-
254
- // 场景①:对话已继承工作区配置(条件 + 手动添加 + 禁用一起搬过来),可在其上继续改
255
- {
256
- const rt = makeRuntime();
257
- const state = {
258
- conversationId: "sess-7f3a", expression: "前端+测试*文档",
259
- manualSkills: ["xlsx"], disabledSkills: ["pdf"],
260
- sourceWorkspaceId: "ws-1", inheritedAt: new Date().toISOString(), lastBody: null,
261
- };
262
- const { Dialog, helpers } = boot(rt, state);
263
- helpers.manageSet({ open: true, workspaceId: "", conversationId: "sess-7f3a" });
264
- const tree = await rt.settle(Dialog, {});
265
- htmls.push(scene(
266
- "场景① 对话双列(已继承工作区)",
267
- "左列多出「继承工作区配置」按钮,并标注继承来源与时间;右列技能同样可查看 tag / 禁用 / 添加",
268
- `<div class="shot">${toHtml(tree)}</div>`,
269
- ));
270
- }
271
-
272
- // 场景②:对话尚未继承任何工作区(独立配置)
273
- {
274
- const rt = makeRuntime();
275
- const state = {
276
- conversationId: "sess-9c11", expression: "",
277
- manualSkills: [], disabledSkills: [],
278
- sourceWorkspaceId: null, inheritedAt: null, lastBody: null,
279
- };
280
- const { Dialog, helpers } = boot(rt, state);
281
- helpers.manageSet({ open: true, workspaceId: "", conversationId: "sess-9c11" });
282
- const tree = await rt.settle(Dialog, {});
283
- htmls.push(scene(
284
- "场景② 对话双列(未继承,独立配置)",
285
- "空条件 = 该对话掌握全部技能;点击「继承工作区配置」会把工作区那份整体搬过来",
286
- `<div class="shot">${toHtml(tree)}</div>`,
287
- ));
288
- }
289
-
290
- // 场景③:对话版「tag 设置」—— 载入标签组后可继续编辑,保存目标是「对话」而不是工作区
291
- {
292
- const rt = makeRuntime();
293
- const state = {
294
- conversationId: "sess-9c11", expression: "前端+测试",
295
- manualSkills: [], disabledSkills: [],
296
- sourceWorkspaceId: "ws-1", inheritedAt: new Date().toISOString(), lastBody: null,
297
- };
298
- const { Dialog, helpers } = boot(rt, state);
299
- helpers.manageSet({ open: true, workspaceId: "", conversationId: "sess-9c11" });
300
- let tree = await rt.settle(Dialog, {});
301
- findBtn(tree, "tag 设置")[0]?.props?.onClick?.();
302
- tree = await rt.settle(Dialog, {});
303
- // 载入标签组「前端组」的运算
304
- let sel = null;
305
- walk(tree, (n) => { if (n.type === "select" && (n.children ?? []).some((c) => (c.children ?? []).map(String).join("").includes("前端组"))) sel = n; });
306
- sel?.props?.onChange?.({ target: { value: "tpl-1" } });
307
- tree = await rt.settle(Dialog, {});
308
- htmls.push(scene(
309
- "场景③ 对话版「tag 设置」",
310
- "载入标签组「前端组」的运算到运算区后可继续编辑;底部按钮是 <b>保存到对话</b>,不会改工作区",
311
- `<div class="shot">${toHtml(tree)}</div>`,
312
- ));
313
- }
314
-
315
- const head = `<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>对话管理标签 · 界面预览</title>
316
- <style>
317
- body{margin:0;background:#0e1117;color:#d7dce5;font-family:"Microsoft YaHei",ui-sans-serif,system-ui;padding:14px 0 30px}
318
- .cap{padding:12px 24px 4px;font-size:13px;color:#8b93a7} .cap b{color:#e6ebf5}
319
- /* 静态预览:把浮层的 fixed/absolute 全部拍平,否则嵌套的「tag 设置」会脱离文档流留下一大段空白 */
320
- .shot *{position:static!important}
321
- .shot>div{display:block!important;background:transparent!important}
322
- .shot>div>div{margin:0 auto}
323
- h1{font-size:16px;padding:0 24px;margin:8px 0 4px}
324
- code{background:#1b2130;padding:1px 5px;border-radius:4px}
325
- </style></head><body><h1>对话 → 管理标签 · 界面预览</h1>`;
326
- fs.writeFileSync(path.join(HERE, "对话管理标签-preview.html"), head + htmls.join("") + "</body></html>");
327
- console.log("wrote docs/preview/对话管理标签-preview.html");
@@ -1,278 +0,0 @@
1
- // 生成「管理标签」界面的可视化预览(不依赖浏览器控制扩展)
2
- //
3
- // 做法:复用 tests/__manage-render.mjs 里的迷你 React 运行时,真正执行 lib/client.js
4
- // 中的 ManageTagsDialog / TagSettingsDialog,把渲染结果序列化成 HTML,再用 headless
5
- // Edge 截图。这样预览图和真实组件同源,不会"图文不符"。
6
- //
7
- // 用法:node docs/preview/render-manage-preview.mjs
8
- // 产物:docs/preview/管理标签-preview.html
9
-
10
- import fs from "node:fs";
11
- import vm from "node:vm";
12
- import path from "node:path";
13
- import { fileURLToPath } from "node:url";
14
-
15
- const HERE = path.dirname(fileURLToPath(import.meta.url));
16
- const ROOT = path.resolve(HERE, "..", "..");
17
- const SOURCE = fs.readFileSync(path.join(ROOT, "lib", "client.js"), "utf8");
18
- const FRAGMENT = Symbol("Fragment");
19
-
20
- const TAGS = [
21
- { tagId: "t-a", name: "前端" },
22
- { tagId: "t-b", name: "测试" },
23
- { tagId: "t-c", name: "文档" },
24
- ];
25
- const TEMPLATES = [
26
- { templateId: "tpl-1", name: "前端组", expression: "前端+测试*文档" },
27
- { templateId: "tpl-2", name: "纯后端", expression: "(前端+测试)*文档" },
28
- ];
29
- const UNIVERSE = ["code-review", "tdd", "docx", "xlsx", "pdf", "webapp-testing"];
30
- const LINKS = [
31
- { skillId: "code-review", tagIds: ["t-a"] },
32
- { skillId: "tdd", tagIds: ["t-b"] },
33
- { skillId: "docx", tagIds: ["t-c"] },
34
- { skillId: "pdf", tagIds: ["t-c"] },
35
- ];
36
-
37
- function makeFetcher(state) {
38
- const view = () => {
39
- const hasFilter = state.expression.trim() !== "";
40
- const hit = (name) => {
41
- const ids = new Set((LINKS.find((l) => l.skillId === name)?.tagIds) ?? []);
42
- if (!hasFilter) return true;
43
- // 简化:命中 = 含「前端」或(含「测试」且含「文档」)
44
- return ids.has("t-a") || (ids.has("t-b") && ids.has("t-c"));
45
- };
46
- const matched = hasFilter ? UNIVERSE.filter(hit) : UNIVERSE.slice();
47
- const visible = [];
48
- for (const n of matched) if (!state.disabledSkills.includes(n)) visible.push(n);
49
- for (const n of state.manualSkills) if (!state.disabledSkills.includes(n) && !visible.includes(n)) visible.push(n);
50
- return { hasFilter, matched, manual: state.manualSkills.slice(), disabled: state.disabledSkills.slice(), visible };
51
- };
52
- const respond = (p) => {
53
- if (p === "/state") return { ok: true, tags: TAGS, skillLinks: LINKS, templates: TEMPLATES, workspaces: [], conversations: [] };
54
- if (p === "/workspaces/skills") {
55
- // config 跟随 expression,让左列「解析结果」能真实显示
56
- const expr = state.expression.trim();
57
- const config = expr === ""
58
- ? { orTags: [], andGroups: [] }
59
- : { orTags: ["t-a"], andGroups: [{ groupId: "g1", tagIds: ["t-b", "t-c"] }] };
60
- return {
61
- ok: true, workspaceId: "ws-1", expression: state.expression,
62
- config,
63
- manualSkills: state.manualSkills, disabledSkills: state.disabledSkills,
64
- candidates: [], universe: UNIVERSE, view: view(),
65
- };
66
- }
67
- if (p === "/templates/validate") {
68
- // 与真实服务端同语义:allowEmpty=true 时空表达式合法(无条件)
69
- const empty = String(state.expression).trim() === "";
70
- if (empty) {
71
- return { ok: true, validation: { ok: true, errors: [], warnings: [], normalized: "", config: { orTags: [], andGroups: [] } } };
72
- }
73
- return {
74
- ok: true,
75
- validation: {
76
- ok: true, errors: [], warnings: [], normalized: state.expression,
77
- config: { orTags: ["t-a"], andGroups: [{ groupId: "g1", tagIds: ["t-b", "t-c"] }] },
78
- },
79
- };
80
- }
81
- if (p === "/workspaces/expression") { state.expression = JSON.parse(state.lastBody ?? "{}").expression ?? ""; return { ok: true, workspace: {} }; }
82
- return { ok: true };
83
- };
84
- return async (url, init) => {
85
- const p = String(url).replace("/skill-tag/api", "");
86
- if (init?.body) state.lastBody = init.body;
87
- const payload = respond(p);
88
- return { json: async () => payload };
89
- };
90
- }
91
-
92
- function makeRuntime() {
93
- const stores = new Map();
94
- const pendingEffects = [];
95
- function storeOf(Comp) {
96
- let s = stores.get(Comp);
97
- if (!s) { s = { hooks: [], cursor: 0 }; stores.set(Comp, s); }
98
- return s;
99
- }
100
- const react = {
101
- createElement: (type, props, ...children) => ({
102
- type, props: props ?? {},
103
- children: children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false),
104
- }),
105
- Fragment: FRAGMENT,
106
- useState(init) {
107
- const s = storeOf(react.__cur);
108
- const i = s.cursor++;
109
- if (!(i in s.hooks)) s.hooks[i] = typeof init === "function" ? init() : init;
110
- return [s.hooks[i], (n) => { s.hooks[i] = typeof n === "function" ? n(s.hooks[i]) : n; }];
111
- },
112
- useRef(init) {
113
- const s = storeOf(react.__cur);
114
- const i = s.cursor++;
115
- if (!(i in s.hooks)) s.hooks[i] = { current: init ?? null };
116
- return s.hooks[i];
117
- },
118
- useEffect(fn) {
119
- const s = storeOf(react.__cur);
120
- s.cursor++;
121
- pendingEffects.push(fn);
122
- },
123
- useMemo(fn) { storeOf(react.__cur).cursor++; return fn(); },
124
- useCallback(fn) { storeOf(react.__cur).cursor++; return fn; },
125
- };
126
- function renderComponent(Comp, props) {
127
- const s = storeOf(Comp);
128
- s.cursor = 0;
129
- const prev = react.__cur;
130
- react.__cur = Comp;
131
- try { return Comp(props); } finally { react.__cur = prev; }
132
- }
133
- function drain() {
134
- for (const fn of pendingEffects.splice(0)) { try { fn(); } catch { /* 忽略 */ } }
135
- }
136
- async function settle(Comp, props, rounds = 8) {
137
- let tree = null;
138
- for (let r = 0; r < rounds; r += 1) {
139
- pendingEffects.length = 0;
140
- tree = renderComponent(Comp, props);
141
- tree = expand(tree);
142
- drain();
143
- await new Promise((res) => setTimeout(res, 0));
144
- await new Promise((res) => setTimeout(res, 0));
145
- }
146
- return tree;
147
- }
148
- function expand(node, depth = 0) {
149
- if (node === null || node === undefined || node === false) return node;
150
- if (typeof node !== "object") return node;
151
- if (depth > 40) return node;
152
- if (typeof node.type === "function") return expand(renderComponent(node.type, node.props), depth + 1);
153
- return { ...node, children: (node.children ?? []).map((c) => expand(c, depth + 1)) };
154
- }
155
- return { react, settle };
156
- }
157
-
158
- const ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
159
- const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ESC[c]);
160
-
161
- function toHtml(node) {
162
- if (node === null || node === undefined || node === false) return "";
163
- if (typeof node !== "object") return esc(node);
164
- const { type, props = {}, children = [] } = node;
165
- if (typeof type === "function") return "";
166
- const inner = children.map(toHtml).join("");
167
- const tag = typeof type === "string" ? type : "div";
168
- const attrs = [];
169
- const p = props ?? {};
170
- if (p.style && typeof p.style === "object") {
171
- attrs.push(`style="${esc(Object.entries(p.style).map(([k, v]) => `${k.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase())}:${v}`).join(";"))}"`);
172
- }
173
- if (p.className) attrs.push(`class="${esc(p.className)}"`);
174
- if (tag === "input") {
175
- if (p.value !== undefined) attrs.push(`value="${esc(p.value)}"`);
176
- if (p.placeholder) attrs.push(`placeholder="${esc(p.placeholder)}"`);
177
- if (p.type) attrs.push(`type="${esc(p.type)}"`);
178
- if (p.checked) attrs.push("checked");
179
- return `<input ${attrs.join(" ")}>`;
180
- }
181
- if (tag === "textarea") {
182
- if (p.value !== undefined) attrs.push(`value="${esc(p.value)}"`);
183
- return `<textarea ${attrs.join(" ")}>${esc(p.value ?? "")}</textarea>`;
184
- }
185
- if (tag === "select") {
186
- const opts = children.map((c) => {
187
- if (!c || typeof c !== "object" || c.type !== "option") return toHtml(c);
188
- const sel = c.props?.value === p.value ? " selected" : "";
189
- return `<option value="${esc(c.props?.value ?? "")}"${sel}>${esc((c.children ?? []).map(toHtml).join(""))}</option>`;
190
- }).join("");
191
- return `<select ${attrs.join(" ")}>${opts}</select>`;
192
- }
193
- if (tag === "option") return `<option>${inner}</option>`;
194
- if (tag === "button") {
195
- if (p.disabled) attrs.push("disabled");
196
- return `<button ${attrs.join(" ")}>${inner}</button>`;
197
- }
198
- return `<${tag} ${attrs.join(" ")}>${inner}</${tag}>`;
199
- }
200
-
201
- function frame(title, bodyHtml) {
202
- return `<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>${esc(title)}</title>
203
- <style>
204
- body { margin:0; background:#0e1117; color:#d7dce5; font-family:"Microsoft YaHei",ui-sans-serif,system-ui; }
205
- .cap { padding:12px 20px 4px; font-size:13px; color:#8b93a7; }
206
- .cap b { color:#e6ebf5; font-weight:600; }
207
- .shot { padding:10px 20px 22px; }
208
- /* 让浮层在静态预览里居中显示,而不是固定定位溢出 */
209
- .shot > div { position:static !important; background:transparent !important; }
210
- button, select, input, textarea { font-family:inherit; }
211
- </style></head><body>${bodyHtml}</body></html>`;
212
- }
213
-
214
- async function renderScene({ runtime, Dialog, helpers, state, steps, title, file }) {
215
- helpers.manageSet({ open: true, workspaceId: "ws-1" });
216
- let tree = await runtime.settle(Dialog, {});
217
- await steps(tree, () => runtime.settle(Dialog, {}));
218
- const html = frame(title, `<div class="cap"><b>${esc(title)}</b></div><div class="shot">${toHtml(tree)}</div>`);
219
- fs.writeFileSync(path.join(HERE, file), html);
220
- return html;
221
- }
222
-
223
- const htmls = [];
224
-
225
- // 场景①:双列主界面(工作区已有条件 前端+测试*文档)
226
- {
227
- const rt = makeRuntime();
228
- const state = { expression: "前端+测试*文档", manualSkills: ["xlsx"], disabledSkills: ["pdf"], lastBody: null };
229
- const sandbox = { console, setTimeout, clearTimeout, fetch: makeFetcher(state), window: { confirm: () => true, __ModuleLoader__: { load: () => {} } } };
230
- sandbox.globalThis = sandbox;
231
- vm.runInNewContext(SOURCE, sandbox, { filename: "lib/client.js" });
232
- let Dialog = null, helpers = null;
233
- sandbox.window.__ModuleLoader__.load = ({ factory }) => {
234
- const exp = factory((n) => (n === "react" ? rt.react : {}));
235
- helpers = exp.__test;
236
- exp.apply({ effect: (fn) => fn(), slots: { inject: (_n, fn) => fn(), register: (o, C) => { if (o.name === "shell.overlay") Dialog = C; } } });
237
- };
238
- vm.runInNewContext(SOURCE, sandbox, { filename: "lib/client.js" });
239
- helpers.manageSet({ open: true, workspaceId: "ws-1" });
240
- const tree = await rt.settle(Dialog, {});
241
- htmls.push(frame("场景① 双列主界面", `<div class="cap"><b>场景①</b> 工作区已有条件 <code>前端+测试*文档</code>,pdf 被禁用、xlsx 为手动添加</div><div class="shot">${toHtml(tree)}</div>`));
242
- }
243
-
244
- // 场景②:tag 设置弹窗(载入标签组后)
245
- {
246
- const rt = makeRuntime();
247
- const state = { expression: "", manualSkills: [], disabledSkills: [], lastBody: null };
248
- const sandbox = { console, setTimeout, clearTimeout, fetch: makeFetcher(state), window: { confirm: () => true, __ModuleLoader__: { load: () => {} } } };
249
- sandbox.globalThis = sandbox;
250
- let Dialog = null, helpers = null;
251
- sandbox.window.__ModuleLoader__.load = ({ factory }) => {
252
- const exp = factory((n) => (n === "react" ? rt.react : {}));
253
- helpers = exp.__test;
254
- exp.apply({ effect: (fn) => fn(), slots: { inject: (_n, fn) => fn(), register: (o, C) => { if (o.name === "shell.overlay") Dialog = C; } } });
255
- };
256
- vm.runInNewContext(SOURCE, sandbox, { filename: "lib/client.js" });
257
- helpers.manageSet({ open: true, workspaceId: "ws-1" });
258
- let tree = await rt.settle(Dialog, {});
259
- // 点「tag 设置」
260
- const walk = (n, f) => { if (!n || typeof n !== "object") return; f(n); for (const c of n.children ?? []) walk(c, f); };
261
- const btn = [];
262
- walk(tree, (n) => { if (n.type === "button" && (n.children ?? []).join("").includes("tag 设置")) btn.push(n); });
263
- if (btn[0]) btn[0].props.onClick();
264
- tree = await rt.settle(Dialog, {});
265
- htmls.push(frame("场景② tag 设置弹窗", `<div class="cap"><b>场景②</b> 「tag 设置」:可选择标签组载入运算、点击已有标签插入、实时校验</div><div class="shot">${toHtml(tree)}</div>`));
266
- }
267
-
268
- const out = htmls.join("");
269
- fs.writeFileSync(path.join(HERE, "管理标签-preview.html"), `<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>管理标签 · 界面预览</title>
270
- <style>
271
- body{margin:0;background:#0e1117;color:#d7dce5;font-family:"Microsoft YaHei",ui-sans-serif,system-ui;padding:18px 0 30px}
272
- .cap{padding:10px 24px 4px;font-size:13px;color:#8b93a7} .cap b{color:#e6ebf5}
273
- .shot{padding:8px 24px 26px}
274
- .shot>div{position:static!important;display:block!important;background:transparent!important;inset:auto!important}
275
- .shot>div>div{margin:0 auto}
276
- h1{font-size:16px;padding:0 24px;margin:8px 0 4px}
277
- </style></head><body><h1>管理标签 · 界面预览</h1>${htmls.map((h) => h.replace(/^[\s\S]*<body>/, "").replace(/<\/body>[\s\S]*$/, "")).join("")}</body></html>`);
278
- console.log("wrote docs/preview/管理标签-preview.html");
@@ -1,274 +0,0 @@
1
- // 生成静态 HTML 预览:不是手写的示意图,而是用「最小 React 运行时」真的执行
2
- // lib/client.js 里的 Section / TemplateGroupPage,把渲染树序列化成 HTML。
3
- // 迷你运行时提供 useState/useEffect/useRef/useCallback,因此实时校验(250ms 防抖)
4
- // 也会真的跑一遍(fetch 被桩成返回真实格式的校验结果),能渲染出校验/解析结果区域。
5
- import fs from "node:fs";
6
- import vm from "node:vm";
7
- import path from "node:path";
8
- import { fileURLToPath } from "node:url";
9
-
10
- const HERE = path.dirname(fileURLToPath(import.meta.url));
11
- // 本脚本可在插件内(docs/preview/)或临时工作区运行,两种位置都自动定位插件源码
12
- const CANDIDATES = [
13
- path.resolve(HERE, "..", "..", "lib", "client.js"),
14
- path.resolve(HERE, "..", "..", "..", ".dsh", "profiles", "web", "my-plugins", "dsh-skill-tag", "lib", "client.js"),
15
- path.resolve(process.cwd(), "lib", "client.js"),
16
- ];
17
- const CLIENT = CANDIDATES.find((p) => fs.existsSync(p));
18
- if (!CLIENT) {
19
- console.error("找不到 lib/client.js,尝试过:\n" + CANDIDATES.join("\n"));
20
- process.exit(1);
21
- }
22
- const SOURCE = fs.readFileSync(CLIENT, "utf8");
23
- const FRAGMENT = Symbol("Fragment");
24
-
25
- const TAGS = [
26
- { tagId: "t-a", name: "A" },
27
- { tagId: "t-b", name: "B" },
28
- { tagId: "t-c", name: "C" },
29
- { tagId: "t-mt", name: "my tag" },
30
- { tagId: "t-plus", name: "a+b" },
31
- ];
32
- const TEMPLATES = [
33
- { templateId: "tpl-1", name: "前端组", expression: "A+B*C", config: { orTags: ["t-a"], andGroups: [{ groupId: "t-b|t-c", tagIds: ["t-b", "t-c"] }] } },
34
- { templateId: "tpl-2", name: "另一组", expression: "(A+B)*C", config: { orTags: [], andGroups: [] } },
35
- ];
36
-
37
- // ── 迷你 React 运行时 ──────────────────────────────────────────────────────
38
- function makeRuntime(initialState, fetchImpl) {
39
- let hooks = [];
40
- let hookIndex = 0;
41
- const effects = [];
42
- const store = { tree: null };
43
-
44
- const react = {
45
- createElement: (type, props, ...children) => ({
46
- type,
47
- props: props ?? {},
48
- children: children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false),
49
- }),
50
- Fragment: FRAGMENT,
51
- useRef: (init) => {
52
- const i = hookIndex++;
53
- if (!(i in hooks)) hooks[i] = { current: init ?? null };
54
- return hooks[i];
55
- },
56
- useState: (init) => {
57
- const i = hookIndex++;
58
- if (!(i in hooks)) {
59
- // Section 的初始 state 形如 {loading:true, ...} —— 用场景注入的状态替换它
60
- if (init && typeof init === "object" && "loading" in init) hooks[i] = initialState;
61
- else hooks[i] = typeof init === "function" ? init() : init;
62
- }
63
- const setter = (next) => {
64
- const value = typeof next === "function" ? next(hooks[i]) : next;
65
- if (value !== hooks[i]) { hooks[i] = value; store.dirty = true; }
66
- };
67
- return [hooks[i], setter];
68
- },
69
- useMemo: (fn) => { const i = hookIndex++; if (!(i in hooks)) hooks[i] = fn(); return hooks[i]; },
70
- useCallback: (fn) => { const i = hookIndex++; if (!(i in hooks)) hooks[i] = fn; return hooks[i]; },
71
- useEffect: (fn) => { effects.push(fn); },
72
- };
73
-
74
- let Section = null;
75
- const sandbox = {
76
- console,
77
- setTimeout: (fn) => { pending.push(fn); return pending.length; },
78
- clearTimeout: () => {},
79
- setInterval: () => 0,
80
- clearInterval: () => {},
81
- fetch: fetchImpl,
82
- window: { __ModuleLoader__: { load: () => {} }, confirm: () => true },
83
- Promise,
84
- JSON,
85
- };
86
- const pending = [];
87
- sandbox.globalThis = sandbox;
88
- vm.runInNewContext(SOURCE, sandbox, { filename: "client.js" });
89
- sandbox.window.__ModuleLoader__.load = ({ factory }) => {
90
- const exp = factory((n) => (n === "react" ? react : {}));
91
- exp.apply({ effect: (fn2) => fn2(), slots: { inject: (_n, fn2) => fn2(), register: (_o, C) => { Section = C; } } });
92
- };
93
- vm.runInNewContext(SOURCE, sandbox, { filename: "client.js" });
94
-
95
- return {
96
- /**
97
- * 跑一轮"渲染 + 序列化"。
98
- * 关键点:Section 返回的树里 TemplateGroupPage 只是个元素节点,只有在序列化
99
- * (即真正执行函数组件)时才会跑它的 hooks —— 所以 effect 与 250ms 防抖必须
100
- * 在序列化过程中收集,不能只在 Section 这一层收集。
101
- */
102
- async run(serializeFn) {
103
- let html = "";
104
- for (let round = 0; round < 10; round += 1) {
105
- hookIndex = 0;
106
- effects.length = 0;
107
- pending.length = 0;
108
- store.dirty = false;
109
- const tree = Section(initialState);
110
- html = serializeFn(tree);
111
- for (const effect of effects) effect();
112
- let ran = false;
113
- while (pending.length) { const fn = pending.shift(); fn(); ran = true; }
114
- // 等待 fetch 的 Promise 链落定(setValidation 在 await 之后调用)
115
- for (let k = 0; k < 5; k += 1) await new Promise((r) => setImmediate(r));
116
- if (!ran && !store.dirty) break;
117
- }
118
- return html;
119
- },
120
- };
121
- }
122
-
123
- const TAGS_BY_NAME = new Map(TAGS.map((t) => [t.name, t]));
124
-
125
- /** 与 src/domain/expr.js 行为一致的校验桩(只覆盖本预览用到的合法表达式) */
126
- function fakeValidate(expression) {
127
- const src = String(expression ?? "");
128
- if (src.trim() === "") {
129
- return { ok: false, errors: [{ code: "EMPTY", message: "表达式不能为空:请点击上方标签写入,或用 A+B*C 的形式输入", position: 0, length: 0 }], warnings: [] };
130
- }
131
- // 用真引擎校验(与服务端同一份实现,直接 import 更可靠,但这里在 vm 外简单判定)
132
- const names = [...src.matchAll(/[A-Za-z\u4e00-\u9fa5][\w\u4e00-\u9fa5]*/g)].map((m) => m[0]);
133
- const unknown = names.filter((n) => !TAGS_BY_NAME.has(n));
134
- if (unknown.length) {
135
- return { ok: false, errors: [{ code: "UNKNOWN_TAG", message: `标签不存在: '${unknown[0]}'`, position: src.indexOf(unknown[0]), length: unknown[0].length }], warnings: [] };
136
- }
137
- // 预览场景里用到的两条合法表达式,config 直接照抄真实引擎(toDnf → dnfToConfig)的结果
138
- const KNOWN = {
139
- "A+B*C": { orTags: ["t-a"], andGroups: [{ groupId: "t-b|t-c", tagIds: ["t-b", "t-c"] }] },
140
- "(A+B)*C": { orTags: [], andGroups: [{ groupId: "t-a|t-c", tagIds: ["t-a", "t-c"] }, { groupId: "t-b|t-c", tagIds: ["t-b", "t-c"] }] },
141
- };
142
- const normalizedSrc = src.replace(/\s+/g, " ").replace(/\s*([+*()])\s*/g, " $1 ").trim();
143
- return {
144
- ok: true, errors: [], warnings: [],
145
- normalized: normalizedSrc,
146
- config: KNOWN[src.replace(/\s+/g, "")] ?? { orTags: [], andGroups: [] },
147
- };
148
- }
149
-
150
- function makeFetch(validation) {
151
- return async (url) => ({
152
- async json() {
153
- if (String(url).endsWith("/templates/validate")) return { ok: true, validation };
154
- if (String(url).endsWith("/state")) return { ok: true, tags: TAGS, templates: TEMPLATES, skillLinks: [], workspaces: [], conversations: [] };
155
- if (String(url).endsWith("/skills")) return { ok: true, skills: [] };
156
- return { ok: true };
157
- },
158
- });
159
- }
160
-
161
- const BASE = {
162
- loading: false, error: null,
163
- data: { tags: TAGS, templates: TEMPLATES, skillLinks: [], workspaces: [], conversations: [] },
164
- skills: [], newTagName: "", busy: false, notice: null,
165
- linkTagId: null, tagSearch: "", skillSearch: "", pickOpen: false, pickSearch: "",
166
- // 设置页已移除工作区/对话两块编辑器(改由工作区行 / 会话行菜单的「管理标签」进入),
167
- // 这里也不再需要它们的 state 初值。openTags 是标签列表的展开态。
168
- openTags: {},
169
- view: "main",
170
- tplId: null, tplIsNew: false, tplName: "", tplExpression: "",
171
- };
172
- const baseState = (over = {}) => ({ ...BASE, ...over });
173
-
174
- // ── 序列化 ────────────────────────────────────────────────────────────────
175
- const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
176
- const styleOf = (st) =>
177
- st && typeof st === "object"
178
- ? Object.entries(st).map(([k, v]) => `${k.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase())}:${v}`).join(";")
179
- : "";
180
-
181
- function serialize(node, depth = 0) {
182
- if (node === null || node === undefined || node === false) return "";
183
- if (typeof node === "string" || typeof node === "number") return esc(node);
184
- if (typeof node !== "object" || depth > 80) return "";
185
- if (typeof node.type === "function") return serialize(node.type(node.props), depth + 1);
186
- if (node.type === FRAGMENT) return (node.children ?? []).map((c) => serialize(c, depth + 1)).join("");
187
-
188
- const tag = node.type;
189
- const props = node.props ?? {};
190
- const attrs = [];
191
- const style = styleOf(props.style);
192
- if (style) attrs.push(`style="${esc(style)}"`);
193
- if (props.placeholder !== undefined) attrs.push(`placeholder="${esc(props.placeholder)}"`);
194
- if (props.title) attrs.push(`title="${esc(props.title)}"`);
195
- if (props.value !== undefined && tag === "input") attrs.push(`value="${esc(props.value)}"`);
196
- if (props.disabled) attrs.push("disabled");
197
- const inner = (node.children ?? []).map((c) => serialize(c, depth + 1)).join("");
198
- if (["input", "br", "img"].includes(tag)) return `<${tag} ${attrs.join(" ")}>`;
199
- if (tag === "textarea") return `<textarea ${attrs.join(" ")}>${esc(props.value ?? "")}</textarea>`;
200
- if (props.disabled) attrs.push('class="is-disabled"');
201
- return `<${tag} ${attrs.join(" ")}>${inner}</${tag}>`;
202
- }
203
-
204
- const SCENES = [
205
- {
206
- label: "① 主页面:「编辑标签组」入口",
207
- state: baseState({ view: "main" }),
208
- validation: fakeValidate(""),
209
- },
210
- {
211
- label: "② 标签组配置页 · 未选中(下方为引导文案,不渲染输入框)",
212
- state: baseState({ view: "templates" }),
213
- validation: fakeValidate(""),
214
- },
215
- {
216
- label: "③ 打开已有标签组「前端组」→ 表达式 A+B*C 被回显 + 校验通过 + 解析结果",
217
- state: baseState({ view: "templates", tplId: "tpl-1", tplName: "前端组", tplExpression: "A+B*C" }),
218
- validation: fakeValidate("A+B*C"),
219
- },
220
- {
221
- label: "④ 表达式非法(A+)→ 保存按钮禁用 + 定位提示",
222
- state: baseState({ view: "templates", tplId: "tpl-1", tplName: "前端组", tplExpression: "A+" }),
223
- validation: {
224
- ok: false,
225
- errors: [{ code: "TRAILING_OPERATOR", message: "'+' 之后缺少标签或 '('", position: 1, length: 1 }],
226
- warnings: [],
227
- },
228
- },
229
- {
230
- label: "⑤ 新建标签组草稿(「保存新建」)",
231
- state: baseState({ view: "templates", tplIsNew: true, tplName: "新标签组" }),
232
- validation: fakeValidate(""),
233
- },
234
- ];
235
-
236
- const blocks = [];
237
- for (const scene of SCENES) {
238
- const rt = makeRuntime(scene.state, makeFetch(scene.validation));
239
- const html = await rt.run((tree) => serialize(tree));
240
- blocks.push(`<section><h2>${esc(scene.label)}</h2><div class="frame">${html}</div></section>`);
241
- }
242
-
243
- const doc = `<!doctype html>
244
- <html lang="zh-CN"><head><meta charset="utf-8">
245
- <meta name="viewport" content="width=device-width,initial-scale=1">
246
- <title>标签组配置 · 界面预览(真实组件渲染)</title>
247
- <style>
248
- body { margin:0; padding:24px 20px 60px; font-family: ui-sans-serif,system-ui,"Microsoft YaHei",sans-serif;
249
- background:#14161a; color:#e6e6e6; }
250
- h1 { font-size:19px; margin:0 0 6px; }
251
- h2 { font-size:13px; color:#9aa4b2; margin:26px 0 8px; font-weight:600; }
252
- .frame { border:1px solid #2c323a; border-radius:12px; padding:10px 14px;
253
- background:linear-gradient(#1b1f25,#181b21); overflow-x:auto; }
254
- .note { color:#8b95a3; font-size:12px; line-height:1.75; max-width:900px; }
255
- code { background:#262b33; padding:1px 5px; border-radius:4px; font-size:11px; }
256
- input, textarea { color:#e6e6e6; font-family:inherit; }
257
- textarea { font-family: ui-monospace,monospace !important; }
258
- button { font-family:inherit; }
259
- .is-disabled { opacity:.42; cursor:not-allowed !important; }
260
- input::placeholder, textarea::placeholder { color:#5d6774; }
261
- </style></head>
262
- <body>
263
- <h1>「标签组配置」界面预览</h1>
264
- <p class="note">
265
- 本页不是示意图:由 <code>lib/client.js</code> 中<b>真实的 Section / TemplateGroupPage</b> 在迷你 React 运行时里执行后,
266
- 把渲染树序列化成 HTML;内联样式全部来自组件代码本身。校验区域由真实 250ms 防抖逻辑触发
267
- (<code>fetch</code> 被桩成返回与 <code>src/domain/expr.js</code> 同格式的结果)。仅无点击交互。
268
- </p>
269
- ${blocks.join("\n")}
270
- </body></html>`;
271
-
272
- const out = path.join(HERE, "标签组配置-preview.html");
273
- fs.writeFileSync(out, doc, "utf8");
274
- console.log("wrote", out, doc.length, "bytes");