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
|
@@ -80,6 +80,9 @@ const COVER_FETCH_TIMEOUT_MS = Number(process.env.CARDBUDDY_COVER_TIMEOUT_MS ||
|
|
|
80
80
|
// FinMall 交易请求超时。IDC10001/IDC10012 偶发挂起(后端不响应),同样没有任何默认超时,
|
|
81
81
|
// 一张卡挂住就会拖垮整批同步——所以和预览图一样,内层 abort + 外层 race 双重设防。
|
|
82
82
|
const FINMALL_POST_TIMEOUT_MS = Number(process.env.CARDBUDDY_POST_TIMEOUT_MS || 30000);
|
|
83
|
+
// 卡片预览图上传(IDC10014.do)超时。与切图上传同款设防:内层 AbortController +
|
|
84
|
+
// 外层 Promise.race 兜住 page.evaluate 自身不返回(evaluate 无默认超时)。
|
|
85
|
+
const PREVIEW_UPLOAD_TIMEOUT_MS = Number(process.env.CARDBUDDY_PREVIEW_UPLOAD_TIMEOUT_MS || 30000);
|
|
83
86
|
|
|
84
87
|
function withTimeout(promise, ms, label) {
|
|
85
88
|
let timer;
|
|
@@ -92,9 +95,10 @@ function withTimeout(promise, ms, label) {
|
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
// 服务端判定会话/权限失效并应「重新进入」的特征集合。命中后由 finmallPostWithHeal
|
|
95
|
-
// 自动清 session 重登再试一次——覆盖会话超时(BLEC0006)、token
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
+
// 自动清 session 重登再试一次——覆盖会话超时(BLEC0006)、token 失效、
|
|
99
|
+
// 「您当前角色菜单权限发生变化,请重新进入」这类权限态异常,以及前端页面会话键丢失
|
|
100
|
+
// (localStorage._sessionId 为空,如后台 Chrome 被清理/重启后):本地凭证仍在即可自愈。
|
|
101
|
+
const SESSION_INVALID_RE = /BLEC0006|会话超时|会话过期|登录超时|未登录|登录失效|登录已失效|请重新登录|invalid token|token|角色菜单权限发生变化|权限发生变化|重新进入|请重新进入|缺少会话凭据/i;
|
|
98
102
|
|
|
99
103
|
/**
|
|
100
104
|
* 带自愈的 FinMall 接口调用:正常请求,若命中会话/权限失效特征则清 session 重登后重试一次。
|
|
@@ -124,6 +128,7 @@ async function finmallPostWithHeal(page, urlPath, requestBody, transCode = urlPa
|
|
|
124
128
|
*
|
|
125
129
|
* @param {object} opts
|
|
126
130
|
* @param {string} [opts.cardCode] 仅同步该英文卡 id(用列表接口 searchValue 定位)
|
|
131
|
+
* @param {string[]} [opts.cardCodes] 仅同步这些英文卡 id(集合筛选;与 cardCode 互斥,cardCode 优先)
|
|
127
132
|
* @param {number} [opts.startPage=1]
|
|
128
133
|
* @param {number} [opts.startIndex=0]
|
|
129
134
|
* @param {number} [opts.pageSize=24]
|
|
@@ -132,7 +137,11 @@ async function finmallPostWithHeal(page, urlPath, requestBody, transCode = urlPa
|
|
|
132
137
|
* @param {string} [opts.workspaceId]
|
|
133
138
|
* @param {string} [opts.cdpUrl] 已废弃:会话现在由 ensureFinMallSession 在专用后台端口自动管理
|
|
134
139
|
* @param {string} [opts.outRoot] 输出根目录(默认 workspace/src/cards)
|
|
135
|
-
* @
|
|
140
|
+
* @param {(p: {done:number,total:number|null,currentId:string,currentTitle:string}) => void} [opts.onProgress]
|
|
141
|
+
* 每张卡处理完毕后的进度回调(失败也回调),供前端进度条消费
|
|
142
|
+
* @param {() => boolean} [opts.shouldCancel] 取消探针:返回 true 时在下一条卡片前中止,
|
|
143
|
+
* 已落盘的卡片保留,summary 标记 cancelled
|
|
144
|
+
* @returns {Promise<{success:boolean, synced:Array, failed:Array, duplicates:Array, cancelled?:boolean}>}
|
|
136
145
|
*/
|
|
137
146
|
export async function syncCards(opts = {}) {
|
|
138
147
|
// 非注入形态(纯 CLI)用 cwd 作为项目根,跨「仓库内 / 消费者工程」一致;server 中间件始终注入绝对 outRoot。
|
|
@@ -152,6 +161,19 @@ export async function syncCards(opts = {}) {
|
|
|
152
161
|
const workspaceId = opts.workspaceId || finmallConfig.workspaceId;
|
|
153
162
|
// 单卡筛选:传 cardCode 时仅同步该卡
|
|
154
163
|
const cardCodeFilter = opts.cardCode || '';
|
|
164
|
+
// 集合筛选:cardCodes 非空时仅同步集合内卡片(与单卡筛选互斥,单卡优先)。
|
|
165
|
+
// 入口处已做格式校验,这里再按 normalizeCard 同源正则兜底,非法项静默剔除。
|
|
166
|
+
const cardCodeSet =
|
|
167
|
+
!cardCodeFilter && Array.isArray(opts.cardCodes) && opts.cardCodes.length
|
|
168
|
+
? new Set(
|
|
169
|
+
opts.cardCodes.filter((c) => typeof c === 'string' && /^[A-Za-z0-9_-]+$/.test(c))
|
|
170
|
+
)
|
|
171
|
+
: null;
|
|
172
|
+
// 进度回调与取消探针(前端「查看全部 → 下载选中/全部下载」的进度弹窗消费)
|
|
173
|
+
const onProgress = typeof opts.onProgress === 'function' ? opts.onProgress : null;
|
|
174
|
+
const shouldCancel = typeof opts.shouldCancel === 'function' ? opts.shouldCancel : () => false;
|
|
175
|
+
// 预期总张数:单卡=1;集合=集合大小;全量=0(未知,边拉边算)
|
|
176
|
+
const expectedTotal = cardCodeFilter ? 1 : cardCodeSet ? cardCodeSet.size : 0;
|
|
155
177
|
|
|
156
178
|
// 自动确保后台 FinMall 会话:未启动/缺失则拉起无界面 Chrome 并登录,无需手动触发。
|
|
157
179
|
const session = await ensureFinMallSession();
|
|
@@ -170,14 +192,17 @@ export async function syncCards(opts = {}) {
|
|
|
170
192
|
const totalPages = Number(firstList.totalPages || Math.ceil(Number(firstList.totalNums || 0) / pageSize) || startPage);
|
|
171
193
|
|
|
172
194
|
let done = false;
|
|
195
|
+
let cancelled = false;
|
|
173
196
|
const allCards = [];
|
|
174
|
-
for (let pageNo = startPage; pageNo <= totalPages && !done; pageNo++) {
|
|
197
|
+
for (let pageNo = startPage; pageNo <= totalPages && !done && !cancelled; pageNo++) {
|
|
175
198
|
const list = pageNo === startPage ? firstList : await fetchCardList(page, { pageNo, pageSize, appId, workspaceId, searchValue: cardCodeFilter });
|
|
176
199
|
const cards = list.cardList || [];
|
|
177
200
|
for (const c of cards) allCards.push(c);
|
|
178
201
|
const begin = pageNo === startPage ? startIndex : 0;
|
|
179
|
-
for (let index = begin; index < cards.length && !done; index++) {
|
|
202
|
+
for (let index = begin; index < cards.length && !done && !cancelled; index++) {
|
|
180
203
|
if (summary.synced.length + summary.failed.length >= maxCards) break;
|
|
204
|
+
// 取消探针:用户点「取消」后在下一条卡片前退出(已落盘的卡片保留)
|
|
205
|
+
if (shouldCancel()) { cancelled = true; break; }
|
|
181
206
|
// normalizeCard 会对非法 code 抛错;单条异常不应中断整批同步。
|
|
182
207
|
let card;
|
|
183
208
|
try {
|
|
@@ -189,6 +214,8 @@ export async function syncCards(opts = {}) {
|
|
|
189
214
|
continue;
|
|
190
215
|
}
|
|
191
216
|
if (cardCodeFilter && card.id !== cardCodeFilter) continue;
|
|
217
|
+
// 集合筛选:不在勾选集合内的卡片跳过(仍进 allCards 供缓存整体刷新)
|
|
218
|
+
if (cardCodeSet && !cardCodeSet.has(card.id)) continue;
|
|
192
219
|
log.info('同步中', { page: `${pageNo}/${totalPages}`, index, id: card.id, title: card.title });
|
|
193
220
|
try {
|
|
194
221
|
const files = await syncCard(page, card, { outRoot, appId, workspaceId, summary, schemaHashes, importMeta });
|
|
@@ -203,12 +230,30 @@ export async function syncCards(opts = {}) {
|
|
|
203
230
|
summary.failed.push({ page: pageNo, index, id: card.id, title: card.title, error: error.message });
|
|
204
231
|
log.error('同步失败', { id: card.id, title: card.title, error: error.message });
|
|
205
232
|
}
|
|
233
|
+
// 进度回调:失败也回调(done 计数含失败),前端据此更新「已完成 X / 全部 Y」
|
|
234
|
+
if (onProgress) {
|
|
235
|
+
try {
|
|
236
|
+
onProgress({
|
|
237
|
+
done: summary.synced.length + summary.failed.length,
|
|
238
|
+
total: expectedTotal || null,
|
|
239
|
+
currentId: card.id,
|
|
240
|
+
currentTitle: card.title || '',
|
|
241
|
+
});
|
|
242
|
+
} catch { /* 前端回调异常不阻断同步 */ }
|
|
243
|
+
}
|
|
206
244
|
if (cardCodeFilter) done = true;
|
|
207
245
|
}
|
|
208
246
|
if (summary.synced.length + summary.failed.length >= maxCards) break;
|
|
209
247
|
}
|
|
248
|
+
// 取消:已落盘的卡片保留,summary 显式标记,由调用方(dev server 任务)转成用户提示
|
|
249
|
+
if (cancelled) {
|
|
250
|
+
summary.cancelled = true;
|
|
251
|
+
summary.success = false;
|
|
252
|
+
log.warn('同步已被用户取消', { done: summary.synced.length + summary.failed.length });
|
|
253
|
+
}
|
|
210
254
|
// 全量同步(无 cardCode 过滤)时整体刷新卡片列表缓存,供推送前「存在性闸门」使用;
|
|
211
|
-
//
|
|
255
|
+
// 单卡同步只增量合并,不整体替换,避免清空其余卡片。集合筛选(cardCodes)会翻遍
|
|
256
|
+
// 全部分页,allCards 即完整列表,整体刷新同样准确。缓存未就绪则跳过(不影响同步)。
|
|
212
257
|
try {
|
|
213
258
|
const cache = await getCardListCache();
|
|
214
259
|
if (cache) cache.refreshCardList(allCards, { full: !cardCodeFilter });
|
|
@@ -222,6 +267,66 @@ export async function syncCards(opts = {}) {
|
|
|
222
267
|
return summary;
|
|
223
268
|
}
|
|
224
269
|
|
|
270
|
+
/**
|
|
271
|
+
* 拉取远端完整卡片列表(IDC10001 分页全量),供编辑器「查看全部」网格浏览。
|
|
272
|
+
* 只读、不落盘;与 syncCards 共用同一后台 FinMall 会话(ensureFinMallSession 自动保活)。
|
|
273
|
+
*
|
|
274
|
+
* @param {object} [opts]
|
|
275
|
+
* @param {number} [opts.pageSize=50] 列表分页大小
|
|
276
|
+
* @param {number} [opts.maxPages=400] 分页上限保护(50×400=2 万张,防服务端异常死循环)
|
|
277
|
+
* @param {string} [opts.appId]
|
|
278
|
+
* @param {string} [opts.workspaceId]
|
|
279
|
+
* @returns {Promise<Array<{id:string,title:string,cardId:string|number,coverUrl:string}>>}
|
|
280
|
+
* id=英文卡 code(本地目录名同源);coverUrl 为 FinMall 图片库绝对地址(可能为空串)
|
|
281
|
+
*/
|
|
282
|
+
export async function listRemoteCards(opts = {}) {
|
|
283
|
+
const appId = opts.appId || finmallConfig.appId;
|
|
284
|
+
const workspaceId = opts.workspaceId || finmallConfig.workspaceId;
|
|
285
|
+
const pageSize = Number(opts.pageSize || 50);
|
|
286
|
+
const maxPages = Number(opts.maxPages || 400);
|
|
287
|
+
|
|
288
|
+
// 自动确保后台 FinMall 会话:未启动/缺失则拉起无界面 Chrome 并登录,无需手动触发。
|
|
289
|
+
const session = await ensureFinMallSession();
|
|
290
|
+
const browser = session.browser;
|
|
291
|
+
try {
|
|
292
|
+
const context = browser.contexts()[0];
|
|
293
|
+
const page = context?.pages().find((p) => p.url().includes('finmall.com')) || context?.pages()[0];
|
|
294
|
+
if (!page) throw new Error('未能在后台 Chrome 中找到 FinMall 页面,请检查凭据或重新触发。');
|
|
295
|
+
await page.bringToFront();
|
|
296
|
+
|
|
297
|
+
const out = [];
|
|
298
|
+
const seen = new Set();
|
|
299
|
+
let pageNo = 1;
|
|
300
|
+
let totalPages = 1;
|
|
301
|
+
do {
|
|
302
|
+
const list = await fetchCardList(page, { pageNo, pageSize, appId, workspaceId });
|
|
303
|
+
totalPages = Number(list.totalPages || Math.ceil(Number(list.totalNums || 0) / pageSize) || 1);
|
|
304
|
+
for (const raw of list.cardList || []) {
|
|
305
|
+
// 非法 code(normalizeCard 抛错)的单条跳过,不阻断整表拉取
|
|
306
|
+
let card;
|
|
307
|
+
try {
|
|
308
|
+
card = normalizeCard(raw);
|
|
309
|
+
} catch {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (seen.has(card.id)) continue;
|
|
313
|
+
seen.add(card.id);
|
|
314
|
+
out.push({
|
|
315
|
+
id: card.id,
|
|
316
|
+
title: card.title || '',
|
|
317
|
+
cardId: card.cardId,
|
|
318
|
+
coverUrl: card.coverUrl || '',
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
pageNo += 1;
|
|
322
|
+
} while (pageNo <= totalPages && pageNo <= maxPages);
|
|
323
|
+
return out;
|
|
324
|
+
} finally {
|
|
325
|
+
// 仅断开连接,不关闭后台 Chrome(与 syncCards 一致,供后续同步/推送复用会话)
|
|
326
|
+
try { await browser.disconnect() } catch { /* ignore */ }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
225
330
|
/**
|
|
226
331
|
* 把一张卡片的最新 schema 写回 FinMall(线上编辑器「保存」接口 IDC10010.do)。
|
|
227
332
|
* 与 syncCards 共用同一套已登录浏览器会话与带自愈的 finmallPost,
|
|
@@ -315,6 +420,175 @@ export async function saveCardToRemote(schema, { skeletonScreen = '', verify = t
|
|
|
315
420
|
}
|
|
316
421
|
}
|
|
317
422
|
|
|
423
|
+
/**
|
|
424
|
+
* 在已认证页面上下文内以 multipart/form-data 上传卡片预览图(IDC10014.do)。
|
|
425
|
+
*
|
|
426
|
+
* 请求形态取自线上编辑器抓包(`/ubomp-web/IDC10014.do`),与切图上传(CC0000.do)的差异:
|
|
427
|
+
* - 表单字段是 `cardId`(纯文本数字 cardId)+ `file`(二进制,filename 固定 `<cardId>.png`,
|
|
428
|
+
* 抓包里 Content-Type 为 application/octet-stream —— Blob 不带 type 时浏览器就是这个值,
|
|
429
|
+
* 这里照抄抓包,不做「更正确」的覆盖);
|
|
430
|
+
* - `sassAppId` / `sassWorkspaceId` 走**表单字段**(CC0000.do 是查询串);
|
|
431
|
+
* - 没有 `fileName` 纯文本字段;
|
|
432
|
+
* - 鉴权头只有 `token`。
|
|
433
|
+
*
|
|
434
|
+
* token 仅在页面上下文内读取并直接用于请求,不回传 Node 侧、不打印、不落盘(与 finmallPost 同纪律)。
|
|
435
|
+
*
|
|
436
|
+
* @returns {Promise<string>} 原始响应文本(交由 parsePreviewUploadResponse 解析)
|
|
437
|
+
*/
|
|
438
|
+
async function postPreviewUpload(page, { cardId, fileName, base64, appId, workspaceId }) {
|
|
439
|
+
return withTimeout(
|
|
440
|
+
page.evaluate(
|
|
441
|
+
async ({ cardId, fileName, base64, appId, workspaceId, timeoutMs }) => {
|
|
442
|
+
const controller = new AbortController();
|
|
443
|
+
setTimeout(() => controller.abort(), timeoutMs);
|
|
444
|
+
const readSessionToken = () => {
|
|
445
|
+
try {
|
|
446
|
+
const raw = localStorage.getItem('_sessionId');
|
|
447
|
+
if (!raw) return '';
|
|
448
|
+
let value = JSON.parse(raw);
|
|
449
|
+
if (value && typeof value === 'object' && 'value' in value) value = value.value;
|
|
450
|
+
if (typeof value === 'string' && value.startsWith('"')) {
|
|
451
|
+
try {
|
|
452
|
+
value = JSON.parse(value);
|
|
453
|
+
} catch {
|
|
454
|
+
/* 保留原字符串 */
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return typeof value === 'string' ? value : '';
|
|
458
|
+
} catch {
|
|
459
|
+
return '';
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
const sessionToken = readSessionToken();
|
|
463
|
+
if (!sessionToken) {
|
|
464
|
+
throw new Error('页面缺少会话凭据(localStorage._sessionId 为空),请先运行 finmall-session-navigator 登录。');
|
|
465
|
+
}
|
|
466
|
+
const binary = atob(base64);
|
|
467
|
+
const bytes = new Uint8Array(binary.length);
|
|
468
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
469
|
+
const form = new FormData();
|
|
470
|
+
form.append('cardId', cardId);
|
|
471
|
+
// 抓包里该 part 的 Content-Type 是 application/octet-stream,filename 为 <cardId>.png
|
|
472
|
+
form.append('file', new Blob([bytes]), fileName);
|
|
473
|
+
form.append('sassAppId', String(appId));
|
|
474
|
+
form.append('sassWorkspaceId', String(workspaceId));
|
|
475
|
+
const response = await fetch(new URL('/ubomp-web/IDC10014.do', location.origin).href, {
|
|
476
|
+
method: 'POST',
|
|
477
|
+
headers: { token: sessionToken, Accept: '*/*' },
|
|
478
|
+
credentials: 'include',
|
|
479
|
+
body: form,
|
|
480
|
+
signal: controller.signal,
|
|
481
|
+
});
|
|
482
|
+
const text = await response.text();
|
|
483
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${text.slice(0, 200)}`);
|
|
484
|
+
return text;
|
|
485
|
+
},
|
|
486
|
+
{ cardId, fileName, base64, appId, workspaceId, timeoutMs: PREVIEW_UPLOAD_TIMEOUT_MS },
|
|
487
|
+
),
|
|
488
|
+
PREVIEW_UPLOAD_TIMEOUT_MS + 2000,
|
|
489
|
+
`上传卡片预览图 ${fileName}`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* 解析 IDC10014.do 响应(导出仅为单测可直接断言;正常路径走 uploadCardPreview)。
|
|
495
|
+
*
|
|
496
|
+
* 该接口的响应体形态没有可比对的既有文档,故按 FinMall 交易接口的通用信封
|
|
497
|
+
* (`{header:{errorCode,errorMsg}, body:{...}}`)做**宽松**判定:
|
|
498
|
+
* - 空响应体 → 视为成功(HTTP 2xx 已由调用方保证,接口可能不返回业务体);
|
|
499
|
+
* - 非 JSON 非空 → 直接报错(多半是网关 HTML 错误页,不能当成功);
|
|
500
|
+
* - errorCode 存在且非 '0' → 抛错(BLEC0006 等会话失效特征会被 uploadCardPreview 捕获并重登重试);
|
|
501
|
+
* - 成功时尽力从 body 里捞预览图地址(字段名依 ubomp 同类接口习惯多键兜底),
|
|
502
|
+
* 捞不到不影响「上传成功」的结论。
|
|
503
|
+
*
|
|
504
|
+
* @param {string} text 原始响应文本
|
|
505
|
+
* @param {string} cardId 请求用的数字型 cardId(用于回显校验 / 日志)
|
|
506
|
+
* @returns {{ ok: boolean, url: string, cardId: string }}
|
|
507
|
+
*/
|
|
508
|
+
export function parsePreviewUploadResponse(text, cardId) {
|
|
509
|
+
const raw = String(text == null ? '' : text).trim();
|
|
510
|
+
if (!raw) return { ok: true, url: '', cardId: String(cardId == null ? '' : cardId) };
|
|
511
|
+
let parsed;
|
|
512
|
+
try {
|
|
513
|
+
parsed = JSON.parse(raw);
|
|
514
|
+
} catch {
|
|
515
|
+
throw new Error(`预览图上传响应不是 JSON:${raw.slice(0, 300)}`);
|
|
516
|
+
}
|
|
517
|
+
const header = (parsed && parsed.header) || {};
|
|
518
|
+
const body = (parsed && parsed.body) || {};
|
|
519
|
+
// header 与 body 各自可能带 errorCode:**逐个**看,不能用 `a || b` 短路——
|
|
520
|
+
// header.errorCode='0' 是真值,会把 body 里的业务错误(如 PEO000003)整段盖掉。
|
|
521
|
+
const codes = [header.errorCode, body.errorCode].filter((c) => c != null && String(c).trim() !== '');
|
|
522
|
+
const bad = codes.find((c) => String(c) !== '0');
|
|
523
|
+
if (bad != null) {
|
|
524
|
+
throw new Error(
|
|
525
|
+
`FinMall 预览图上传失败 [errorCode=${bad}] ${header.errorMsg || body.errorMsg || ''} | ${raw.slice(0, 300)}`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
// 地址字段多键兜底:接口未回地址属正常(上传即生效),所以这里只做尽力提取。
|
|
529
|
+
const url =
|
|
530
|
+
body.picUrl || body.previewUrl || body.imageUrl || body.filePath || body.url ||
|
|
531
|
+
parsed.picUrl || '';
|
|
532
|
+
return {
|
|
533
|
+
ok: true,
|
|
534
|
+
url: typeof url === 'string' ? url : '',
|
|
535
|
+
cardId: String(body.cardId || cardId || ''),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* 上传卡片预览图到远端(IDC10014.do)。供「推送」成功后由 server/core/cardSave.js 调用:
|
|
541
|
+
* 本地无头渲染出透明底 PNG → 交给已登录的 FinMall 后台会话上传,卡片列表缩略图随之更新。
|
|
542
|
+
*
|
|
543
|
+
* 与 saveCardToRemote 的会话纪律一致:saveCardToRemote 末尾会 disconnect,故本函数自行
|
|
544
|
+
* ensureFinMallSession 重新取一次连接(Chrome 已常驻时这一步是廉价的 attach)。
|
|
545
|
+
* 会话/权限失效命中 SESSION_INVALID_RE 时自动清 session 重登并重试一次。
|
|
546
|
+
*
|
|
547
|
+
* @param {{ cardId: string|number, pngBase64: string, appId?: string, workspaceId?: string }} opts
|
|
548
|
+
* @returns {Promise<{ ok: true, url: string, cardId: string, bytes: number }>}
|
|
549
|
+
*/
|
|
550
|
+
export async function uploadCardPreview({ cardId, pngBase64, appId, workspaceId } = {}) {
|
|
551
|
+
const id = String(cardId == null ? '' : cardId).trim();
|
|
552
|
+
if (!/^\d+$/.test(id)) {
|
|
553
|
+
throw new Error(`上传卡片预览图需要数字型 cardId(IDC10014.do 主键),收到:${JSON.stringify(cardId)}`);
|
|
554
|
+
}
|
|
555
|
+
const base64 = String(pngBase64 == null ? '' : pngBase64);
|
|
556
|
+
if (!base64) throw new Error('上传卡片预览图缺少 PNG 内容(pngBase64 为空)。');
|
|
557
|
+
const appIdv = appId || finmallConfig.appId;
|
|
558
|
+
const workspaceIdv = workspaceId || finmallConfig.workspaceId;
|
|
559
|
+
const fileName = `${id}.png`;
|
|
560
|
+
|
|
561
|
+
const session = await ensureFinMallSession();
|
|
562
|
+
const browser = session.browser;
|
|
563
|
+
try {
|
|
564
|
+
const context = browser.contexts()[0];
|
|
565
|
+
const page = context?.pages().find((p) => p.url().includes('finmall.com')) || context?.pages()[0];
|
|
566
|
+
if (!page) throw new Error('未能在后台 Chrome 中找到 FinMall 页面,请检查凭据或重新触发同步。');
|
|
567
|
+
await page.bringToFront();
|
|
568
|
+
|
|
569
|
+
const payload = { cardId: id, fileName, base64, appId: appIdv, workspaceId: workspaceIdv };
|
|
570
|
+
let parsed;
|
|
571
|
+
try {
|
|
572
|
+
parsed = parsePreviewUploadResponse(await postPreviewUpload(page, payload), id);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if (SESSION_INVALID_RE.test(String(error && error.message))) {
|
|
575
|
+
log.warn('会话/权限失效,自动重新登录后重试预览图上传', {
|
|
576
|
+
cardId: id,
|
|
577
|
+
reason: String(error && error.message).slice(0, 40),
|
|
578
|
+
});
|
|
579
|
+
await relogin(page);
|
|
580
|
+
parsed = parsePreviewUploadResponse(await postPreviewUpload(page, payload), id);
|
|
581
|
+
} else {
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
log.ok('卡片预览图已上传', { cardId: id, fileName, url: parsed.url || '(接口未回地址)' });
|
|
586
|
+
return { ...parsed, bytes: Math.round((base64.length * 3) / 4) };
|
|
587
|
+
} finally {
|
|
588
|
+
try { await browser.disconnect() } catch { /* 后台 Chrome 常驻,仅断开连接 */ }
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
318
592
|
/**
|
|
319
593
|
* 远端预览:复刻 ubomp-h5-sub PreviewPopover.vue 的 onPopoverShow 全链路。
|
|
320
594
|
* 1) prepreview:先把当前编辑的卡片保存到远端(IDC10010.do,对齐 ubomp 的 prepreview 钩子)
|
|
@@ -418,11 +692,40 @@ export async function previewCardRemote(schema, { skeletonScreen = '', publish =
|
|
|
418
692
|
}
|
|
419
693
|
|
|
420
694
|
/**
|
|
421
|
-
*
|
|
695
|
+
* FinMall 版本号格式:8 位「年份 + 4 位序号」,如 20260025。
|
|
696
|
+
* 该格式的最小值约 20200001,故任何 < 10000000 的纯数字都不符合格式
|
|
697
|
+
* (含旧实现首发时误写出的 1 / 2 / 3…)。
|
|
698
|
+
*/
|
|
699
|
+
const MIN_YEAR_SEQ_VERSION = 10000000;
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* 由远端最大版本号推导「下一个待发布版本号」。
|
|
703
|
+
* - 远端从未发布(maxVersion 为 null / 空 / '0')→ `${当前年份}0001`,而不是裸 '1';
|
|
704
|
+
* - 已是「年份+4 位序号」→ 数值 +1。注意这里**不按年份重置**:跨年后沿用上一年的序号递增
|
|
705
|
+
* (如 20250028 → 20250029),保证版本号单调递增,避免回退到比线上更小的值;
|
|
706
|
+
* - 历史遗留的旧格式小版本(旧 bug 写出的 '1' 等)→ 视同首发,规范化为 `${当前年份}0001`,
|
|
707
|
+
* 数值上 20260001 > 2,不会造成版本回退,可自愈已受该 bug 影响的卡片。
|
|
708
|
+
* @param {string|number|null|undefined} maxVersion IDC10004 返回的 maxVersion
|
|
709
|
+
* @returns {string} 8 位版本号
|
|
710
|
+
* @example nextPublishVersion(null) // '20260001'
|
|
711
|
+
* @example nextPublishVersion('20260025') // '20260026'
|
|
712
|
+
* @example nextPublishVersion('1') // '20260001'
|
|
713
|
+
*/
|
|
714
|
+
export function nextPublishVersion(maxVersion) {
|
|
715
|
+
const raw = String(maxVersion == null ? '' : maxVersion).trim();
|
|
716
|
+
const num = Number(raw);
|
|
717
|
+
const valid = /^\d+$/.test(raw) && Number.isFinite(num) && num >= MIN_YEAR_SEQ_VERSION;
|
|
718
|
+
if (!valid) return `${new Date().getFullYear()}0001`;
|
|
719
|
+
return String(num + 1);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* 远端发布:取当前卡片远端最大版本号,推导下一个版本号并发布上线。
|
|
422
724
|
* 链路(对齐 ubomp 发布流程,仅两步):
|
|
423
725
|
* 1) IDC10004.do 取当前卡片最大版本号 maxVersion;
|
|
424
|
-
* 2) IDC10016.do 以 maxVersion
|
|
726
|
+
* 2) IDC10016.do 以 nextPublishVersion(maxVersion) 作为新版本号发布(发布的是远端「当前已保存」的卡片内容——
|
|
425
727
|
* 故发布前应先用「推送当前卡」把本地内容保存到远端,否则会发布陈旧内容)。
|
|
728
|
+
* 版本号格式为 8 位「年份 + 4 位序号」:远端从未发布时取 `${当前年份}0001`(不是裸 1)。
|
|
426
729
|
* 会话由 ensureFinMallSession 自动保证;与 saveCardToRemote / syncCards / previewCardRemote
|
|
427
730
|
* 共用同一套已登录后台 Chrome,命中会话/权限失效(BLEC0006 等)时自动清 session 重登重试一次。
|
|
428
731
|
*
|
|
@@ -452,8 +755,7 @@ export async function publishCard(schema) {
|
|
|
452
755
|
'IDC10004.do',
|
|
453
756
|
)
|
|
454
757
|
const maxVersion = String(maxResp && maxResp.maxVersion != null ? maxResp.maxVersion : '0')
|
|
455
|
-
const
|
|
456
|
-
const nextVersion = String(Number.isFinite(maxNum) ? maxNum + 1 : 1)
|
|
758
|
+
const nextVersion = nextPublishVersion(maxVersion)
|
|
457
759
|
log.info('发布前取最大版本', { cardId, maxVersion, nextVersion })
|
|
458
760
|
|
|
459
761
|
// 2) 以 maxVersion+1 发布(发布类型/平台等参数对齐 ubomp 发布弹窗的默认值)
|
|
@@ -518,6 +820,335 @@ export async function fetchCardLatestVersion(cardId) {
|
|
|
518
820
|
}
|
|
519
821
|
}
|
|
520
822
|
|
|
823
|
+
/**
|
|
824
|
+
* 取远端「发布版本记录」分页列表:IDC10004.do 的 versionList(新→旧,最多一页 pageSize 条)。
|
|
825
|
+
* 供调试面板「导入信息」tab 的「发布版本记录」弹框渲染(版本号 / 平台 / 状态 / 发布时间)。
|
|
826
|
+
* 只读;与 fetchCardLatestVersion / fetchCardHistory 共用同一后台 FinMall 会话
|
|
827
|
+
* (ensureFinMallSession + finmallPostWithHeal,命中会话失效自动清 session 重登重试一次)。
|
|
828
|
+
*
|
|
829
|
+
* 响应体(逆向 innermanage 前端 inner_flamePublish 页面):
|
|
830
|
+
* body.versionList[] = { versionId, version, platformStatus(JSON 字符串,如 '["iOS","Android"]'),
|
|
831
|
+
* publishStatus, createTime }
|
|
832
|
+
* body.maxVersion / pageNo / pageSize / totalNums / totalPages
|
|
833
|
+
*
|
|
834
|
+
* publishStatus 枚举(逆向页面 statusEnum,见 34.*.js:{ UNPUB:'0', PUB:'1', FINISH:'2', STOP:'3' };
|
|
835
|
+
* 页面渲染仅对 0/1 出标签「未发布 / 发布中」,2/3 依枚举名与「停止发布」动作(publishStatus:'3')释义):
|
|
836
|
+
* 0=未发布 1=发布中 2=已完成 3=已停止
|
|
837
|
+
*
|
|
838
|
+
* @param {string|number} cardId
|
|
839
|
+
* @param {{ pageNo?: number, pageSize?: number }} [opts]
|
|
840
|
+
* @returns {Promise<{ list: Array<object>, maxVersion: string, totalNums: number, totalPages: number }>}
|
|
841
|
+
*/
|
|
842
|
+
export async function fetchCardPublishVersions(cardId, { pageNo = 1, pageSize = 10 } = {}) {
|
|
843
|
+
if (cardId == null) {
|
|
844
|
+
throw new Error('缺少 cardId,无法查询发布版本记录(IDC10004.do 需要 cardId)。');
|
|
845
|
+
}
|
|
846
|
+
const appId = finmallConfig.appId;
|
|
847
|
+
const workspaceId = finmallConfig.workspaceId;
|
|
848
|
+
const id = String(cardId);
|
|
849
|
+
const session = await ensureFinMallSession();
|
|
850
|
+
const browser = session.browser;
|
|
851
|
+
try {
|
|
852
|
+
const context = browser.contexts()[0];
|
|
853
|
+
const page = context?.pages().find((p) => p.url().includes('finmall.com')) || context?.pages()[0];
|
|
854
|
+
if (!page) throw new Error('未能在后台 Chrome 中找到 FinMall 页面,请先运行 finmall-session-navigator 登录。');
|
|
855
|
+
await page.bringToFront();
|
|
856
|
+
const resp = await finmallPostWithHeal(
|
|
857
|
+
page,
|
|
858
|
+
`/ubomp-web/IDC10004.do?sassAppId=${appId}&sassWorkspaceId=${workspaceId}`,
|
|
859
|
+
{
|
|
860
|
+
channel: '',
|
|
861
|
+
pageNo,
|
|
862
|
+
pageSize,
|
|
863
|
+
publishStatus: '',
|
|
864
|
+
version: '',
|
|
865
|
+
cardId: id,
|
|
866
|
+
sassAppId: appId,
|
|
867
|
+
sassWorkspaceId: workspaceId,
|
|
868
|
+
},
|
|
869
|
+
'IDC10004.do',
|
|
870
|
+
);
|
|
871
|
+
const rawList = Array.isArray(resp && resp.versionList) ? resp.versionList : [];
|
|
872
|
+
const list = rawList.map((raw) => {
|
|
873
|
+
let platforms = [];
|
|
874
|
+
try {
|
|
875
|
+
const parsed = JSON.parse(raw.platformStatus || '[]');
|
|
876
|
+
if (Array.isArray(parsed)) platforms = parsed.map((p) => String(p));
|
|
877
|
+
} catch {
|
|
878
|
+
/* platformStatus 损坏时按空平台处理,不阻断列表 */
|
|
879
|
+
}
|
|
880
|
+
const status = String(raw.publishStatus == null ? '' : raw.publishStatus);
|
|
881
|
+
return {
|
|
882
|
+
versionId: String(raw.versionId == null ? '' : raw.versionId),
|
|
883
|
+
version: String(raw.version == null ? '' : raw.version),
|
|
884
|
+
platforms,
|
|
885
|
+
platformStr: platforms.join('、'),
|
|
886
|
+
publishStatus: status,
|
|
887
|
+
publishTime: String(raw.createTime == null ? '' : raw.createTime),
|
|
888
|
+
};
|
|
889
|
+
});
|
|
890
|
+
log.info('查询远端发布版本记录', {
|
|
891
|
+
cardId: id,
|
|
892
|
+
pageNo,
|
|
893
|
+
count: list.length,
|
|
894
|
+
totalNums: resp && resp.totalNums,
|
|
895
|
+
});
|
|
896
|
+
return {
|
|
897
|
+
list,
|
|
898
|
+
maxVersion: String(resp && resp.maxVersion != null ? resp.maxVersion : ''),
|
|
899
|
+
totalNums: Number(resp && resp.totalNums != null ? resp.totalNums : list.length) || list.length,
|
|
900
|
+
totalPages: Number(resp && resp.totalPages != null ? resp.totalPages : 1) || 1,
|
|
901
|
+
};
|
|
902
|
+
} finally {
|
|
903
|
+
try { await browser.disconnect(); } catch { /* ignore */ }
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* 取远端「卡片历史版本时间线」:IDC10013 按 cardId 分页返回 schemaValueList(新→旧)。
|
|
909
|
+
* 供编辑器「卡片历史记录」tab 渲染 git 式时间线。只读;与 fetchCardLatestVersion 共用
|
|
910
|
+
* 同一后台 FinMall 会话(ensureFinMallSession + finmallPostWithHeal)。
|
|
911
|
+
*
|
|
912
|
+
* schema 全量(每条数 KB~上百 KB)不跨端透传:服务端就地解析为轻量摘要 + 相邻版本 diff
|
|
913
|
+
* 标签,响应体积与 schema 大小解耦,UI 拿到的就是可直接渲染的字段。
|
|
914
|
+
*
|
|
915
|
+
* @param {string|number} cardId
|
|
916
|
+
* @param {{ pageNo?: number, pageSize?: number }} [opts]
|
|
917
|
+
* @returns {Promise<{ list: Array<object>, totalNums: number, totalPages: number }>}
|
|
918
|
+
*/
|
|
919
|
+
// —— 版本 schema 进程内缓存((cardId, versionId) → schema)——
|
|
920
|
+
// IDC10013 单页 100 条实测 ~6.5MB / 4.2s,而「加载此版本」只需要其中一条。
|
|
921
|
+
// fetchCardHistory(时间线列表)本来就会解析页内全部 schema 算 diff,顺手按
|
|
922
|
+
// (cardId, versionId) 缓存;fetchCardVersionSchema 命中缓存时零网络请求,
|
|
923
|
+
// 把点击到加载的等待从 ~7s 降到毫秒级。未命中仍走原分页翻找兜底。
|
|
924
|
+
const SCHEMA_CACHE_TTL_MS = Number(process.env.FINMALL_SCHEMA_CACHE_TTL_MS || 300000)
|
|
925
|
+
const SCHEMA_CACHE_MAX = 500
|
|
926
|
+
const _versionSchemaCache = new Map() // `${cardId}::${versionId}` → { schema, createTime, at }
|
|
927
|
+
|
|
928
|
+
function cacheVersionSchema(cardId, versionId, schema, createTime) {
|
|
929
|
+
const key = `${cardId}::${versionId}`
|
|
930
|
+
_versionSchemaCache.set(key, { schema, createTime: createTime || '', at: Date.now() })
|
|
931
|
+
if (_versionSchemaCache.size > SCHEMA_CACHE_MAX) {
|
|
932
|
+
// 惰性容量控制:淘汰最早写入的一批(Map 保序)
|
|
933
|
+
let excess = _versionSchemaCache.size - SCHEMA_CACHE_MAX
|
|
934
|
+
for (const k of _versionSchemaCache.keys()) {
|
|
935
|
+
_versionSchemaCache.delete(k)
|
|
936
|
+
if (--excess <= 0) break
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function getCachedVersionSchema(cardId, versionId) {
|
|
942
|
+
const key = `${cardId}::${versionId}`
|
|
943
|
+
const hit = _versionSchemaCache.get(key)
|
|
944
|
+
if (!hit) return null
|
|
945
|
+
if (Date.now() - hit.at > SCHEMA_CACHE_TTL_MS) {
|
|
946
|
+
_versionSchemaCache.delete(key)
|
|
947
|
+
return null
|
|
948
|
+
}
|
|
949
|
+
return hit
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
export async function fetchCardHistory(cardId, { pageNo = 1, pageSize = 30 } = {}) {
|
|
953
|
+
if (cardId == null) {
|
|
954
|
+
throw new Error('缺少 cardId,无法查询远端历史(IDC10013.do 需要 cardId)。')
|
|
955
|
+
}
|
|
956
|
+
const appId = finmallConfig.appId
|
|
957
|
+
const workspaceId = finmallConfig.workspaceId
|
|
958
|
+
const id = String(cardId)
|
|
959
|
+
const session = await ensureFinMallSession()
|
|
960
|
+
const browser = session.browser
|
|
961
|
+
try {
|
|
962
|
+
const context = browser.contexts()[0]
|
|
963
|
+
const page = context?.pages().find((p) => p.url().includes('finmall.com')) || context?.pages()[0]
|
|
964
|
+
if (!page) throw new Error('未能在后台 Chrome 中找到 FinMall 页面,请先运行 finmall-session-navigator 登录。')
|
|
965
|
+
await page.bringToFront()
|
|
966
|
+
const resp = await finmallPostWithHeal(
|
|
967
|
+
page,
|
|
968
|
+
`/ubomp-web/IDC10013.do?sassAppId=${appId}&sassWorkspaceId=${workspaceId}`,
|
|
969
|
+
{ cardId: id, pageNo, pageSize, sassAppId: appId, sassWorkspaceId: workspaceId },
|
|
970
|
+
'IDC10013.do',
|
|
971
|
+
)
|
|
972
|
+
const rawList = Array.isArray(resp && resp.schemaValueList) ? resp.schemaValueList : []
|
|
973
|
+
// 顺带填充版本 schema 缓存:供「加载此版本」零网络命中(同进程内,list 已解析过一份)
|
|
974
|
+
for (const raw of rawList) {
|
|
975
|
+
try {
|
|
976
|
+
const parsed = typeof raw.schema === 'string' ? JSON.parse(raw.schema) : raw.schema
|
|
977
|
+
if (parsed && typeof parsed === 'object') {
|
|
978
|
+
cacheVersionSchema(id, String(raw.id != null ? raw.id : ''), parsed, raw.createTime)
|
|
979
|
+
}
|
|
980
|
+
} catch {
|
|
981
|
+
/* 单条 schema 损坏不阻断时间线,缓存自然跳过 */
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
// 接口顺序即新→旧,时间线按此顺序直接渲染(最新在上,同 git log)
|
|
985
|
+
const list = rawList.map((raw) => summarizeHistoryItem(raw))
|
|
986
|
+
// 相邻 diff:第 i 条相对第 i+1 条(更旧的一个版本)算变更标签
|
|
987
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
988
|
+
const older = list[i + 1]
|
|
989
|
+
list[i].tags = diffHistoryTags(list[i].summary, older ? older.summary : null)
|
|
990
|
+
}
|
|
991
|
+
log.info('查询远端卡片历史', { cardId: id, pageNo, count: list.length, totalNums: resp && resp.totalNums })
|
|
992
|
+
return {
|
|
993
|
+
list,
|
|
994
|
+
totalNums: Number(resp && resp.totalNums != null ? resp.totalNums : list.length) || list.length,
|
|
995
|
+
totalPages: Number(resp && resp.totalPages != null ? resp.totalPages : 1) || 1,
|
|
996
|
+
}
|
|
997
|
+
} finally {
|
|
998
|
+
try { await browser.disconnect() } catch { /* ignore */ }
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* 按版本记录 id 取该历史版本的完整 schema(「加载此版本到编辑器」的数据源)。
|
|
1004
|
+
* IDC10013 没有「按 id 单查」能力,只能分页翻找:pageSize 100 逐页扫到 totalPages,命中即返回。
|
|
1005
|
+
* schema 为原始 JSON(顶层 version/pageId/name/desc + view.components 组件树),与 syncCard 落盘同构。
|
|
1006
|
+
*
|
|
1007
|
+
* @param {string|number} cardId
|
|
1008
|
+
* @param {string|number} versionId 历史记录 id(IDC10013 schemaValueList[].id)
|
|
1009
|
+
* @returns {Promise<{ schema: object, record: { id: string, createTime: string } }>}
|
|
1010
|
+
*/
|
|
1011
|
+
export async function fetchCardVersionSchema(cardId, versionId) {
|
|
1012
|
+
if (cardId == null) {
|
|
1013
|
+
throw new Error('缺少 cardId,无法查询远端历史版本(IDC10013.do 需要 cardId)。')
|
|
1014
|
+
}
|
|
1015
|
+
const want = String(versionId == null ? '' : versionId)
|
|
1016
|
+
if (!want) {
|
|
1017
|
+
throw new Error('缺少版本记录 id,无法定位远端历史版本。')
|
|
1018
|
+
}
|
|
1019
|
+
const id = String(cardId)
|
|
1020
|
+
// 缓存优先:时间线列表(fetchCardHistory)已按 (cardId, versionId) 填充过页内 schema,
|
|
1021
|
+
// 命中即零网络请求返回(毫秒级);未命中才走下面的分页翻找。
|
|
1022
|
+
const cached = getCachedVersionSchema(id, want)
|
|
1023
|
+
if (cached) {
|
|
1024
|
+
log.info('命中版本 schema 缓存(零网络)', { cardId: id, versionId: want })
|
|
1025
|
+
return { schema: cached.schema, record: { id: want, createTime: cached.createTime || '' } }
|
|
1026
|
+
}
|
|
1027
|
+
const appId = finmallConfig.appId
|
|
1028
|
+
const workspaceId = finmallConfig.workspaceId
|
|
1029
|
+
const session = await ensureFinMallSession()
|
|
1030
|
+
const browser = session.browser
|
|
1031
|
+
try {
|
|
1032
|
+
const context = browser.contexts()[0]
|
|
1033
|
+
const page = context?.pages().find((p) => p.url().includes('finmall.com')) || context?.pages()[0]
|
|
1034
|
+
if (!page) throw new Error('未能在后台 Chrome 中找到 FinMall 页面,请先运行 finmall-session-navigator 登录。')
|
|
1035
|
+
await page.bringToFront()
|
|
1036
|
+
const url = `/ubomp-web/IDC10013.do?sassAppId=${appId}&sassWorkspaceId=${workspaceId}`
|
|
1037
|
+
let pageNo = 1
|
|
1038
|
+
let totalPages = 1
|
|
1039
|
+
do {
|
|
1040
|
+
const resp = await finmallPostWithHeal(
|
|
1041
|
+
page,
|
|
1042
|
+
url,
|
|
1043
|
+
{ cardId: id, pageNo, pageSize: 100, sassAppId: appId, sassWorkspaceId: workspaceId },
|
|
1044
|
+
'IDC10013.do',
|
|
1045
|
+
)
|
|
1046
|
+
const rawList = Array.isArray(resp && resp.schemaValueList) ? resp.schemaValueList : []
|
|
1047
|
+
const hit = rawList.find((raw) => String(raw && raw.id != null ? raw.id : '') === want)
|
|
1048
|
+
if (hit) {
|
|
1049
|
+
const schema = typeof hit.schema === 'string' ? JSON.parse(hit.schema) : hit.schema
|
|
1050
|
+
if (!schema || typeof schema !== 'object') {
|
|
1051
|
+
throw new Error(`远端版本记录 #${want} 的 schema 为空或损坏,无法加载。`)
|
|
1052
|
+
}
|
|
1053
|
+
log.info('定位远端历史版本', { cardId: id, versionId: want, pageNo })
|
|
1054
|
+
return {
|
|
1055
|
+
schema,
|
|
1056
|
+
record: {
|
|
1057
|
+
id: String(hit.id != null ? hit.id : ''),
|
|
1058
|
+
createTime: hit.createTime || '',
|
|
1059
|
+
},
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
totalPages = Number(resp && resp.totalPages != null ? resp.totalPages : 1) || 1
|
|
1063
|
+
pageNo += 1
|
|
1064
|
+
} while (pageNo <= totalPages)
|
|
1065
|
+
throw new Error(`远端历史中找不到版本记录 #${want}(cardId=${id},共 ${totalPages} 页)。`)
|
|
1066
|
+
} finally {
|
|
1067
|
+
try { await browser.disconnect() } catch { /* ignore */ }
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
function summarizeHistoryItem(raw) {
|
|
1071
|
+
let name = ''
|
|
1072
|
+
let desc = ''
|
|
1073
|
+
let version = ''
|
|
1074
|
+
let platform = ''
|
|
1075
|
+
let width = ''
|
|
1076
|
+
let components = []
|
|
1077
|
+
try {
|
|
1078
|
+
const schema = typeof raw.schema === 'string' ? JSON.parse(raw.schema) : raw.schema || {}
|
|
1079
|
+
name = schema.name || ''
|
|
1080
|
+
desc = schema.desc || ''
|
|
1081
|
+
version = schema.version || ''
|
|
1082
|
+
platform = schema.platform || ''
|
|
1083
|
+
width = (schema.view && schema.view.width) || ''
|
|
1084
|
+
// 组件树挂在 view.components 下(schema 顶层只有 version/pageId/name/desc/platform/dictionary/view)
|
|
1085
|
+
components = (schema.view && Array.isArray(schema.view.components)) ? schema.view.components : []
|
|
1086
|
+
} catch {
|
|
1087
|
+
/* 单条 schema 损坏不阻断时间线渲染,摘要退化为空 */
|
|
1088
|
+
}
|
|
1089
|
+
return {
|
|
1090
|
+
id: String(raw.id != null ? raw.id : ''),
|
|
1091
|
+
createTime: raw.createTime || '',
|
|
1092
|
+
updateTime: raw.updateTime || '',
|
|
1093
|
+
bizChannel: raw.bizChannel || '',
|
|
1094
|
+
// @filestore/x → /minio/x(同源相对路径,经 8787 assetProxy 回源;与 adapter.js 约定一致)
|
|
1095
|
+
screenshotPath: filestorePathToLocal(raw.screenshotUrl),
|
|
1096
|
+
// compKeys / eventCount 仅服务端相邻 diff 消费,体积恒定(数十字节/条)
|
|
1097
|
+
summary: {
|
|
1098
|
+
name,
|
|
1099
|
+
desc,
|
|
1100
|
+
version,
|
|
1101
|
+
platform,
|
|
1102
|
+
width,
|
|
1103
|
+
componentCount: components.length,
|
|
1104
|
+
compKeys: components.map(compKey),
|
|
1105
|
+
eventCount: countEvents(components),
|
|
1106
|
+
},
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** 组件 diff 标识:componentId::className(同卡内 componentId 稳定,跨版本可比) */
|
|
1111
|
+
function compKey(c) {
|
|
1112
|
+
return `${c && c.componentId != null ? c.componentId : ''}::${c && c.className != null ? c.className : ''}`
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/** 交互事件总数:各组 eventactions 条目数之和(事件代码增删的粗粒度信号) */
|
|
1116
|
+
function countEvents(comps) {
|
|
1117
|
+
return comps.reduce((n, c) => n + (Array.isArray(c && c.eventactions) ? c.eventactions.length : 0), 0)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** @filestore/ 前缀切为同源 /minio/ 相对路径;已是绝对地址则原样返回 */
|
|
1121
|
+
function filestorePathToLocal(value) {
|
|
1122
|
+
if (!value || typeof value !== 'string') return ''
|
|
1123
|
+
if (value.startsWith('http://') || value.startsWith('https://')) return value
|
|
1124
|
+
if (value.startsWith('@filestore/')) return '/minio/' + value.slice('@filestore/'.length)
|
|
1125
|
+
if (value.startsWith('/')) return value
|
|
1126
|
+
return filestoreToUrl(value)
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* 相邻版本 diff 摘要:相对更旧版本算变更标签(组件增减 / 交互事件变化 / 重命名 /
|
|
1131
|
+
* 描述变更 / 画布宽度变化 / 结构未变)。tags 为字符串数组,前端直接渲染徽标。
|
|
1132
|
+
*/
|
|
1133
|
+
function diffHistoryTags(cur, older) {
|
|
1134
|
+
if (!older) return ['初始版本']
|
|
1135
|
+
const curIds = new Set(cur.compKeys)
|
|
1136
|
+
const oldIds = new Set(older.compKeys)
|
|
1137
|
+
let added = 0
|
|
1138
|
+
let removed = 0
|
|
1139
|
+
for (const k of curIds) if (!oldIds.has(k)) added += 1
|
|
1140
|
+
for (const k of oldIds) if (!curIds.has(k)) removed += 1
|
|
1141
|
+
const tags = []
|
|
1142
|
+
if (added > 0) tags.push(`新增 ${added} 个组件`)
|
|
1143
|
+
if (removed > 0) tags.push(`移除 ${removed} 个组件`)
|
|
1144
|
+
if (cur.eventCount !== older.eventCount) tags.push(`交互事件 ${older.eventCount} → ${cur.eventCount}`)
|
|
1145
|
+
if (cur.name !== older.name) tags.push('重命名')
|
|
1146
|
+
if (cur.desc !== older.desc) tags.push('描述变更')
|
|
1147
|
+
if (cur.width !== older.width) tags.push(`画布宽度 ${older.width} → ${cur.width}`)
|
|
1148
|
+
if (tags.length === 0) tags.push('结构未变')
|
|
1149
|
+
return tags
|
|
1150
|
+
}
|
|
1151
|
+
|
|
521
1152
|
// —— 保存落库校验辅助 ——
|
|
522
1153
|
|
|
523
1154
|
/** 稳定序列化:对象键排序、带 id/key/className/name 的数组元素按该键排序,避免服务端字段重排导致指纹误判。 */
|
|
@@ -632,15 +1263,17 @@ async function fetchCardSchema(page, card, { appId, workspaceId }) {
|
|
|
632
1263
|
/** 清空过期会话并以保存的凭据重新登录(复用导航器的登录逻辑,全程在页面上下文内完成)。 */
|
|
633
1264
|
async function relogin(page) {
|
|
634
1265
|
await clearTimeoutPrompts(page);
|
|
635
|
-
await
|
|
636
|
-
.evaluate(() => {
|
|
1266
|
+
await withTimeout(
|
|
1267
|
+
page.evaluate(() => {
|
|
637
1268
|
try {
|
|
638
1269
|
localStorage.clear();
|
|
639
1270
|
} catch {
|
|
640
1271
|
/* ignore */
|
|
641
1272
|
}
|
|
642
|
-
})
|
|
643
|
-
|
|
1273
|
+
}),
|
|
1274
|
+
FINMALL_POST_TIMEOUT_MS,
|
|
1275
|
+
'page.evaluate(localStorage.clear)',
|
|
1276
|
+
).catch(() => {});
|
|
644
1277
|
await page.reload({ waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => {});
|
|
645
1278
|
await sleep(1500);
|
|
646
1279
|
const credentials = await loadCredentials();
|
|
@@ -955,6 +1588,7 @@ async function findExistingCover(cardDir, id) {
|
|
|
955
1588
|
async function writeImportSidecar(cardDir, { appId, workspaceId, card, files, importMeta }) {
|
|
956
1589
|
try {
|
|
957
1590
|
const mod = importMeta || (await import(pathToFileURL(path.resolve(workspace, 'server', 'core', 'cardImportMeta.js')).href));
|
|
1591
|
+
const raw = card.raw || {};
|
|
958
1592
|
const meta = mod.buildImportMeta({
|
|
959
1593
|
baseUrl: finmallConfig.baseUrl,
|
|
960
1594
|
appId,
|
|
@@ -963,6 +1597,14 @@ async function writeImportSidecar(cardDir, { appId, workspaceId, card, files, im
|
|
|
963
1597
|
cardCode: card.id,
|
|
964
1598
|
cardName: card.title,
|
|
965
1599
|
files,
|
|
1600
|
+
// 远端列表元数据原样快照:导入包(cards.json)的 picUrl / cardClassId / applyScene
|
|
1601
|
+
// 只能从这里取真值,本地无处可推。缺了它导出侧只能伪造或留空,而留空会让
|
|
1602
|
+
// 导入侧按空串推封面文件名、退化成卡目录路径并抛异常(2026-10-22 事故)。
|
|
1603
|
+
remote: {
|
|
1604
|
+
picUrl: typeof raw.picUrl === 'string' ? raw.picUrl : '',
|
|
1605
|
+
cardClassId: raw.cardClassId == null ? '' : String(raw.cardClassId),
|
|
1606
|
+
applyScene: raw.applyScene == null ? '' : String(raw.applyScene),
|
|
1607
|
+
},
|
|
966
1608
|
});
|
|
967
1609
|
mod.writeImportMeta(cardDir, meta);
|
|
968
1610
|
} catch (e) {
|