dsh-skill-tag 0.1.16 → 0.1.18
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 +1 -1
- package/docs//345/257/271/350/257/235/347/256/241/347/220/206/346/240/207/347/255/276.md +4 -4
- package/docs//346/212/200/350/203/275/345/217/221/347/216/260/344/270/216/345/256/277/344/270/273/345/257/271/351/275/220.md +14 -0
- package/docs//346/240/207/347/255/276/347/273/204/351/205/215/347/275/256.md +3 -3
- package/docs//347/256/241/347/220/206/346/240/207/347/255/276.md +4 -4
- package/lib/index.js +10 -4
- package/package.json +10 -3
- package/src/api.js +23 -8
- package/src/skill-scan.js +18 -0
- package/docs/preview/render-conv-preview.mjs +0 -327
- package/docs/preview/render-manage-preview.mjs +0 -278
- package/docs/preview/render-preview.mjs +0 -274
package/README.md
CHANGED
|
@@ -355,7 +355,7 @@ npm test # 194 项
|
|
|
355
355
|
界面预览可以在没有浏览器扩展时生成静态 HTML:
|
|
356
356
|
|
|
357
357
|
```bash
|
|
358
|
-
node
|
|
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
|
-
`
|
|
475
|
+
`dev/preview/` 下的预览图由 `render-conv-preview.mjs` 用**真实组件**渲染生成
|
|
476
476
|
(迷你 React 运行时执行 `lib/client.js` 的 `ManageTagsDialog` / `TagSettingsDialog`,
|
|
477
477
|
再序列化成 HTML,用 headless Edge 截图),与线上界面同源:
|
|
478
478
|
|
|
479
|
-
- `
|
|
480
|
-
- `
|
|
479
|
+
- `dev/preview/对话管理标签-preview.html` — 完整预览页(3 个场景)
|
|
480
|
+
- `dev/preview/conv-manage-shot.png` — 截图
|
|
481
481
|
|
|
482
482
|
重新生成:
|
|
483
483
|
|
|
484
484
|
```bash
|
|
485
|
-
node
|
|
485
|
+
node dev/preview/render-conv-preview.mjs
|
|
486
486
|
```
|
|
487
487
|
|
|
488
488
|
场景:①已继承工作区 ②未继承(独立配置)③对话版「tag 设置」载入标签组后可继续编辑。
|
|
@@ -181,6 +181,20 @@ tagging-skills 那 17 个长期漏网的原因(详见 `deny-shadow-覆盖率.m
|
|
|
181
181
|
再发 —— 一个不合法候选会让**整个** `provider.list()` 抛错,宿主随即丢弃本插件的
|
|
182
182
|
全部贡献(日志 `skill provider "skill-tag" skipped`),比漏一个更糟。
|
|
183
183
|
|
|
184
|
+
**两段都要校验(2026-11-12,issue #6)**。上面这条当时只堵了「`denied` 里看不见
|
|
185
|
+
的名字」那一段,**看得见的**那一段(照抄上游 summary)一直没校验,于是扫盘带进来的
|
|
186
|
+
脏名字能原样发出去。触发链:`src/skill-scan.js` 把技能根下裸放的 `README-官方技能说明.md`
|
|
187
|
+
当成技能(名字取文件名去 `.md`)→ 进 universe → `denied = universe − visible` 里也有它 →
|
|
188
|
+
第一段照抄发射 → 宿主 `validateCandidate` 抛错 → **所有对话**每轮都失败,报
|
|
189
|
+
`returned invalid skill name`。
|
|
190
|
+
|
|
191
|
+
现在两层纵深防御:
|
|
192
|
+
|
|
193
|
+
- `src/skill-scan.js`:根级裸放 `.md` **必须有 frontmatter** 才算技能,名字非 kebab-case
|
|
194
|
+
一律跳过 —— 与宿主 `discoverRoot` / `parseSkillFile` 同口径(宿主对同样的文件打的是
|
|
195
|
+
`skill file ... ignored: invalid skill name`)。
|
|
196
|
+
- `lib/index.js`:第一段与第二段都过 `isSkillName()`,兜住任何别的来源漏进来的脏名字。
|
|
197
|
+
|
|
184
198
|
## 5. 约束(踩过的坑)
|
|
185
199
|
|
|
186
200
|
- **cordis 服务必须声明式注入**:`export const inject` 里没有的服务,读 `ctx.xxx`
|
|
@@ -285,13 +285,13 @@ npm test
|
|
|
285
285
|
|
|
286
286
|
### 6.3 界面预览(无需浏览器扩展)
|
|
287
287
|
|
|
288
|
-
`
|
|
289
|
-
`
|
|
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
|
|
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
|
-
`
|
|
258
|
+
`dev/preview/` 下的预览图由 `render-manage-preview.mjs` 用**真实组件**渲染生成
|
|
259
259
|
(迷你 React 运行时执行 `lib/client.js` 里的 `ManageTagsDialog` / `TagSettingsDialog`,
|
|
260
260
|
再序列化成 HTML,用 headless Edge 截图),因此与线上界面同源、不会图文不符:
|
|
261
261
|
|
|
262
|
-
- `
|
|
263
|
-
- `
|
|
262
|
+
- `dev/preview/管理标签-preview.html` — 完整预览页
|
|
263
|
+
- `dev/preview/manage-shot.png` — 截图
|
|
264
264
|
|
|
265
265
|
重新生成:
|
|
266
266
|
|
|
267
267
|
```bash
|
|
268
|
-
node
|
|
268
|
+
node dev/preview/render-manage-preview.mjs
|
|
269
269
|
```
|
|
270
270
|
|
|
271
271
|
---
|
package/lib/index.js
CHANGED
|
@@ -101,8 +101,15 @@ export function createFilterProvider(sessionId, upstream, denied, ensureFresh, l
|
|
|
101
101
|
// 还是上游那份,影子只换掉 invocation 与 provider,右列能看出"它被屏蔽了"。
|
|
102
102
|
const shadows = [];
|
|
103
103
|
const emitted = new Set();
|
|
104
|
+
const invalid = new Set();
|
|
104
105
|
for (const summary of summaries) {
|
|
105
106
|
if (!summary || !denied.has(summary.name)) continue;
|
|
107
|
+
// 看得见 ≠ 合法(2026-11-12,issue #6):扫盘会把技能根下裸放的
|
|
108
|
+
// `README-官方技能说明.md` 这类文件当成技能,名字取文件名去 `.md`。
|
|
109
|
+
// 旧实现只在第二段校验,这一段整条照抄 → 非法名字原样发出去 →
|
|
110
|
+
// 宿主 validateCandidate 抛错 → 整个 provider 作废 → **所有对话**
|
|
111
|
+
// 报 "returned invalid skill name" 且每轮都失败。两段都必须校验。
|
|
112
|
+
if (!isSkillName(summary.name)) { invalid.add(summary.name); continue; }
|
|
106
113
|
shadows.push({
|
|
107
114
|
...summary,
|
|
108
115
|
provider: PROVIDER_NAME,
|
|
@@ -123,10 +130,9 @@ export function createFilterProvider(sessionId, upstream, denied, ensureFresh, l
|
|
|
123
130
|
// 修法:屏蔽是**按名字**生效的,不需要知道那名字长什么样 —— 看不见的
|
|
124
131
|
// 名字直接造一条最小合法候选,就能在近层整条覆盖上游那份。
|
|
125
132
|
let backfilled = 0;
|
|
126
|
-
const invalid = [];
|
|
127
133
|
for (const name of denied) {
|
|
128
134
|
if (emitted.has(name)) continue;
|
|
129
|
-
if (!isSkillName(name)) { invalid.
|
|
135
|
+
if (!isSkillName(name)) { invalid.add(name); continue; }
|
|
130
136
|
shadows.push({
|
|
131
137
|
name,
|
|
132
138
|
description: `[blocked by skill-tag] ${name}`,
|
|
@@ -148,8 +154,8 @@ export function createFilterProvider(sessionId, upstream, denied, ensureFresh, l
|
|
|
148
154
|
+ ` shadowed=${emitted.size} backfilled=${backfilled}`
|
|
149
155
|
+ `${upstream.lastDegraded === true ? " hostSnapshot=skipped(nested)" : ""}`);
|
|
150
156
|
}
|
|
151
|
-
if (invalid.
|
|
152
|
-
logWarn(`[skill-tag]
|
|
157
|
+
if (invalid.size > 0) {
|
|
158
|
+
logWarn(`[skill-tag] names skipped (not kebab-case): ${[...invalid].slice(0, 8).join(", ")}`);
|
|
153
159
|
}
|
|
154
160
|
} catch { /* 观测不得影响主路径 */ }
|
|
155
161
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-skill-tag",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
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
|
-
//
|
|
578
|
-
//
|
|
579
|
-
|
|
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 {
|
|
591
|
+
try {
|
|
592
|
+
const got = resolveWorkspaceSessions(wid);
|
|
593
|
+
if (Array.isArray(got)) hostIds = got;
|
|
594
|
+
} catch { hostIds = null; }
|
|
582
595
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
|
package/src/skill-scan.js
CHANGED
|
@@ -70,6 +70,17 @@ function collectRoot(dir, depth, root, out) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* 合法技能名:kebab-case,与宿主 @deepseek-ai/dsh-skill 的 SKILL_NAME 同一正则。
|
|
75
|
+
*
|
|
76
|
+
* 为什么扫描器也要过这一关(2026-11-12,issue #6):扫描结果会进 universe,
|
|
77
|
+
* 于是 denied = universe − visible 里也会出现它;只要这个名字非法,宿主
|
|
78
|
+
* validateCandidate 就会抛错、整个 provider 作废、**所有对话**都跑不下去。
|
|
79
|
+
* 宿主自己的文件系统 provider 早就这么做了(`skill file ... ignored: invalid
|
|
80
|
+
* skill name`),自扫描必须同口径,否则插件会把宿主本来就不认的名字当成技能。
|
|
81
|
+
*/
|
|
82
|
+
const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
83
|
+
|
|
73
84
|
function pushSkill(out, mdPath, root, disabled, fallbackName) {
|
|
74
85
|
let fm = null;
|
|
75
86
|
try {
|
|
@@ -77,8 +88,15 @@ function pushSkill(out, mdPath, root, disabled, fallbackName) {
|
|
|
77
88
|
} catch {
|
|
78
89
|
return; // unreadable file — skip
|
|
79
90
|
}
|
|
91
|
+
// 裸放的根级 .md(flat skill)与宿主同口径:**必须有 frontmatter**。
|
|
92
|
+
// 没有 frontmatter 的 .md 只是一份普通文档(README、笔记…),不是技能;
|
|
93
|
+
// 旧实现会用文件名凭空造一个技能名,正是 issue #6 的来源。
|
|
94
|
+
const isFlat = fallbackName !== undefined;
|
|
95
|
+
if (isFlat && fm === null) return;
|
|
80
96
|
const name = fm?.name || fallbackName || path.basename(path.dirname(mdPath));
|
|
81
97
|
if (!name) return;
|
|
98
|
+
// 非法名字宁可丢掉:它既进不了宿主注册表,还会炸掉整批候选。
|
|
99
|
+
if (!SKILL_NAME_RE.test(name)) return;
|
|
82
100
|
out.push({
|
|
83
101
|
name,
|
|
84
102
|
description: fm?.description ?? "",
|
|
@@ -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 = { "&": "&", "<": "<", ">": ">", '"': """ };
|
|
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 = { "&": "&", "<": "<", ">": ">", '"': """ };
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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");
|