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,203 @@
1
+ import { pathToFileURL } from 'node:url'
2
+ import { findCardDir } from './cardSidecar.js'
3
+ import { readImportMeta, markDirty } from './cardImportMeta.js'
4
+ import { writeCardFiles } from './cardFiles.js'
5
+ import { log } from './logger.js'
6
+ import { resolveSkillPathOrProject } from './skillPaths.js'
7
+
8
+ const SYNC_SCRIPT_REL = 'finmall-card-sync/scripts/api_batch_sync.mjs'
9
+
10
+ /** 读取 JSON 请求体(1MB 上限防失控) */
11
+ function readJsonBody(req) {
12
+ return new Promise((resolve, reject) => {
13
+ let raw = ''
14
+ req.on('data', (chunk) => {
15
+ raw += chunk
16
+ if (raw.length > 1024 * 1024) {
17
+ reject(new Error('请求体过大'))
18
+ req.destroy()
19
+ }
20
+ })
21
+ req.on('end', () => {
22
+ try {
23
+ resolve(raw ? JSON.parse(raw) : {})
24
+ } catch {
25
+ reject(new Error('请求体不是合法 JSON'))
26
+ }
27
+ })
28
+ req.on('error', reject)
29
+ })
30
+ }
31
+
32
+ /**
33
+ * 调试面板「卡片历史记录」tab 的远端版本时间线(只读):GET /__card-history/:cardCode
34
+ *
35
+ * 语义:经 .import-meta.json 侧车里的 cardId 调 IDC10013,取该卡历史版本列表(新→旧),
36
+ * 每条含时间 / 预览图(/minio 同源路径)/ 版本记录 id / 相对上一版本的变更标签。
37
+ * 触发时机由前端控制(切到 tab / 点刷新 / 加载更多),不自动轮询——与「卡片来源」tab
38
+ * 的懒加载策略一致,避免每次加载卡片都拉起后台 Chrome。
39
+ *
40
+ * 恢复端点:POST /__card-history/:cardCode/restore(body { versionId })——把指定历史
41
+ * 版本的完整 schema 写入本地 src/cards/<code>/schema.json(原子写 + 派生 data.json),
42
+ * 并标记 dirty(本地偏离导入镜像;推送成功后自会清除)。watcher 推 SSE、编辑器重载后
43
+ * 即可继续编辑,经既有「推送」链路(IDC10010)保存为远端最新版本。
44
+ *
45
+ * 安全闸(与 /__card-version / /__save 同源):仅「已同步导入卡」(有 .import-meta.json
46
+ * 侧车且含 cardId)可操作;本地自建卡 / 未同步卡返回 409 local_card_no_source。
47
+ * 恢复另有防串卡校验:历史 schema.pageId 必须等于侧车 cardId,否则 409 pageId_mismatch。
48
+ *
49
+ * @param {{ root: string }} opts
50
+ * @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
51
+ */
52
+ export function createCardHistoryMiddleware({ root }) {
53
+ return function cardHistoryMiddleware(req, res, next) {
54
+ const pathname = decodeURIComponent((req.url || '').split('?')[0])
55
+ const mRestore = pathname.match(/^\/__card-history\/([^/]+)\/restore$/)
56
+ const mList = pathname.match(/^\/__card-history\/([^/]+)$/)
57
+ if (!mRestore && !mList) return next()
58
+ const method = (req.method || 'GET').toUpperCase()
59
+ if (mRestore ? method !== 'POST' : method !== 'GET') return next()
60
+
61
+ const id = decodeURIComponent((mRestore || mList)[1])
62
+ const dir = findCardDir(root, { code: id })
63
+ const meta = dir ? readImportMeta(dir) : null
64
+ if (!meta || !meta.cardId) {
65
+ res.statusCode = 409
66
+ res.setHeader('Content-Type', 'application/json')
67
+ return res.end(
68
+ JSON.stringify({
69
+ ok: false,
70
+ error: 'local_card_no_source',
71
+ message: '该卡片为本地自建、无远端同步来源(缺少 .import-meta.json 侧车),无法查看远端历史。',
72
+ }),
73
+ )
74
+ }
75
+
76
+ if (mRestore) return handleRestore(req, res, { root, id, dir, meta })
77
+ return handleList(req, res, { root, id, meta })
78
+ }
79
+ }
80
+
81
+ /** GET 列表:分页参数(query)pageNo / pageSize,缺省 1 / 30,上限 100 防失控 */
82
+ function handleList(req, res, { root, id, meta }) {
83
+ const qs = new URL(req.url || '/', 'http://localhost').searchParams
84
+ const pageNo = Math.max(1, parseInt(qs.get('pageNo') || '1', 10) || 1)
85
+ const pageSize = Math.min(100, Math.max(1, parseInt(qs.get('pageSize') || '30', 10) || 30))
86
+
87
+ ;(async () => {
88
+ try {
89
+ const skillPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
90
+ const { fetchCardHistory } = await import(pathToFileURL(skillPath).href)
91
+ const r = await fetchCardHistory(meta.cardId, { pageNo, pageSize })
92
+ log.ok('查询远端卡片历史', { cardCode: id, cardId: String(meta.cardId), count: r.list.length })
93
+ res.statusCode = 200
94
+ res.setHeader('Content-Type', 'application/json')
95
+ res.setHeader('Cache-Control', 'no-store')
96
+ res.end(
97
+ JSON.stringify({
98
+ ok: true,
99
+ cardId: String(meta.cardId),
100
+ cardCode: id,
101
+ list: r.list,
102
+ totalNums: r.totalNums,
103
+ totalPages: r.totalPages,
104
+ }),
105
+ )
106
+ } catch (e) {
107
+ // 区分「凭据缺失」与「真正的查询错误」:前者让前端提示登录,而非裸抛 ENOENT。
108
+ const isNoCred =
109
+ e && /credentials\.json|credentials 不存在|NO_CREDENTIALS/.test(String(e && e.message))
110
+ res.statusCode = isNoCred ? 200 : 500
111
+ res.setHeader('Content-Type', 'application/json')
112
+ res.end(
113
+ JSON.stringify(
114
+ isNoCred
115
+ ? { error: 'need_credentials', needCredentials: true, message: String(e && e.message) }
116
+ : { error: 'history failed', message: String(e && e.message) },
117
+ ),
118
+ )
119
+ }
120
+ })()
121
+ }
122
+
123
+ /** POST restore:按 versionId 取历史 schema → 防串卡校验 → 原子写 schema.json + 派生 data.json → 标记 dirty */
124
+ function handleRestore(req, res, { root, id, dir, meta }) {
125
+ ;(async () => {
126
+ try {
127
+ const body = await readJsonBody(req)
128
+ const versionId = String((body && body.versionId) || '')
129
+ if (!versionId) {
130
+ res.statusCode = 400
131
+ res.setHeader('Content-Type', 'application/json')
132
+ return res.end(JSON.stringify({ ok: false, error: 'missing_version_id', message: '缺少 versionId(远端版本记录 id)。' }))
133
+ }
134
+ const skillPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
135
+ const { fetchCardVersionSchema } = await import(pathToFileURL(skillPath).href)
136
+ let schema
137
+ let record
138
+ try {
139
+ ;({ schema, record } = await fetchCardVersionSchema(meta.cardId, versionId))
140
+ } catch (e) {
141
+ // 缓存未命中(时间线未加载过 / TTL 过期 / 目标在更旧页):先拉一页 100 条预热
142
+ // (cardId, versionId) 缓存再重试——一页 4.2s,通常快于翻页到命中位置,且覆盖最新
143
+ // 100 个版本(用户点击的多为时间线可见项)。
144
+ if (!/找不到版本记录/.test(String(e && e.message))) throw e
145
+ const { fetchCardHistory } = await import(pathToFileURL(skillPath).href)
146
+ await fetchCardHistory(meta.cardId, { pageNo: 1, pageSize: 100 })
147
+ ;({ schema, record } = await fetchCardVersionSchema(meta.cardId, versionId))
148
+ }
149
+ // 防串卡:历史 schema 的 pageId 必须等于侧车登记的 cardId(与 /__save 的 pageId 闸门同语义)
150
+ if (String(schema.pageId) !== String(meta.cardId)) {
151
+ res.statusCode = 409
152
+ res.setHeader('Content-Type', 'application/json')
153
+ return res.end(
154
+ JSON.stringify({
155
+ ok: false,
156
+ error: 'pageId_mismatch',
157
+ message: `版本记录 #${versionId} 的 schema.pageId(${schema.pageId})与本地卡片来源(${meta.cardId})不一致,已拒绝加载。`,
158
+ }),
159
+ )
160
+ }
161
+ // 原子写 schema.json + 单向派生 data.json(与编辑器本地保存同一写入口)
162
+ const written = writeCardFiles(dir, { schema })
163
+ // 本地内容已偏离导入镜像:标记 dirty,来源 tab 可见;推送成功后由 recordExport/markDirty 清除
164
+ markDirty(dir, true)
165
+ log.ok('恢复远端历史版本到本地', {
166
+ cardCode: id,
167
+ cardId: String(meta.cardId),
168
+ versionId,
169
+ createTime: record.createTime,
170
+ written,
171
+ })
172
+ res.statusCode = 200
173
+ res.setHeader('Content-Type', 'application/json')
174
+ res.setHeader('Cache-Control', 'no-store')
175
+ res.end(
176
+ JSON.stringify({
177
+ ok: true,
178
+ cardCode: id,
179
+ cardId: String(meta.cardId),
180
+ versionId,
181
+ createTime: record.createTime,
182
+ written,
183
+ }),
184
+ )
185
+ } catch (e) {
186
+ const msg = String(e && e.message)
187
+ const isNoCred = /credentials\.json|credentials 不存在|NO_CREDENTIALS/.test(msg)
188
+ const isNotFound = /找不到版本记录|schema 为空或损坏/.test(msg)
189
+ const status = isNoCred ? 200 : isNotFound ? 404 : 500
190
+ res.statusCode = status
191
+ res.setHeader('Content-Type', 'application/json')
192
+ res.end(
193
+ JSON.stringify(
194
+ isNoCred
195
+ ? { ok: false, error: 'need_credentials', needCredentials: true, message: msg }
196
+ : isNotFound
197
+ ? { ok: false, error: 'version_not_found', message: msg }
198
+ : { ok: false, error: 'restore failed', message: msg },
199
+ ),
200
+ )
201
+ }
202
+ })()
203
+ }
@@ -26,8 +26,9 @@ export const META_SCHEMA_VERSION = 1
26
26
  * @param {string} o.cardCode 英文卡 id(目录名)
27
27
  * @param {string} [o.cardName] 卡片标题
28
28
  * @param {object} [o.files] { schema, data, cover } 字节数
29
+ * @param {object} [o.remote] 远端列表元数据快照(picUrl / cardClassId / applyScene)
29
30
  */
30
- export function buildImportMeta({ baseUrl, appId, workspaceId, cardId, cardCode, cardName, files }) {
31
+ export function buildImportMeta({ baseUrl, appId, workspaceId, cardId, cardCode, cardName, files, remote }) {
31
32
  return {
32
33
  schemaVersion: META_SCHEMA_VERSION,
33
34
  source: 'IDC10012',
@@ -39,6 +40,10 @@ export function buildImportMeta({ baseUrl, appId, workspaceId, cardId, cardCode,
39
40
  cardCode: cardCode || '',
40
41
  cardName: cardName || '',
41
42
  files: files || null,
43
+ // 远端卡片列表(IDC10001)才有、本地 DSL 产品数据不持久化的元数据:picUrl / cardClassId / applyScene。
44
+ // 导出包必须带上**真值**:导入侧(ImportCardAction.java:61-64)无条件按 cardPO.picUrl 末段
45
+ // 推文件名去读卡目录根的封面,picUrl 为空会退化成目录路径、直接抛异常。真值只在同步时可获得。
46
+ remote: remote || null,
42
47
  syncedAt: new Date().toISOString(),
43
48
  tool: 'finmall-card-sync',
44
49
  dirty: false, // 本地编辑后偏离导入镜像为 true(重置为导入镜像后回 false)
@@ -0,0 +1,91 @@
1
+ import { pathToFileURL } from 'node:url'
2
+ import { findCardDir } from './cardSidecar.js'
3
+ import { readImportMeta } from './cardImportMeta.js'
4
+ import { log } from './logger.js'
5
+ import { resolveSkillPathOrProject } from './skillPaths.js'
6
+
7
+ const SYNC_SCRIPT_REL = 'finmall-card-sync/scripts/api_batch_sync.mjs'
8
+
9
+ /**
10
+ * 调试面板「导入信息」tab「发布版本记录」弹框的远端数据源(只读):
11
+ * GET /__card-publish-versions/:cardCode?pageNo=&pageSize=
12
+ *
13
+ * 语义:经 .import-meta.json 侧车里的 cardId 调 IDC10004,取该卡发布版本记录分页(新→旧),
14
+ * 每条含版本号 / 平台(iOS·Android·HarmonyOS 子集)/ 发布状态 / 发布时间。
15
+ * 触发时机由前端控制(打开弹框 / 滚动到底懒加载下一页),不自动轮询——与「编辑记录」tab
16
+ * 的懒加载策略一致,避免每次加载卡片都拉起后台 Chrome。
17
+ *
18
+ * 安全闸(与 /__card-history / /__card-version 同源):仅「已同步导入卡」(有
19
+ * .import-meta.json 侧车且含 cardId)可查;本地自建卡 / 未同步卡返回 409 local_card_no_source。
20
+ *
21
+ * @param {{ root: string }} opts
22
+ * @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
23
+ */
24
+ export function createCardPublishVersionsMiddleware({ root }) {
25
+ return function cardPublishVersionsMiddleware(req, res, next) {
26
+ const pathname = decodeURIComponent((req.url || '').split('?')[0])
27
+ const m = pathname.match(/^\/__card-publish-versions\/([^/]+)$/)
28
+ if (!m) return next()
29
+ if ((req.method || 'GET').toUpperCase() !== 'GET') return next()
30
+
31
+ const id = decodeURIComponent(m[1])
32
+ const dir = findCardDir(root, { code: id })
33
+ const meta = dir ? readImportMeta(dir) : null
34
+ if (!meta || !meta.cardId) {
35
+ res.statusCode = 409
36
+ res.setHeader('Content-Type', 'application/json')
37
+ return res.end(
38
+ JSON.stringify({
39
+ ok: false,
40
+ error: 'local_card_no_source',
41
+ message: '该卡片为本地自建、无远端同步来源(缺少 .import-meta.json 侧车),无法查看发布版本记录。',
42
+ }),
43
+ )
44
+ }
45
+
46
+ const qs = new URL(req.url || '/', 'http://localhost').searchParams
47
+ const pageNo = Math.max(1, parseInt(qs.get('pageNo') || '1', 10) || 1)
48
+ const pageSize = Math.min(100, Math.max(1, parseInt(qs.get('pageSize') || '10', 10) || 10))
49
+
50
+ ;(async () => {
51
+ try {
52
+ const skillPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
53
+ const { fetchCardPublishVersions } = await import(pathToFileURL(skillPath).href)
54
+ const r = await fetchCardPublishVersions(meta.cardId, { pageNo, pageSize })
55
+ log.ok('查询远端发布版本记录', {
56
+ cardCode: id,
57
+ cardId: String(meta.cardId),
58
+ pageNo,
59
+ count: r.list.length,
60
+ })
61
+ res.statusCode = 200
62
+ res.setHeader('Content-Type', 'application/json')
63
+ res.setHeader('Cache-Control', 'no-store')
64
+ res.end(
65
+ JSON.stringify({
66
+ ok: true,
67
+ cardId: String(meta.cardId),
68
+ cardCode: id,
69
+ list: r.list,
70
+ maxVersion: r.maxVersion,
71
+ totalNums: r.totalNums,
72
+ totalPages: r.totalPages,
73
+ }),
74
+ )
75
+ } catch (e) {
76
+ // 区分「凭据缺失」与「真正的查询错误」:前者让前端提示登录,而非裸抛 ENOENT。
77
+ const isNoCred =
78
+ e && /credentials\.json|credentials 不存在|NO_CREDENTIALS/.test(String(e && e.message))
79
+ res.statusCode = isNoCred ? 200 : 500
80
+ res.setHeader('Content-Type', 'application/json')
81
+ res.end(
82
+ JSON.stringify(
83
+ isNoCred
84
+ ? { error: 'need_credentials', needCredentials: true, message: String(e && e.message) }
85
+ : { error: 'publish versions failed', message: String(e && e.message) },
86
+ ),
87
+ )
88
+ }
89
+ })()
90
+ }
91
+ }
@@ -9,17 +9,115 @@ import { checkPushAllowed } from './cardListCache.js'
9
9
  import { assertRemoteSource, findCardDir } from './cardSidecar.js'
10
10
  import { log } from './logger.js'
11
11
  import { resolveSkillPathOrProject } from './skillPaths.js'
12
+ import { generateCardCover } from './screenshot.js'
13
+ import { serialize } from './browser.js'
12
14
 
13
15
  /** 远端保存脚本相对 skills/ 的路径(与「同步」共用 finmall-card-sync) */
14
16
  const SYNC_SCRIPT_REL = 'finmall-card-sync/scripts/api_batch_sync.mjs'
15
17
 
18
+ /**
19
+ * 推送成功后「截图 + 上传为远端预览图」的整段预算。
20
+ *
21
+ * 为什么要预算:这一步串了无头渲染(首次拉起 Chrome 可达数秒)与一次跨网 HTTPS 上传,
22
+ * 两步各有 30s 级超时。不设总闸的话,最坏情况会把 /__save 的响应拖到 1 分钟以上——
23
+ * 用户看到的是「点了推送一直在转圈」,而卡片内容其实早就写成功了。到点即放弃并
24
+ * 在响应里带 preview.error 说明,不影响推送本身的成功结论。
25
+ */
26
+ const PUSH_PREVIEW_TIMEOUT_MS = Number(process.env.CARDBUDDY_PUSH_PREVIEW_TIMEOUT_MS || 45000)
27
+
28
+ /**
29
+ * 推送成功后自动更新远端预览图的总开关(默认开)。
30
+ * 需要临时关闭时设 `CARDBUDDY_PUSH_PREVIEW=0`(例如远端图片库维护中,避免每条推送都多等一次失败)。
31
+ */
32
+ const PUSH_PREVIEW_ENABLED = process.env.CARDBUDDY_PUSH_PREVIEW !== '0'
33
+
34
+ /** 带总时限的 Promise 竞速(超时不取消底层任务,只放弃等待) */
35
+ function withDeadline(promise, ms, label) {
36
+ let timer
37
+ return Promise.race([
38
+ promise,
39
+ new Promise((_, reject) => {
40
+ timer = setTimeout(() => reject(new Error(`${label}超时(>${ms}ms),已放弃`)), ms)
41
+ }),
42
+ ]).finally(() => clearTimeout(timer))
43
+ }
44
+
45
+ /**
46
+ * 推送成功后把「当前卡片」截图上传为远端预览图(IDC10014.do)。
47
+ *
48
+ * 为什么放在服务端而不是前端:①截图走本地无头渲染(screenshot.js 单例 Chrome),
49
+ * 与「预览图」按钮同一条链路,产出透明底、与卡片实际渲染尺寸 1:1 的 PNG;
50
+ * ②上传必须带 FinMall token,只有已登录的后台会话拿得到,前端不接触凭据。
51
+ *
52
+ * 三条纪律:
53
+ * - **best-effort**:任何失败都返回结构化结果,绝不向上抛——预览图是推送的附加产物,
54
+ * 不能让它把「卡片已保存到远端」这个既成事实变成一次报错;
55
+ * - **不写本地预览图**:只上传,不覆盖 src/cards/<id>/<id>.png。写盘会触发 watcher 的
56
+ * cards-changed,而截图 + 上传耗时可能超出前端 2.5s 的自保存抑制窗口,会误弹
57
+ * 「卡片已在编辑器外被修改」——本地封面仍由「预览图」按钮显式生成(单一触发点);
58
+ * - **渲染基于磁盘态**:调用方(前端「推送」)需先确保未保存编辑已落盘,否则截到的是
59
+ * 上一版布局(见 App.vue saveToRemote 的前置 saveCard)。
60
+ *
61
+ * @param {object} opts
62
+ * @param {string} opts.cardCode 卡片目录名(= 英文卡 id,截图入口用它定位卡片)
63
+ * @param {string|number} opts.cardId 数字型远端 cardId(= schema.pageId,IDC10014.do 主键)
64
+ * @param {number} [opts.width] 卡片设计宽度(缺省 375,取自 schema.view.width)
65
+ * @param {unknown[]} [opts.components] 卡片组件数组(用于识别空白卡:空数组时不截图)
66
+ * @param {(p: {cardId: string, pngBase64: string}) => Promise<object>} opts.upload
67
+ * 上传实现(注入 skill 的 uploadCardPreview)
68
+ * @param {(id: string, o?: object) => Promise<object>} [opts.shoot]
69
+ * 截图实现(默认 generateCardCover;单测可注入)
70
+ * @returns {Promise<{ ok: boolean, width?: number|null, height?: number|null, bytes?: number, url?: string, error?: string, message?: string }>}
71
+ */
72
+ export async function uploadPreviewAfterPush({
73
+ cardCode,
74
+ cardId,
75
+ width,
76
+ components,
77
+ upload,
78
+ shoot = generateCardCover,
79
+ } = {}) {
80
+ if (!cardCode) return { ok: false, error: 'no_card_code', message: '未定位到卡片目录,跳过预览图上传' }
81
+ if (!cardId) return { ok: false, error: 'no_card_id', message: '缺少数字型 cardId,跳过预览图上传' }
82
+ if (typeof upload !== 'function') {
83
+ return { ok: false, error: 'upload_unavailable', message: '当前 skill 未提供 uploadCardPreview,跳过预览图上传' }
84
+ }
85
+ // 空白卡(组件数组为空):无头渲染只会得到一张纯透明图,传上去等于把列表缩略图刷没。
86
+ // 与 /__card-image 的 422 判定同源(那里同样按「数组存在且为空」认定空白卡)。
87
+ if (Array.isArray(components) && !components.length) {
88
+ return { ok: false, error: 'empty-card', message: '空白卡(无组件)没有可预览内容,跳过预览图上传' }
89
+ }
90
+
91
+ // 与 /__card-image、/__screenshot 共用同一浏览器单例 + 串行队列,避免同时渲多张打爆内存
92
+ const shot = await serialize(() => shoot(cardCode, { width: Number(width) > 0 ? Number(width) : 375 }))
93
+ if (!shot || shot.tooBusy) {
94
+ return { ok: false, error: 'too-busy', message: '截图队列已满,跳过预览图上传' }
95
+ }
96
+ if (!shot.buffer) {
97
+ return { ok: false, error: shot.error || 'screenshot-failed', message: shot.message || '截图失败' }
98
+ }
99
+
100
+ const uploaded = await upload({ cardId: String(cardId), pngBase64: shot.buffer.toString('base64') })
101
+ return {
102
+ ok: true,
103
+ width: shot.width,
104
+ height: shot.height,
105
+ bytes: shot.buffer.length,
106
+ url: (uploaded && uploaded.url) || '',
107
+ }
108
+ }
109
+
16
110
  /**
17
111
  * 远端保存(写回 FinMall)服务端处理。
18
112
  * 前端「保存」按钮把当前预览的 schema(含实时数据绑定)POST 过来,
19
113
  * 本中间件校验 pageId 后,经已登录浏览器中转调用 IDC10010.do 写回远端,
20
114
  * 与「同步」共用 finmall-card-sync 的 saveCardToRemote(带会话自愈)。
115
+ *
116
+ * @param {{ root: string, previewAfterPush?: typeof uploadPreviewAfterPush }} opts
117
+ * - previewAfterPush:推送成功后的「截图 + 上传预览图」实现,默认即上面的
118
+ * uploadPreviewAfterPush;单测注入替身以便在无 Chrome / 无 FinMall 会话的环境断言接线。
21
119
  */
22
- export function createSaveMiddleware({ root }) {
120
+ export function createSaveMiddleware({ root, previewAfterPush = uploadPreviewAfterPush }) {
23
121
  return async function saveMiddleware(req, res, next) {
24
122
  const pathname = decodeURIComponent((req.url || '').split('?')[0])
25
123
  const method = (req.method || 'GET').toUpperCase()
@@ -102,17 +200,18 @@ export function createSaveMiddleware({ root }) {
102
200
  pathToFileURL(resolveSkillPathOrProject(root || process.cwd(), SYNC_SCRIPT_REL)).href
103
201
  )
104
202
  const { response, verified } = await mod.saveCardToRemote(schema, { skeletonScreen })
203
+ // 卡片目录反查只做一次:既供导出元数据落盘,也供随后的预览图截图定位(目录名 = 英文卡 id)。
204
+ const cardDir = root ? findCardDir(root, { code, pageId: schema.pageId }) : null
105
205
  // 导出可追溯(Phase E):把远端保存的读回校验结果写进导入元数据侧车。
106
206
  // 注意:目录按 cardCode 命名,必须按 pageId 反查到正确的 src/cards/<cardCode>/。
107
207
  if (root) {
108
208
  try {
109
- const dir = findCardDir(root, { pageId: schema.pageId })
110
- if (dir) {
111
- await recordExport(dir, verified)
209
+ if (cardDir) {
210
+ await recordExport(cardDir, verified)
112
211
  // 推送成功即与远端对齐:清除「偏离导入镜像」标记(来源 tab 据此提示)。
113
212
  // 注:本地磁盘(含编辑)仍可能 ≠ 当初导入镜像,此处语义转为「已与远端同步」,
114
213
  // 符合用户预期——推送到远端后不应再被标为「偏离」。
115
- await markDirty(dir, false)
214
+ await markDirty(cardDir, false)
116
215
  } else console.warn(`[__save] 未找到 pageId=${schema.pageId} 对应的本地卡片目录,跳过导出元数据`)
117
216
  } catch (e) {
118
217
  log.warn('写导出元数据失败(不影响保存结果)', { error: e && e.message })
@@ -126,9 +225,47 @@ export function createSaveMiddleware({ root }) {
126
225
  if (!verified.fingerprintMatched && verified.diff && verified.diff.length) {
127
226
  log.warn('指纹不匹配(落库内容与本地不一致)', { diff: verified.diff.slice(0, 3) })
128
227
  }
228
+
229
+ // 推送成功后自动更新远端预览图:本地无头渲染当前卡片 → IDC10014.do 上传。
230
+ // 全程 best-effort:失败只记日志 + 在响应里带 preview.error,推送本身的成功结论不受影响。
231
+ let preview = null
232
+ if (PUSH_PREVIEW_ENABLED) {
233
+ try {
234
+ preview = await withDeadline(
235
+ previewAfterPush({
236
+ cardCode: cardDir ? path.basename(cardDir) : '',
237
+ cardId: String(schema.pageId),
238
+ width: Number((schema.view && schema.view.width) || 0),
239
+ components: schema.view && schema.view.components,
240
+ upload: typeof mod.uploadCardPreview === 'function' ? mod.uploadCardPreview : null,
241
+ }),
242
+ PUSH_PREVIEW_TIMEOUT_MS,
243
+ '推送后预览图上传',
244
+ )
245
+ } catch (e) {
246
+ preview = { ok: false, error: 'preview_failed', message: String(e && e.message) }
247
+ }
248
+ if (preview && preview.ok) {
249
+ log.ok('推送后预览图已更新', {
250
+ cardId: String(schema.pageId),
251
+ width: preview.width,
252
+ height: preview.height,
253
+ bytes: preview.bytes,
254
+ })
255
+ } else {
256
+ log.warn('推送成功但预览图未更新(不影响卡片内容)', {
257
+ cardId: String(schema.pageId),
258
+ error: (preview && preview.error) || 'unknown',
259
+ message: (preview && preview.message) || '',
260
+ })
261
+ }
262
+ }
263
+
129
264
  res.statusCode = 200
130
265
  res.setHeader('Content-Type', 'application/json')
131
- return res.end(JSON.stringify({ ok: true, code, cardId: String(schema.pageId), response, verified }))
266
+ return res.end(
267
+ JSON.stringify({ ok: true, code, cardId: String(schema.pageId), response, verified, preview }),
268
+ )
132
269
  } catch (e) {
133
270
  log.error('远端保存失败', e)
134
271
  res.statusCode = 502