dsh-plugin-deck-studio 0.0.0-stage → 0.2.3
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/LICENSE +21 -0
- package/README.md +47 -2
- package/client.js +500 -0
- package/cordis.example.yml +163 -0
- package/cordis.patch.yml +147 -0
- package/index.js +2340 -0
- package/lib/config.js +216 -0
- package/lib/content-density.js +98 -0
- package/lib/deck-brief.js +87 -0
- package/lib/deck-docs.js +1095 -0
- package/lib/deck-rules.js +124 -0
- package/lib/deck-templates.js +149 -0
- package/lib/deck-workspace.js +480 -0
- package/lib/design-style.js +157 -0
- package/lib/designs.js +175 -0
- package/lib/diff.js +88 -0
- package/lib/exporter.js +336 -0
- package/lib/faq.js +300 -0
- package/lib/git-commit.js +87 -0
- package/lib/home-catalog.js +249 -0
- package/lib/png.js +275 -0
- package/lib/proposal-decision.js +204 -0
- package/lib/render-nodes.js +171 -0
- package/lib/render-settings.js +213 -0
- package/lib/render.js +287 -0
- package/lib/review-personas.js +258 -0
- package/lib/script-style.js +55 -0
- package/lib/script-writer.js +165 -0
- package/lib/source-adapter.js +327 -0
- package/lib/store.js +212 -0
- package/lib/tools.js +2594 -0
- package/lib/watermark.js +137 -0
- package/package.json +67 -4
- package/seeds/docs/assets/screenshots/00-hero.png +0 -0
- package/seeds/docs/assets/screenshots/01-home.png +0 -0
- package/seeds/docs/assets/screenshots/02-deck-pages.png +0 -0
- package/seeds/docs/assets/screenshots/03-script.png +0 -0
- package/seeds/docs/assets/screenshots/04-design.png +0 -0
- package/seeds/docs/assets/screenshots/05-governance.png +0 -0
- package/seeds/docs/assets/screenshots/06-settings.png +0 -0
- package/seeds/docs/assets/screenshots/07-new-deck.png +0 -0
- package/seeds/docs/assets/screenshots/manual/01-home.png +0 -0
- package/seeds/docs/assets/screenshots/manual/02-deck-pages.png +0 -0
- package/seeds/docs/assets/screenshots/manual/03-script.png +0 -0
- package/seeds/docs/assets/screenshots/manual/04-design.png +0 -0
- package/seeds/docs/assets/screenshots/manual/05-governance.png +0 -0
- package/seeds/docs/assets/screenshots/manual/06-settings.png +0 -0
- package/seeds/docs/assets/screenshots/manual/07-new-deck.png +0 -0
- package/seeds/docs/assets/screenshots/manual/legend.json +12 -0
- package/seeds/docs/compat.md +134 -0
- package/seeds/docs/install.en.md +252 -0
- package/seeds/docs/install.md +304 -0
- package/seeds/docs/manual.en.md +294 -0
- package/seeds/docs/manual.md +258 -0
- package/seeds/docs/productization.en.md +212 -0
- package/seeds/docs/productization.md +193 -0
- package/seeds/docs/sample-deck.en.md +114 -0
- package/seeds/docs/sample-deck.md +104 -0
- package/seeds/docs/styles.en.md +112 -0
- package/seeds/docs/styles.md +100 -0
- package/seeds/resources/deckline-logo.png +0 -0
- package/seeds/resources/deckline-logo.svg +12 -0
- package/seeds/resources/deckline-mark.png +0 -0
- package/seeds/resources/index.json +19 -0
- package/seeds/samples/BIZ-01.jpg +0 -0
- package/seeds/samples/BIZ-02.jpg +0 -0
- package/seeds/samples/BIZ-03.jpg +0 -0
- package/seeds/samples/EDT-01.jpg +0 -0
- package/seeds/samples/EDT-02.jpg +0 -0
- package/seeds/samples/EDT-03.jpg +0 -0
- package/seeds/samples/FYI-01.jpg +0 -0
- package/seeds/samples/FYI-02.jpg +0 -0
- package/seeds/samples/FYI-03.jpg +0 -0
- package/seeds/samples/GRD-01.jpg +0 -0
- package/seeds/samples/GRD-02.jpg +0 -0
- package/seeds/samples/GRD-03.jpg +0 -0
- package/seeds/samples/JPN-01.jpg +0 -0
- package/seeds/samples/JPN-02.jpg +0 -0
- package/seeds/samples/JPN-03.jpg +0 -0
- package/seeds/samples/SKT-01.jpg +0 -0
- package/seeds/samples/SKT-02.jpg +0 -0
- package/seeds/samples/SKT-03.jpg +0 -0
- package/seeds/samples/SWS-01.jpg +0 -0
- package/seeds/samples/SWS-02.jpg +0 -0
- package/seeds/samples/SWS-03.jpg +0 -0
- package/seeds/samples/TEC-01.jpg +0 -0
- package/seeds/samples/TEC-02.jpg +0 -0
- package/seeds/samples/TEC-03.jpg +0 -0
- package/seeds/samples/deckline-intro/README.md +41 -0
- package/seeds/samples/deckline-intro/assets/deckline-logo.png +0 -0
- package/seeds/samples/deckline-intro/assets/deckline-logo.svg +12 -0
- package/seeds/samples/deckline-intro/assets/deckline-mark.png +0 -0
- package/seeds/samples/deckline-intro/deckline-intro.pptx +0 -0
- package/seeds/samples/deckline-intro/intent.md +48 -0
- package/seeds/samples/deckline-intro/materials/project-summary.md +90 -0
- package/seeds/samples/deckline-intro/materials/screenshots/01-workbench-deck.png +0 -0
- package/seeds/samples/deckline-intro/materials/screenshots/02-home-decks.png +0 -0
- package/seeds/samples/deckline-intro/materials/screenshots/03-home-designs.png +0 -0
- package/seeds/samples/deckline-intro/materials/screenshots/04-new-deck-popup.png +0 -0
- package/seeds/samples/deckline-intro/materials.md +110 -0
- package/seeds/samples/deckline-intro/pages/p01.png +0 -0
- package/seeds/samples/deckline-intro/pages/p02.png +0 -0
- package/seeds/samples/deckline-intro/pages/p03.png +0 -0
- package/seeds/samples/deckline-intro/pages/p04.png +0 -0
- package/seeds/samples/deckline-intro/pages/p05.png +0 -0
- package/seeds/samples/deckline-intro/pages/p06.png +0 -0
- package/seeds/samples/deckline-intro/pages/p07.png +0 -0
- package/seeds/samples/deckline-intro/pages/p08.png +0 -0
- package/seeds/samples/deckline-intro/pages/p09.png +0 -0
- package/seeds/samples/deckline-intro/pages/p10.png +0 -0
- package/seeds/samples/deckline-intro/popup.md +87 -0
- package/seeds/samples/deckline-intro/prompts/p01.txt +4 -0
- package/seeds/samples/deckline-intro/prompts/p02.txt +6 -0
- package/seeds/samples/deckline-intro/prompts/p03.txt +6 -0
- package/seeds/samples/deckline-intro/prompts/p04.txt +6 -0
- package/seeds/samples/deckline-intro/prompts/p05.txt +6 -0
- package/seeds/samples/deckline-intro/prompts/p06.txt +6 -0
- package/seeds/samples/deckline-intro/prompts/p07.txt +6 -0
- package/seeds/samples/deckline-intro/prompts/p08.txt +6 -0
- package/seeds/samples/deckline-intro/prompts/p09.txt +6 -0
- package/seeds/samples/deckline-intro/prompts/p10.txt +6 -0
- package/seeds/samples/deckline-intro/script.md +88 -0
- package/seeds/styles/BIZ.json +22 -0
- package/seeds/styles/EDT.json +22 -0
- package/seeds/styles/FYI.json +22 -0
- package/seeds/styles/GRD.json +22 -0
- package/seeds/styles/JPN.json +22 -0
- package/seeds/styles/SKT.json +22 -0
- package/seeds/styles/SWS.json +22 -0
- package/seeds/styles/TEC.json +22 -0
- package/ui/logo.svg +20 -0
- package/ui/workbench.css +687 -0
- package/ui/workbench.html +4554 -0
package/lib/faq.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工作台内置 FAQ(F-140)。
|
|
3
|
+
*
|
|
4
|
+
* 只回答「怎么操作 / 在哪点 / 为什么不行」这类产品使用问题——它是产品知识的唯一来源,
|
|
5
|
+
* Chat 里的 Agent 用 deck_help 工具查,工作台的「?帮助」面板走同一个 API。
|
|
6
|
+
* 不写死具体 deck、客户名、节点地址;答案尽量给可点击的路径与工具名。
|
|
7
|
+
*
|
|
8
|
+
* 维护约定:改 UI 文案 / 流程时同步改这里(smoke 会检查条目数与必填字段)。
|
|
9
|
+
*/
|
|
10
|
+
export const FAQ_ENTRIES = [
|
|
11
|
+
{
|
|
12
|
+
id: "new-deck",
|
|
13
|
+
title: "怎么新建一份 deck",
|
|
14
|
+
keywords: ["新建", "创建", "新deck", "new", "开一个deck"],
|
|
15
|
+
answer:
|
|
16
|
+
"首页 → 右上「+ 新建 Deck」:填标题、听众、场合、时长、要讲什么、参考资料(可拖文件 / 粘贴路径),选内容密度,点「创建」。\n" +
|
|
17
|
+
"创建后会生成源目录(script.md / intent.md / 素材),左侧自动开一个绑定这份 deck 的会话。",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "how-to-edit",
|
|
21
|
+
title: "怎么改某一页的内容(提案 → 确认)",
|
|
22
|
+
keywords: ["改内容", "修改", "改稿", "提案", "确认", "正文", "标题", "副标题"],
|
|
23
|
+
answer:
|
|
24
|
+
"在 Chat 里说要改第几页、改成什么。Agent 会提「提案」(diff 预览,此时文件不动);你在 Chat 说「可以」或点提案卡片上的「确认」,才落版成新 revision。\n" +
|
|
25
|
+
"自己的工作台里也可以点每页右上「微调」直接改标题 / 副标题 / 正文(写回 script.md)。\n" +
|
|
26
|
+
"字段口径:标题 / 副标题(这页回答)/ 正文(具体内容,①②③ 写这里)/ 提示词(画面要求,只写画面)。",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "fields-wrong",
|
|
30
|
+
title: "为什么改了内容,脚本页看不到",
|
|
31
|
+
keywords: ["看不到", "没变化", "不生效", "主张", "字段", "写反"],
|
|
32
|
+
answer:
|
|
33
|
+
"先看是不是把长内容写进了「主张」:卡片的正文区只显示正文(body),主张是一句话结论。\n" +
|
|
34
|
+
"脚本卡片现在会显示主张和字数,超过 120 字会标红「主张过长 · 长内容应写正文」。\n" +
|
|
35
|
+
"让 Agent 把长内容提到正文:deck_script_propose_edit 用 field=body 提一条,确认后正文才更新。",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "image-edit",
|
|
39
|
+
title: "能把已经出好的图修一下吗(修图 / 去水印)",
|
|
40
|
+
keywords: ["修图", "改图", "去水印", "以图生图", "编辑图片", "局部修改", "retouch", "修一下"],
|
|
41
|
+
answer:
|
|
42
|
+
"可以,用修图工具 deck_image_edit:说「P03 的图修一下:去掉右下角水印,标题再大一点」「把 P05 那张图里的人换成剪影」\n" +
|
|
43
|
+
"——Agent 会拿这页最新版本的图当底图,只改你点名的地方,产出新版本(旧图不覆盖)。\n" +
|
|
44
|
+
"一次修一张;多张就按页说「P03–P05 都修掉水印」。它只改画面,不改 script / 正文:要改文字内容还是走提案。\n" +
|
|
45
|
+
"想拿 deck 里的素材图(assets/ 下的 png / jpg / webp)修,也可以直接说「用 assets/xxx.png 修一下,背景换米白」,\n" +
|
|
46
|
+
"结果会落成独立产物,不会自动变成某一页的图。整页重画用「P03 重出」,不要用修图去重跑整页。",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "render-one",
|
|
50
|
+
title: "怎么出图 / 重出(一页或多页)",
|
|
51
|
+
keywords: ["出图", "生成图片", "渲染", "重出", "重新生成", "来一张"],
|
|
52
|
+
answer:
|
|
53
|
+
"说「出第 N 页」即可(Agent 调 deck_render_page,一次一页)。想一次出好几页见「能一次出多页吗」那条。\n" +
|
|
54
|
+
"每页右下「重新出图」也行。\n" +
|
|
55
|
+
"每次出新版本(v001 → v002),旧图保留、不覆盖;不满意继续重出即可。",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "render-batch",
|
|
59
|
+
title: "能一次出多页 / 整套吗",
|
|
60
|
+
keywords: ["批量", "整套", "全部出图", "一起出", "多页"],
|
|
61
|
+
answer:
|
|
62
|
+
"可以,而且批量是正常用法:说「出 P04–P06」「把这 10 页都出一下」「整份出图」就行——用户点名 = 已确认,\n" +
|
|
63
|
+
"Agent 按页序连续出完,不用每页停下来等确认。工具层仍是一次一页调用:每页各自留 prompt / 图 / QA / 版本,\n" +
|
|
64
|
+
"失败只重出那一页,成本和质量都可控。不会自行扩到没点名的页。",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "brief-empty",
|
|
68
|
+
title: "页面还没有画面要求,能出图吗",
|
|
69
|
+
keywords: ["visualbrief", "画面要求", "提示词为空", "没写提示词", "空"],
|
|
70
|
+
answer:
|
|
71
|
+
"能。没有画面要求时,系统用标题 + 正文拼「自动草稿」prompt 直接出图(qa.json 标 briefSource=draft),出完再补 brief 重出即可。\n" +
|
|
72
|
+
"想一次到位:让 Agent 出图时带上 visualBrief 参数,系统会随手把这条画面要求存进这一页。",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "export",
|
|
76
|
+
title: "怎么导出 PDF / PPTX",
|
|
77
|
+
keywords: ["导出", "pdf", "pptx", "ppt", "下载"],
|
|
78
|
+
answer:
|
|
79
|
+
"右上「导出 ▾」→ PDF / PPTX / 交接包。每页导出一张整页图。\n" +
|
|
80
|
+
"还有页没出图也能导:缺的页会跳过,状态栏提示「6/10 页 · 缺 P04…」;只有一页都没出图才会拒绝。",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "export-handoff",
|
|
84
|
+
title: "交接包(zip)里有什么",
|
|
85
|
+
keywords: ["交接", "交接包", "handoff", "打包", "给别人"],
|
|
86
|
+
answer:
|
|
87
|
+
"zip 里有:script.md、每页 prompt、每页整页图、9 份文档、事件记录。\n" +
|
|
88
|
+
"交接前建议先跑一次治理页看缺什么(缺文档不影响出图,只是交接完备度)。",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: "change-style",
|
|
92
|
+
title: "怎么换风格 / 关联 design",
|
|
93
|
+
keywords: ["风格", "style", "design", "换风格", "样式", "关联"],
|
|
94
|
+
answer:
|
|
95
|
+
"Design 标签 →「这个 deck 用哪个登记风格」→ 选一个风格「关联到本 deck」,之后出图自动带这个风格提示词。\n" +
|
|
96
|
+
"换风格只改画面语言,不改页面内容、不覆盖旧图;已写好的旧画面要求可以保留,也可以让 Agent 按新风格重写(逐页提案确认)。\n" +
|
|
97
|
+
"想要新风格:首页 Designs →「+ 添加风格」(描述 + 样张),或从风格画布会话里用 deck_design_sample 生子样张。",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: "style-canvas",
|
|
101
|
+
title: "风格画布 / 样张怎么用",
|
|
102
|
+
keywords: ["样张", "风格画布", "design canvas", "试风格"],
|
|
103
|
+
answer:
|
|
104
|
+
"Designs 页点一个风格进入它的空白画布会话:产物是「风格定义 + 样张」,不是 deck 页面。\n" +
|
|
105
|
+
"让 Agent 先给 2–3 个版面 brief,你选一个再逐张出样张;样张会存进 designs/<缩写>/ 并出现在 Designs 页。",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: "density",
|
|
109
|
+
title: "内容密度是什么,在哪改",
|
|
110
|
+
keywords: ["密度", "字数", "正文多长", "多少字", "简洁", "密集"],
|
|
111
|
+
answer:
|
|
112
|
+
"密度决定一页写多少字:简洁(正文 50–100)/ 适中(100–200)/ 密集(200–500)。\n" +
|
|
113
|
+
"deck 工具栏「密度」下拉可随时改;也可以让 Agent「按当前密度检查并重写」不达标的页。\n" +
|
|
114
|
+
"注意:正文长 ≠ 画面字多——正文是讲稿,画面里的字要少。",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: "watermark",
|
|
118
|
+
title: "怎么加 logo 水印",
|
|
119
|
+
keywords: ["水印", "logo", "角标", "水印素材"],
|
|
120
|
+
answer:
|
|
121
|
+
"跟 Agent 说「盖水印 / 用 XX logo」:它会先列可选素材(deck_watermark_assets),确认后整份盖上(默认右上角,可指定位置 / 透明度 / 大小 / 指定页)。\n" +
|
|
122
|
+
"每次出新版本、不覆盖原图;没出图的页会跳过并告知哪几页没盖上。",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: "resources",
|
|
126
|
+
title: "资源库 / 怎么用自己的 logo 和头像",
|
|
127
|
+
keywords: ["资源库", "素材", "logo", "头像", "二维码", "导入图片"],
|
|
128
|
+
answer:
|
|
129
|
+
"Library 面板的「资源库」列出共享素材(<数据目录>/resources)。放图进这个目录(配 index.json 写显示名)后,所有 deck 都能看到。\n" +
|
|
130
|
+
"在 Chat 里可以直接说「用资源库里的 XX logo」,Agent 会用 deck_media / 水印工具取素材。",
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: "inspector",
|
|
134
|
+
title: "Inspector 逐页编辑保存的是什么",
|
|
135
|
+
keywords: ["inspector", "微调", "保存", "编辑页", "右侧栏"],
|
|
136
|
+
answer:
|
|
137
|
+
"右栏 Inspector 改的是标题 / 副标题 / 正文 / 提示词,「保存」会写回源 script.md(本机新建 deck)并留 git 提交,然后可以「重新出图」。\n" +
|
|
138
|
+
"受控客户源 deck 的源目录只读,保存只写工作副本;要回写源请走提案 + 确认。",
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: "script-card",
|
|
142
|
+
title: "脚本页卡片上的数字是什么意思",
|
|
143
|
+
keywords: ["卡片", "字数", "达标", "目标", "脚本标签"],
|
|
144
|
+
answer:
|
|
145
|
+
"每页卡片:副标题、主张(一句话结论)、正文、提示词。\n" +
|
|
146
|
+
"灰底小标签是当前密度下的字数:「正文 163 字 · 达标」= 在目标区间内;橙色「· 目标 100–200」= 不达标,可以让 Agent 重写。",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: "versions",
|
|
150
|
+
title: "怎么回退到上一版",
|
|
151
|
+
keywords: ["回退", "历史", "版本", "撤销", "恢复到上一版"],
|
|
152
|
+
answer:
|
|
153
|
+
"Context 面板 / 版本列表里能看到每次 revision(v001、v002…),选旧版本「回退」即可;旧版永远保留,不覆盖。\n" +
|
|
154
|
+
"图片同理:每页每次出图各成一个版本,切回旧版就行。",
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: "governance",
|
|
158
|
+
title: "治理评分不到 100 能出图 / 导出吗",
|
|
159
|
+
keywords: ["治理", "评分", "100分", "文档缺失", "齐备"],
|
|
160
|
+
answer:
|
|
161
|
+
"能。治理页是「交接完备度」,不是门槛:3 份与生成相关(意图 / 脚本 / Design),另外 6 份交接可选。\n" +
|
|
162
|
+
"缺文档不影响出图 / 改稿 / 导出;要交接别人时再补齐,或先跑 deck_docs_check 看缺什么。",
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: "review",
|
|
166
|
+
title: "审稿 review 是必须的吗",
|
|
167
|
+
keywords: ["审稿", "review", "检查", "校对"],
|
|
168
|
+
answer:
|
|
169
|
+
"不是必须。要交付前建议让 Agent 审一次(deck_review),它会读文档、写 review.md,按 P0/P1 列问题;只审不改、不出图。\n" +
|
|
170
|
+
"P0 优先修,P1 看时间;审稿结论不阻塞任何工具。",
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "research",
|
|
174
|
+
title: "调研 / 参考资料怎么进台账",
|
|
175
|
+
keywords: ["调研", "资料", "参考", "research", "来源"],
|
|
176
|
+
answer:
|
|
177
|
+
"新建 deck 时填的参考资料会进支撑材料;Agent 用 deck_research_plan 分批读,可引用的事实写进调研台账(链接 + 存档 + 摘要)。\n" +
|
|
178
|
+
"资料 ≥5 份或很长时建议让 Agent 分批读,避免一次塞满上下文。",
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: "sessions",
|
|
182
|
+
title: "左侧会话怎么切换 / 为什么默认是这个",
|
|
183
|
+
keywords: ["会话", "对话", "切换", "绑定", "默认会话", "新会话"],
|
|
184
|
+
answer:
|
|
185
|
+
"左侧顶部标题旁边的数字是这份 deck 的会话数,点开可切换;「+」新建一个并自动绑定本 deck。\n" +
|
|
186
|
+
"进入 deck 默认打开的是「绑定会话里最近活跃的那个」;如果当前会话没绑在这份 deck 上,工作台会自动切回绑定会话。",
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: "add-page",
|
|
190
|
+
title: "页数不够怎么加页",
|
|
191
|
+
keywords: ["加页", "增加页面", "页数", "扩页", "多一页"],
|
|
192
|
+
answer:
|
|
193
|
+
"工具栏「+ 添加页面」或让 Agent 加页(deck_script_add_page,可一次多页,只追加到末尾,产出提案,确认后落版)。\n" +
|
|
194
|
+
"新页没有画面要求也能先出草稿图,再决定要不要补写。",
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
id: "delete-deck",
|
|
198
|
+
title: "怎么删除 deck",
|
|
199
|
+
keywords: ["删除", "删掉", "移除deck", "清理"],
|
|
200
|
+
answer:
|
|
201
|
+
"首页 deck 卡片上的「…」→ 删除。会删工作副本(.deck 下产物);源目录是否一起删按弹窗提示确认。\n" +
|
|
202
|
+
"受控客户源 deck 建议只删工作副本,不要动源目录。",
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
id: "render-fail",
|
|
206
|
+
title: "出图失败怎么排查",
|
|
207
|
+
keywords: ["失败", "报错", "超时", "error", "出不来图", "qa"],
|
|
208
|
+
answer:
|
|
209
|
+
"看 Agent 回复的错误类型:\n" +
|
|
210
|
+
"· QA 门禁:prompt 太短 / 尺寸不合法 / 含疑似密钥——改提示词重试;\n" +
|
|
211
|
+
"· 节点或 key:设置页看出图节点与 key 状态(只显示配没配);\n" +
|
|
212
|
+
"· 超时 / 网络:插件会重试 5 次,仍失败可换节点或稍等;\n" +
|
|
213
|
+
"· 「页不存在」:先确认页号与 deck 是否切对。",
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
id: "switch-node",
|
|
217
|
+
title: "怎么换出图节点 / 模型(线路切不过去)",
|
|
218
|
+
keywords: ["换节点", "切节点", "换线路", "换模型", "出图节点", "线路", "模型下拉", "节点不换"],
|
|
219
|
+
answer:
|
|
220
|
+
"两个地方都能换:① 工作台顶部「出图」下拉,选节点 / 模型;② 右栏「设置 → 出图节点」,点某个模型或「用这个节点」。\n" +
|
|
221
|
+
"切换只对当前这本 deck 生效,写进它的 render-selection.json,下一次出图就用新线路,不用重启。\n" +
|
|
222
|
+
"要加线路:设置页「+ 新增节点」,填 名称 / 接口地址(一般以 /v1 结尾)/ API Key(直接粘贴,存本机不进 git)/ 模型名,\n" +
|
|
223
|
+
"保存后会自动切到新节点(不用再点「用这个节点」);节点卡上还能「编辑 / 删除」(至少留一个)。\n" +
|
|
224
|
+
"节点显示「key 未配置」时切过去也出不了图,先把 key 填好。",
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
id: "install",
|
|
228
|
+
title: "安装 / 配置出图 key",
|
|
229
|
+
keywords: ["安装", "配置", "key", "api", "启动", "profile"],
|
|
230
|
+
answer:
|
|
231
|
+
"README / docs/install.md 有完整步骤:建 profile → 装插件 → 配置出图节点(endpoint / 模型 / key 走环境变量或本机 key 文件)→ 启动打开工作台。\n" +
|
|
232
|
+
"没有 key 也能用 fake 模式跑通整条流程(不产生真实图片)。",
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
id: "fullscreen",
|
|
236
|
+
title: "怎么全屏演示",
|
|
237
|
+
keywords: ["全屏", "演示", "present", "翻页", "放映"],
|
|
238
|
+
answer:
|
|
239
|
+
"工具栏「▶ 全屏演示」:↑ ↓ ← → 都能翻页(笔记本方向键即可),空格下一页,Esc 退出。\n" +
|
|
240
|
+
"用的是已出图的整页图,不重新出图、不额外花钱。",
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: "deck-rules",
|
|
244
|
+
title: "deck.rules.md 是什么",
|
|
245
|
+
keywords: ["规则", "rules", "项目规则", "约束"],
|
|
246
|
+
answer:
|
|
247
|
+
"deck 项目根目录下的 deck.rules.md 是这份 deck 的创作约束(语气、禁用词、版式习惯),必须遵守,但不能覆盖硬纪律(客户源只读 / QA 门禁 / 不编造 / 需用户确认)。\n" +
|
|
248
|
+
"在治理页能看到它是否生效。",
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: "why-no-batch-button",
|
|
252
|
+
title: "为什么没有「一键出整套」按钮(Chat 里可以批量)",
|
|
253
|
+
keywords: ["一键", "批量按钮", "整批", "一次出完", "批量出图", "全部出图"],
|
|
254
|
+
answer:
|
|
255
|
+
"界面上没放一键按钮,是为了避免误触把整份重出、成本失控;但没有限制你批量——\n" +
|
|
256
|
+
"在 Chat 里点名「整份都出」「出 P04–P10」,Agent 会按页序连续出完,不用每页等确认。\n" +
|
|
257
|
+
"逐页调用的好处是每页独立版本 / QA,哪页不满意只重出哪页,不会一锅端。",
|
|
258
|
+
},
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
/** 查询归一化:去空白、小写,保留中文。 */
|
|
262
|
+
function normalize(text) {
|
|
263
|
+
return String(text ?? "").toLowerCase().replace(/\s+/g, "");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 检索:关键词包含 / 标题字重叠,返回命中的条目(最多 limit 条)。 */
|
|
267
|
+
export function searchFaq(query, limit = 3) {
|
|
268
|
+
const q = normalize(query);
|
|
269
|
+
if (!q) return [];
|
|
270
|
+
const scored = [];
|
|
271
|
+
for (const entry of FAQ_ENTRIES) {
|
|
272
|
+
let score = 0;
|
|
273
|
+
const title = normalize(entry.title);
|
|
274
|
+
for (const keyword of entry.keywords ?? []) {
|
|
275
|
+
const key = normalize(keyword);
|
|
276
|
+
if (!key) continue;
|
|
277
|
+
if (q.includes(key)) score += 5;
|
|
278
|
+
else if (key.includes(q)) score += 3;
|
|
279
|
+
}
|
|
280
|
+
// 中文没有空格:用二元组补召回(单个字符太容易误命中,会把无关问题也排上来)。
|
|
281
|
+
const keywordText = (entry.keywords ?? []).map(normalize).join("|");
|
|
282
|
+
for (let index = 0; index < q.length - 1; index += 1) {
|
|
283
|
+
const pair = q.slice(index, index + 2);
|
|
284
|
+
if (/^[\u4e00-\u9fa5]{2}$/.test(pair)) {
|
|
285
|
+
if (title.includes(pair)) score += 1.2;
|
|
286
|
+
if (keywordText.includes(pair)) score += 1.5;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (score >= 2.4) scored.push({ entry, score });
|
|
290
|
+
}
|
|
291
|
+
return scored
|
|
292
|
+
.sort((a, b) => b.score - a.score || a.entry.id.localeCompare(b.entry.id))
|
|
293
|
+
.slice(0, Math.max(1, Math.min(10, Number(limit) || 3)))
|
|
294
|
+
.map((item) => item.entry);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 目录:只给标题 + 关键词,避免把整份 FAQ 塞进上下文。 */
|
|
298
|
+
export function faqIndex() {
|
|
299
|
+
return FAQ_ENTRIES.map((entry) => ({ id: entry.id, title: entry.title, keywords: (entry.keywords ?? []).slice(0, 5) }));
|
|
300
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地 git 提交(页面微调 script 后自动留痕)。
|
|
3
|
+
*
|
|
4
|
+
* 只提交明确列出的文件:`git add -- <files>` + `git commit -- <files>`,
|
|
5
|
+
* 不会把仓库里别人的改动带进来。提交失败只返回错误,不影响已经写好的文件。
|
|
6
|
+
* @module dsh-plugin-deck-studio/git-commit
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
11
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
12
|
+
|
|
13
|
+
function git(dir, args) {
|
|
14
|
+
return execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 找文件所属 git 仓库根(不在仓库里返回 null)。 */
|
|
18
|
+
export function findRepo(dir) {
|
|
19
|
+
try {
|
|
20
|
+
return execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim() || null;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 提交指定文件。
|
|
28
|
+
* @param {object} input
|
|
29
|
+
* @param {string} input.dir 工作目录(repo 内任意路径)
|
|
30
|
+
* @param {string[]} input.files 绝对路径
|
|
31
|
+
* @param {string} input.message commit message
|
|
32
|
+
* @returns {{ok: boolean, commit?: string, repo?: string, error?: string, output?: string}}
|
|
33
|
+
*/
|
|
34
|
+
export function commitFiles({ dir, files, message }) {
|
|
35
|
+
const list = (files ?? []).filter((file) => existsSync(file));
|
|
36
|
+
if (!list.length) return { ok: false, error: "没有需要提交的文件" };
|
|
37
|
+
const repo = findRepo(dir);
|
|
38
|
+
if (!repo) return { ok: false, error: "源 deck 不在 git 仓库里,已保存但未提交" };
|
|
39
|
+
// macOS /tmp → /private/tmp 之类的符号链接会让 relative() 算出 ../,两边都取真实路径再比。
|
|
40
|
+
const realRepo = realpathSync(repo);
|
|
41
|
+
const rels = list.map((file) => relative(realRepo, realpathSync(file)));
|
|
42
|
+
if (rels.some((rel) => rel.startsWith(".."))) return { ok: false, error: "文件在仓库外,拒绝提交" };
|
|
43
|
+
try {
|
|
44
|
+
git(repo, ["add", "--", ...rels]);
|
|
45
|
+
const output = git(repo, ["commit", "-m", message, "--", ...rels]);
|
|
46
|
+
const commit = git(repo, ["rev-parse", "--short", "HEAD"]);
|
|
47
|
+
return { ok: true, commit, repo, output };
|
|
48
|
+
} catch (error) {
|
|
49
|
+
// F-104:commit 被拒(hook / 无身份 / 策略)时把 add 回滚,别把暂存条目(AD)留给用户。
|
|
50
|
+
// 文件本身已写好,调用方仍然可以继续;只是「没提交」。
|
|
51
|
+
try {
|
|
52
|
+
git(repo, ["reset", "-q", "--", ...rels]);
|
|
53
|
+
} catch {
|
|
54
|
+
// 回滚失败不能覆盖真正的 commit 失败原因
|
|
55
|
+
}
|
|
56
|
+
const detail = String(error?.stderr ?? error?.message ?? error).trim().split("\n").slice(-3).join(" ");
|
|
57
|
+
return { ok: false, repo, error: detail || "git commit 失败" };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 删除目录后清掉 index 里属于它的暂存条目(F-104)。
|
|
63
|
+
*
|
|
64
|
+
* 先 `findRepo(target)`(必须在 rm 之前调用,目录删了 git 就找不到仓库),
|
|
65
|
+
* 再 `unstageRemovedPath(repo, target)`;这样不会留下 `AD` 之类的半删除状态。
|
|
66
|
+
* @param {string} repo 仓库根
|
|
67
|
+
* @param {string} target 已删除的绝对路径
|
|
68
|
+
* @returns {boolean} 是否执行了清理
|
|
69
|
+
*/
|
|
70
|
+
export function unstageRemovedPath(repo, target) {
|
|
71
|
+
if (!repo || !target) return false;
|
|
72
|
+
try {
|
|
73
|
+
// 目录已被 rm:realpath(target) 会失败,用「父目录真实路径 + 目录名」还原。
|
|
74
|
+
let abs;
|
|
75
|
+
try {
|
|
76
|
+
abs = realpathSync(target);
|
|
77
|
+
} catch {
|
|
78
|
+
abs = join(realpathSync(dirname(target)), basename(target));
|
|
79
|
+
}
|
|
80
|
+
const rel = relative(realpathSync(repo), abs);
|
|
81
|
+
if (!rel || rel.startsWith("..")) return false;
|
|
82
|
+
git(repo, ["reset", "-q", "--", rel]);
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 首页目录:Designs(风格 + 样张)与 Resources(资源库)。
|
|
3
|
+
*
|
|
4
|
+
* 约定(docs/deck-documents.md「design / 资源」):
|
|
5
|
+
* - 一个风格 = 一个目录:`designs/<ABBR>/`(ABBR 是风格缩写,目录名即缩写,如 `PC`);
|
|
6
|
+
* - 风格元信息:目录下 `style.json`({abbr,name,description})或 `style.md`
|
|
7
|
+
* (可选 frontmatter: abbr/name,正文即风格提示词/描述);
|
|
8
|
+
* - 样张:风格目录下的图片文件,全部属于该风格,首页点 block 弹层预览多张;
|
|
9
|
+
* - 资源库:`resources/index.json`({items:[{name,description,file}]})描述文件;
|
|
10
|
+
* 没有 index 时自动登记目录里的图片(logo 等素材)。
|
|
11
|
+
*
|
|
12
|
+
* 只做扫描与描述,不复制文件、不写盘;文件由 media 路由按白名单伺服。
|
|
13
|
+
* @module dsh-plugin-deck-studio/home-catalog
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
17
|
+
import { extname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".svg"]);
|
|
20
|
+
const MEDIA_DIRS_SOURCE = new Set(["designs", "resources", "assets", "samples", "probe", "styles", "style-samples"]);
|
|
21
|
+
const MEDIA_DIRS_WORK = new Set(["designs", "resources"]);
|
|
22
|
+
// 共享资源库(F-134):<dataRoot>/resources,跨 deck 共用 logo / 头像 / 二维码等。
|
|
23
|
+
const MEDIA_DIRS_SHARED = new Set(["resources"]);
|
|
24
|
+
|
|
25
|
+
const isImage = (name) => IMAGE_EXTS.has(extname(name).toLowerCase());
|
|
26
|
+
const byName = (a, b) => a.localeCompare(b, "zh-Hans-CN");
|
|
27
|
+
|
|
28
|
+
/** 解析极简 frontmatter(只认 `---` 之间的 `key: value`;不引第三方依赖)。 */
|
|
29
|
+
export function parseFrontmatter(text) {
|
|
30
|
+
const source = String(text ?? "").replace(/\r\n/g, "\n");
|
|
31
|
+
if (!source.startsWith("---\n")) return { fields: {}, body: source };
|
|
32
|
+
const end = source.indexOf("\n---", 4);
|
|
33
|
+
if (end < 0) return { fields: {}, body: source };
|
|
34
|
+
const fields = {};
|
|
35
|
+
for (const line of source.slice(4, end).split("\n")) {
|
|
36
|
+
const match = line.match(/^([A-Za-z][\w-]*)\s*[::]\s*(.*)$/);
|
|
37
|
+
if (match) fields[match[1].toLowerCase()] = match[2].trim();
|
|
38
|
+
}
|
|
39
|
+
return { fields, body: source.slice(end + 4).replace(/^\n+/, "") };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 读风格元信息:style.json > style.md > 目录名兜底。 */
|
|
43
|
+
function readStyleMeta(dir, dirName) {
|
|
44
|
+
const jsonFile = join(dir, "style.json");
|
|
45
|
+
if (existsSync(jsonFile)) {
|
|
46
|
+
try {
|
|
47
|
+
const raw = JSON.parse(readFileSync(jsonFile, "utf8"));
|
|
48
|
+
return {
|
|
49
|
+
abbr: String(raw.abbr || dirName).trim(),
|
|
50
|
+
name: String(raw.name || raw.abbr || dirName).trim(),
|
|
51
|
+
description: String(raw.description || raw.prompt || "").trim(),
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
/* 坏 json 退回 style.md */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const mdFile = join(dir, "style.md");
|
|
58
|
+
let description = "";
|
|
59
|
+
let name = dirName;
|
|
60
|
+
let abbr = dirName;
|
|
61
|
+
if (existsSync(mdFile)) {
|
|
62
|
+
const { fields, body } = parseFrontmatter(readFileSync(mdFile, "utf8"));
|
|
63
|
+
if (fields.abbr) abbr = fields.abbr;
|
|
64
|
+
if (fields.name) name = fields.name;
|
|
65
|
+
description = body.trim();
|
|
66
|
+
if (!description) {
|
|
67
|
+
const heading = body.match(/^#+\s*(.*)$/m);
|
|
68
|
+
if (heading) name = heading[1].trim() || name;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { abbr: abbr.trim() || dirName, name: name.trim() || dirName, description };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 目录里的图片样张(只直接子文件,避免把子目录结构带进来)。 */
|
|
75
|
+
function listImages(dir) {
|
|
76
|
+
if (!existsSync(dir)) return [];
|
|
77
|
+
try {
|
|
78
|
+
return readdirSync(dir)
|
|
79
|
+
.filter((name) => !name.startsWith("."))
|
|
80
|
+
.filter((name) => isImage(name))
|
|
81
|
+
.map((name) => ({ name, file: join(dir, name) }))
|
|
82
|
+
.filter((item) => {
|
|
83
|
+
try {
|
|
84
|
+
return statSync(item.file).isFile();
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
.sort((a, b) => byName(a.name, b.name));
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 扫描风格:一个子目录 = 一个风格(source 优先,其次工作副本;同缩写去重)。
|
|
97
|
+
* @param {{deckId: string, sourceRoot?: string, deckDir?: string}} input
|
|
98
|
+
*/
|
|
99
|
+
export function scanDesignStyles({ deckId, sourceRoot = "", deckDir = "" } = {}) {
|
|
100
|
+
const roots = [];
|
|
101
|
+
if (sourceRoot && existsSync(join(sourceRoot, "designs"))) roots.push({ scope: "source", dir: join(sourceRoot, "designs") });
|
|
102
|
+
if (deckDir && existsSync(join(deckDir, "designs"))) roots.push({ scope: "work", dir: join(deckDir, "designs") });
|
|
103
|
+
|
|
104
|
+
const styles = [];
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
for (const root of roots) {
|
|
107
|
+
let entries = [];
|
|
108
|
+
try {
|
|
109
|
+
entries = readdirSync(root.dir).filter((name) => !name.startsWith(".")).sort(byName);
|
|
110
|
+
} catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
for (const dirName of entries) {
|
|
114
|
+
const styleDir = join(root.dir, dirName);
|
|
115
|
+
let stat;
|
|
116
|
+
try {
|
|
117
|
+
stat = statSync(styleDir);
|
|
118
|
+
} catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!stat.isDirectory()) continue;
|
|
122
|
+
const meta = readStyleMeta(styleDir, dirName);
|
|
123
|
+
const key = meta.abbr.toLowerCase();
|
|
124
|
+
if (seen.has(key)) continue;
|
|
125
|
+
seen.add(key);
|
|
126
|
+
const samples = listImages(styleDir).map((item) => ({
|
|
127
|
+
name: item.name,
|
|
128
|
+
sizeBytes: (() => {
|
|
129
|
+
try {
|
|
130
|
+
return statSync(item.file).size;
|
|
131
|
+
} catch {
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
})(),
|
|
135
|
+
url: mediaUrl({ deckId, scope: root.scope, path: `designs/${dirName}/${item.name}` }),
|
|
136
|
+
}));
|
|
137
|
+
styles.push({
|
|
138
|
+
abbr: meta.abbr,
|
|
139
|
+
name: meta.name,
|
|
140
|
+
description: meta.description,
|
|
141
|
+
dir: dirName,
|
|
142
|
+
scope: root.scope,
|
|
143
|
+
samples,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return styles;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 扫描资源库:`resources/index.json` 描述优先,其次自动登记 resources/ 与 assets/ 里的图片。
|
|
152
|
+
*/
|
|
153
|
+
export function scanResources({ deckId, sourceRoot = "", deckDir = "", sharedRoot = "" } = {}) {
|
|
154
|
+
const roots = [];
|
|
155
|
+
// 共享库放最前:同一张图在 deck 内也有时,优先展示共享条目的名称/描述。
|
|
156
|
+
if (sharedRoot) roots.push({ scope: "shared", dir: join(sharedRoot, "resources") });
|
|
157
|
+
if (deckDir) roots.push({ scope: "work", dir: join(deckDir, "resources") });
|
|
158
|
+
if (sourceRoot) {
|
|
159
|
+
roots.push({ scope: "source", dir: join(sourceRoot, "resources") });
|
|
160
|
+
roots.push({ scope: "source", dir: join(sourceRoot, "assets") });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const resources = [];
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
const seenNames = new Set();
|
|
166
|
+
const push = (item, fileName = "") => {
|
|
167
|
+
const urlKey = item.url;
|
|
168
|
+
const nameKey = String(fileName || item.name || "").toLowerCase();
|
|
169
|
+
if (seen.has(urlKey) || (nameKey && seenNames.has(nameKey))) return;
|
|
170
|
+
seen.add(urlKey);
|
|
171
|
+
if (nameKey) seenNames.add(nameKey);
|
|
172
|
+
resources.push(item);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
for (const root of roots) {
|
|
176
|
+
if (!existsSync(root.dir)) continue;
|
|
177
|
+
const dirName = root.dir.split("/").pop();
|
|
178
|
+
const indexFile = join(root.dir, "index.json");
|
|
179
|
+
if (existsSync(indexFile)) {
|
|
180
|
+
let items = [];
|
|
181
|
+
try {
|
|
182
|
+
const raw = JSON.parse(readFileSync(indexFile, "utf8"));
|
|
183
|
+
items = Array.isArray(raw) ? raw : (raw.items ?? []);
|
|
184
|
+
} catch {
|
|
185
|
+
items = [];
|
|
186
|
+
}
|
|
187
|
+
for (const entry of items) {
|
|
188
|
+
const file = String(entry?.file ?? "").trim();
|
|
189
|
+
if (!file || file.includes("..") || file.includes("/")) continue;
|
|
190
|
+
const full = join(root.dir, file);
|
|
191
|
+
if (!existsSync(full) || !isImage(file)) continue;
|
|
192
|
+
push({
|
|
193
|
+
name: String(entry.name || file).trim(),
|
|
194
|
+
description: String(entry.description || "").trim(),
|
|
195
|
+
kind: extname(file).slice(1).toUpperCase(),
|
|
196
|
+
scope: root.scope,
|
|
197
|
+
sizeBytes: statSync(full).size,
|
|
198
|
+
url: mediaUrl({ deckId, scope: root.scope, path: `${dirName}/${file}` }),
|
|
199
|
+
}, file);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// 没有 index.json 或 index 里没登记的图片:自动登记(按文件名当描述)
|
|
203
|
+
for (const item of listImages(root.dir)) {
|
|
204
|
+
if (item.name.toLowerCase() === "index.json") continue;
|
|
205
|
+
const already = resources.some((resource) => resource.url.endsWith(`/${encodeURIComponent(item.name)}`) || resource.url.endsWith(`/${item.name}`));
|
|
206
|
+
if (already) continue;
|
|
207
|
+
try {
|
|
208
|
+
push({
|
|
209
|
+
name: item.name.replace(/\.[a-z0-9]+$/i, ""),
|
|
210
|
+
description: `源素材 · ${dirName}/`,
|
|
211
|
+
kind: extname(item.name).slice(1).toUpperCase(),
|
|
212
|
+
scope: root.scope,
|
|
213
|
+
sizeBytes: statSync(item.file).size,
|
|
214
|
+
url: mediaUrl({ deckId, scope: root.scope, path: `${dirName}/${item.name}` }),
|
|
215
|
+
}, item.name);
|
|
216
|
+
} catch {
|
|
217
|
+
/* 单文件读不到就跳过 */
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return resources;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** media 路由 URL(所有目录/文件都在 URL 里编码,前端直接用)。 */
|
|
225
|
+
export function mediaUrl({ deckId, scope, path }) {
|
|
226
|
+
return `/api/deck-studio.media?` + new URLSearchParams({ deckId, scope, path });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* media 路由解析:只允许白名单目录 + 图片扩展名 + 不允许 `..`/绝对路径。
|
|
231
|
+
* @returns {{file: string}|null}
|
|
232
|
+
*/
|
|
233
|
+
export function resolveMediaFile({ deckId, scope, path, sourceRoot = "", deckDir = "", sharedRoot = "" }) {
|
|
234
|
+
const base = scope === "shared" ? sharedRoot : scope === "work" ? deckDir : sourceRoot;
|
|
235
|
+
if (scope === "shared" && !sharedRoot) return null;
|
|
236
|
+
if (!base || !path || typeof path !== "string") return null;
|
|
237
|
+
if (path.includes("..") || path.startsWith("/") || path.includes("\\")) return null;
|
|
238
|
+
const first = path.split("/")[0];
|
|
239
|
+
const allowed = scope === "shared" ? MEDIA_DIRS_SHARED : scope === "work" ? MEDIA_DIRS_WORK : MEDIA_DIRS_SOURCE;
|
|
240
|
+
if (!allowed.has(first)) return null;
|
|
241
|
+
if (!isImage(path)) return null;
|
|
242
|
+
const file = join(base, path);
|
|
243
|
+
if (!existsSync(file)) return null;
|
|
244
|
+
try {
|
|
245
|
+
return statSync(file).isFile() ? { file } : null;
|
|
246
|
+
} catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|