cardbuddy 0.19.3 → 0.20.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 +7 -7
- package/dist/cli.js +7 -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-08GutEI0.css +1 -0
- package/dist/preview-editor/assets/{index-DRzBK4qj.js → index-Dx5hfJ7f.js} +83 -72
- package/dist/preview-editor/index.html +2 -2
- package/dist/skills-upgrade.js +64 -25
- package/dist/vendored/card-editor/components/MultiColumn.vue +19 -10
- package/dist/vendored/server/core/cardHistoryRemote.js +203 -0
- package/dist/vendored/server/core/cardSync.js +425 -95
- package/dist/vendored/server/core/codeBridge.js +4 -2
- package/dist/vendored/server/core/index.js +5 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +354 -7
- package/dist/vendored/skills/finmall-session-navigator/scripts/finmall_login.mjs +37 -5
- package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +25 -1
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-C_uqjKqI.css +0 -1
|
@@ -92,9 +92,10 @@ function withTimeout(promise, ms, label) {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
// 服务端判定会话/权限失效并应「重新进入」的特征集合。命中后由 finmallPostWithHeal
|
|
95
|
-
// 自动清 session 重登再试一次——覆盖会话超时(BLEC0006)、token
|
|
96
|
-
//
|
|
97
|
-
|
|
95
|
+
// 自动清 session 重登再试一次——覆盖会话超时(BLEC0006)、token 失效、
|
|
96
|
+
// 「您当前角色菜单权限发生变化,请重新进入」这类权限态异常,以及前端页面会话键丢失
|
|
97
|
+
// (localStorage._sessionId 为空,如后台 Chrome 被清理/重启后):本地凭证仍在即可自愈。
|
|
98
|
+
const SESSION_INVALID_RE = /BLEC0006|会话超时|会话过期|登录超时|未登录|登录失效|登录已失效|请重新登录|invalid token|token|角色菜单权限发生变化|权限发生变化|重新进入|请重新进入|缺少会话凭据/i;
|
|
98
99
|
|
|
99
100
|
/**
|
|
100
101
|
* 带自愈的 FinMall 接口调用:正常请求,若命中会话/权限失效特征则清 session 重登后重试一次。
|
|
@@ -124,6 +125,7 @@ async function finmallPostWithHeal(page, urlPath, requestBody, transCode = urlPa
|
|
|
124
125
|
*
|
|
125
126
|
* @param {object} opts
|
|
126
127
|
* @param {string} [opts.cardCode] 仅同步该英文卡 id(用列表接口 searchValue 定位)
|
|
128
|
+
* @param {string[]} [opts.cardCodes] 仅同步这些英文卡 id(集合筛选;与 cardCode 互斥,cardCode 优先)
|
|
127
129
|
* @param {number} [opts.startPage=1]
|
|
128
130
|
* @param {number} [opts.startIndex=0]
|
|
129
131
|
* @param {number} [opts.pageSize=24]
|
|
@@ -132,7 +134,11 @@ async function finmallPostWithHeal(page, urlPath, requestBody, transCode = urlPa
|
|
|
132
134
|
* @param {string} [opts.workspaceId]
|
|
133
135
|
* @param {string} [opts.cdpUrl] 已废弃:会话现在由 ensureFinMallSession 在专用后台端口自动管理
|
|
134
136
|
* @param {string} [opts.outRoot] 输出根目录(默认 workspace/src/cards)
|
|
135
|
-
* @
|
|
137
|
+
* @param {(p: {done:number,total:number|null,currentId:string,currentTitle:string}) => void} [opts.onProgress]
|
|
138
|
+
* 每张卡处理完毕后的进度回调(失败也回调),供前端进度条消费
|
|
139
|
+
* @param {() => boolean} [opts.shouldCancel] 取消探针:返回 true 时在下一条卡片前中止,
|
|
140
|
+
* 已落盘的卡片保留,summary 标记 cancelled
|
|
141
|
+
* @returns {Promise<{success:boolean, synced:Array, failed:Array, duplicates:Array, cancelled?:boolean}>}
|
|
136
142
|
*/
|
|
137
143
|
export async function syncCards(opts = {}) {
|
|
138
144
|
// 非注入形态(纯 CLI)用 cwd 作为项目根,跨「仓库内 / 消费者工程」一致;server 中间件始终注入绝对 outRoot。
|
|
@@ -152,6 +158,19 @@ export async function syncCards(opts = {}) {
|
|
|
152
158
|
const workspaceId = opts.workspaceId || finmallConfig.workspaceId;
|
|
153
159
|
// 单卡筛选:传 cardCode 时仅同步该卡
|
|
154
160
|
const cardCodeFilter = opts.cardCode || '';
|
|
161
|
+
// 集合筛选:cardCodes 非空时仅同步集合内卡片(与单卡筛选互斥,单卡优先)。
|
|
162
|
+
// 入口处已做格式校验,这里再按 normalizeCard 同源正则兜底,非法项静默剔除。
|
|
163
|
+
const cardCodeSet =
|
|
164
|
+
!cardCodeFilter && Array.isArray(opts.cardCodes) && opts.cardCodes.length
|
|
165
|
+
? new Set(
|
|
166
|
+
opts.cardCodes.filter((c) => typeof c === 'string' && /^[A-Za-z0-9_-]+$/.test(c))
|
|
167
|
+
)
|
|
168
|
+
: null;
|
|
169
|
+
// 进度回调与取消探针(前端「查看全部 → 下载选中/全部下载」的进度弹窗消费)
|
|
170
|
+
const onProgress = typeof opts.onProgress === 'function' ? opts.onProgress : null;
|
|
171
|
+
const shouldCancel = typeof opts.shouldCancel === 'function' ? opts.shouldCancel : () => false;
|
|
172
|
+
// 预期总张数:单卡=1;集合=集合大小;全量=0(未知,边拉边算)
|
|
173
|
+
const expectedTotal = cardCodeFilter ? 1 : cardCodeSet ? cardCodeSet.size : 0;
|
|
155
174
|
|
|
156
175
|
// 自动确保后台 FinMall 会话:未启动/缺失则拉起无界面 Chrome 并登录,无需手动触发。
|
|
157
176
|
const session = await ensureFinMallSession();
|
|
@@ -170,14 +189,17 @@ export async function syncCards(opts = {}) {
|
|
|
170
189
|
const totalPages = Number(firstList.totalPages || Math.ceil(Number(firstList.totalNums || 0) / pageSize) || startPage);
|
|
171
190
|
|
|
172
191
|
let done = false;
|
|
192
|
+
let cancelled = false;
|
|
173
193
|
const allCards = [];
|
|
174
|
-
for (let pageNo = startPage; pageNo <= totalPages && !done; pageNo++) {
|
|
194
|
+
for (let pageNo = startPage; pageNo <= totalPages && !done && !cancelled; pageNo++) {
|
|
175
195
|
const list = pageNo === startPage ? firstList : await fetchCardList(page, { pageNo, pageSize, appId, workspaceId, searchValue: cardCodeFilter });
|
|
176
196
|
const cards = list.cardList || [];
|
|
177
197
|
for (const c of cards) allCards.push(c);
|
|
178
198
|
const begin = pageNo === startPage ? startIndex : 0;
|
|
179
|
-
for (let index = begin; index < cards.length && !done; index++) {
|
|
199
|
+
for (let index = begin; index < cards.length && !done && !cancelled; index++) {
|
|
180
200
|
if (summary.synced.length + summary.failed.length >= maxCards) break;
|
|
201
|
+
// 取消探针:用户点「取消」后在下一条卡片前退出(已落盘的卡片保留)
|
|
202
|
+
if (shouldCancel()) { cancelled = true; break; }
|
|
181
203
|
// normalizeCard 会对非法 code 抛错;单条异常不应中断整批同步。
|
|
182
204
|
let card;
|
|
183
205
|
try {
|
|
@@ -189,6 +211,8 @@ export async function syncCards(opts = {}) {
|
|
|
189
211
|
continue;
|
|
190
212
|
}
|
|
191
213
|
if (cardCodeFilter && card.id !== cardCodeFilter) continue;
|
|
214
|
+
// 集合筛选:不在勾选集合内的卡片跳过(仍进 allCards 供缓存整体刷新)
|
|
215
|
+
if (cardCodeSet && !cardCodeSet.has(card.id)) continue;
|
|
192
216
|
log.info('同步中', { page: `${pageNo}/${totalPages}`, index, id: card.id, title: card.title });
|
|
193
217
|
try {
|
|
194
218
|
const files = await syncCard(page, card, { outRoot, appId, workspaceId, summary, schemaHashes, importMeta });
|
|
@@ -203,12 +227,30 @@ export async function syncCards(opts = {}) {
|
|
|
203
227
|
summary.failed.push({ page: pageNo, index, id: card.id, title: card.title, error: error.message });
|
|
204
228
|
log.error('同步失败', { id: card.id, title: card.title, error: error.message });
|
|
205
229
|
}
|
|
230
|
+
// 进度回调:失败也回调(done 计数含失败),前端据此更新「已完成 X / 全部 Y」
|
|
231
|
+
if (onProgress) {
|
|
232
|
+
try {
|
|
233
|
+
onProgress({
|
|
234
|
+
done: summary.synced.length + summary.failed.length,
|
|
235
|
+
total: expectedTotal || null,
|
|
236
|
+
currentId: card.id,
|
|
237
|
+
currentTitle: card.title || '',
|
|
238
|
+
});
|
|
239
|
+
} catch { /* 前端回调异常不阻断同步 */ }
|
|
240
|
+
}
|
|
206
241
|
if (cardCodeFilter) done = true;
|
|
207
242
|
}
|
|
208
243
|
if (summary.synced.length + summary.failed.length >= maxCards) break;
|
|
209
244
|
}
|
|
245
|
+
// 取消:已落盘的卡片保留,summary 显式标记,由调用方(dev server 任务)转成用户提示
|
|
246
|
+
if (cancelled) {
|
|
247
|
+
summary.cancelled = true;
|
|
248
|
+
summary.success = false;
|
|
249
|
+
log.warn('同步已被用户取消', { done: summary.synced.length + summary.failed.length });
|
|
250
|
+
}
|
|
210
251
|
// 全量同步(无 cardCode 过滤)时整体刷新卡片列表缓存,供推送前「存在性闸门」使用;
|
|
211
|
-
//
|
|
252
|
+
// 单卡同步只增量合并,不整体替换,避免清空其余卡片。集合筛选(cardCodes)会翻遍
|
|
253
|
+
// 全部分页,allCards 即完整列表,整体刷新同样准确。缓存未就绪则跳过(不影响同步)。
|
|
212
254
|
try {
|
|
213
255
|
const cache = await getCardListCache();
|
|
214
256
|
if (cache) cache.refreshCardList(allCards, { full: !cardCodeFilter });
|
|
@@ -222,6 +264,66 @@ export async function syncCards(opts = {}) {
|
|
|
222
264
|
return summary;
|
|
223
265
|
}
|
|
224
266
|
|
|
267
|
+
/**
|
|
268
|
+
* 拉取远端完整卡片列表(IDC10001 分页全量),供编辑器「查看全部」网格浏览。
|
|
269
|
+
* 只读、不落盘;与 syncCards 共用同一后台 FinMall 会话(ensureFinMallSession 自动保活)。
|
|
270
|
+
*
|
|
271
|
+
* @param {object} [opts]
|
|
272
|
+
* @param {number} [opts.pageSize=50] 列表分页大小
|
|
273
|
+
* @param {number} [opts.maxPages=400] 分页上限保护(50×400=2 万张,防服务端异常死循环)
|
|
274
|
+
* @param {string} [opts.appId]
|
|
275
|
+
* @param {string} [opts.workspaceId]
|
|
276
|
+
* @returns {Promise<Array<{id:string,title:string,cardId:string|number,coverUrl:string}>>}
|
|
277
|
+
* id=英文卡 code(本地目录名同源);coverUrl 为 FinMall 图片库绝对地址(可能为空串)
|
|
278
|
+
*/
|
|
279
|
+
export async function listRemoteCards(opts = {}) {
|
|
280
|
+
const appId = opts.appId || finmallConfig.appId;
|
|
281
|
+
const workspaceId = opts.workspaceId || finmallConfig.workspaceId;
|
|
282
|
+
const pageSize = Number(opts.pageSize || 50);
|
|
283
|
+
const maxPages = Number(opts.maxPages || 400);
|
|
284
|
+
|
|
285
|
+
// 自动确保后台 FinMall 会话:未启动/缺失则拉起无界面 Chrome 并登录,无需手动触发。
|
|
286
|
+
const session = await ensureFinMallSession();
|
|
287
|
+
const browser = session.browser;
|
|
288
|
+
try {
|
|
289
|
+
const context = browser.contexts()[0];
|
|
290
|
+
const page = context?.pages().find((p) => p.url().includes('finmall.com')) || context?.pages()[0];
|
|
291
|
+
if (!page) throw new Error('未能在后台 Chrome 中找到 FinMall 页面,请检查凭据或重新触发。');
|
|
292
|
+
await page.bringToFront();
|
|
293
|
+
|
|
294
|
+
const out = [];
|
|
295
|
+
const seen = new Set();
|
|
296
|
+
let pageNo = 1;
|
|
297
|
+
let totalPages = 1;
|
|
298
|
+
do {
|
|
299
|
+
const list = await fetchCardList(page, { pageNo, pageSize, appId, workspaceId });
|
|
300
|
+
totalPages = Number(list.totalPages || Math.ceil(Number(list.totalNums || 0) / pageSize) || 1);
|
|
301
|
+
for (const raw of list.cardList || []) {
|
|
302
|
+
// 非法 code(normalizeCard 抛错)的单条跳过,不阻断整表拉取
|
|
303
|
+
let card;
|
|
304
|
+
try {
|
|
305
|
+
card = normalizeCard(raw);
|
|
306
|
+
} catch {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (seen.has(card.id)) continue;
|
|
310
|
+
seen.add(card.id);
|
|
311
|
+
out.push({
|
|
312
|
+
id: card.id,
|
|
313
|
+
title: card.title || '',
|
|
314
|
+
cardId: card.cardId,
|
|
315
|
+
coverUrl: card.coverUrl || '',
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
pageNo += 1;
|
|
319
|
+
} while (pageNo <= totalPages && pageNo <= maxPages);
|
|
320
|
+
return out;
|
|
321
|
+
} finally {
|
|
322
|
+
// 仅断开连接,不关闭后台 Chrome(与 syncCards 一致,供后续同步/推送复用会话)
|
|
323
|
+
try { await browser.disconnect() } catch { /* ignore */ }
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
225
327
|
/**
|
|
226
328
|
* 把一张卡片的最新 schema 写回 FinMall(线上编辑器「保存」接口 IDC10010.do)。
|
|
227
329
|
* 与 syncCards 共用同一套已登录浏览器会话与带自愈的 finmallPost,
|
|
@@ -518,6 +620,251 @@ export async function fetchCardLatestVersion(cardId) {
|
|
|
518
620
|
}
|
|
519
621
|
}
|
|
520
622
|
|
|
623
|
+
/**
|
|
624
|
+
* 取远端「卡片历史版本时间线」:IDC10013 按 cardId 分页返回 schemaValueList(新→旧)。
|
|
625
|
+
* 供编辑器「卡片历史记录」tab 渲染 git 式时间线。只读;与 fetchCardLatestVersion 共用
|
|
626
|
+
* 同一后台 FinMall 会话(ensureFinMallSession + finmallPostWithHeal)。
|
|
627
|
+
*
|
|
628
|
+
* schema 全量(每条数 KB~上百 KB)不跨端透传:服务端就地解析为轻量摘要 + 相邻版本 diff
|
|
629
|
+
* 标签,响应体积与 schema 大小解耦,UI 拿到的就是可直接渲染的字段。
|
|
630
|
+
*
|
|
631
|
+
* @param {string|number} cardId
|
|
632
|
+
* @param {{ pageNo?: number, pageSize?: number }} [opts]
|
|
633
|
+
* @returns {Promise<{ list: Array<object>, totalNums: number, totalPages: number }>}
|
|
634
|
+
*/
|
|
635
|
+
// —— 版本 schema 进程内缓存((cardId, versionId) → schema)——
|
|
636
|
+
// IDC10013 单页 100 条实测 ~6.5MB / 4.2s,而「加载此版本」只需要其中一条。
|
|
637
|
+
// fetchCardHistory(时间线列表)本来就会解析页内全部 schema 算 diff,顺手按
|
|
638
|
+
// (cardId, versionId) 缓存;fetchCardVersionSchema 命中缓存时零网络请求,
|
|
639
|
+
// 把点击到加载的等待从 ~7s 降到毫秒级。未命中仍走原分页翻找兜底。
|
|
640
|
+
const SCHEMA_CACHE_TTL_MS = Number(process.env.FINMALL_SCHEMA_CACHE_TTL_MS || 300000)
|
|
641
|
+
const SCHEMA_CACHE_MAX = 500
|
|
642
|
+
const _versionSchemaCache = new Map() // `${cardId}::${versionId}` → { schema, createTime, at }
|
|
643
|
+
|
|
644
|
+
function cacheVersionSchema(cardId, versionId, schema, createTime) {
|
|
645
|
+
const key = `${cardId}::${versionId}`
|
|
646
|
+
_versionSchemaCache.set(key, { schema, createTime: createTime || '', at: Date.now() })
|
|
647
|
+
if (_versionSchemaCache.size > SCHEMA_CACHE_MAX) {
|
|
648
|
+
// 惰性容量控制:淘汰最早写入的一批(Map 保序)
|
|
649
|
+
let excess = _versionSchemaCache.size - SCHEMA_CACHE_MAX
|
|
650
|
+
for (const k of _versionSchemaCache.keys()) {
|
|
651
|
+
_versionSchemaCache.delete(k)
|
|
652
|
+
if (--excess <= 0) break
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function getCachedVersionSchema(cardId, versionId) {
|
|
658
|
+
const key = `${cardId}::${versionId}`
|
|
659
|
+
const hit = _versionSchemaCache.get(key)
|
|
660
|
+
if (!hit) return null
|
|
661
|
+
if (Date.now() - hit.at > SCHEMA_CACHE_TTL_MS) {
|
|
662
|
+
_versionSchemaCache.delete(key)
|
|
663
|
+
return null
|
|
664
|
+
}
|
|
665
|
+
return hit
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export async function fetchCardHistory(cardId, { pageNo = 1, pageSize = 30 } = {}) {
|
|
669
|
+
if (cardId == null) {
|
|
670
|
+
throw new Error('缺少 cardId,无法查询远端历史(IDC10013.do 需要 cardId)。')
|
|
671
|
+
}
|
|
672
|
+
const appId = finmallConfig.appId
|
|
673
|
+
const workspaceId = finmallConfig.workspaceId
|
|
674
|
+
const id = String(cardId)
|
|
675
|
+
const session = await ensureFinMallSession()
|
|
676
|
+
const browser = session.browser
|
|
677
|
+
try {
|
|
678
|
+
const context = browser.contexts()[0]
|
|
679
|
+
const page = context?.pages().find((p) => p.url().includes('finmall.com')) || context?.pages()[0]
|
|
680
|
+
if (!page) throw new Error('未能在后台 Chrome 中找到 FinMall 页面,请先运行 finmall-session-navigator 登录。')
|
|
681
|
+
await page.bringToFront()
|
|
682
|
+
const resp = await finmallPostWithHeal(
|
|
683
|
+
page,
|
|
684
|
+
`/ubomp-web/IDC10013.do?sassAppId=${appId}&sassWorkspaceId=${workspaceId}`,
|
|
685
|
+
{ cardId: id, pageNo, pageSize, sassAppId: appId, sassWorkspaceId: workspaceId },
|
|
686
|
+
'IDC10013.do',
|
|
687
|
+
)
|
|
688
|
+
const rawList = Array.isArray(resp && resp.schemaValueList) ? resp.schemaValueList : []
|
|
689
|
+
// 顺带填充版本 schema 缓存:供「加载此版本」零网络命中(同进程内,list 已解析过一份)
|
|
690
|
+
for (const raw of rawList) {
|
|
691
|
+
try {
|
|
692
|
+
const parsed = typeof raw.schema === 'string' ? JSON.parse(raw.schema) : raw.schema
|
|
693
|
+
if (parsed && typeof parsed === 'object') {
|
|
694
|
+
cacheVersionSchema(id, String(raw.id != null ? raw.id : ''), parsed, raw.createTime)
|
|
695
|
+
}
|
|
696
|
+
} catch {
|
|
697
|
+
/* 单条 schema 损坏不阻断时间线,缓存自然跳过 */
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
// 接口顺序即新→旧,时间线按此顺序直接渲染(最新在上,同 git log)
|
|
701
|
+
const list = rawList.map((raw) => summarizeHistoryItem(raw))
|
|
702
|
+
// 相邻 diff:第 i 条相对第 i+1 条(更旧的一个版本)算变更标签
|
|
703
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
704
|
+
const older = list[i + 1]
|
|
705
|
+
list[i].tags = diffHistoryTags(list[i].summary, older ? older.summary : null)
|
|
706
|
+
}
|
|
707
|
+
log.info('查询远端卡片历史', { cardId: id, pageNo, count: list.length, totalNums: resp && resp.totalNums })
|
|
708
|
+
return {
|
|
709
|
+
list,
|
|
710
|
+
totalNums: Number(resp && resp.totalNums != null ? resp.totalNums : list.length) || list.length,
|
|
711
|
+
totalPages: Number(resp && resp.totalPages != null ? resp.totalPages : 1) || 1,
|
|
712
|
+
}
|
|
713
|
+
} finally {
|
|
714
|
+
try { await browser.disconnect() } catch { /* ignore */ }
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* 按版本记录 id 取该历史版本的完整 schema(「加载此版本到编辑器」的数据源)。
|
|
720
|
+
* IDC10013 没有「按 id 单查」能力,只能分页翻找:pageSize 100 逐页扫到 totalPages,命中即返回。
|
|
721
|
+
* schema 为原始 JSON(顶层 version/pageId/name/desc + view.components 组件树),与 syncCard 落盘同构。
|
|
722
|
+
*
|
|
723
|
+
* @param {string|number} cardId
|
|
724
|
+
* @param {string|number} versionId 历史记录 id(IDC10013 schemaValueList[].id)
|
|
725
|
+
* @returns {Promise<{ schema: object, record: { id: string, createTime: string } }>}
|
|
726
|
+
*/
|
|
727
|
+
export async function fetchCardVersionSchema(cardId, versionId) {
|
|
728
|
+
if (cardId == null) {
|
|
729
|
+
throw new Error('缺少 cardId,无法查询远端历史版本(IDC10013.do 需要 cardId)。')
|
|
730
|
+
}
|
|
731
|
+
const want = String(versionId == null ? '' : versionId)
|
|
732
|
+
if (!want) {
|
|
733
|
+
throw new Error('缺少版本记录 id,无法定位远端历史版本。')
|
|
734
|
+
}
|
|
735
|
+
const id = String(cardId)
|
|
736
|
+
// 缓存优先:时间线列表(fetchCardHistory)已按 (cardId, versionId) 填充过页内 schema,
|
|
737
|
+
// 命中即零网络请求返回(毫秒级);未命中才走下面的分页翻找。
|
|
738
|
+
const cached = getCachedVersionSchema(id, want)
|
|
739
|
+
if (cached) {
|
|
740
|
+
log.info('命中版本 schema 缓存(零网络)', { cardId: id, versionId: want })
|
|
741
|
+
return { schema: cached.schema, record: { id: want, createTime: cached.createTime || '' } }
|
|
742
|
+
}
|
|
743
|
+
const appId = finmallConfig.appId
|
|
744
|
+
const workspaceId = finmallConfig.workspaceId
|
|
745
|
+
const session = await ensureFinMallSession()
|
|
746
|
+
const browser = session.browser
|
|
747
|
+
try {
|
|
748
|
+
const context = browser.contexts()[0]
|
|
749
|
+
const page = context?.pages().find((p) => p.url().includes('finmall.com')) || context?.pages()[0]
|
|
750
|
+
if (!page) throw new Error('未能在后台 Chrome 中找到 FinMall 页面,请先运行 finmall-session-navigator 登录。')
|
|
751
|
+
await page.bringToFront()
|
|
752
|
+
const url = `/ubomp-web/IDC10013.do?sassAppId=${appId}&sassWorkspaceId=${workspaceId}`
|
|
753
|
+
let pageNo = 1
|
|
754
|
+
let totalPages = 1
|
|
755
|
+
do {
|
|
756
|
+
const resp = await finmallPostWithHeal(
|
|
757
|
+
page,
|
|
758
|
+
url,
|
|
759
|
+
{ cardId: id, pageNo, pageSize: 100, sassAppId: appId, sassWorkspaceId: workspaceId },
|
|
760
|
+
'IDC10013.do',
|
|
761
|
+
)
|
|
762
|
+
const rawList = Array.isArray(resp && resp.schemaValueList) ? resp.schemaValueList : []
|
|
763
|
+
const hit = rawList.find((raw) => String(raw && raw.id != null ? raw.id : '') === want)
|
|
764
|
+
if (hit) {
|
|
765
|
+
const schema = typeof hit.schema === 'string' ? JSON.parse(hit.schema) : hit.schema
|
|
766
|
+
if (!schema || typeof schema !== 'object') {
|
|
767
|
+
throw new Error(`远端版本记录 #${want} 的 schema 为空或损坏,无法加载。`)
|
|
768
|
+
}
|
|
769
|
+
log.info('定位远端历史版本', { cardId: id, versionId: want, pageNo })
|
|
770
|
+
return {
|
|
771
|
+
schema,
|
|
772
|
+
record: {
|
|
773
|
+
id: String(hit.id != null ? hit.id : ''),
|
|
774
|
+
createTime: hit.createTime || '',
|
|
775
|
+
},
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
totalPages = Number(resp && resp.totalPages != null ? resp.totalPages : 1) || 1
|
|
779
|
+
pageNo += 1
|
|
780
|
+
} while (pageNo <= totalPages)
|
|
781
|
+
throw new Error(`远端历史中找不到版本记录 #${want}(cardId=${id},共 ${totalPages} 页)。`)
|
|
782
|
+
} finally {
|
|
783
|
+
try { await browser.disconnect() } catch { /* ignore */ }
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
function summarizeHistoryItem(raw) {
|
|
787
|
+
let name = ''
|
|
788
|
+
let desc = ''
|
|
789
|
+
let version = ''
|
|
790
|
+
let platform = ''
|
|
791
|
+
let width = ''
|
|
792
|
+
let components = []
|
|
793
|
+
try {
|
|
794
|
+
const schema = typeof raw.schema === 'string' ? JSON.parse(raw.schema) : raw.schema || {}
|
|
795
|
+
name = schema.name || ''
|
|
796
|
+
desc = schema.desc || ''
|
|
797
|
+
version = schema.version || ''
|
|
798
|
+
platform = schema.platform || ''
|
|
799
|
+
width = (schema.view && schema.view.width) || ''
|
|
800
|
+
// 组件树挂在 view.components 下(schema 顶层只有 version/pageId/name/desc/platform/dictionary/view)
|
|
801
|
+
components = (schema.view && Array.isArray(schema.view.components)) ? schema.view.components : []
|
|
802
|
+
} catch {
|
|
803
|
+
/* 单条 schema 损坏不阻断时间线渲染,摘要退化为空 */
|
|
804
|
+
}
|
|
805
|
+
return {
|
|
806
|
+
id: String(raw.id != null ? raw.id : ''),
|
|
807
|
+
createTime: raw.createTime || '',
|
|
808
|
+
updateTime: raw.updateTime || '',
|
|
809
|
+
bizChannel: raw.bizChannel || '',
|
|
810
|
+
// @filestore/x → /minio/x(同源相对路径,经 8787 assetProxy 回源;与 adapter.js 约定一致)
|
|
811
|
+
screenshotPath: filestorePathToLocal(raw.screenshotUrl),
|
|
812
|
+
// compKeys / eventCount 仅服务端相邻 diff 消费,体积恒定(数十字节/条)
|
|
813
|
+
summary: {
|
|
814
|
+
name,
|
|
815
|
+
desc,
|
|
816
|
+
version,
|
|
817
|
+
platform,
|
|
818
|
+
width,
|
|
819
|
+
componentCount: components.length,
|
|
820
|
+
compKeys: components.map(compKey),
|
|
821
|
+
eventCount: countEvents(components),
|
|
822
|
+
},
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** 组件 diff 标识:componentId::className(同卡内 componentId 稳定,跨版本可比) */
|
|
827
|
+
function compKey(c) {
|
|
828
|
+
return `${c && c.componentId != null ? c.componentId : ''}::${c && c.className != null ? c.className : ''}`
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** 交互事件总数:各组 eventactions 条目数之和(事件代码增删的粗粒度信号) */
|
|
832
|
+
function countEvents(comps) {
|
|
833
|
+
return comps.reduce((n, c) => n + (Array.isArray(c && c.eventactions) ? c.eventactions.length : 0), 0)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** @filestore/ 前缀切为同源 /minio/ 相对路径;已是绝对地址则原样返回 */
|
|
837
|
+
function filestorePathToLocal(value) {
|
|
838
|
+
if (!value || typeof value !== 'string') return ''
|
|
839
|
+
if (value.startsWith('http://') || value.startsWith('https://')) return value
|
|
840
|
+
if (value.startsWith('@filestore/')) return '/minio/' + value.slice('@filestore/'.length)
|
|
841
|
+
if (value.startsWith('/')) return value
|
|
842
|
+
return filestoreToUrl(value)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* 相邻版本 diff 摘要:相对更旧版本算变更标签(组件增减 / 交互事件变化 / 重命名 /
|
|
847
|
+
* 描述变更 / 画布宽度变化 / 结构未变)。tags 为字符串数组,前端直接渲染徽标。
|
|
848
|
+
*/
|
|
849
|
+
function diffHistoryTags(cur, older) {
|
|
850
|
+
if (!older) return ['初始版本']
|
|
851
|
+
const curIds = new Set(cur.compKeys)
|
|
852
|
+
const oldIds = new Set(older.compKeys)
|
|
853
|
+
let added = 0
|
|
854
|
+
let removed = 0
|
|
855
|
+
for (const k of curIds) if (!oldIds.has(k)) added += 1
|
|
856
|
+
for (const k of oldIds) if (!curIds.has(k)) removed += 1
|
|
857
|
+
const tags = []
|
|
858
|
+
if (added > 0) tags.push(`新增 ${added} 个组件`)
|
|
859
|
+
if (removed > 0) tags.push(`移除 ${removed} 个组件`)
|
|
860
|
+
if (cur.eventCount !== older.eventCount) tags.push(`交互事件 ${older.eventCount} → ${cur.eventCount}`)
|
|
861
|
+
if (cur.name !== older.name) tags.push('重命名')
|
|
862
|
+
if (cur.desc !== older.desc) tags.push('描述变更')
|
|
863
|
+
if (cur.width !== older.width) tags.push(`画布宽度 ${older.width} → ${cur.width}`)
|
|
864
|
+
if (tags.length === 0) tags.push('结构未变')
|
|
865
|
+
return tags
|
|
866
|
+
}
|
|
867
|
+
|
|
521
868
|
// —— 保存落库校验辅助 ——
|
|
522
869
|
|
|
523
870
|
/** 稳定序列化:对象键排序、带 id/key/className/name 的数组元素按该键排序,避免服务端字段重排导致指纹误判。 */
|
|
@@ -625,7 +625,8 @@ export async function performLogin(page, credentials) {
|
|
|
625
625
|
*
|
|
626
626
|
* @param {object} [options]
|
|
627
627
|
* @param {string} [options.credentialsPath] 凭据文件路径
|
|
628
|
-
* @param {number} [options.maxAttempts=3]
|
|
628
|
+
* @param {number} [options.maxAttempts=3] 单份凭据的最大登录重试次数
|
|
629
|
+
* @param {number} [options.maxCredentialPrompts=3] 账号/密码错误时允许重新输入凭据的次数上限(仅交互终端生效)
|
|
629
630
|
* @param {number} [options.retryDelayMs=1000] 重试间隔
|
|
630
631
|
* @param {number} [options.debugPort=9222] Chrome 调试端口
|
|
631
632
|
* @param {boolean} [options.closeBrowser=false] 完成后是否关闭浏览器
|
|
@@ -635,6 +636,7 @@ export async function runFinmallLogin(options = {}) {
|
|
|
635
636
|
const {
|
|
636
637
|
credentialsPath = DEFAULT_CREDENTIALS_PATH,
|
|
637
638
|
maxAttempts = 3,
|
|
639
|
+
maxCredentialPrompts = 3,
|
|
638
640
|
retryDelayMs = 1000,
|
|
639
641
|
debugPort = DEBUG_PORT,
|
|
640
642
|
closeBrowser = false,
|
|
@@ -729,9 +731,12 @@ export async function runFinmallLogin(options = {}) {
|
|
|
729
731
|
};
|
|
730
732
|
}
|
|
731
733
|
|
|
732
|
-
//
|
|
734
|
+
// 登录流程(带重试;账号/密码错误时,交互终端下请用户重新输入)
|
|
733
735
|
const failures = [];
|
|
734
|
-
|
|
736
|
+
let credentialPrompts = 0;
|
|
737
|
+
let attempt = 0;
|
|
738
|
+
while (attempt < maxAttempts) {
|
|
739
|
+
attempt += 1;
|
|
735
740
|
try {
|
|
736
741
|
const dismissed = await clearTimeoutPrompts(page);
|
|
737
742
|
state = await waitForPageDecision(page);
|
|
@@ -779,6 +784,29 @@ export async function runFinmallLogin(options = {}) {
|
|
|
779
784
|
};
|
|
780
785
|
}
|
|
781
786
|
failures.push({ attempt, reason: "登录后未看到卡片数据列表" });
|
|
787
|
+
} else if (
|
|
788
|
+
loginResult.businessFailure &&
|
|
789
|
+
process.stdout.isTTY &&
|
|
790
|
+
credentialPrompts < maxCredentialPrompts
|
|
791
|
+
) {
|
|
792
|
+
// 账号/密码错误:交互终端下请用户重新输入,覆盖本地凭据后重来,
|
|
793
|
+
// 不消耗固定的重试次数(避免一次性输错就把次数吃光)。
|
|
794
|
+
credentialPrompts += 1;
|
|
795
|
+
console.error(
|
|
796
|
+
`✖ 登录失败:账号或密码错误。请重新输入 FinMall 凭据(${credentialPrompts}/${maxCredentialPrompts})`,
|
|
797
|
+
);
|
|
798
|
+
try {
|
|
799
|
+
credentials = await promptAndSaveCredentials(credentialsPath);
|
|
800
|
+
attempt -= 1; // 回退本次计数,下一轮用新凭据重试
|
|
801
|
+
continue;
|
|
802
|
+
} catch (promptError) {
|
|
803
|
+
failures.push({
|
|
804
|
+
attempt,
|
|
805
|
+
reason:
|
|
806
|
+
"重新输入凭据失败:" +
|
|
807
|
+
String(promptError?.message || promptError).slice(0, 200),
|
|
808
|
+
});
|
|
809
|
+
}
|
|
782
810
|
} else {
|
|
783
811
|
failures.push({
|
|
784
812
|
attempt,
|
|
@@ -807,11 +835,15 @@ export async function runFinmallLogin(options = {}) {
|
|
|
807
835
|
}
|
|
808
836
|
}
|
|
809
837
|
|
|
838
|
+
const exhaustedReason =
|
|
839
|
+
credentialPrompts >= maxCredentialPrompts && credentialPrompts > 0
|
|
840
|
+
? `已连续 ${credentialPrompts} 次重新输入凭据仍登录失败,请核对账号密码或稍后重试`
|
|
841
|
+
: "登录重试多次仍失败,服务端可能异常";
|
|
810
842
|
return {
|
|
811
843
|
success: false,
|
|
812
|
-
attempts:
|
|
844
|
+
attempts: attempt,
|
|
813
845
|
terminal: true,
|
|
814
|
-
reason:
|
|
846
|
+
reason: exhaustedReason,
|
|
815
847
|
failures,
|
|
816
848
|
};
|
|
817
849
|
} finally {
|
|
@@ -28,6 +28,13 @@ for (const key of ["NO_PROXY", "no_proxy"]) {
|
|
|
28
28
|
process.env[key] = cur && cur.length ? `${cur},${extra}` : extra;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// 进程内会话缓存:runFinmallLogin 热路径固定消耗导航 + networkidle + sleep(2000)(实测 ~2.6s),
|
|
32
|
+
// 而同步/保存/历史查询等高频只读操作都会经过 ensureFinMallSession。TTL 内且 CDP 仍连通时
|
|
33
|
+
// 直接复用,跳过重复登录校验;服务端会话真过期时由 finmallPostWithHeal 的 SESSION_INVALID
|
|
34
|
+
// 自愈(清 session 重登重试)兜底,故此处缓存不削弱正确性。
|
|
35
|
+
const SESSION_CACHE_TTL_MS = Number(process.env.FINMALL_SESSION_CACHE_TTL_MS || 60000);
|
|
36
|
+
let _sessionCache = null; // { browser, login, at }
|
|
37
|
+
|
|
31
38
|
/**
|
|
32
39
|
* 确保存在一个已登录的 FinMall 后台浏览器会话(无界面 headless,端口 SYNC_PORT)。
|
|
33
40
|
*
|
|
@@ -36,6 +43,9 @@ for (const key of ["NO_PROXY", "no_proxy"]) {
|
|
|
36
43
|
* 以 headless 模式启动一个独立(detached)Chrome,并用保存的凭据自动登录。
|
|
37
44
|
* - 若 SYNC_PORT 已启动(含跨 dev server 重启复用的上一实例):直接连上并复用,
|
|
38
45
|
* 仅在 _sessionId 缺失时由 runFinmallLogin 自动登录。
|
|
46
|
+
* - 进程内热路径:距上次成功 ensure 不足 TTL 且 CDP 仍连通(isConnected)时直接返回缓存,
|
|
47
|
+
* 省去重复导航与固定等待;调用方 finally 的 browser.disconnect() 会令 isConnected()
|
|
48
|
+
* 为 false,缓存随之作废走完整流程,与原先行为一致。
|
|
39
49
|
*
|
|
40
50
|
* 注意:runFinmallLogin 只校验 _sessionId 是否存在,无法发现「服务端会话已过期」
|
|
41
51
|
* (IDC10001 列表接口不鉴权,会骗过它)。最终的服务端过期由 syncCards 以需鉴权的
|
|
@@ -51,6 +61,19 @@ for (const key of ["NO_PROXY", "no_proxy"]) {
|
|
|
51
61
|
* @returns {Promise<{ browser: import('playwright').Browser, login: object }>}
|
|
52
62
|
*/
|
|
53
63
|
export async function ensureFinMallSession({ debugPort = SYNC_PORT, headless = true } = {}) {
|
|
64
|
+
// 热路径:进程内缓存未过期且 CDP 仍连通 → 直接复用(跳过 runFinmallLogin 的
|
|
65
|
+
// 导航 + networkidle + sleep(2000),实测可从 ~2.6s 降到毫秒级)。
|
|
66
|
+
if (_sessionCache && Date.now() - _sessionCache.at < SESSION_CACHE_TTL_MS) {
|
|
67
|
+
try {
|
|
68
|
+
if (_sessionCache.browser.isConnected()) {
|
|
69
|
+
return _sessionCache;
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
/* isConnected 抛错视作失效,走完整流程 */
|
|
73
|
+
}
|
|
74
|
+
_sessionCache = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
54
77
|
let browser = await tryConnectBrowser(debugPort);
|
|
55
78
|
|
|
56
79
|
if (!browser) {
|
|
@@ -90,7 +113,8 @@ export async function ensureFinMallSession({ debugPort = SYNC_PORT, headless = t
|
|
|
90
113
|
throw new Error(`后台自动登录 FinMall 失败:${reason}${captchaNote}`);
|
|
91
114
|
}
|
|
92
115
|
|
|
93
|
-
|
|
116
|
+
_sessionCache = { browser, login, at: Date.now() };
|
|
117
|
+
return _sessionCache;
|
|
94
118
|
}
|
|
95
119
|
|
|
96
120
|
export { SYNC_PORT, SYNC_BROWSER_DATA_DIR };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cardbuddy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Local dev service for the CardBuddy card editor — decouples card CRUD (sync/save/validate/render-status/screenshot) from the static public editor, so the editor can be deployed to any static host while each developer runs a local service against their own card source.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|