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.
- package/README.md +8 -8
- package/dist/cli.js +21 -8
- package/dist/init.js +3 -3
- package/dist/preview-editor/assets/codeBridgeHeartbeat.worker-Zb_A5RUd.js +1 -0
- package/dist/preview-editor/assets/index-C8VLzU18.css +1 -0
- package/dist/preview-editor/assets/index-CpDojF__.js +383 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/skills-upgrade.js +64 -25
- package/dist/standalone.js +49 -0
- package/dist/vendored/card-editor/components/MultiColumn.vue +19 -10
- package/dist/vendored/card-editor/runtime/card-runtime.js +1 -1
- package/dist/vendored/mcp/client.mjs +22 -3
- package/dist/vendored/server/core/ai-chat.js +92 -12
- package/dist/vendored/server/core/cardExport.js +831 -0
- package/dist/vendored/server/core/cardHistoryRemote.js +203 -0
- package/dist/vendored/server/core/cardImportMeta.js +6 -1
- package/dist/vendored/server/core/cardPublishVersions.js +91 -0
- package/dist/vendored/server/core/cardSave.js +143 -6
- package/dist/vendored/server/core/cardSync.js +467 -95
- package/dist/vendored/server/core/codeBridge.js +4 -2
- package/dist/vendored/server/core/index.js +16 -0
- package/dist/vendored/skills/finmall-card-sync/SKILL.md +46 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +657 -15
- package/dist/vendored/skills/finmall-session-navigator/scripts/finmall_login.mjs +79 -8
- package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +25 -1
- package/dist/vendored/skills/mbank-design-gen/tools/screenshot.mjs +10 -1
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-C_uqjKqI.css +0 -1
- 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
|
-
|
|
110
|
-
|
|
111
|
-
await recordExport(dir, verified)
|
|
209
|
+
if (cardDir) {
|
|
210
|
+
await recordExport(cardDir, verified)
|
|
112
211
|
// 推送成功即与远端对齐:清除「偏离导入镜像」标记(来源 tab 据此提示)。
|
|
113
212
|
// 注:本地磁盘(含编辑)仍可能 ≠ 当初导入镜像,此处语义转为「已与远端同步」,
|
|
114
213
|
// 符合用户预期——推送到远端后不应再被标为「偏离」。
|
|
115
|
-
await markDirty(
|
|
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(
|
|
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
|