cardbuddy 0.9.1 → 0.11.0

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.
Files changed (64) hide show
  1. package/README.md +21 -6
  2. package/dist/cli.js +17 -6
  3. package/dist/config.js +20 -2
  4. package/dist/portGuard.js +293 -0
  5. package/dist/preview-editor/assets/index-B4VZgNSK.css +1 -0
  6. package/dist/preview-editor/assets/index-jl4YJzKv.js +180 -0
  7. package/dist/preview-editor/index.html +2 -2
  8. package/dist/server-core/_vendor/finmall-env.mjs +4 -4
  9. package/dist/server-core/ai-chat.js +3710 -0
  10. package/dist/server-core/assetProxy.js +3 -22
  11. package/dist/server-core/buildDataFromSchema.js +36 -4
  12. package/dist/server-core/cardFiles.js +95 -2
  13. package/dist/server-core/cardImage.js +14 -0
  14. package/dist/server-core/cardListCache.js +0 -17
  15. package/dist/server-core/cardSave.js +2 -0
  16. package/dist/server-core/cardValidate.js +3 -0
  17. package/dist/server-core/index.js +6 -5
  18. package/dist/server-core/logStream.js +1 -6
  19. package/dist/server-core/sliceUpload.js +8 -1
  20. package/dist/standalone.js +24 -8
  21. package/dist/vendored/App.vue +405 -125
  22. package/dist/vendored/api.js +4 -4
  23. package/dist/vendored/assets/svgIcons/icons.svg +28 -0
  24. package/dist/vendored/components/AiSettings.vue +392 -0
  25. package/dist/vendored/components/CardGallery.vue +51 -5
  26. package/dist/vendored/components/ChatComposer.vue +413 -0
  27. package/dist/vendored/components/ChatMessage.vue +383 -0
  28. package/dist/vendored/components/ChatPanel.vue +937 -0
  29. package/dist/vendored/components/ComponentPalette.vue +1 -1
  30. package/dist/vendored/components/DebugPanel.vue +732 -90
  31. package/dist/vendored/components/Dropdown.vue +4 -2
  32. package/dist/vendored/components/MarkdownBody.vue +64 -0
  33. package/dist/vendored/components/NewCardDialog.vue +132 -0
  34. package/dist/vendored/components/NumberStepper.vue +125 -0
  35. package/dist/vendored/components/ProgressSlider.vue +9 -1
  36. package/dist/vendored/components/RemotePreview.vue +1 -1
  37. package/dist/vendored/components/StructureTree.vue +2 -2
  38. package/dist/vendored/main.js +1 -0
  39. package/dist/vendored/runtime/adapter.js +1 -1
  40. package/dist/vendored/runtime/ai-api.js +165 -0
  41. package/dist/vendored/runtime/clipboard-node.js +0 -5
  42. package/dist/vendored/runtime/markdown.js +140 -0
  43. package/dist/vendored/skills/.manifest.json +1101 -0
  44. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +249 -0
  45. package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +678 -0
  46. package/dist/vendored/skills/dsl-card-generator/scripts/derive_data_json.py +81 -0
  47. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +105 -0
  48. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +624 -0
  49. package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +900 -0
  50. package/dist/vendored/skills/finmall-card-sync/scripts/slice_upload.mjs +699 -0
  51. package/dist/vendored/skills/shared/finmall-config.mjs +124 -0
  52. package/dist/vendored/styles/ai-chat.css +1850 -0
  53. package/dist/vendored/styles/app.css +725 -213
  54. package/dist/vendored/styles/card-gallery.css +48 -10
  55. package/dist/vendored/styles/card-select.css +13 -6
  56. package/dist/vendored/styles/dropdown.css +1 -2
  57. package/dist/vendored/styles/json-tree.css +3 -3
  58. package/dist/vendored/styles/node-inspector.css +13 -289
  59. package/dist/vendored/styles/setters.css +48 -49
  60. package/dist/vendored/styles/structure-tree.css +6 -11
  61. package/package.json +1 -1
  62. package/dist/preview-editor/assets/index-B9MOriNv.js +0 -80
  63. package/dist/preview-editor/assets/index-CwD14gm3.css +0 -1
  64. package/dist/vendored/assets/logo.png +0 -0
@@ -0,0 +1,699 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * slice_upload —— 切图上传(slices_convention.md 的「③ 远程同步」阶段实现)。
4
+ *
5
+ * 把 `src/slices/` 下被卡片引用的切图上传到 FinMall 图片库(`CC0000.do`,multipart:
6
+ * `fileName` + `file`),拿到线上地址(形如 `@filestore/dzqd-download/<ts>_<name>.png`),
7
+ * 然后做两件事:
8
+ * 1. 写「本地文件 ↔ 线上地址」映射:`src/slices/manifest.json`;
9
+ * 2. 把卡片 `schema.json` / `data.json` 里的 `"/slices/<name>"` 引用改写为该线上地址
10
+ * (运行时 `adapter.doReplaceUrl` 再把 `@filestore/x` 解析成 `/minio/x`,预览链路无需改动)。
11
+ *
12
+ * 鉴权与会话复用与 api_batch_sync.mjs 完全一致:ensureFinMallSession 拉起/复用后台无界面
13
+ * Chrome,token(localStorage `_sessionId`)只在页面上下文内读取并直接用于请求——
14
+ * **绝不回传 Node 侧、不打印、不落盘**(见 shared/auth-model.md §1.5)。
15
+ * 命中会话/权限失效(BLEC0006 等)时自动清 session 重登并重试一次。
16
+ *
17
+ * 幂等:按内容 sha256 判重,已上传且内容未变的切图直接跳过;卡片引用改写可反复执行。
18
+ * **同内容只上传一次**:判重不认文件名——src/slices 里内容完全相同但命名不同的副本、或多张卡共用
19
+ * 的同一张切图,都按 sha256 命中已上传条目直接复用同一线上地址(manifest 记 `reusedFrom`),
20
+ * 绝不为同一张图产生第二个 @filestore 地址(`--force` 才强制重传)。
21
+ * 切图内容变了会重新上传得到新地址,旧地址记入 manifest 的 `superseded` 并一并被改写,
22
+ * 避免卡片继续指向过期的线上图。
23
+ *
24
+ * 用法:
25
+ * node skills/finmall-card-sync/scripts/slice_upload.mjs
26
+ * node skills/finmall-card-sync/scripts/slice_upload.mjs --only=arrowRightGray.png,icon.png
27
+ * node skills/finmall-card-sync/scripts/slice_upload.mjs --all --dry-run
28
+ * node skills/finmall-card-sync/scripts/slice_upload.mjs --force --no-rewrite
29
+ */
30
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
31
+ import { createHash } from 'node:crypto';
32
+ import path from 'node:path';
33
+ import { fileURLToPath } from 'node:url';
34
+ import { finmallConfig } from '../../shared/finmall-config.mjs';
35
+
36
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
37
+ const skillDir = path.resolve(scriptDir, '..');
38
+ // 与 api_batch_sync.mjs 一致:skill 位于 <repo-root>/skills/<name>,仓库根为上两级。
39
+ const workspace = path.resolve(skillDir, '..', '..');
40
+
41
+ // 统一日志美化(容错:缺 server/core/logger 时回退极简实现,保证脚本可独立运行)。
42
+ let log;
43
+ try {
44
+ ({ log } = await import('../../../server/core/logger.js'));
45
+ } catch {
46
+ const w = (m, ...a) => console.log(`[slice] ${m}`, ...a);
47
+ log = {
48
+ info: w,
49
+ ok: w,
50
+ warn: (m, ...a) => console.warn(`[slice] ${m}`, ...a),
51
+ error: (m, ...a) => console.error(`[slice] ${m}`, ...a),
52
+ box: () => {},
53
+ rule: () => {},
54
+ };
55
+ }
56
+
57
+ import { ensureFinMallSession } from '../../finmall-session-navigator/scripts/session-manager.mjs';
58
+ import {
59
+ performLogin,
60
+ clearTimeoutPrompts,
61
+ waitForAuthenticatedPage,
62
+ loadCredentials,
63
+ } from '../../finmall-session-navigator/scripts/finmall_login.mjs';
64
+
65
+ // 本机设了 HTTP_PROXY 时,Node→Chrome 的 CDP 连接(127.0.0.1)必须排除在代理外。
66
+ for (const key of ['NO_PROXY', 'no_proxy']) {
67
+ const cur = process.env[key];
68
+ const extra = '127.0.0.1,localhost';
69
+ process.env[key] = cur && cur.length ? `${cur},${extra}` : extra;
70
+ }
71
+
72
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
73
+
74
+ /** 映射清单文件名(与切图真源同目录,随 src/ 一起入库) */
75
+ export const MANIFEST_FILE = 'manifest.json';
76
+
77
+ const IMAGE_MIME = {
78
+ '.png': 'image/png',
79
+ '.jpg': 'image/jpeg',
80
+ '.jpeg': 'image/jpeg',
81
+ '.webp': 'image/webp',
82
+ '.gif': 'image/gif',
83
+ '.svg': 'image/svg+xml',
84
+ };
85
+
86
+ // 单文件上传超时。切图都很小(当前全量 <1MB),但 Filestore 对异常对象有「挂起不响应」的
87
+ // 先例(见 api_batch_sync 的封面下载闸门),故内层 AbortController + 外层 race 双重设防。
88
+ const UPLOAD_TIMEOUT_MS = Number(process.env.CARDBUDDY_UPLOAD_TIMEOUT_MS || 60000);
89
+
90
+ // 服务端判定会话/权限失效并应「重新进入」的特征集合(与 api_batch_sync 同义)。
91
+ const SESSION_INVALID_RE = /BLEC0006|会话超时|会话过期|登录超时|未登录|登录失效|登录已失效|请重新登录|invalid token|token|角色菜单权限发生变化|权限发生变化|重新进入|请重新进入/i;
92
+
93
+ /** 卡片目录里参与引用改写的文件名(.history/ 快照与封面图不碰) */
94
+ const CARD_JSON_FILES = ['schema.json', 'data.json'];
95
+
96
+ function withTimeout(promise, ms, label) {
97
+ let timer;
98
+ return Promise.race([
99
+ promise,
100
+ new Promise((_, reject) => {
101
+ timer = setTimeout(() => reject(new Error(`${label}超时(>${ms}ms),已放弃`)), ms);
102
+ }),
103
+ ]).finally(() => clearTimeout(timer));
104
+ }
105
+
106
+ /** 清空过期会话并以保存的凭据重新登录(与 api_batch_sync.relogin 同实现)。 */
107
+ async function relogin(page) {
108
+ await clearTimeoutPrompts(page);
109
+ await page
110
+ .evaluate(() => {
111
+ try {
112
+ localStorage.clear();
113
+ } catch {
114
+ /* ignore */
115
+ }
116
+ })
117
+ .catch(() => {});
118
+ await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
119
+ await sleep(1500);
120
+ const credentials = await loadCredentials();
121
+ const result = await performLogin(page, credentials);
122
+ if (!result.sessionCreated) {
123
+ throw new Error(
124
+ '自动重新登录失败:' +
125
+ (result.businessFailure ? '登录接口返回业务失败' : `HTTP ${result?.status || '失败'}`),
126
+ );
127
+ }
128
+ await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
129
+ await sleep(1500);
130
+ await clearTimeoutPrompts(page);
131
+ await waitForAuthenticatedPage(page, 20000);
132
+ }
133
+
134
+ /**
135
+ * 在已认证页面上下文内以 multipart/form-data 上传单个文件。
136
+ * 请求形态取自线上编辑器抓包:表单字段 `fileName`(纯文本原名)+ `file`(二进制,带
137
+ * filename 与 Content-Type),查询串带 `sassAppId` / `sassWorkspaceId`。
138
+ *
139
+ * @returns {Promise<string>} 原始响应文本(交由 parseUploadResponse 解析)
140
+ */
141
+ async function postUpload(page, { fileName, mime, base64, appId, workspaceId, endpoint }) {
142
+ return withTimeout(
143
+ page.evaluate(
144
+ async ({ fileName, mime, base64, appId, workspaceId, endpoint, timeoutMs }) => {
145
+ const controller = new AbortController();
146
+ setTimeout(() => controller.abort(), timeoutMs);
147
+ // token 仅在页面上下文内读取并直接用于请求,不回传 Node 侧、不打印、不落盘。
148
+ const readSessionToken = () => {
149
+ try {
150
+ const raw = localStorage.getItem('_sessionId');
151
+ if (!raw) return '';
152
+ let value = JSON.parse(raw);
153
+ if (value && typeof value === 'object' && 'value' in value) value = value.value;
154
+ if (typeof value === 'string' && value.startsWith('"')) {
155
+ try {
156
+ value = JSON.parse(value);
157
+ } catch {
158
+ /* 保留原字符串 */
159
+ }
160
+ }
161
+ return typeof value === 'string' ? value : '';
162
+ } catch {
163
+ return '';
164
+ }
165
+ };
166
+ const sessionToken = readSessionToken();
167
+ if (!sessionToken) {
168
+ throw new Error('页面缺少会话凭据(localStorage._sessionId 为空),请先运行 finmall-session-navigator 登录。');
169
+ }
170
+ const binary = atob(base64);
171
+ const bytes = new Uint8Array(binary.length);
172
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
173
+ const form = new FormData();
174
+ form.append('fileName', fileName);
175
+ form.append('file', new Blob([bytes], { type: mime }), fileName);
176
+ const url = new URL(endpoint, location.origin);
177
+ url.searchParams.set('sassAppId', String(appId));
178
+ url.searchParams.set('sassWorkspaceId', String(workspaceId));
179
+ const response = await fetch(url.href, {
180
+ method: 'POST',
181
+ headers: { token: sessionToken, iCIFID: sessionToken, eCIFID: sessionToken, Accept: '*/*' },
182
+ credentials: 'include',
183
+ body: form,
184
+ signal: controller.signal,
185
+ });
186
+ const text = await response.text();
187
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${text.slice(0, 200)}`);
188
+ return text;
189
+ },
190
+ { fileName, mime, base64, appId, workspaceId, endpoint, timeoutMs: UPLOAD_TIMEOUT_MS },
191
+ ),
192
+ UPLOAD_TIMEOUT_MS + 2000,
193
+ `上传 ${fileName}`,
194
+ );
195
+ }
196
+
197
+ /**
198
+ * 解析 CC0000.do 响应,取出线上地址。已验证响应形态:
199
+ * `{ header:{errorCode:"0"}, body:{ errorCode:"0", uploadFileList:[
200
+ * { fileName:"x.png", filePath:"@filestore/dzqd-download/<ts>_x.png", size:"443", dimensions:"60x60" } ] } }`
201
+ * 会话失效时 header/body 双双返回 errorCode=BLEC0006,由 uploadWithHeal 捕获后重登重试。
202
+ */
203
+ function parseUploadResponse(text, fileName) {
204
+ let parsed;
205
+ try {
206
+ parsed = JSON.parse(text);
207
+ } catch {
208
+ throw new Error(`上传响应不是 JSON:${text.slice(0, 300)}`);
209
+ }
210
+ const header = (parsed && parsed.header) || {};
211
+ const body = (parsed && parsed.body) || {};
212
+ const errorCode = header.errorCode || body.errorCode;
213
+ if (errorCode && errorCode !== '0') {
214
+ throw new Error(`FinMall 上传失败 [errorCode=${errorCode}] ${header.errorMsg || body.errorMsg || ''}`);
215
+ }
216
+ const list = Array.isArray(body.uploadFileList)
217
+ ? body.uploadFileList
218
+ : Array.isArray(parsed.uploadFileList)
219
+ ? parsed.uploadFileList
220
+ : null;
221
+ if (!list || !list.length) {
222
+ throw new Error(`上传响应缺少 uploadFileList:${text.slice(0, 300)}`);
223
+ }
224
+ // uploadFileList 是数组(接口支持一次多文件),按 fileName 精确回显匹配,避免批量时错位。
225
+ const hit = list.find((item) => item && item.fileName === fileName) || list[0];
226
+ const url = hit && (hit.filePath || hit.fileUrl || hit.url);
227
+ if (!url || typeof url !== 'string') {
228
+ throw new Error(`uploadFileList 未给出可用地址:${JSON.stringify(hit).slice(0, 300)}`);
229
+ }
230
+ return { url, dimensions: String(hit.dimensions || ''), remoteSize: String(hit.size || '') };
231
+ }
232
+
233
+ /** 带自愈的上传:命中会话/权限失效特征则清 session 重登后重试一次。 */
234
+ async function uploadWithHeal(page, payload) {
235
+ try {
236
+ return parseUploadResponse(await postUpload(page, payload), payload.fileName);
237
+ } catch (error) {
238
+ if (SESSION_INVALID_RE.test(error.message)) {
239
+ log.warn('会话/权限失效,自动重新登录后重试上传', { file: payload.fileName, reason: error.message.slice(0, 40) });
240
+ await relogin(page);
241
+ return parseUploadResponse(await postUpload(page, payload), payload.fileName);
242
+ }
243
+ throw error;
244
+ }
245
+ }
246
+
247
+ /** 扫描 src/slices 下的图片真源(扁平落点,manifest.json 等非图片文件自动忽略) */
248
+ function listSlices(slicesRoot) {
249
+ if (!existsSync(slicesRoot)) return [];
250
+ const out = [];
251
+ for (const name of readdirSync(slicesRoot).sort()) {
252
+ const file = path.join(slicesRoot, name);
253
+ if (!statSync(file).isFile()) continue; // 约定为扁平落点,子目录一律忽略
254
+ const mime = IMAGE_MIME[path.extname(name).toLowerCase()];
255
+ if (!mime) continue;
256
+ const buf = readFileSync(file);
257
+ out.push({
258
+ name,
259
+ file,
260
+ mime,
261
+ size: buf.length,
262
+ sha256: createHash('sha256').update(buf).digest('hex'),
263
+ base64: buf.toString('base64'),
264
+ });
265
+ }
266
+ return out;
267
+ }
268
+
269
+ /**
270
+ * 扫描卡片目录,收集 `/slices/<name>` 引用。
271
+ * 只认「整串等于 /slices/<name>」的字符串值(当前全量 71 处引用均为此形态);同时认
272
+ * 「整串等于映射清单里已记录的线上地址(含 superseded 历史地址)」——已改写过的卡片不再
273
+ * 出现 `/slices/`,若不反查就会把这张卡当成「没引用切图」,导致 manifest 的 cards 关联被
274
+ * 抹掉、二次点击误报「无需上传」。
275
+ *
276
+ * @param {string} cardsRoot
277
+ * @param {Set<string>} sliceNames src/slices 下实际存在的文件名,用于识别悬空引用
278
+ * @param {Map<string, string>} urlToName 线上地址(含历史地址)→ 本地切图文件名
279
+ * @returns {{ refs: Map<string, Set<string>>, cardFiles: string[], dangling: string[] }}
280
+ */
281
+ function collectCardRefs(cardsRoot, sliceNames, urlToName) {
282
+ const refs = new Map();
283
+ const cardFiles = [];
284
+ if (!existsSync(cardsRoot)) return { refs, cardFiles, dangling: [] };
285
+ const walk = (value, cardId) => {
286
+ if (typeof value === 'string') {
287
+ const matched = /^\/slices\/([^/]+)$/.exec(value);
288
+ const name = matched ? matched[1] : urlToName.get(value);
289
+ if (name) {
290
+ if (!refs.has(name)) refs.set(name, new Set());
291
+ refs.get(name).add(cardId);
292
+ }
293
+ return;
294
+ }
295
+ if (Array.isArray(value)) {
296
+ for (const item of value) walk(item, cardId);
297
+ return;
298
+ }
299
+ if (value && typeof value === 'object') {
300
+ for (const item of Object.values(value)) walk(item, cardId);
301
+ }
302
+ };
303
+ for (const cardId of readdirSync(cardsRoot).sort()) {
304
+ const dir = path.join(cardsRoot, cardId);
305
+ if (!statSync(dir).isDirectory()) continue;
306
+ for (const name of CARD_JSON_FILES) {
307
+ const file = path.join(dir, name);
308
+ if (!existsSync(file)) continue;
309
+ cardFiles.push(file);
310
+ try {
311
+ walk(JSON.parse(readFileSync(file, 'utf8')), cardId);
312
+ } catch {
313
+ /* 单卡 JSON 损坏不阻断整批上传,改写阶段会再次跳过 */
314
+ }
315
+ }
316
+ }
317
+ const dangling = [...refs.keys()].filter((name) => !sliceNames.has(name)).sort();
318
+ return { refs, cardFiles, dangling };
319
+ }
320
+
321
+ /**
322
+ * 把「全量引用表」收窄到单张卡片(顶栏「切图上传」按钮走的就是单卡模式)。
323
+ * @param {Map<string, Set<string>>} refs 文件名 → 引用它的卡片 id 集合
324
+ * @param {string} cardId
325
+ * @returns {Map<string, Set<string>>}
326
+ */
327
+ function scopeRefsToCard(refs, cardId) {
328
+ const scoped = new Map();
329
+ for (const [name, cards] of refs) {
330
+ if (cards.has(cardId)) scoped.set(name, new Set([cardId]));
331
+ }
332
+ return scoped;
333
+ }
334
+
335
+ /**
336
+ * 由映射清单构造反查表:线上地址(当前地址 + superseded 历史地址)→ 本地切图文件名。
337
+ * @param {Record<string, {url?: string, superseded?: string[]}>} files
338
+ * @returns {Map<string, string>}
339
+ */
340
+ function buildUrlToName(files) {
341
+ const urlToName = new Map();
342
+ for (const [name, entry] of Object.entries(files || {})) {
343
+ if (!entry) continue;
344
+ if (entry.url) urlToName.set(entry.url, name);
345
+ for (const old of entry.superseded || []) {
346
+ if (old) urlToName.set(old, name);
347
+ }
348
+ }
349
+ return urlToName;
350
+ }
351
+
352
+ function readManifest(manifestPath) {
353
+ if (!existsSync(manifestPath)) return { version: 1, files: {} };
354
+ let parsed;
355
+ try {
356
+ parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
357
+ } catch (error) {
358
+ throw new Error(`映射清单损坏(${manifestPath}):${error.message}`);
359
+ }
360
+ return {
361
+ version: parsed.version || 1,
362
+ updatedAt: parsed.updatedAt,
363
+ files: parsed.files && typeof parsed.files === 'object' ? parsed.files : {},
364
+ };
365
+ }
366
+
367
+ /** 原子写(临时文件 + rename),与 server/core/cardFiles.js 的落盘纪律一致 */
368
+ function writeJsonAtomic(file, value) {
369
+ const tmp = `${file}.tmp`;
370
+ writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8');
371
+ renameSync(tmp, file);
372
+ }
373
+
374
+ /**
375
+ * 把卡片里的本地切图引用改写为线上地址。
376
+ *
377
+ * 采用「JSON 解析定位 + 原文精确替换」:只把 `JSON.stringify(旧值)` 的字面量替换为
378
+ * `JSON.stringify(新值)`,从而**完全不改动文件的缩进 / 键序 / 末尾换行**(仓库约定
379
+ * schema.json 无末尾换行,整体重新序列化会造成全文件 diff)。
380
+ * 除 `/slices/<name>` 外,同时替换 manifest 记录的 `superseded` 旧线上地址,
381
+ * 保证切图内容更新后卡片不会继续指向过期图。
382
+ *
383
+ * @param {string[]} cardFiles
384
+ * @param {string} root 仓库根(仅用于把结果里的路径写成相对路径)
385
+ * @param {Map<string, {url:string, superseded:string[]}>} mapping
386
+ * @returns {Array<{file:string, cardId:string, count:number}>}
387
+ */
388
+ function rewriteCardRefs(cardFiles, root, mapping) {
389
+ const rewritten = [];
390
+ for (const file of cardFiles) {
391
+ const raw = readFileSync(file, 'utf8');
392
+ if (!raw.includes('/slices/') && !raw.includes('@filestore/')) continue;
393
+ let out = raw;
394
+ let count = 0;
395
+ for (const [name, entry] of mapping) {
396
+ if (!entry || !entry.url) continue;
397
+ const pairs = [[`/slices/${name}`, entry.url]];
398
+ for (const old of entry.superseded || []) {
399
+ if (old && old !== entry.url) pairs.push([old, entry.url]);
400
+ }
401
+ for (const [from, to] of pairs) {
402
+ if (from === to) continue;
403
+ const needle = JSON.stringify(from);
404
+ if (!out.includes(needle)) continue;
405
+ count += out.split(needle).length - 1;
406
+ out = out.split(needle).join(JSON.stringify(to));
407
+ }
408
+ }
409
+ if (!count) continue;
410
+ const tmp = `${file}.tmp`;
411
+ writeFileSync(tmp, out, 'utf8');
412
+ renameSync(tmp, file);
413
+ rewritten.push({ file: path.relative(root, file), cardId: path.basename(path.dirname(file)), count });
414
+ }
415
+ return rewritten;
416
+ }
417
+
418
+ /**
419
+ * 上传 src/slices 切图并改写卡片引用。
420
+ *
421
+ * @param {object} [opts]
422
+ * @param {string} [opts.cardId] 单卡模式:只上传该卡引用的切图、只改写该卡的 schema/data
423
+ * (顶栏「切图上传」按钮走此模式);缺省 = 全量卡片
424
+ * @param {string[]} [opts.only] 仅处理这些文件名(默认:全部被卡片引用的切图)
425
+ * @param {boolean} [opts.all] true=连未被任何卡片引用的切图一起上传
426
+ * @param {boolean} [opts.force] true=忽略 sha256 判重,强制重新上传
427
+ * @param {boolean} [opts.dryRun] true=只报告计划,不上传、不写盘
428
+ * @param {boolean} [opts.noRewrite] true=只上传 + 写映射,不改写卡片引用
429
+ * @param {string} [opts.appId] 覆盖 sassAppId(默认取 .env.cb 的 FINMALL_APP_ID)
430
+ * @param {string} [opts.workspaceId] 覆盖 sassWorkspaceId
431
+ * @param {string} [opts.slicesRoot] 切图目录(默认 <repo>/src/slices)
432
+ * @param {string} [opts.cardsRoot] 卡片目录(默认 <repo>/src/cards)
433
+ * @returns {Promise<object>} 汇总结果
434
+ */
435
+ export async function uploadSlices(opts = {}) {
436
+ const slicesRoot = path.resolve(opts.slicesRoot || path.join(workspace, 'src', 'slices'));
437
+ const cardsRoot = path.resolve(opts.cardsRoot || path.join(workspace, 'src', 'cards'));
438
+ const manifestPath = path.join(slicesRoot, MANIFEST_FILE);
439
+ const appId = String(opts.appId || finmallConfig.appId);
440
+ const workspaceId = String(opts.workspaceId || finmallConfig.workspaceId);
441
+ const dryRun = !!opts.dryRun;
442
+ const force = !!opts.force;
443
+ const cardId = opts.cardId ? String(opts.cardId) : '';
444
+ // 卡片 id 即目录名,只允许 api_batch_sync.normalizeCard 同款安全字符集;
445
+ // 首字符排除 `.`,避免 `..` 这类纯点号 id 拼出越权路径。
446
+ if (cardId && !/^[\w][\w.-]*$/.test(cardId)) throw new Error(`非法卡片 id:${cardId}`);
447
+ if (cardId && !existsSync(path.join(cardsRoot, cardId))) {
448
+ throw new Error(`卡片不存在:${cardId}(${path.relative(workspace, cardsRoot)})`);
449
+ }
450
+
451
+ const slices = listSlices(slicesRoot);
452
+ const sliceNames = new Set(slices.map((s) => s.name));
453
+ // 映射清单先读:已改写过的卡片引用的是线上地址而非 /slices/,要靠反查表才能把它们
454
+ // 认回本地切图(否则二次运行会误判「该卡没引用切图」并抹掉 manifest 的 cards 关联)。
455
+ const manifest = readManifest(manifestPath);
456
+ let manifestDirty = false;
457
+ // 跨文件名同内容去重:sha256 → 已上传条目。共用切图(多卡引用同一张图,或同图在 src/slices
458
+ // 里有命名不同的副本)只上传一次,其余按内容命中直接复用同一线上地址。
459
+ const shaToUploaded = new Map();
460
+ for (const [name, entry] of Object.entries(manifest.files)) {
461
+ if (entry && entry.url && entry.sha256 && !shaToUploaded.has(entry.sha256)) {
462
+ shaToUploaded.set(entry.sha256, { name, entry });
463
+ }
464
+ }
465
+ // 引用表始终按「全量卡片」统计:manifest 的 cards 字段要如实反映一张切图被哪些卡引用,
466
+ // 不能因为本次只处理单卡就把其他卡的引用关系抹掉。
467
+ const scanned = collectCardRefs(cardsRoot, sliceNames, buildUrlToName(manifest.files));
468
+ // 单卡模式把「上传范围 + 改写范围」都收窄到当前卡;共享切图若已被别的卡上传过,
469
+ // 这里按 sha256 命中映射直接复用线上地址,不会重复上传。
470
+ const refs = cardId ? scopeRefsToCard(scanned.refs, cardId) : scanned.refs;
471
+ const cardFiles = cardId
472
+ ? scanned.cardFiles.filter((file) => path.basename(path.dirname(file)) === cardId)
473
+ : scanned.cardFiles;
474
+ const dangling = cardId
475
+ ? scanned.dangling.filter((name) => scanned.refs.get(name)?.has(cardId))
476
+ : scanned.dangling;
477
+ const only = Array.isArray(opts.only) && opts.only.length ? new Set(opts.only) : null;
478
+ if (only) {
479
+ const unknown = [...only].filter((name) => !sliceNames.has(name));
480
+ if (unknown.length) throw new Error(`--only 指定的切图不存在:${unknown.join('、')}`);
481
+ }
482
+
483
+ const summary = {
484
+ success: true,
485
+ dryRun,
486
+ cardId: cardId || null,
487
+ appId,
488
+ workspaceId,
489
+ slicesRoot: path.relative(workspace, slicesRoot),
490
+ manifestPath: path.relative(workspace, manifestPath),
491
+ total: slices.length,
492
+ referenced: refs.size,
493
+ dangling,
494
+ pending: [],
495
+ skipped: [],
496
+ uploaded: [],
497
+ failed: [],
498
+ rewritten: [],
499
+ manifestWritten: false,
500
+ };
501
+
502
+ // 待上传集合:默认只上传「被卡片引用」的切图(切图约定的目标就是让卡片引用可远端解析);
503
+ // --all 时连未引用的一起上传,便于预先铺素材。sha256 未变的直接跳过(--force 可强制重传)。
504
+ // 同内容只传一次(slices_convention.md §4.1):命中「已上传条目」立即复用线上地址;命中「本批次
505
+ // 计划上传的同内容文件」则延后复用(等它传完拿到地址再写映射),两种情况都不进 pending。
506
+ const pending = [];
507
+ const deferredReuse = [];
508
+ const batchSha = new Map();
509
+ const writeReusedEntry = (slice, fromName, fromEntry) => {
510
+ const reused = {
511
+ url: fromEntry.url,
512
+ httpUrl: fromEntry.httpUrl || finmallConfig.filestoreUrl(fromEntry.url),
513
+ sha256: slice.sha256,
514
+ size: slice.size,
515
+ reusedFrom: fromName,
516
+ uploadedAt: new Date().toISOString(),
517
+ cards: scanned.refs.has(slice.name) ? [...scanned.refs.get(slice.name)].sort() : [],
518
+ };
519
+ if (fromEntry.dimensions) reused.dimensions = fromEntry.dimensions;
520
+ manifest.files[slice.name] = reused;
521
+ manifestDirty = true;
522
+ };
523
+ for (const slice of slices) {
524
+ const inScope = only ? only.has(slice.name) : !!opts.all || refs.has(slice.name);
525
+ if (!inScope) continue;
526
+ const prev = manifest.files[slice.name];
527
+ if (prev && prev.url && prev.sha256 === slice.sha256 && !force) {
528
+ summary.skipped.push({ name: slice.name, url: prev.url });
529
+ continue;
530
+ }
531
+ if (!force) {
532
+ const uploadedTwin = shaToUploaded.get(slice.sha256);
533
+ if (uploadedTwin && uploadedTwin.name !== slice.name) {
534
+ summary.skipped.push({ name: slice.name, url: uploadedTwin.entry.url, reusedFrom: uploadedTwin.name });
535
+ if (!dryRun) writeReusedEntry(slice, uploadedTwin.name, uploadedTwin.entry);
536
+ continue;
537
+ }
538
+ const batchTwin = batchSha.get(slice.sha256);
539
+ if (batchTwin && batchTwin !== slice.name) {
540
+ summary.skipped.push({ name: slice.name, url: null, reusedFrom: batchTwin, deferred: true });
541
+ deferredReuse.push({ slice, fromName: batchTwin });
542
+ continue;
543
+ }
544
+ }
545
+ if (!batchSha.has(slice.sha256)) batchSha.set(slice.sha256, slice.name);
546
+ pending.push(slice);
547
+ }
548
+ summary.pending = pending.map((s) => ({ name: s.name, size: s.size }));
549
+
550
+ // 引用关系(cards)随每次运行刷新,让映射清单能回答「这张图被哪些卡用」。
551
+ for (const slice of slices) {
552
+ const prev = manifest.files[slice.name];
553
+ if (!prev) continue;
554
+ const cards = scanned.refs.has(slice.name) ? [...scanned.refs.get(slice.name)].sort() : [];
555
+ if (JSON.stringify(prev.cards || []) !== JSON.stringify(cards)) {
556
+ prev.cards = cards;
557
+ manifestDirty = true;
558
+ }
559
+ }
560
+
561
+ if (dryRun) return summary;
562
+
563
+ if (pending.length) {
564
+ log.info('待上传切图', { count: pending.length, appId, workspaceId });
565
+ const session = await ensureFinMallSession();
566
+ const browser = session.browser;
567
+ try {
568
+ const context = browser.contexts()[0];
569
+ const page = context?.pages().find((p) => p.url().includes('finmall.com')) || context?.pages()[0];
570
+ if (!page) throw new Error('未能在后台 Chrome 中找到 FinMall 页面,请检查凭据或重新触发上传。');
571
+ await page.bringToFront();
572
+
573
+ for (const slice of pending) {
574
+ try {
575
+ const result = await uploadWithHeal(page, {
576
+ fileName: slice.name,
577
+ mime: slice.mime,
578
+ base64: slice.base64,
579
+ appId,
580
+ workspaceId,
581
+ endpoint: finmallConfig.uploadImgEndpoint,
582
+ });
583
+ const prev = manifest.files[slice.name];
584
+ // 内容变了会拿到新地址:旧地址进 superseded(上限 5),改写阶段一并替换,
585
+ // 否则卡片会继续指向已过期的线上图。
586
+ const superseded =
587
+ prev && prev.url && prev.url !== result.url
588
+ ? [prev.url, ...(prev.superseded || [])].slice(0, 5)
589
+ : (prev && prev.superseded) || [];
590
+ const entry = {
591
+ url: result.url,
592
+ httpUrl: finmallConfig.filestoreUrl(result.url),
593
+ sha256: slice.sha256,
594
+ size: slice.size,
595
+ uploadedAt: new Date().toISOString(),
596
+ cards: scanned.refs.has(slice.name) ? [...scanned.refs.get(slice.name)].sort() : [],
597
+ };
598
+ if (result.dimensions) entry.dimensions = result.dimensions;
599
+ if (superseded.length) entry.superseded = superseded;
600
+ manifest.files[slice.name] = entry;
601
+ if (!shaToUploaded.has(slice.sha256)) shaToUploaded.set(slice.sha256, { name: slice.name, entry });
602
+ manifestDirty = true;
603
+ summary.uploaded.push({ name: slice.name, url: result.url, size: slice.size, dimensions: result.dimensions });
604
+ log.ok('已上传', { file: slice.name, url: result.url });
605
+ } catch (error) {
606
+ summary.success = false;
607
+ summary.failed.push({ name: slice.name, error: error.message });
608
+ log.error('上传失败', { file: slice.name, error: error.message });
609
+ }
610
+ }
611
+ } finally {
612
+ // 仅断开连接,后台 Chrome 常驻以便复用(与 syncCards 一致)。
613
+ try {
614
+ await browser.disconnect();
615
+ } catch {
616
+ /* ignore */
617
+ }
618
+ }
619
+ }
620
+
621
+ // 延后复用:同批次里内容相同的副本,等「源文件」上传拿到地址后再写复用映射
622
+ for (const { slice, fromName } of deferredReuse) {
623
+ const fromEntry = manifest.files[fromName];
624
+ const item = summary.skipped.find((x) => x.name === slice.name && x.deferred);
625
+ if (fromEntry && fromEntry.url) {
626
+ if (item) { item.url = fromEntry.url; delete item.deferred; }
627
+ writeReusedEntry(slice, fromName, fromEntry);
628
+ if (!shaToUploaded.has(slice.sha256)) shaToUploaded.set(slice.sha256, { name: slice.name, entry: fromEntry });
629
+ } else if (item) {
630
+ item.deferred = false;
631
+ item.note = `同内容源 ${fromName} 未上传成功,本次未复用`;
632
+ log.warn('同内容复用失败', { file: slice.name, from: fromName });
633
+ }
634
+ }
635
+
636
+ if (manifestDirty) {
637
+ manifest.updatedAt = new Date().toISOString();
638
+ mkdirSync(slicesRoot, { recursive: true });
639
+ writeJsonAtomic(manifestPath, manifest);
640
+ summary.manifestWritten = true;
641
+ }
642
+
643
+ if (!opts.noRewrite) {
644
+ const mapping = new Map(
645
+ Object.entries(manifest.files)
646
+ .filter(([, entry]) => entry && entry.url)
647
+ .map(([name, entry]) => [name, { url: entry.url, superseded: entry.superseded || [] }]),
648
+ );
649
+ summary.rewritten = rewriteCardRefs(cardFiles, workspace, mapping);
650
+ }
651
+
652
+ return summary;
653
+ }
654
+
655
+ function parseArgs(argv) {
656
+ const parsed = {};
657
+ for (let i = 0; i < argv.length; i += 1) {
658
+ const arg = argv[i];
659
+ if (!arg.startsWith('--')) continue;
660
+ const eq = arg.indexOf('=');
661
+ if (eq >= 0) parsed[arg.slice(2, eq)] = arg.slice(eq + 1);
662
+ else parsed[arg.slice(2)] = argv[i + 1]?.startsWith('--') ? true : (argv[++i] ?? true);
663
+ }
664
+ return parsed;
665
+ }
666
+
667
+ // 仅当作为脚本直接运行时执行 CLI 逻辑(被 dev server 中间件 import 时不触发)
668
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
669
+ if (isMain) {
670
+ const args = parseArgs(process.argv.slice(2));
671
+ const summary = await uploadSlices({
672
+ cardId: args.cardId ? String(args.cardId) : undefined,
673
+ only: args.only ? String(args.only).split(',').map((s) => s.trim()).filter(Boolean) : undefined,
674
+ all: !!args.all,
675
+ force: !!args.force,
676
+ dryRun: !!args['dry-run'],
677
+ noRewrite: !!args['no-rewrite'],
678
+ appId: args.appId ? String(args.appId) : undefined,
679
+ workspaceId: args.workspaceId ? String(args.workspaceId) : undefined,
680
+ slicesRoot: args.slicesRoot ? path.resolve(workspace, String(args.slicesRoot)) : undefined,
681
+ cardsRoot: args.cardsRoot ? path.resolve(workspace, String(args.cardsRoot)) : undefined,
682
+ });
683
+ const lines = [
684
+ `目标:sassAppId=${summary.appId} sassWorkspaceId=${summary.workspaceId}${summary.dryRun ? '(dry-run,未上传)' : ''}`,
685
+ `范围:${summary.cardId ? `单卡 ${summary.cardId}` : '全部卡片'}`,
686
+ `切图:${summary.total} 个(本次范围内被引用 ${summary.referenced} 个),待上传 ${summary.pending.length} 个`,
687
+ `本次上传:${summary.uploaded.length} 个${summary.uploaded.length ? ` → ${summary.uploaded.map((u) => u.name).join('、')}` : ''}`,
688
+ `跳过:${summary.skipped.length} 个(内容未变 ${summary.skipped.filter((x) => !x.reusedFrom).length};同内容复用已上传地址 ${summary.skipped.filter((x) => x.reusedFrom).length}${summary.skipped.some((x) => x.reusedFrom) ? ` → ${summary.skipped.filter((x) => x.reusedFrom).map((x) => `${x.name}≡${x.reusedFrom}`).join('、')}` : ''})`,
689
+ `改写引用:${summary.rewritten.length} 个文件 / ${summary.rewritten.reduce((a, r) => a + r.count, 0)} 处`,
690
+ `映射清单:${summary.manifestPath}${summary.manifestWritten ? '(已更新)' : '(无变化)'}`,
691
+ ...(summary.failed.length ? [`失败:${summary.failed.map((f) => `${f.name}(${f.error})`).join('、')}`] : []),
692
+ ...(summary.dangling.length ? [`悬空引用(schema 引用但 src/slices 无此文件):${summary.dangling.join('、')}`] : []),
693
+ ];
694
+ log.box({ title: '切图上传结果', level: summary.success ? 'ok' : 'warn', lines });
695
+ // 显式退出:ensureFinMallSession 内部的登录流程会另开一条 CDP 连接且按约定不关闭
696
+ // (后台 Chrome 要常驻复用),残留的 WebSocket 会让事件循环一直不空、进程挂住不退出。
697
+ // 与 finmall_login.mjs CLI 同款处理——detached 的 Chrome 进程不受影响,继续运行。
698
+ process.exit(summary.success ? 0 : 1);
699
+ }