cardbuddy 0.19.3 → 0.21.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 (29) hide show
  1. package/README.md +8 -8
  2. package/dist/cli.js +21 -8
  3. package/dist/init.js +3 -3
  4. package/dist/preview-editor/assets/codeBridgeHeartbeat.worker-Zb_A5RUd.js +1 -0
  5. package/dist/preview-editor/assets/index-C8VLzU18.css +1 -0
  6. package/dist/preview-editor/assets/index-CpDojF__.js +383 -0
  7. package/dist/preview-editor/index.html +2 -2
  8. package/dist/skills-upgrade.js +64 -25
  9. package/dist/standalone.js +49 -0
  10. package/dist/vendored/card-editor/components/MultiColumn.vue +19 -10
  11. package/dist/vendored/card-editor/runtime/card-runtime.js +1 -1
  12. package/dist/vendored/mcp/client.mjs +22 -3
  13. package/dist/vendored/server/core/ai-chat.js +92 -12
  14. package/dist/vendored/server/core/cardExport.js +831 -0
  15. package/dist/vendored/server/core/cardHistoryRemote.js +203 -0
  16. package/dist/vendored/server/core/cardImportMeta.js +6 -1
  17. package/dist/vendored/server/core/cardPublishVersions.js +91 -0
  18. package/dist/vendored/server/core/cardSave.js +143 -6
  19. package/dist/vendored/server/core/cardSync.js +467 -95
  20. package/dist/vendored/server/core/codeBridge.js +4 -2
  21. package/dist/vendored/server/core/index.js +16 -0
  22. package/dist/vendored/skills/finmall-card-sync/SKILL.md +46 -0
  23. package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +657 -15
  24. package/dist/vendored/skills/finmall-session-navigator/scripts/finmall_login.mjs +79 -8
  25. package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +25 -1
  26. package/dist/vendored/skills/mbank-design-gen/tools/screenshot.mjs +10 -1
  27. package/package.json +1 -1
  28. package/dist/preview-editor/assets/index-C_uqjKqI.css +0 -1
  29. package/dist/preview-editor/assets/index-DRzBK4qj.js +0 -347
@@ -0,0 +1,831 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import zlib from 'node:zlib'
5
+ import { spawn } from 'node:child_process'
6
+ import { log } from './logger.js'
7
+ import { readImportMeta } from './cardImportMeta.js'
8
+ import { resolveCardDir } from './cardOpenFolder.js'
9
+
10
+ /**
11
+ * 卡片导出端点(编辑器「卡片网格预览 → 勾选 → 导出」的服务端实现)。
12
+ *
13
+ * 产物契约(对桌面两份 FinMall 导出样本的逆向结论,详见
14
+ * docs/卡片导出产物逆向分析与导出功能.md):
15
+ * <targetDir>/<yyMMddHHmmss+4位序号>/ ← 临时文件夹(完成后删除)
16
+ * cards.json ← 卡片清单(cardPO 数组,殿后写入)
17
+ * <数字id>/<数字id>.json ← { cardPO, cardJobPO, variablePOS, imageLists, platformStatus }
18
+ * <数字id>/<封面文件> ← 封面,文件名恒等于 cards.json 里该卡 picUrl 的末段
19
+ * <数字id>/image/<imageName> ← schema.view 树引用的 @filestore 图片下载落盘
20
+ * <targetDir>/<同名>.zip ← 纯 Node 写出的 zip(条目平铺、无顶层文件夹)
21
+ *
22
+ * 封面铁律(2026-10-22 事故后定,依据 57 张官方导出样本 + ImportCardAction.java:61-64):
23
+ * - picUrl 恒非空,且末段 === `<数字id>/` 下封面文件名。导入侧按 picUrl 末段推文件名后直接读该文件,
24
+ * 对所有空值/缺失零防御(空串时路径退化成卡目录,读目录抛异常)。
25
+ * - 无本地封面的卡**不允许导出**(计入 failed 并给出补齐指引),不产「有卡无封面」的残包。
26
+ * - 有 `.import-meta.json` 的 remote 快照时沿用远端真值(picUrl/cardClassId/applyScene);
27
+ * 本地新建卡按同一形态合成 picUrl,cardClassId 留空、applyScene 回落 '1'。
28
+ * - 写 zip 前跑 verifyCardsJsonInvariants 自检,违例即整单失败。
29
+ * 任务制端点(与 /__sync 同构:单飞制 + 看门狗 + 轮询 + 取消):
30
+ * - POST /__export-cards body { cardIds[], targetDir } → { ok, jobId, total }
31
+ * - GET /__export/progress?jobId → { ok, done, total, currentId, currentTitle, finished, result }
32
+ * - POST /__export/cancel body { jobId } → 下一张卡片前中止(已写内容随任务清理)
33
+ * - GET /__export/dir-suggestions → { ok, home, desktop, downloads, default }
34
+ *
35
+ * previewSchema 生成复用编辑器同源实现:经 viteServer.ssrLoadModule 加载
36
+ * card-editor/runtime/toPreview.js 的 buildPreview(与 SSR 自检端点同一模式),
37
+ * 无 vite 实例时退化为 schema 原样序列化(可用但非预览态)。
38
+ */
39
+
40
+ /** 单次导出勾选上限(与 /__sync 的 500 上限同口径) */
41
+ const MAX_EXPORT_CARDS = 500
42
+
43
+ /** 导出任务硬超时(看门狗):大批量卡片下载图片可能很慢,10 分钟不收尾即代收 */
44
+ const EXPORT_JOB_TIMEOUT_MS = Number(process.env.CARDBUDDY_EXPORT_JOB_TIMEOUT_MS || 10 * 60 * 1000)
45
+
46
+ /** 图片下载超时(与资源代理回源超时同口径) */
47
+ const IMAGE_FETCH_TIMEOUT_MS = 12000
48
+
49
+ /** 封面候选扩展名(与 finmall-card-sync 的 findExistingCover 同集) */
50
+ const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'])
51
+
52
+ /**
53
+ * 本地无远端 picUrl 时,封面在包内所用的 filestore 前缀。
54
+ * 依据 57 张官方导出的实测:picUrl 恒为 `@filestore/<桶名>/<文件名>`,桶名出现两种
55
+ * (dzqd-download 54 张、sit-public-cbbiz 3 张)——说明导入侧只取末段做文件名,桶名不参与校验。
56
+ * 取多数派即可,不必也无法还原真实桶。
57
+ */
58
+ const COVER_FILESTORE_PREFIX = '@filestore/dzqd-download'
59
+
60
+ /** 平台状态常量:两份样本恒为三端全平台(与 FinMall 发布接口默认平台集一致) */
61
+ const PLATFORM_STATUS = ['iOS', 'Android', 'HarmonyOS']
62
+
63
+ /** 最小合法骨架屏 SVG:样本为线上编辑器按布局生成,本地不做布局推测,占位保证字段非空 */
64
+ const MIN_SKELETON =
65
+ '<svg xmlns="http://www.w3.org/2000/svg" width="375" height="200" viewBox="0 0 375 200">' +
66
+ '<rect width="100%" height="100%" fill="#ffffff"/></svg>'
67
+
68
+ /* ------------------------------ 任务表(单例) ------------------------------ */
69
+
70
+ const exportJobs = new Map() // jobId -> job
71
+ const EXPORT_JOB_KEEP = 8
72
+ let exportJobSeq = 0
73
+
74
+ function activeExportJob() {
75
+ for (const job of exportJobs.values()) {
76
+ if (!job.finished) return job
77
+ }
78
+ return null
79
+ }
80
+
81
+ function rememberExportJob(job) {
82
+ exportJobs.set(job.id, job)
83
+ while (exportJobs.size > EXPORT_JOB_KEEP) {
84
+ const oldest = exportJobs.keys().next().value
85
+ exportJobs.delete(oldest)
86
+ }
87
+ }
88
+
89
+ /* ------------------------------ ZIP 写入器(纯 Node) ------------------------------ */
90
+
91
+ const CRC_TABLE = (() => {
92
+ const t = new Uint32Array(256)
93
+ for (let n = 0; n < 256; n += 1) {
94
+ let c = n
95
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
96
+ t[n] = c >>> 0
97
+ }
98
+ return t
99
+ })()
100
+
101
+ function crc32(buf) {
102
+ let c = 0xffffffff
103
+ for (let i = 0; i < buf.length; i += 1) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
104
+ return (c ^ 0xffffffff) >>> 0
105
+ }
106
+
107
+ function dosDateTime(d) {
108
+ const time = ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xffff
109
+ const date = (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xffff
110
+ return { time, date }
111
+ }
112
+
113
+ /**
114
+ * 生成 zip 文件字节(无外部依赖,条目平铺、不含目录项、不含顶层文件夹)。
115
+ * 每条目自动在 deflate / store 中取小者;非 ASCII 文件名置 UTF-8 标志位(bit 11),
116
+ * 保证中文 imageName 在 unzip 后不乱码(样本产物的图片名大量含中文)。
117
+ * @param {Array<{ name: string, data: Buffer }>} entries
118
+ * @param {Date} [when] 条目时间戳(默认现在)
119
+ * @returns {Buffer}
120
+ */
121
+ function buildZip(entries, when = new Date()) {
122
+ const { time, date } = dosDateTime(when)
123
+ const chunks = []
124
+ const central = []
125
+ let offset = 0
126
+
127
+ for (const entry of entries) {
128
+ const nameBuf = Buffer.from(entry.name, 'utf8')
129
+ const raw = entry.data
130
+ const deflated = zlib.deflateRawSync(raw, { level: 9 })
131
+ // 已压缩数据(png/jpg)deflate 可能反而变大:取小者,方法号随之切换
132
+ const useDeflate = deflated.length < raw.length
133
+ const body = useDeflate ? deflated : raw
134
+ const method = useDeflate ? 8 : 0
135
+ const crc = crc32(raw)
136
+ // 非 ASCII 名置 UTF-8 语言编码标志(EFS)
137
+ const flags = /[^\x20-\x7e]/.test(entry.name) ? 0x0800 : 0
138
+
139
+ const local = Buffer.alloc(30)
140
+ local.writeUInt32LE(0x04034b50, 0) // local file header 签名
141
+ local.writeUInt16LE(20, 4) // 解压所需版本
142
+ local.writeUInt16LE(flags, 6)
143
+ local.writeUInt16LE(method, 8)
144
+ local.writeUInt16LE(time, 10)
145
+ local.writeUInt16LE(date, 12)
146
+ local.writeUInt32LE(crc, 14)
147
+ local.writeUInt32LE(body.length, 18) // 压缩后大小
148
+ local.writeUInt32LE(raw.length, 22) // 原始大小
149
+ local.writeUInt16LE(nameBuf.length, 26)
150
+ local.writeUInt16LE(0, 28) // extra 长度
151
+ chunks.push(local, nameBuf, body)
152
+
153
+ const cd = Buffer.alloc(46)
154
+ cd.writeUInt32LE(0x02014b50, 0) // central directory header 签名
155
+ cd.writeUInt16LE(20, 4) // 制作版本
156
+ cd.writeUInt16LE(20, 6) // 解压所需版本
157
+ cd.writeUInt16LE(flags, 8)
158
+ cd.writeUInt16LE(method, 10)
159
+ cd.writeUInt16LE(time, 12)
160
+ cd.writeUInt16LE(date, 14)
161
+ cd.writeUInt32LE(crc, 16)
162
+ cd.writeUInt32LE(body.length, 20)
163
+ cd.writeUInt32LE(raw.length, 24)
164
+ cd.writeUInt16LE(nameBuf.length, 28)
165
+ cd.writeUInt32LE(0, 38) // 外部属性(普通文件)
166
+ cd.writeUInt32LE(offset, 42) // 本地头偏移
167
+ central.push(cd, nameBuf)
168
+
169
+ offset += local.length + nameBuf.length + body.length
170
+ }
171
+
172
+ const cdBuf = Buffer.concat(central)
173
+ const eocd = Buffer.alloc(22)
174
+ eocd.writeUInt32LE(0x06054b50, 0)
175
+ eocd.writeUInt16LE(entries.length, 8)
176
+ eocd.writeUInt16LE(entries.length, 10)
177
+ eocd.writeUInt32LE(cdBuf.length, 12)
178
+ eocd.writeUInt32LE(offset, 16)
179
+
180
+ return Buffer.concat([...chunks, cdBuf, eocd])
181
+ }
182
+
183
+ /* ------------------------------ 平台 reveal ------------------------------ */
184
+
185
+ /**
186
+ * 在系统文件管理器中 reveal 指定文件(选中态)。
187
+ * macOS: open -R;Windows: explorer /select,;Linux: xdg-open 所在目录。
188
+ * @returns {boolean} 是否成功触发(仅表示进程拉起)
189
+ */
190
+ export function revealFileInOS(filePath) {
191
+ let spec = null
192
+ switch (process.platform) {
193
+ case 'darwin':
194
+ spec = { cmd: 'open', args: ['-R', filePath] }
195
+ break
196
+ case 'win32':
197
+ spec = { cmd: 'explorer', args: [`/select,${filePath}`] }
198
+ break
199
+ case 'linux':
200
+ spec = { cmd: 'xdg-open', args: [path.dirname(filePath)] }
201
+ break
202
+ default:
203
+ break
204
+ }
205
+ if (!spec) {
206
+ log.warn('reveal 文件不支持当前平台', { platform: process.platform })
207
+ return false
208
+ }
209
+ try {
210
+ const child = spawn(spec.cmd, spec.args, { shell: false, stdio: 'ignore' })
211
+ child.on('error', (e) => {
212
+ log.error('reveal 文件命令失败', { cmd: spec.cmd, message: String(e.message) })
213
+ })
214
+ return true
215
+ } catch (e) {
216
+ log.error('reveal 文件异常', e)
217
+ return false
218
+ }
219
+ }
220
+
221
+ /* ------------------------------ 产物构建 ------------------------------ */
222
+
223
+ /** URL 段编码:已带 %XX 的段保持原样,其余段 encodeURIComponent(与资源代理同规则) */
224
+ function encodePath(p) {
225
+ return p
226
+ .split('/')
227
+ .map((seg) => (/%[0-9a-fA-F]{2}/.test(seg) ? seg : encodeURIComponent(seg)))
228
+ .join('/')
229
+ }
230
+
231
+ /** @filestore/x → <upstream>/minio/x(minio 公网可读,无需凭据) */
232
+ function filestoreUrlFor(ref, upstream) {
233
+ if (!upstream) return ''
234
+ if (ref.startsWith('@filestore/')) return `${upstream}/minio/${encodePath(ref.slice('@filestore/'.length))}`
235
+ if (ref.startsWith('/')) return `${upstream}${encodePath(ref)}`
236
+ return ''
237
+ }
238
+
239
+ /** 时间戳文件夹名:yyMMddHHmmss + 4 位序号(样本规律;重名顺延保证唯一) */
240
+ function stampFolderName(targetDir, now = new Date()) {
241
+ const p = (n) => String(n).padStart(2, '0')
242
+ const base =
243
+ `${String(now.getFullYear() % 100).padStart(2, '0')}${p(now.getMonth() + 1)}${p(now.getDate())}` +
244
+ `${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`
245
+ for (let seq = 1; seq <= 9999; seq += 1) {
246
+ const name = base + String(seq).padStart(4, '0')
247
+ if (!fs.existsSync(path.join(targetDir, name)) && !fs.existsSync(path.join(targetDir, `${name}.zip`))) {
248
+ return name
249
+ }
250
+ }
251
+ // 9999 个同名全部占用(理论上不可能):加随机后缀兜底
252
+ return base + Math.random().toString(36).slice(2, 6).padStart(4, '0')
253
+ }
254
+
255
+ /** 找本地封面预览图(<cardId>.<ext>);没有返回 null(与 finmall-card-sync 同规则) */
256
+ function findExistingCover(cardDir, id) {
257
+ let entries = []
258
+ try {
259
+ entries = fs.readdirSync(cardDir)
260
+ } catch {
261
+ return null
262
+ }
263
+ for (const name of entries) {
264
+ const ext = path.extname(name).toLowerCase()
265
+ if (IMAGE_EXTS.has(ext) && path.basename(name, ext) === id) return path.join(cardDir, name)
266
+ }
267
+ return null
268
+ }
269
+
270
+ /**
271
+ * 采集 schema.view 组件树中的图片引用(首现序去重)。
272
+ * 规则(样本逆向结论):仅 view 树,不含 dictionary;@filestore 走远端下载,
273
+ * /slices/ 走本地切图拷贝。两者按遍历首现顺序混合排列。
274
+ * @returns {Array<{ ref: string, kind: 'filestore'|'slice' }>}
275
+ */
276
+ function collectViewImageRefs(view) {
277
+ const out = []
278
+ const seen = new Set()
279
+ const push = (ref) => {
280
+ if (seen.has(ref)) return
281
+ seen.add(ref)
282
+ if (ref.startsWith('@filestore/')) out.push({ ref, kind: 'filestore' })
283
+ else if (ref.startsWith('/slices/')) out.push({ ref, kind: 'slice' })
284
+ }
285
+ const walk = (v) => {
286
+ if (typeof v === 'string') {
287
+ // 与 imageLists 提取口径一致:字符串内可能嵌多个引用(如 style backgroundImage)
288
+ const re = /(?:@filestore\/|\/slices\/)[^\s"'\\)]+/g
289
+ let m
290
+ while ((m = re.exec(v)) !== null) push(m[0])
291
+ return
292
+ }
293
+ if (Array.isArray(v)) {
294
+ for (const x of v) walk(x)
295
+ return
296
+ }
297
+ if (v && typeof v === 'object') {
298
+ for (const x of Object.values(v)) walk(x)
299
+ }
300
+ }
301
+ walk(view)
302
+ return out
303
+ }
304
+
305
+ /** 末段文件名(@filestore 路径的 imageName;/slices/ 的切片名) */
306
+ function refBasename(ref) {
307
+ const seg = ref.split('/').pop() || ''
308
+ return seg
309
+ }
310
+
311
+ /**
312
+ * 计算导出用的封面文件名与 `cardPO.picUrl`,两者必须自洽:**picUrl 末段 === 包内卡目录根的封面文件名**。
313
+ *
314
+ * 契约依据(57 张官方导出样本逐卡核对:picUrl 非空 57/57、末段与目录根封面文件名一致 57/57):
315
+ * 导入侧 `ImportCardAction.java:61-64` 用 `extractFileNameMethod1(picUrl)` 得到文件名后
316
+ * `new File(cardUrl + File.separator + imageName)` 去读封面,**无任何空值/存在性防御**:
317
+ * picUrl 为空串时路径退化成卡目录本身,读目录抛异常,整卡导入失败。
318
+ * 故此处绝不允许产出空 picUrl。
319
+ *
320
+ * @param {{ remotePicUrl?: string, coverPath: string }} o
321
+ * remotePicUrl:`.import-meta.json` 记录的远端真值(有则原样沿用,形态与官方产物同构);
322
+ * coverPath:本地封面绝对路径(无远端真值时以其文件名为准)。
323
+ * @returns {{ fileName: string, picUrl: string, inherited: boolean }}
324
+ */
325
+ export function resolveCoverForExport({ remotePicUrl, coverPath }) {
326
+ const localName = path.basename(coverPath)
327
+ const remote = typeof remotePicUrl === 'string' ? remotePicUrl.trim() : ''
328
+ if (remote) {
329
+ // 远端真值可能带 query(极少见),取文件名时剥掉;picUrl 本身保持原样不重写
330
+ const name = path.basename(remote.split('?')[0].split('#')[0])
331
+ if (name) return { fileName: name, picUrl: remote, inherited: true }
332
+ }
333
+ return { fileName: localName, picUrl: `${COVER_FILESTORE_PREFIX}/${localName}`, inherited: false }
334
+ }
335
+
336
+ /**
337
+ * 产物自检:cards.json 的每条 cardPO 都能被导入侧无障碍消费。
338
+ * 只做**与后端无关**的结构断言(不依赖远端服务,纯本地即可证伪):
339
+ * 1. picUrl 非空;
340
+ * 2. picUrl 末段在 `<id>/` 卡目录根存在且是文件(不是目录);
341
+ * 3. 每张卡都有 `<id>/<id>.json`。
342
+ * 返回问题清单(空数组 = 通过)。任何一条违例都会被导入侧的同一段逻辑打出异常。
343
+ * @param {{ cardsPO: Array<object>, outDir: string }} o
344
+ * @returns {string[]}
345
+ */
346
+ export function verifyCardsJsonInvariants({ cardsPO, outDir }) {
347
+ const problems = []
348
+ for (const po of cardsPO || []) {
349
+ const id = String(po && po.id)
350
+ const picUrl = po && po.picUrl
351
+ if (!picUrl) {
352
+ problems.push(`${id}: picUrl 为空(导入侧会按空串推封面文件名)`)
353
+ continue
354
+ }
355
+ const name = path.basename(String(picUrl).split('?')[0])
356
+ const coverAbs = path.join(outDir, id, name)
357
+ let stat = null
358
+ try {
359
+ stat = fs.statSync(coverAbs)
360
+ } catch {
361
+ /* 不存在即视为违例 */
362
+ }
363
+ if (!stat) problems.push(`${id}: 封面文件缺失 ${name}`)
364
+ else if (!stat.isFile()) problems.push(`${id}: 封面路径不是文件 ${name}`)
365
+ if (!fs.existsSync(path.join(outDir, id, `${id}.json`))) problems.push(`${id}: 单卡 JSON 缺失`)
366
+ }
367
+ return problems
368
+ }
369
+
370
+
371
+ /**
372
+ * 导出单张卡:写 <id>.json + 落图片/封面,返回 { cardPO, imageLists }。
373
+ * 图片下载失败不中断该卡(记 warnings),契约字段 imageLists 仍按全量引用生成。
374
+ */
375
+ async function exportOneCard({ root, id, outDir, upstream, buildPreview }) {
376
+ const cardDir = resolveCardDir(root, id)
377
+ if (!cardDir) throw new Error('非法卡片 id')
378
+ const schemaPath = path.join(cardDir, 'schema.json')
379
+ let schema
380
+ try {
381
+ schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'))
382
+ } catch (e) {
383
+ throw new Error(`读取 schema.json 失败:${e && e.message}`)
384
+ }
385
+ if (!schema || typeof schema !== 'object') throw new Error('schema.json 内容非法')
386
+
387
+ // 数字 id:优先 schema.pageId(导入卡与远端一致);自建卡 pageId 常等于 code,回落目录名
388
+ const numericId = /^\d+$/.test(String(schema.pageId)) ? String(schema.pageId) : id
389
+
390
+ let meta = null
391
+ try {
392
+ meta = readImportMeta(cardDir)
393
+ } catch {
394
+ /* 侧车缺失/损坏不影响导出 */
395
+ }
396
+
397
+ // 封面先定:picUrl 与封面文件名是同一个决定,缺封面必须当场失败(不能产出自带空 picUrl 的包)。
398
+ const coverPath = findExistingCover(cardDir, id)
399
+ if (!coverPath) {
400
+ throw new Error(
401
+ `本地无封面图(src/cards/${id}/${id}.png):导入侧无条件按 picUrl 末段读卡目录根的封面,` +
402
+ '无封面必导入失败。请先在卡片上「生成封面预览图」补齐,再重试导出'
403
+ )
404
+ }
405
+ const remoteMeta = (meta && meta.remote) || null
406
+ const cover = resolveCoverForExport({
407
+ remotePicUrl: remoteMeta && remoteMeta.picUrl,
408
+ coverPath,
409
+ })
410
+
411
+ const cardPO = {
412
+ id: numericId,
413
+ name: (schema.name || '').toString(),
414
+ cardStatus: '1',
415
+ createTime: null,
416
+ updateTime: null,
417
+ sassAppId: (meta && meta.appId) || '100001',
418
+ sassWorkspaceId: (meta && meta.workspaceId) || 'dev',
419
+ // picUrl 恒非空,且末段 === 卡目录根封面文件名(见 resolveCoverForExport 的契约说明):
420
+ // 有远端真值(侧车 remote.picUrl)就原样沿用,与官方产物同构;本地新建卡按同一形态合成。
421
+ picUrl: cover.picUrl,
422
+ // 远端列表才有的分类字段:有真值用真值(官方 57 张样本里恒非空),未知时留空由导入侧兜底
423
+ cardClassId: (remoteMeta && remoteMeta.cardClassId) || '',
424
+ code: id,
425
+ // applyScene 不是常量:官方两份产物分别是 '1'(2 张)与 '2'(57 张),必须取真值
426
+ applyScene: (remoteMeta && remoteMeta.applyScene) || '1',
427
+ }
428
+
429
+ // previewSchema:优先 buildPreview(编辑器同源实现);不可用或抛错时退化为 schema 原样。
430
+ // 序列化时剥离 _schemaNode:全代码库约定(server/core/cardFiles.js、App.vue 存档 /
431
+ // 剪贴板 / dirty 比对等同一 replacer),它是对原始 schema 节点的运行时自引用,
432
+ // 不带 replacer 会让 previewSchema 膨胀约 6 倍并偏离样本形态(样本无该字段)。
433
+ let previewSchema
434
+ try {
435
+ const preview = buildPreview ? buildPreview(schema) : schema
436
+ previewSchema = JSON.stringify(preview, (k, v) => (k === '_schemaNode' ? undefined : v))
437
+ } catch (e) {
438
+ previewSchema = JSON.stringify(schema)
439
+ log.warn('previewSchema 生成失败,退化为 schema 原样', { id, error: e && e.message })
440
+ }
441
+
442
+ const cardJobPO = {
443
+ id: null,
444
+ cardId: numericId,
445
+ schemaValue: JSON.stringify(schema),
446
+ previewSchema,
447
+ screenshotUrl: '',
448
+ createTime: null,
449
+ updateTime: null,
450
+ skeletonScreen: MIN_SKELETON,
451
+ }
452
+
453
+ const variablePOS = ((schema.dictionary && schema.dictionary.inputParams) || []).map((p) => ({
454
+ id: null,
455
+ cardId: numericId,
456
+ variableKey: p.key,
457
+ variableName: p.name != null ? p.name : p.key,
458
+ variableType: p.type,
459
+ variableDescribe: p.desc || '',
460
+ variableDefaultValue: JSON.stringify(p.defaultValue === undefined ? null : p.defaultValue),
461
+ variableStructure: JSON.stringify(p),
462
+ createTime: null,
463
+ updateTime: null,
464
+ }))
465
+
466
+ const refs = collectViewImageRefs(schema.view)
467
+ const imageLists = refs.map(({ ref }) => {
468
+ const imageName = refBasename(ref)
469
+ return {
470
+ originalUrls: ref,
471
+ localUrl: `/image/${imageName}`,
472
+ targetUrl: null,
473
+ imageName,
474
+ }
475
+ })
476
+
477
+ // 落盘:<outDir>/<numericId>/
478
+ const cardOutDir = path.join(outDir, numericId)
479
+ fs.mkdirSync(cardOutDir, { recursive: true })
480
+ const warnings = []
481
+
482
+ // 图片:@filestore 走 minio 公网下载;/slices/ 从 src/slices 拷贝
483
+ for (const { ref, kind } of refs) {
484
+ const imageName = refBasename(ref)
485
+ if (!imageName) continue
486
+ const dest = path.join(cardOutDir, 'image', imageName)
487
+ try {
488
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
489
+ if (kind === 'slice') {
490
+ const src = path.join(root, 'src', 'slices', imageName)
491
+ if (!fs.existsSync(src)) throw new Error('本地切图不存在')
492
+ fs.copyFileSync(src, dest)
493
+ } else {
494
+ const url = filestoreUrlFor(ref, upstream)
495
+ if (!url) throw new Error('未配置 FinMall 上游地址,无法下载 @filestore 图片')
496
+ const res = await fetch(url, {
497
+ headers: { 'User-Agent': 'Mozilla/5.0', Referer: upstream ? `${upstream}/` : '' },
498
+ signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS),
499
+ })
500
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
501
+ const ct = res.headers.get('content-type') || ''
502
+ if (ct.includes('text/html')) throw new Error('上游返回 HTML(疑似 404 页)')
503
+ const buf = Buffer.from(await res.arrayBuffer())
504
+ if (!buf.length) throw new Error('空响应')
505
+ fs.writeFileSync(dest, buf)
506
+ }
507
+ } catch (e) {
508
+ warnings.push(`图片落地失败 ${imageName}:${e && e.message}`)
509
+ log.warn('导出图片落地失败', { id, imageName, error: e && e.message })
510
+ }
511
+ }
512
+
513
+ // 封面:以 picUrl 末段的文件名落卡目录根(导入侧按此名取文件;官方产物同形)
514
+ fs.copyFileSync(coverPath, path.join(cardOutDir, cover.fileName))
515
+ if (!cover.inherited) {
516
+ warnings.push(`本地无远端 picUrl,封面已合成 ${cover.picUrl}(文件名为本地预览图名)`)
517
+ }
518
+
519
+ const cardJson = JSON.stringify({
520
+ cardPO,
521
+ cardJobPO,
522
+ variablePOS,
523
+ imageLists,
524
+ platformStatus: PLATFORM_STATUS,
525
+ })
526
+ fs.writeFileSync(path.join(cardOutDir, `${numericId}.json`), cardJson, 'utf8')
527
+
528
+ return { cardPO, warnings }
529
+ }
530
+
531
+ /**
532
+ * 跑导出任务(后台执行,进度经 job.progress 暴露)。
533
+ * @param {object} job
534
+ * @param {{ root: string, cardIds: string[], targetDir: string, upstream: string, viteServer: object|null }} ctx
535
+ */
536
+ async function runExportJob(job, { root, cardIds, targetDir, upstream, viteServer }) {
537
+ // buildPreview:优先经 vite ssrLoadModule 复用编辑器同源实现(与 /__render-status 同模式)
538
+ let buildPreview = null
539
+ if (viteServer && typeof viteServer.ssrLoadModule === 'function') {
540
+ try {
541
+ const mod = await viteServer.ssrLoadModule('/runtime/toPreview.js')
542
+ if (typeof mod.buildPreview === 'function') buildPreview = mod.buildPreview
543
+ } catch (e) {
544
+ log.warn('toPreview 模块加载失败,previewSchema 将退化为 schema 原样', { error: e && e.message })
545
+ }
546
+ }
547
+ if (!buildPreview) log.warn('无 vite 实例或 toPreview 不可用,previewSchema 退化为 schema 原样')
548
+
549
+ const cardsPO = []
550
+ const failed = []
551
+ const warnings = []
552
+ const zipEntries = []
553
+ const folderName = stampFolderName(targetDir)
554
+ const outDir = path.join(targetDir, folderName)
555
+ let revealed = false
556
+
557
+ try {
558
+ fs.mkdirSync(outDir, { recursive: true })
559
+ log.ok('开始导出卡片', { jobId: job.id, folderName, targetDir, total: cardIds.length })
560
+
561
+ for (const id of cardIds) {
562
+ if (job.cancelRequested) break
563
+ job.progress.currentId = id
564
+ job.progress.currentTitle = ''
565
+ try {
566
+ const { cardPO, warnings: w } = await exportOneCard({
567
+ root,
568
+ id,
569
+ outDir,
570
+ upstream,
571
+ buildPreview,
572
+ })
573
+ cardsPO.push(cardPO)
574
+ for (const x of w) warnings.push(`${id}: ${x}`)
575
+ } catch (e) {
576
+ failed.push({ id, error: e && e.message })
577
+ log.error('导出单卡失败', { id, error: e && e.message })
578
+ }
579
+ job.progress.done = cardsPO.length + failed.length
580
+ }
581
+
582
+ if (job.cancelRequested) {
583
+ // 取消:清理半成品目录(无 zip 产出,留着会违反命名契约),不 reveal
584
+ fs.rmSync(outDir, { recursive: true, force: true })
585
+ job.result = {
586
+ success: false,
587
+ cancelled: true,
588
+ exported: cardsPO.length,
589
+ failed,
590
+ message: `导出已取消(已完成 ${cardsPO.length} 张)`,
591
+ }
592
+ log.warn('导出任务被取消', { jobId: job.id, done: cardsPO.length })
593
+ return
594
+ }
595
+
596
+ if (!cardsPO.length) {
597
+ throw new Error(failed.length ? `全部 ${failed.length} 张卡片导出失败` : '没有可导出的卡片')
598
+ }
599
+
600
+ // 产物自检:卡片清单的每条都必须能被导入侧无防御地消费(picUrl 非空 + 封面文件在位)。
601
+ // 与后端无关的纯本地断言,任何一条违例都意味着导入侧同一段逻辑必炸 —— 宁可整单失败,不出残包。
602
+ const problems = verifyCardsJsonInvariants({ cardsPO, outDir })
603
+ if (problems.length) {
604
+ throw new Error(
605
+ `导出产物自检未通过(${problems.length} 项):${problems.slice(0, 5).join(';')}` +
606
+ (problems.length > 5 ? ' 等' : '')
607
+ )
608
+ }
609
+
610
+ // 收集 zip 条目(按名排序保证确定性),cards.json 殿后(与样本一致)
611
+ const collectEntries = (dir, prefix) => {
612
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
613
+ const full = path.join(dir, entry.name)
614
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name
615
+ if (entry.isDirectory()) collectEntries(full, rel)
616
+ else zipEntries.push({ name: rel, data: fs.readFileSync(full) })
617
+ }
618
+ }
619
+ collectEntries(outDir, '')
620
+ zipEntries.sort((a, b) => a.name.localeCompare(b.name))
621
+ fs.writeFileSync(path.join(outDir, 'cards.json'), JSON.stringify(cardsPO), 'utf8')
622
+ zipEntries.push({ name: 'cards.json', data: Buffer.from(JSON.stringify(cardsPO), 'utf8') })
623
+
624
+ const zipPath = path.join(targetDir, `${folderName}.zip`)
625
+ fs.writeFileSync(zipPath, buildZip(zipEntries))
626
+
627
+ // 原文件夹删除,只留同名 zip
628
+ fs.rmSync(outDir, { recursive: true, force: true })
629
+
630
+ revealed = revealFileInOS(zipPath)
631
+
632
+ job.result = {
633
+ success: true,
634
+ folderName,
635
+ zipPath,
636
+ exported: cardsPO.length,
637
+ failed,
638
+ warnings,
639
+ revealed,
640
+ }
641
+ log.ok('导出完成', { jobId: job.id, folderName, zipPath, exported: cardsPO.length, failed: failed.length })
642
+ } catch (e) {
643
+ try {
644
+ fs.rmSync(outDir, { recursive: true, force: true })
645
+ } catch {
646
+ /* 清理失败不掩盖原始错误 */
647
+ }
648
+ job.result = { success: false, error: e && e.message, exported: cardsPO.length, failed }
649
+ log.error('导出任务失败', e)
650
+ }
651
+ }
652
+
653
+ /**
654
+ * @param {{ root: string, viteServer?: import('vite').ViteDevServer|null, upstream?: string }} opts
655
+ * @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
656
+ */
657
+ export function createExportMiddleware({ root, viteServer = null, upstream = '' }) {
658
+ const readJsonBody = (req, res, cb) => {
659
+ let body = ''
660
+ req.setEncoding('utf8')
661
+ req.on('data', (c) => {
662
+ body += c
663
+ if (body.length > 1024 * 1024) req.destroy()
664
+ })
665
+ req.on('end', () => {
666
+ try {
667
+ cb(JSON.parse(body || '{}'))
668
+ } catch (e) {
669
+ res.statusCode = 400
670
+ res.setHeader('Content-Type', 'application/json')
671
+ res.end(JSON.stringify({ ok: false, error: 'invalid json', message: String(e.message) }))
672
+ }
673
+ })
674
+ }
675
+ const sendJson = (res, status, obj) => {
676
+ res.statusCode = status
677
+ res.setHeader('Content-Type', 'application/json')
678
+ res.setHeader('Cache-Control', 'no-store')
679
+ res.end(JSON.stringify(obj))
680
+ }
681
+
682
+ return function exportMiddleware(req, res, next) {
683
+ const pathname = decodeURIComponent((req.url || '').split('?')[0])
684
+ const method = (req.method || 'GET').toUpperCase()
685
+
686
+ // ---- 目录建议(导出目录弹框预填):桌面 / 下载 / 主目录,跨平台尽力而为 ----
687
+ if (pathname === '/__export/dir-suggestions') {
688
+ if (method !== 'GET') return next()
689
+ const home = os.homedir()
690
+ const pick = (name) => {
691
+ const p = path.join(home, name)
692
+ return fs.existsSync(p) ? p : ''
693
+ }
694
+ const desktop = pick('Desktop') || home
695
+ const downloads = pick('Downloads') || home
696
+ return sendJson(res, 200, { ok: true, home, desktop, downloads, default: desktop })
697
+ }
698
+
699
+ // ---- 导出进度查询(轮询) ----
700
+ if (pathname === '/__export/progress') {
701
+ if (method !== 'GET') return next()
702
+ const jobId = new URL(req.url, 'http://127.0.0.1').searchParams.get('jobId') || ''
703
+ const job = exportJobs.get(jobId)
704
+ if (!job) {
705
+ return sendJson(res, 404, { ok: false, error: 'unknown job', message: '导出任务不存在或已过期' })
706
+ }
707
+ return sendJson(res, 200, {
708
+ ok: true,
709
+ jobId: job.id,
710
+ done: job.progress.done,
711
+ total: job.progress.total,
712
+ currentId: job.progress.currentId,
713
+ currentTitle: job.progress.currentTitle,
714
+ finished: job.finished,
715
+ result: job.result,
716
+ })
717
+ }
718
+
719
+ // ---- 取消导出(下一张卡片前生效) ----
720
+ if (pathname === '/__export/cancel') {
721
+ if (method !== 'POST') return next()
722
+ return readJsonBody(req, res, (payload) => {
723
+ const jobId = String(payload.jobId || '')
724
+ const job = exportJobs.get(jobId)
725
+ if (!job || job.finished) {
726
+ return sendJson(res, 404, { ok: false, error: 'unknown job', message: '导出任务不存在或已结束' })
727
+ }
728
+ job.cancelRequested = true
729
+ return sendJson(res, 200, { ok: true })
730
+ })
731
+ }
732
+
733
+ if (pathname !== '/__export-cards') return next()
734
+ if (method !== 'POST') return next()
735
+
736
+ return readJsonBody(req, res, (payload) => {
737
+ // 入参:cardIds 集合 + targetDir 导出目录
738
+ const ids = Array.isArray(payload.cardIds)
739
+ ? [...new Set(payload.cardIds.filter((c) => typeof c === 'string'))]
740
+ : []
741
+ const targetDirRaw = typeof payload.targetDir === 'string' ? payload.targetDir.trim() : ''
742
+
743
+ if (!ids.length) {
744
+ return sendJson(res, 400, { ok: false, error: 'empty_card_ids', message: '请先勾选要导出的卡片' })
745
+ }
746
+ if (ids.length > MAX_EXPORT_CARDS) {
747
+ return sendJson(res, 400, { ok: false, error: 'too_many', message: `单次导出勾选不能超过 ${MAX_EXPORT_CARDS} 张` })
748
+ }
749
+ if (!targetDirRaw || !path.isAbsolute(targetDirRaw)) {
750
+ return sendJson(res, 400, { ok: false, error: 'invalid_target_dir', message: '导出目录必须是绝对路径' })
751
+ }
752
+ // 卡片 id 合法性 + 归属校验(resolveCardDir 内含白名单与穿越防护)
753
+ const safeIds = []
754
+ for (const id of ids) {
755
+ const dir = resolveCardDir(root, id)
756
+ if (!dir || !fs.existsSync(dir)) {
757
+ return sendJson(res, 400, { ok: false, error: 'invalid_card_id', message: `卡片不存在或 id 非法:${id}` })
758
+ }
759
+ safeIds.push(id)
760
+ }
761
+
762
+ const targetDir = path.resolve(targetDirRaw)
763
+ // 功能性禁区:src/cards(会被卡片列表扫描成垃圾卡片)与 node_modules(污染依赖目录)
764
+ const cardsRoot = path.resolve(root, 'src', 'cards')
765
+ const nmRoot = path.resolve(root, 'node_modules')
766
+ if (targetDir === cardsRoot || targetDir.startsWith(cardsRoot + path.sep)) {
767
+ return sendJson(res, 400, {
768
+ ok: false,
769
+ error: 'forbidden_target_dir',
770
+ message: '不能导出到 src/cards 目录(会被卡片列表扫描收录)',
771
+ })
772
+ }
773
+ if (targetDir === nmRoot || targetDir.startsWith(nmRoot + path.sep)) {
774
+ return sendJson(res, 400, {
775
+ ok: false,
776
+ error: 'forbidden_target_dir',
777
+ message: '不能导出到 node_modules 目录',
778
+ })
779
+ }
780
+ try {
781
+ fs.mkdirSync(targetDir, { recursive: true })
782
+ } catch (e) {
783
+ return sendJson(res, 400, { ok: false, error: 'target_dir_unwritable', message: `导出目录不可用:${e && e.message}` })
784
+ }
785
+
786
+ // 同一时刻只允许一个导出任务(与 /__sync 同口径)
787
+ if (activeExportJob()) {
788
+ return sendJson(res, 409, { ok: false, error: 'export_in_progress', message: '已有导出任务正在进行,请等待完成或取消' })
789
+ }
790
+
791
+ exportJobSeq += 1
792
+ const job = {
793
+ id: `ej-${Date.now().toString(36)}-${exportJobSeq}`,
794
+ createdAt: Date.now(),
795
+ cancelRequested: false,
796
+ timedOut: false,
797
+ finished: false,
798
+ progress: { done: 0, total: safeIds.length, currentId: '', currentTitle: '' },
799
+ result: null,
800
+ }
801
+ rememberExportJob(job)
802
+
803
+ // 看门狗:与同步任务同款兜底,防单次导出无限期占用服务
804
+ const watchdog = setTimeout(() => {
805
+ if (job.finished) return
806
+ job.timedOut = true
807
+ job.cancelRequested = true
808
+ job.finished = true
809
+ job.result = {
810
+ success: false,
811
+ timedOut: true,
812
+ error: `导出任务超过 ${Math.round(EXPORT_JOB_TIMEOUT_MS / 1000)} 秒未完成,已自动收尾。请重试,或检查网络(导出需下载远端图片)。`,
813
+ }
814
+ log.error('导出看门狗触发:任务超时,已代其收尾', { jobId: job.id })
815
+ }, EXPORT_JOB_TIMEOUT_MS)
816
+ if (typeof watchdog.unref === 'function') watchdog.unref()
817
+
818
+ ;(async () => {
819
+ try {
820
+ await runExportJob(job, { root, cardIds: safeIds, targetDir, upstream, viteServer })
821
+ } finally {
822
+ clearTimeout(watchdog)
823
+ // 看门狗已代收尾时不回滚结论
824
+ if (!job.timedOut) job.finished = true
825
+ }
826
+ })()
827
+
828
+ return sendJson(res, 200, { ok: true, jobId: job.id, total: job.progress.total })
829
+ })
830
+ }
831
+ }