cardbuddy 0.9.1 → 0.11.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 +21 -6
- package/dist/cli.js +17 -6
- package/dist/config.js +20 -2
- package/dist/portGuard.js +293 -0
- package/dist/preview-editor/assets/index-B4VZgNSK.css +1 -0
- package/dist/preview-editor/assets/index-jl4YJzKv.js +180 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/server-core/_vendor/finmall-env.mjs +4 -4
- package/dist/server-core/ai-chat.js +3710 -0
- package/dist/server-core/assetProxy.js +3 -22
- package/dist/server-core/buildDataFromSchema.js +36 -4
- package/dist/server-core/cardFiles.js +95 -2
- package/dist/server-core/cardImage.js +14 -0
- package/dist/server-core/cardListCache.js +0 -17
- package/dist/server-core/cardSave.js +2 -0
- package/dist/server-core/cardValidate.js +3 -0
- package/dist/server-core/index.js +6 -5
- package/dist/server-core/logStream.js +1 -6
- package/dist/server-core/sliceUpload.js +8 -1
- package/dist/standalone.js +24 -8
- package/dist/vendored/App.vue +405 -125
- package/dist/vendored/api.js +4 -4
- package/dist/vendored/assets/svgIcons/icons.svg +28 -0
- package/dist/vendored/components/AiSettings.vue +392 -0
- package/dist/vendored/components/CardGallery.vue +51 -5
- package/dist/vendored/components/ChatComposer.vue +413 -0
- package/dist/vendored/components/ChatMessage.vue +383 -0
- package/dist/vendored/components/ChatPanel.vue +937 -0
- package/dist/vendored/components/ComponentPalette.vue +1 -1
- package/dist/vendored/components/DebugPanel.vue +732 -90
- package/dist/vendored/components/Dropdown.vue +4 -2
- package/dist/vendored/components/MarkdownBody.vue +64 -0
- package/dist/vendored/components/NewCardDialog.vue +132 -0
- package/dist/vendored/components/NumberStepper.vue +125 -0
- package/dist/vendored/components/ProgressSlider.vue +9 -1
- package/dist/vendored/components/RemotePreview.vue +1 -1
- package/dist/vendored/components/StructureTree.vue +2 -2
- package/dist/vendored/main.js +1 -0
- package/dist/vendored/runtime/adapter.js +1 -1
- package/dist/vendored/runtime/ai-api.js +165 -0
- package/dist/vendored/runtime/clipboard-node.js +0 -5
- package/dist/vendored/runtime/markdown.js +140 -0
- package/dist/vendored/skills/.manifest.json +1101 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +249 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +678 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/derive_data_json.py +81 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +105 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +624 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +900 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/slice_upload.mjs +699 -0
- package/dist/vendored/skills/shared/finmall-config.mjs +124 -0
- package/dist/vendored/styles/ai-chat.css +1850 -0
- package/dist/vendored/styles/app.css +725 -213
- package/dist/vendored/styles/card-gallery.css +48 -10
- package/dist/vendored/styles/card-select.css +13 -6
- package/dist/vendored/styles/dropdown.css +1 -2
- package/dist/vendored/styles/json-tree.css +3 -3
- package/dist/vendored/styles/node-inspector.css +13 -289
- package/dist/vendored/styles/setters.css +48 -49
- package/dist/vendored/styles/structure-tree.css +6 -11
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-B9MOriNv.js +0 -80
- package/dist/preview-editor/assets/index-CwD14gm3.css +0 -1
- package/dist/vendored/assets/logo.png +0 -0
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
import { mkdir, stat, writeFile, readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
import { finmallConfig } from '../../shared/finmall-config.mjs';
|
|
6
|
+
|
|
7
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const skillDir = path.resolve(scriptDir, '..');
|
|
9
|
+
// skill now lives under <repo-root>/skills/<name>: repo root is two levels up from skillDir
|
|
10
|
+
const workspace = path.resolve(skillDir, '..', '..');
|
|
11
|
+
// 与 finmall_login.mjs 保持一致的解析方式:用裸标识符让 Node 从项目根 node_modules 解析
|
|
12
|
+
// playwright。原硬编码路径指向 .workbuddy/skills/finmall-session-navigator/scripts/node_modules
|
|
13
|
+
// (该 node_modules 是已失效的断链符号链接),导致「Cannot find module」报错。
|
|
14
|
+
const { chromium } = await import('playwright');
|
|
15
|
+
|
|
16
|
+
// 统一日志美化(容错:缺 server/core/logger 时回退极简实现,保证 demo / 老项目不崩)。
|
|
17
|
+
// 实时项目与 build 后的 dist/server-core 均含该模块;demo 等缺少 server/ 目录的场景走 fallback。
|
|
18
|
+
let log;
|
|
19
|
+
try {
|
|
20
|
+
({ log } = await import('../../../server/core/logger.js'));
|
|
21
|
+
} catch {
|
|
22
|
+
const w = (m, ...a) => console.log(`[card] ${m}`, ...a);
|
|
23
|
+
log = {
|
|
24
|
+
info: w,
|
|
25
|
+
ok: w,
|
|
26
|
+
warn: (m, ...a) => console.warn(`[card] ${m}`, ...a),
|
|
27
|
+
error: (m, ...a) => console.error(`[card] ${m}`, ...a),
|
|
28
|
+
box: () => {},
|
|
29
|
+
rule: () => {},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 后台会话管理:点击预览「同步远程数据」时自动拉起/复用无界面 Chrome 并登录,
|
|
34
|
+
// 无需手动运行 finmall-session-navigator。
|
|
35
|
+
import { ensureFinMallSession } from "../../finmall-session-navigator/scripts/session-manager.mjs";
|
|
36
|
+
import {
|
|
37
|
+
performLogin,
|
|
38
|
+
clearTimeoutPrompts,
|
|
39
|
+
waitForAuthenticatedPage,
|
|
40
|
+
loadCredentials,
|
|
41
|
+
} from "../../finmall-session-navigator/scripts/finmall_login.mjs";
|
|
42
|
+
|
|
43
|
+
// 代理处理:本机设了 HTTP_PROXY,但 Chrome 远程调试端口在 127.0.0.1,需把
|
|
44
|
+
// localhost 排除在代理外,否则 connectOverCDP 会经代理拿到 502 / ECONNREFUSED。
|
|
45
|
+
// 仅影响 Node 侧 CDP 连接;浏览器内访问 finmall.com 仍走代理。
|
|
46
|
+
for (const key of ["NO_PROXY", "no_proxy"]) {
|
|
47
|
+
const cur = process.env[key];
|
|
48
|
+
const extra = "127.0.0.1,localhost";
|
|
49
|
+
process.env[key] = cur && cur.length ? `${cur},${extra}` : extra;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 卡片列表缓存(推送前存在性校验用):与 server 共享同一 dev server 进程内单例。
|
|
53
|
+
// 仅在 5180 经中间件触发同步时本进程即 server 进程,缓存写入/读取天然一致;
|
|
54
|
+
// 独立 CLI 运行时不写 server 缓存(无推送发生在该进程),故惰性加载、失败静默降级。
|
|
55
|
+
let _cardListCache = null;
|
|
56
|
+
async function getCardListCache() {
|
|
57
|
+
if (_cardListCache) return _cardListCache;
|
|
58
|
+
try {
|
|
59
|
+
const cachePath = path.resolve(workspace, 'server', 'core', 'cardListCache.js');
|
|
60
|
+
_cardListCache = await import(pathToFileURL(cachePath).href);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
_cardListCache = null;
|
|
63
|
+
log.warn('卡片列表缓存模块加载失败(存在性校验将跳过)', { error: e && e.message });
|
|
64
|
+
}
|
|
65
|
+
return _cardListCache;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
69
|
+
|
|
70
|
+
// 预览图下载超时闸门。实测:Filestore 对不存在的对象(典型如空白卡的 picUrl)不会返回
|
|
71
|
+
// 404,而是直接挂起不响应;page.evaluate 本身没有默认超时,裸 fetch 会把整次同步永久拖死。
|
|
72
|
+
// 内部用 AbortController 掐断请求,外部再包一层 Promise.race 兜住 evaluate 自身不返回的情况。
|
|
73
|
+
const COVER_FETCH_TIMEOUT_MS = Number(process.env.CARDBUDDY_COVER_TIMEOUT_MS || 15000);
|
|
74
|
+
// FinMall 交易请求超时。IDC10001/IDC10012 偶发挂起(后端不响应),同样没有任何默认超时,
|
|
75
|
+
// 一张卡挂住就会拖垮整批同步——所以和预览图一样,内层 abort + 外层 race 双重设防。
|
|
76
|
+
const FINMALL_POST_TIMEOUT_MS = Number(process.env.CARDBUDDY_POST_TIMEOUT_MS || 30000);
|
|
77
|
+
|
|
78
|
+
function withTimeout(promise, ms, label) {
|
|
79
|
+
let timer;
|
|
80
|
+
return Promise.race([
|
|
81
|
+
promise,
|
|
82
|
+
new Promise((_, reject) => {
|
|
83
|
+
timer = setTimeout(() => reject(new Error(`${label}超时(>${ms}ms),已放弃`)), ms);
|
|
84
|
+
}),
|
|
85
|
+
]).finally(() => clearTimeout(timer));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 服务端判定会话/权限失效并应「重新进入」的特征集合。命中后由 finmallPostWithHeal
|
|
89
|
+
// 自动清 session 重登再试一次——覆盖会话超时(BLEC0006)、token 失效,以及
|
|
90
|
+
// 本次遇到的「您当前角色菜单权限发生变化,请重新进入」这类权限态异常。
|
|
91
|
+
const SESSION_INVALID_RE = /BLEC0006|会话超时|会话过期|登录超时|未登录|登录失效|登录已失效|请重新登录|invalid token|token|角色菜单权限发生变化|权限发生变化|重新进入|请重新进入/i;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 带自愈的 FinMall 接口调用:正常请求,若命中会话/权限失效特征则清 session 重登后重试一次。
|
|
95
|
+
* 列表(IDC10001)与 schema(IDC10012)共用,确保任意一步出现权限态异常都能自动恢复。
|
|
96
|
+
*/
|
|
97
|
+
async function finmallPostWithHeal(page, urlPath, requestBody, transCode = urlPath) {
|
|
98
|
+
try {
|
|
99
|
+
return await finmallPost(page, urlPath, requestBody, transCode);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (SESSION_INVALID_RE.test(error.message)) {
|
|
102
|
+
log.warn('会话/权限失效,自动重新登录后重试', { url: urlPath, reason: error.message.slice(0, 40) });
|
|
103
|
+
await relogin(page);
|
|
104
|
+
return await finmallPost(page, urlPath, requestBody, transCode);
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 从已认证的 FinMall 浏览器会话同步卡片到 src/cards。
|
|
112
|
+
* 既可由 CLI 直接运行(api_batch_sync.mjs),也可被 Vite 中间件 import 后供预览「同步远程数据」按钮调用。
|
|
113
|
+
*
|
|
114
|
+
* 会话由 ensureFinMallSession 自动保证:若后台无界面 Chrome(专用端口)未启动或会话缺失,
|
|
115
|
+
* 会自动拉起并登录;完成后只 disconnect 不关闭进程,便于复用。
|
|
116
|
+
* 若服务端会话/权限失效(IDC10012 返回 BLEC0006 或「角色菜单权限发生变化」等),
|
|
117
|
+
* finmallPostWithHeal 会自动清 session 重登再重试。
|
|
118
|
+
*
|
|
119
|
+
* @param {object} opts
|
|
120
|
+
* @param {string} [opts.cardCode] 仅同步该英文卡 id(用列表接口 searchValue 定位)
|
|
121
|
+
* @param {number} [opts.startPage=1]
|
|
122
|
+
* @param {number} [opts.startIndex=0]
|
|
123
|
+
* @param {number} [opts.pageSize=24]
|
|
124
|
+
* @param {number} [opts.maxCards] 最多同步张数(不传=全部)
|
|
125
|
+
* @param {string} [opts.appId]
|
|
126
|
+
* @param {string} [opts.workspaceId]
|
|
127
|
+
* @param {string} [opts.cdpUrl] 已废弃:会话现在由 ensureFinMallSession 在专用后台端口自动管理
|
|
128
|
+
* @param {string} [opts.outRoot] 输出根目录(默认 workspace/src/cards)
|
|
129
|
+
* @returns {Promise<{success:boolean, synced:Array, failed:Array, duplicates:Array}>}
|
|
130
|
+
*/
|
|
131
|
+
export async function syncCards(opts = {}) {
|
|
132
|
+
const outRoot = path.resolve(opts.outRoot || path.join(workspace, 'src', 'cards'));
|
|
133
|
+
const startPage = Number(opts.startPage || 1);
|
|
134
|
+
const startIndex = Number(opts.startIndex || 0);
|
|
135
|
+
const pageSize = Number(opts.pageSize || 24);
|
|
136
|
+
const maxCards = opts.maxCards != null ? Number(opts.maxCards) : Infinity;
|
|
137
|
+
const appId = opts.appId || finmallConfig.appId;
|
|
138
|
+
const workspaceId = opts.workspaceId || finmallConfig.workspaceId;
|
|
139
|
+
// 单卡筛选:传 cardCode 时仅同步该卡
|
|
140
|
+
const cardCodeFilter = opts.cardCode || '';
|
|
141
|
+
|
|
142
|
+
// 自动确保后台 FinMall 会话:未启动/缺失则拉起无界面 Chrome 并登录,无需手动触发。
|
|
143
|
+
const session = await ensureFinMallSession();
|
|
144
|
+
const browser = session.browser;
|
|
145
|
+
const summary = { success: true, synced: [], failed: [], duplicates: [] };
|
|
146
|
+
// 重复 schema 检测:记录每张卡 schema 的 MD5,若不同 code 命中相同 schema 说明抓取仍串卡。
|
|
147
|
+
const schemaHashes = new Map();
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const context = browser.contexts()[0];
|
|
151
|
+
const page = context?.pages().find((p) => p.url().includes('finmall.com')) || context?.pages()[0];
|
|
152
|
+
if (!page) throw new Error('未能在后台 Chrome 中找到 FinMall 页面,请检查凭据或重新触发同步。');
|
|
153
|
+
await page.bringToFront();
|
|
154
|
+
|
|
155
|
+
const firstList = await fetchCardList(page, { pageNo: startPage, pageSize, appId, workspaceId, searchValue: cardCodeFilter });
|
|
156
|
+
const totalPages = Number(firstList.totalPages || Math.ceil(Number(firstList.totalNums || 0) / pageSize) || startPage);
|
|
157
|
+
|
|
158
|
+
let done = false;
|
|
159
|
+
const allCards = [];
|
|
160
|
+
for (let pageNo = startPage; pageNo <= totalPages && !done; pageNo++) {
|
|
161
|
+
const list = pageNo === startPage ? firstList : await fetchCardList(page, { pageNo, pageSize, appId, workspaceId, searchValue: cardCodeFilter });
|
|
162
|
+
const cards = list.cardList || [];
|
|
163
|
+
for (const c of cards) allCards.push(c);
|
|
164
|
+
const begin = pageNo === startPage ? startIndex : 0;
|
|
165
|
+
for (let index = begin; index < cards.length && !done; index++) {
|
|
166
|
+
if (summary.synced.length + summary.failed.length >= maxCards) break;
|
|
167
|
+
// normalizeCard 会对非法 code 抛错;单条异常不应中断整批同步。
|
|
168
|
+
let card;
|
|
169
|
+
try {
|
|
170
|
+
card = normalizeCard(cards[index]);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
summary.success = false;
|
|
173
|
+
summary.failed.push({ page: pageNo, index, id: cards[index]?.code || '(unknown)', title: cards[index]?.cardName || '', error: error.message });
|
|
174
|
+
log.error('抓取失败', { page: pageNo, index, id: cards[index]?.code || '(unknown)', error: error.message });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (cardCodeFilter && card.id !== cardCodeFilter) continue;
|
|
178
|
+
log.info('同步中', { page: `${pageNo}/${totalPages}`, index, id: card.id, title: card.title });
|
|
179
|
+
try {
|
|
180
|
+
const files = await syncCard(page, card, { outRoot, appId, workspaceId, summary, schemaHashes });
|
|
181
|
+
// 空白卡不算失败,但要在结果里显式标记,避免与「抓到完整 schema」混为一谈。
|
|
182
|
+
summary.synced.push({
|
|
183
|
+
page: pageNo, index, id: card.id, title: card.title, files,
|
|
184
|
+
blank: !!files.blank,
|
|
185
|
+
keptLocal: !!files.keptLocal,
|
|
186
|
+
});
|
|
187
|
+
} catch (error) {
|
|
188
|
+
summary.success = false;
|
|
189
|
+
summary.failed.push({ page: pageNo, index, id: card.id, title: card.title, error: error.message });
|
|
190
|
+
log.error('同步失败', { id: card.id, title: card.title, error: error.message });
|
|
191
|
+
}
|
|
192
|
+
if (cardCodeFilter) done = true;
|
|
193
|
+
}
|
|
194
|
+
if (summary.synced.length + summary.failed.length >= maxCards) break;
|
|
195
|
+
}
|
|
196
|
+
// 全量同步(无 cardCode 过滤)时整体刷新卡片列表缓存,供推送前「存在性闸门」使用;
|
|
197
|
+
// 单卡同步只增量合并,不整体替换,避免清空其余卡片。缓存未就绪则跳过(不影响同步)。
|
|
198
|
+
try {
|
|
199
|
+
const cache = await getCardListCache();
|
|
200
|
+
if (cache) cache.refreshCardList(allCards, { full: !cardCodeFilter });
|
|
201
|
+
} catch (e) {
|
|
202
|
+
log.warn('刷新卡片列表缓存失败(不影响同步)', { error: e && e.message });
|
|
203
|
+
}
|
|
204
|
+
} finally {
|
|
205
|
+
// 仅断开连接,不关闭用户自己启动的 Chrome(connectOverCDP 连的是外部浏览器)
|
|
206
|
+
try { await browser.disconnect() } catch { /* ignore */ }
|
|
207
|
+
}
|
|
208
|
+
return summary;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 把一张卡片的最新 schema 写回 FinMall(线上编辑器「保存」接口 IDC10010.do)。
|
|
213
|
+
* 与 syncCards 共用同一套已登录浏览器会话与带自愈的 finmallPost,
|
|
214
|
+
* 确保任意一步会话/权限失效都能自动重登重试。
|
|
215
|
+
*
|
|
216
|
+
* 入参 schema 即本地 src/cards/<id>/schema.json 的完整内容(含 pageId / dictionary / view …)。
|
|
217
|
+
* 关键映射(来自线上编辑器抓包):
|
|
218
|
+
* - body.cardId = 数字型 schema.pageId(非英文 code)
|
|
219
|
+
* - body.schemaValue = 整张 schema 的 JSON 字符串(非对象)
|
|
220
|
+
* - body.skeletonScreen = 骨架屏 SVG(仅用于编辑器列表缩略图,可传空串)
|
|
221
|
+
*
|
|
222
|
+
* 保存成功后默认立刻读回(IDC10012.do)做落库校验——FinMall 网关有
|
|
223
|
+
* 「errorCode=0 但 body 空/未落库」的静默空返回先例,不校验无法判断真实生效。
|
|
224
|
+
*
|
|
225
|
+
* @param {object} schema 完整卡片 schema(含 pageId)
|
|
226
|
+
* @param {{ skeletonScreen?: string, verify?: boolean }} [opts]
|
|
227
|
+
* @returns {Promise<{ response: object, verified: object }>}
|
|
228
|
+
* verified = { ok, cardIdMatched, fingerprintMatched, diff, readBackError }
|
|
229
|
+
*/
|
|
230
|
+
export async function saveCardToRemote(schema, { skeletonScreen = '', verify = true } = {}) {
|
|
231
|
+
if (!schema || !schema.pageId) {
|
|
232
|
+
throw new Error('schema 缺少 pageId,无法确定要保存的卡片(IDC10010.do 需要数字型 cardId)。')
|
|
233
|
+
}
|
|
234
|
+
const appId = finmallConfig.appId
|
|
235
|
+
const workspaceId = finmallConfig.workspaceId
|
|
236
|
+
const session = await ensureFinMallSession()
|
|
237
|
+
const browser = session.browser
|
|
238
|
+
try {
|
|
239
|
+
const context = browser.contexts()[0]
|
|
240
|
+
const page = context?.pages().find((p) => p.url().includes('finmall.com')) || context?.pages()[0]
|
|
241
|
+
if (!page) throw new Error('未能在后台 Chrome 中找到 FinMall 页面,请检查凭据或重新触发同步。')
|
|
242
|
+
await page.bringToFront()
|
|
243
|
+
|
|
244
|
+
// 保护远端列表缩略图:本地未提供骨架屏时,先读回远端已有的 skeletonScreen 复用,
|
|
245
|
+
// 避免把缩略图刷成空白(仅影响列表显示,不影响卡片内容)。
|
|
246
|
+
let sk = skeletonScreen && String(skeletonScreen).trim() ? skeletonScreen : ''
|
|
247
|
+
if (!sk) {
|
|
248
|
+
try {
|
|
249
|
+
const pre = await finmallPostWithHeal(page, '/ubomp-web/IDC10012.do', {
|
|
250
|
+
cardId: String(schema.pageId),
|
|
251
|
+
sassAppId: appId,
|
|
252
|
+
sassWorkspaceId: workspaceId,
|
|
253
|
+
}, 'IDC10012.do')
|
|
254
|
+
if (pre && pre.skeletonScreen && String(pre.skeletonScreen).trim()) sk = pre.skeletonScreen
|
|
255
|
+
} catch { /* 读不到就退化成最小合法 SVG */ }
|
|
256
|
+
}
|
|
257
|
+
if (!sk) sk = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="400"></svg>'
|
|
258
|
+
|
|
259
|
+
// finmallPost 会把 requestBody 放进 payload.body,并自己生成 payload.header;
|
|
260
|
+
// 因此这里只传「内层 body」(与读接口 fetchCardSchema 保持一致),切勿再包一层 {header, body}。
|
|
261
|
+
// 关键:IDC10010.do 需要 sassAppId/sassWorkspaceId(与读接口一致),缺失会返回 PEO000003。
|
|
262
|
+
const innerBody = {
|
|
263
|
+
cardId: String(schema.pageId),
|
|
264
|
+
skeletonScreen: sk,
|
|
265
|
+
schemaValue: JSON.stringify(schema),
|
|
266
|
+
sassAppId: appId,
|
|
267
|
+
sassWorkspaceId: workspaceId,
|
|
268
|
+
}
|
|
269
|
+
const urlPath = `/ubomp-web/IDC10010.do?sassAppId=${appId}&sassWorkspaceId=${workspaceId}`
|
|
270
|
+
log.info('保存卡片', { pageId: schema.pageId, name: schema.name || '' })
|
|
271
|
+
const response = await finmallPostWithHeal(page, urlPath, innerBody, 'IDC10010.do')
|
|
272
|
+
|
|
273
|
+
// 落库校验:保存后立刻读回,确认内容真的写进去了(而非静默空返回)。
|
|
274
|
+
const verified = { ok: false, cardIdMatched: false, fingerprintMatched: false, diff: null, readBackError: null }
|
|
275
|
+
if (verify) {
|
|
276
|
+
try {
|
|
277
|
+
const readBody = await finmallPostWithHeal(page, '/ubomp-web/IDC10012.do', {
|
|
278
|
+
cardId: String(schema.pageId),
|
|
279
|
+
sassAppId: appId,
|
|
280
|
+
sassWorkspaceId: workspaceId,
|
|
281
|
+
}, 'IDC10012.do')
|
|
282
|
+
verified.cardIdMatched = String(readBody.cardId) === String(schema.pageId)
|
|
283
|
+
const remoteSchema = readBody.schemaValue ? JSON.parse(readBody.schemaValue) : null
|
|
284
|
+
if (!remoteSchema) {
|
|
285
|
+
verified.readBackError = '远端读回无 schemaValue(疑似静默空返回)'
|
|
286
|
+
} else {
|
|
287
|
+
const localFp = createHash('md5').update(stableStringify(schema)).digest('hex')
|
|
288
|
+
const remoteFp = createHash('md5').update(stableStringify(remoteSchema)).digest('hex')
|
|
289
|
+
verified.fingerprintMatched = localFp === remoteFp
|
|
290
|
+
if (!verified.fingerprintMatched) verified.diff = schemaDiff(schema, remoteSchema)
|
|
291
|
+
}
|
|
292
|
+
verified.ok = verified.cardIdMatched && verified.fingerprintMatched
|
|
293
|
+
} catch (e) {
|
|
294
|
+
verified.readBackError = String(e && e.message)
|
|
295
|
+
verified.ok = false
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return { response, verified }
|
|
299
|
+
} finally {
|
|
300
|
+
try { await browser.disconnect() } catch { /* ignore */ }
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 远端预览:复刻 ubomp-h5-sub PreviewPopover.vue 的 onPopoverShow 全链路。
|
|
306
|
+
* 1) prepreview:先把当前编辑的卡片保存到远端(IDC10010.do,对齐 ubomp 的 prepreview 钩子)
|
|
307
|
+
* 2) queryCardDetail:读卡片详情取 code(IDC10015.do)
|
|
308
|
+
* 3) submitForm:发布预览测试版(IDC10020.do)
|
|
309
|
+
* 4) doNotify:消息通知已连接的真机(IDC10021.do)
|
|
310
|
+
* 前端据返回的 cardCode 生成 scheme 二维码:
|
|
311
|
+
* app://previewCard?cardId=<id>&cardCode=<code>&cardVersion=00000000&socket=<wss>
|
|
312
|
+
*
|
|
313
|
+
* 会话由 ensureFinMallSession 自动保证;与「保存到远端」共用同一套已登录后台 Chrome。
|
|
314
|
+
* 注意:saveCardToRemote 内部会自行 ensure+disconnect,故本函数先调用它(自带会话),
|
|
315
|
+
* 之后再重新 ensure 一次拿干净会话处理后续三个接口,避免复用已被 disconnect 的 page。
|
|
316
|
+
*
|
|
317
|
+
* @param {object} schema 当前卡片 schema(需含 pageId)
|
|
318
|
+
* @param {{skeletonScreen?:string, publish?:boolean, notify?:boolean}} [opts]
|
|
319
|
+
* @returns {Promise<{cardCode:string, version:string, steps:object}>}
|
|
320
|
+
*/
|
|
321
|
+
export async function previewCardRemote(schema, { skeletonScreen = '', publish = true, notify = true } = {}) {
|
|
322
|
+
if (!schema || !schema.pageId) {
|
|
323
|
+
throw new Error('schema 缺少 pageId,无法确定要预览的卡片(IDC10020.do 需要 cardId)。')
|
|
324
|
+
}
|
|
325
|
+
const appId = finmallConfig.appId
|
|
326
|
+
const workspaceId = finmallConfig.workspaceId
|
|
327
|
+
const cardId = String(schema.pageId)
|
|
328
|
+
const result = { cardCode: '', version: '00000000', steps: {} }
|
|
329
|
+
|
|
330
|
+
// 1) 先保存当前卡片(对齐 ubomp 的 prepreview 钩子:预览前先把编辑器内容落库)。
|
|
331
|
+
// saveCardToRemote 自行管理会话、末尾 disconnect,失败不阻断后续预览流程。
|
|
332
|
+
try {
|
|
333
|
+
await saveCardToRemote(schema, { skeletonScreen, verify: false })
|
|
334
|
+
result.steps.saved = true
|
|
335
|
+
} catch (e) {
|
|
336
|
+
result.steps.saved = false
|
|
337
|
+
result.savedError = String(e && e.message)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 后续三个接口使用一次干净的会话
|
|
341
|
+
const session = await ensureFinMallSession()
|
|
342
|
+
const browser = session.browser
|
|
343
|
+
try {
|
|
344
|
+
const context = browser.contexts()[0]
|
|
345
|
+
const page = context?.pages().find((p) => p.url().includes('finmall.com')) || context?.pages()[0]
|
|
346
|
+
if (!page) throw new Error('未能在后台 Chrome 中找到 FinMall 页面,请先运行 finmall-session-navigator 登录。')
|
|
347
|
+
await page.bringToFront()
|
|
348
|
+
|
|
349
|
+
// 2) 取卡片 code(对齐 ubomp queryCardDetail → IDC10015.do 的 code 字段)
|
|
350
|
+
try {
|
|
351
|
+
const detail = await finmallPostWithHeal(
|
|
352
|
+
page,
|
|
353
|
+
`/ubomp-web/IDC10015.do?sassAppId=${appId}&sassWorkspaceId=${workspaceId}`,
|
|
354
|
+
{ cardId, sassAppId: appId, sassWorkspaceId: workspaceId },
|
|
355
|
+
'IDC10015.do'
|
|
356
|
+
)
|
|
357
|
+
result.cardCode = (detail && detail.code) || ''
|
|
358
|
+
} catch (e) {
|
|
359
|
+
result.codeError = String(e && e.message)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 3) 发布预览测试版(对齐 ubomp submitForm → IDC10020.do)
|
|
363
|
+
if (publish) {
|
|
364
|
+
try {
|
|
365
|
+
await finmallPostWithHeal(
|
|
366
|
+
page,
|
|
367
|
+
`/ubomp-web/IDC10020.do?sassAppId=${appId}&sassWorkspaceId=${workspaceId}`,
|
|
368
|
+
{
|
|
369
|
+
cardId,
|
|
370
|
+
version: '00000000',
|
|
371
|
+
platformStatus: JSON.stringify(['iOS', 'Android', 'HarmonyOS']),
|
|
372
|
+
sassAppId: appId,
|
|
373
|
+
sassWorkspaceId: workspaceId,
|
|
374
|
+
},
|
|
375
|
+
'IDC10020.do'
|
|
376
|
+
)
|
|
377
|
+
result.steps.published = true
|
|
378
|
+
} catch (e) {
|
|
379
|
+
result.steps.published = false
|
|
380
|
+
result.publishError = String(e && e.message)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// 4) 消息通知(对齐 ubomp doNotify → IDC10021.do)
|
|
385
|
+
if (notify) {
|
|
386
|
+
try {
|
|
387
|
+
await finmallPostWithHeal(
|
|
388
|
+
page,
|
|
389
|
+
`/ubomp-web/IDC10021.do?sassAppId=${appId}&sassWorkspaceId=${workspaceId}`,
|
|
390
|
+
{ agentId: cardId, sassAppId: appId, sassWorkspaceId: workspaceId },
|
|
391
|
+
'IDC10021.do'
|
|
392
|
+
)
|
|
393
|
+
result.steps.notified = true
|
|
394
|
+
} catch (e) {
|
|
395
|
+
result.steps.notified = false
|
|
396
|
+
result.notifyError = String(e && e.message)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return result
|
|
401
|
+
} finally {
|
|
402
|
+
try { await browser.disconnect() } catch { /* ignore */ }
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// —— 保存落库校验辅助 ——
|
|
407
|
+
|
|
408
|
+
/** 稳定序列化:对象键排序、带 id/key/className/name 的数组元素按该键排序,避免服务端字段重排导致指纹误判。 */
|
|
409
|
+
function stableStringify(v) {
|
|
410
|
+
if (Array.isArray(v)) {
|
|
411
|
+
const arr = v.map(stableStringify)
|
|
412
|
+
if (v.length && v[0] && typeof v[0] === 'object') {
|
|
413
|
+
const keyOf = (o) => (o.id != null ? o.id : o.key != null ? o.key : o.className != null ? o.className : o.name != null ? o.name : null)
|
|
414
|
+
if (keyOf(v[0]) != null) arr.sort()
|
|
415
|
+
}
|
|
416
|
+
return '[' + arr.join(',') + ']'
|
|
417
|
+
}
|
|
418
|
+
if (v && typeof v === 'object') {
|
|
419
|
+
const keys = Object.keys(v).sort()
|
|
420
|
+
return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(v[k])).join(',') + '}'
|
|
421
|
+
}
|
|
422
|
+
return JSON.stringify(v)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** 把本地 schema 与远端读回的 schema 做关键字段 diff(顶层标量 + inputParams.defaultValue + view 指纹)。 */
|
|
426
|
+
function schemaDiff(local, remote) {
|
|
427
|
+
const out = []
|
|
428
|
+
for (const k of ['version', 'pageId', 'name', 'desc', 'platform']) {
|
|
429
|
+
if (String(local[k] ?? '') !== String(remote[k] ?? '')) {
|
|
430
|
+
out.push(`顶层.${k}: 本地=${JSON.stringify(local[k])} 远端=${JSON.stringify(remote[k])}`)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const lp = (local.dictionary && local.dictionary.inputParams) || []
|
|
434
|
+
const rp = (remote.dictionary && remote.dictionary.inputParams) || []
|
|
435
|
+
const rmap = {}
|
|
436
|
+
for (const p of rp) rmap[p.key || p.name] = p
|
|
437
|
+
for (const p of lp) {
|
|
438
|
+
const key = p.key || p.name
|
|
439
|
+
const r = rmap[key]
|
|
440
|
+
if (!r) { out.push(`inputParams 远端缺失项: ${key}`); continue }
|
|
441
|
+
if (String(p.defaultValue ?? '') !== String(r.defaultValue ?? '')) {
|
|
442
|
+
out.push(`inputParams[${key}].defaultValue: 本地=${JSON.stringify(p.defaultValue)} 远端=${JSON.stringify(r.defaultValue)}`)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const lv = createHash('md5').update(stableStringify(local.view)).digest('hex')
|
|
446
|
+
const rv = createHash('md5').update(stableStringify(remote.view)).digest('hex')
|
|
447
|
+
if (lv !== rv) out.push('view 结构不一致(指纹不同)')
|
|
448
|
+
return out
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
function parseArgs(argv) {
|
|
453
|
+
const parsed = {};
|
|
454
|
+
for (let i = 0; i < argv.length; i++) {
|
|
455
|
+
const arg = argv[i];
|
|
456
|
+
if (!arg.startsWith('--')) continue;
|
|
457
|
+
const eq = arg.indexOf('=');
|
|
458
|
+
if (eq >= 0) parsed[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
459
|
+
else parsed[arg.slice(2)] = argv[i + 1]?.startsWith('--') ? true : argv[++i] ?? true;
|
|
460
|
+
}
|
|
461
|
+
return parsed;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function fetchCardList(page, { pageNo, pageSize, appId, workspaceId, searchValue = '' }) {
|
|
465
|
+
const body = await finmallPostWithHeal(page, `/ubomp-web/IDC10001.do?sassAppId=${appId}&sassWorkspaceId=${workspaceId}`, {
|
|
466
|
+
channel: '',
|
|
467
|
+
applyScene: '1',
|
|
468
|
+
pageNo,
|
|
469
|
+
pageSize,
|
|
470
|
+
searchValue,
|
|
471
|
+
cardClassId: '',
|
|
472
|
+
sassAppId: appId,
|
|
473
|
+
sassWorkspaceId: workspaceId,
|
|
474
|
+
});
|
|
475
|
+
return body;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 拉取远端 schema。返回 `null` 表示「空白卡」——该卡片在 FinMall 侧从未保存过
|
|
480
|
+
* schema(新建后未编辑),IDC10012.do 会返回 errorCode=0 但 body 无 schemaValue。
|
|
481
|
+
* 这是合法状态而非主键错误,调用方须走「空白卡落盘」分支,不要当成同步失败。
|
|
482
|
+
*/
|
|
483
|
+
async function fetchCardSchema(page, card, { appId, workspaceId }) {
|
|
484
|
+
// IDC10012.do 只接受数字型 `cardId` 作为主键(实测:传 cardCode 会返回
|
|
485
|
+
// errorCode=0「交易成功」,但 body 里根本没有 schemaValue —— 是静默空返回,
|
|
486
|
+
// 极易被误判成抓取成功)。cardId 由 IDC10001.do 列表逐条给出,页面卡与其
|
|
487
|
+
// dialog 变体的 cardId 本就不同(如 activityLevel=10000840 / activityLevelDialog=10000841),
|
|
488
|
+
// 因此按 cardId 抓取不会串卡;此前的串卡来自已废弃的编辑器 DOM 抓取路径。
|
|
489
|
+
if (!card.cardId) throw new Error(`列表未返回 cardId,无法抓取 ${card.id} 的 schema。`);
|
|
490
|
+
const body = await finmallPostWithHeal(page, '/ubomp-web/IDC10012.do', {
|
|
491
|
+
cardId: card.cardId,
|
|
492
|
+
sassAppId: appId,
|
|
493
|
+
sassWorkspaceId: workspaceId,
|
|
494
|
+
}, 'IDC10012.do');
|
|
495
|
+
// 响应体整体缺失(而非缺 schemaValue)时同样按空白卡处理:绝不把整批同步拖成失败。
|
|
496
|
+
if (!body || typeof body !== 'object') {
|
|
497
|
+
log.warn('远端响应体为空,按空白卡处理', { id: card.id, cardId: card.cardId });
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
// 防串卡:服务端会回显 cardId,与请求不一致说明拿到了别的卡片,宁可失败也不写盘。
|
|
501
|
+
if (body.cardId && String(body.cardId) !== String(card.cardId)) {
|
|
502
|
+
throw new Error(`${card.id} 响应 cardId=${body.cardId} 与请求 cardId=${card.cardId} 不一致,已中止以防串卡。`);
|
|
503
|
+
}
|
|
504
|
+
if (!body.schemaValue) {
|
|
505
|
+
log.warn('远端无 schemaValue,判定为空白卡', { id: card.id, cardId: card.cardId });
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
return JSON.parse(body.schemaValue);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* 取 schema(已在 finmallPostWithHeal 内带会话/权限失效自愈)。
|
|
513
|
+
* IDC10012.do 只接受数字型 `cardId` 作为主键,命中 SESSION_INVALID_RE 特征时
|
|
514
|
+
* finmallPostWithHeal 会自动清 session 重登再试一次,覆盖会话超时与权限态异常。
|
|
515
|
+
*/
|
|
516
|
+
|
|
517
|
+
/** 清空过期会话并以保存的凭据重新登录(复用导航器的登录逻辑,全程在页面上下文内完成)。 */
|
|
518
|
+
async function relogin(page) {
|
|
519
|
+
await clearTimeoutPrompts(page);
|
|
520
|
+
await page
|
|
521
|
+
.evaluate(() => {
|
|
522
|
+
try {
|
|
523
|
+
localStorage.clear();
|
|
524
|
+
} catch {
|
|
525
|
+
/* ignore */
|
|
526
|
+
}
|
|
527
|
+
})
|
|
528
|
+
.catch(() => {});
|
|
529
|
+
await page.reload({ waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => {});
|
|
530
|
+
await sleep(1500);
|
|
531
|
+
const credentials = await loadCredentials();
|
|
532
|
+
const result = await performLogin(page, credentials);
|
|
533
|
+
if (!result.sessionCreated) {
|
|
534
|
+
throw new Error(
|
|
535
|
+
"自动重新登录失败:" +
|
|
536
|
+
(result.businessFailure ? "登录接口返回业务失败" : `HTTP ${result?.status || "失败"}`),
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
await page.reload({ waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => {});
|
|
540
|
+
await sleep(1500);
|
|
541
|
+
await clearTimeoutPrompts(page);
|
|
542
|
+
await waitForAuthenticatedPage(page, 20000);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function finmallPost(page, urlPath, requestBody, transCode = urlPath) {
|
|
546
|
+
return withTimeout(
|
|
547
|
+
page.evaluate(async ({ urlPath, requestBody, transCode, timeoutMs }) => {
|
|
548
|
+
const controller = new AbortController();
|
|
549
|
+
setTimeout(() => controller.abort(), timeoutMs);
|
|
550
|
+
// FinMall 不用 Cookie 鉴权(document.cookie 恒为空),会话凭据存在 localStorage
|
|
551
|
+
// 的 `_sessionId` 中,由前端以 `token` 请求头透传。缺这个头时 IDC10001.do(不鉴权)
|
|
552
|
+
// 仍能返回,但 IDC10012.do 会返回 BLEC0006「会话超时」——这正是此前抓取失败的根因。
|
|
553
|
+
// 该值仅在页面上下文内读取并直接用于请求,不回传 Node 侧、不打印、不落盘。
|
|
554
|
+
const readSessionToken = () => {
|
|
555
|
+
try {
|
|
556
|
+
const raw = localStorage.getItem('_sessionId');
|
|
557
|
+
if (!raw) return '';
|
|
558
|
+
let value = JSON.parse(raw);
|
|
559
|
+
if (value && typeof value === 'object' && 'value' in value) value = value.value;
|
|
560
|
+
if (typeof value === 'string' && value.startsWith('"')) {
|
|
561
|
+
try { value = JSON.parse(value); } catch { /* 保留原字符串 */ }
|
|
562
|
+
}
|
|
563
|
+
return typeof value === 'string' ? value : '';
|
|
564
|
+
} catch {
|
|
565
|
+
return '';
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
const sessionToken = readSessionToken();
|
|
569
|
+
if (!sessionToken) {
|
|
570
|
+
throw new Error('页面缺少会话凭据(localStorage._sessionId 为空),请先运行 finmall-session-navigator 登录。');
|
|
571
|
+
}
|
|
572
|
+
const url = new URL(urlPath, location.origin).href;
|
|
573
|
+
const payload = {
|
|
574
|
+
header: {
|
|
575
|
+
version: '1.0.0',
|
|
576
|
+
commType: '',
|
|
577
|
+
transCode,
|
|
578
|
+
srcIP: '',
|
|
579
|
+
channelType: '',
|
|
580
|
+
channelNo: '',
|
|
581
|
+
srcSystemId: '',
|
|
582
|
+
srcSystemDevId: '',
|
|
583
|
+
charset: 'json',
|
|
584
|
+
respFormat: 'UTF-8',
|
|
585
|
+
reqNo: '',
|
|
586
|
+
securityFlag: '',
|
|
587
|
+
macCode: '',
|
|
588
|
+
macValue: '',
|
|
589
|
+
requestTime: Date.now(),
|
|
590
|
+
transTime: Date.now(),
|
|
591
|
+
globalFlowNo: '',
|
|
592
|
+
},
|
|
593
|
+
body: requestBody,
|
|
594
|
+
};
|
|
595
|
+
const response = await fetch(url, {
|
|
596
|
+
method: 'POST',
|
|
597
|
+
headers: { 'content-type': 'application/json;charset=UTF-8', token: sessionToken },
|
|
598
|
+
credentials: 'include',
|
|
599
|
+
signal: controller.signal,
|
|
600
|
+
body: JSON.stringify(payload),
|
|
601
|
+
});
|
|
602
|
+
const text = await response.text();
|
|
603
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${text.slice(0, 200)}`);
|
|
604
|
+
const parsed = JSON.parse(text);
|
|
605
|
+
const errorCode = parsed.header?.errorCode || parsed.body?.errorCode;
|
|
606
|
+
if (errorCode && errorCode !== '0') {
|
|
607
|
+
// 把 errorCode 与响应体片段一并抛出,便于排查(如 cardId 不存在 / 无权限 / 入参结构不符)。
|
|
608
|
+
const detail = JSON.stringify(parsed).slice(0, 600);
|
|
609
|
+
throw new Error(`FinMall 交易失败 [errorCode=${errorCode}] ${parsed.header?.errorMsg || parsed.body?.errorMsg || ''} | ${detail}`);
|
|
610
|
+
}
|
|
611
|
+
return parsed.body;
|
|
612
|
+
}, { urlPath, requestBody, transCode, timeoutMs: FINMALL_POST_TIMEOUT_MS }),
|
|
613
|
+
FINMALL_POST_TIMEOUT_MS + 2000,
|
|
614
|
+
`FinMall 请求 ${transCode}`
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function normalizeCard(raw) {
|
|
619
|
+
const id = raw.code;
|
|
620
|
+
if (!id || !/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`Unsafe card code: ${id}`);
|
|
621
|
+
return {
|
|
622
|
+
id,
|
|
623
|
+
title: raw.cardName || '',
|
|
624
|
+
cardId: raw.cardId,
|
|
625
|
+
coverUrl: filestoreToUrl(raw.picUrl),
|
|
626
|
+
raw,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function filestoreToUrl(value) {
|
|
631
|
+
return finmallConfig.filestoreUrl(value);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function syncCard(page, card, { outRoot, appId, workspaceId, summary, schemaHashes }) {
|
|
635
|
+
const cardDir = path.join(outRoot, card.id);
|
|
636
|
+
await mkdir(cardDir, { recursive: true });
|
|
637
|
+
const schema = await fetchCardSchema(page, card, { appId, workspaceId });
|
|
638
|
+
// 空白卡(远端从未保存过 schema):合法状态,走专门的落盘分支,不再整批失败。
|
|
639
|
+
if (!schema) {
|
|
640
|
+
return syncBlankCard(page, card, { cardDir, appId, workspaceId });
|
|
641
|
+
}
|
|
642
|
+
// 推送前一致性闸门(基于实测不变量:IDC10001 列表 cardId ≡ schema 内 pageId)。
|
|
643
|
+
// 若二者不等,说明抓取的 schema 与该实例错位(串卡 / 模板错挂),落盘后推送会按错 pageId 打错卡。
|
|
644
|
+
// 直接抛错由调用方记入 failed 并中止本卡落盘,避免错位 pageId 进入本地与远端。
|
|
645
|
+
if (card.cardId != null && String(schema.pageId) !== String(card.cardId)) {
|
|
646
|
+
throw new Error(`${card.id} pageId 错位:列表 cardId=${card.cardId} ≠ schema.pageId=${schema.pageId},疑似串卡,已拒绝落盘`);
|
|
647
|
+
}
|
|
648
|
+
const coverPath = await saveCover(page, card, cardDir);
|
|
649
|
+
// 注意:schema 内部的 pageId 一律以「远端拉取」的为准,原样落盘,不做覆盖。
|
|
650
|
+
// 远端返回的 pageId 是 FinMall 编辑器维护的权威页面/模板 ID,落盘后随 schema 一并保留,
|
|
651
|
+
// 供保存(IDC10010)/预览(IDC10015/10020/10021)链路按此主键打到线上。
|
|
652
|
+
// 重复 schema 检测:若不同 code 抓到了字节完全相同的 schema,说明仍串卡,
|
|
653
|
+
// 记录到 duplicates 并把整体结果标为失败,便于重抓时立即发现而非事后排查。
|
|
654
|
+
const schemaHash = createHash('md5').update(JSON.stringify(schema)).digest('hex');
|
|
655
|
+
const dupOf = schemaHashes.get(schemaHash);
|
|
656
|
+
if (dupOf && dupOf !== card.id) {
|
|
657
|
+
summary.duplicates.push({ id: card.id, dupOf, hash: schemaHash.slice(0, 8) });
|
|
658
|
+
summary.success = false;
|
|
659
|
+
log.warn('检测到重复 schema(疑似串卡)', { id: card.id, dupOf, hash: schemaHash.slice(0, 8) });
|
|
660
|
+
}
|
|
661
|
+
schemaHashes.set(schemaHash, card.id);
|
|
662
|
+
const schemaPath = path.join(cardDir, 'schema.json');
|
|
663
|
+
await writeFile(schemaPath, JSON.stringify(schema, null, 2), 'utf8');
|
|
664
|
+
const dataPath = path.join(cardDir, 'data.json');
|
|
665
|
+
await writeFile(dataPath, JSON.stringify(buildDataFromSchema(schema), null, 2), 'utf8');
|
|
666
|
+
const [cover, schemaInfo, data] = await Promise.all([
|
|
667
|
+
stat(coverPath), stat(schemaPath), stat(dataPath),
|
|
668
|
+
]);
|
|
669
|
+
await writeImportSidecar(cardDir, {
|
|
670
|
+
appId,
|
|
671
|
+
workspaceId,
|
|
672
|
+
card,
|
|
673
|
+
files: { schema: schemaInfo.size, data: data.size, cover: cover.size },
|
|
674
|
+
});
|
|
675
|
+
return { cover: cover.size, schema: schemaInfo.size, data: data.size };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* 空白卡落盘:远端从未保存过 schema(IDC10012 无 schemaValue),但卡片实例真实存在。
|
|
680
|
+
* 规则(用户确认):
|
|
681
|
+
* 1) 本地已有同名卡片 → 以本地 schema.json / data.json 为准,远端空白绝不覆盖;
|
|
682
|
+
* 2) 本地已有同名预览图 → 跳过下载;否则照常同步;
|
|
683
|
+
* 3) pageId 必须与远端列表返回的 cardId 一致(保存 IDC10010 / 预览 10015·10020·10021
|
|
684
|
+
* 全链路按这个数字主键定位线上卡片,错位会打到别的卡上)。
|
|
685
|
+
* 本地无任何卡片时,落一份最小合法空白 schema,保证目录可被编辑器/预览正常加载。
|
|
686
|
+
*/
|
|
687
|
+
async function syncBlankCard(page, card, { cardDir, appId, workspaceId }) {
|
|
688
|
+
const schemaPath = path.join(cardDir, 'schema.json');
|
|
689
|
+
const dataPath = path.join(cardDir, 'data.json');
|
|
690
|
+
const localSchema = await readJsonIfValid(schemaPath);
|
|
691
|
+
const localData = await readJsonIfValid(dataPath);
|
|
692
|
+
const keptLocal = !!localSchema;
|
|
693
|
+
|
|
694
|
+
// 本地有实体卡片:保留本地内容;否则建最小空白骨架。
|
|
695
|
+
const schema = localSchema || buildBlankSchema(card);
|
|
696
|
+
const data = localData || (keptLocal ? buildDataFromSchema(schema) : {});
|
|
697
|
+
|
|
698
|
+
// pageId 对齐远端 cardId:只改这一个字段,其余本地内容原样保留。
|
|
699
|
+
const pageIdRewritten = String(schema.pageId ?? '') !== String(card.cardId);
|
|
700
|
+
if (pageIdRewritten) schema.pageId = String(card.cardId);
|
|
701
|
+
|
|
702
|
+
if (!keptLocal || pageIdRewritten) {
|
|
703
|
+
await writeFile(schemaPath, JSON.stringify(schema, null, 2), 'utf8');
|
|
704
|
+
}
|
|
705
|
+
if (!localData) {
|
|
706
|
+
await writeFile(dataPath, JSON.stringify(data, null, 2), 'utf8');
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// 预览图:本地已有同名图则跳过,否则下载(无可用图源时静默跳过,不算失败)。
|
|
710
|
+
let coverPath = await findExistingCover(cardDir, card.id);
|
|
711
|
+
let coverSkipped = !!coverPath;
|
|
712
|
+
let coverError = '';
|
|
713
|
+
if (!coverPath && card.coverUrl) {
|
|
714
|
+
try {
|
|
715
|
+
coverPath = await saveCover(page, card, cardDir);
|
|
716
|
+
} catch (e) {
|
|
717
|
+
coverError = String(e && e.message);
|
|
718
|
+
log.warn('空白卡预览图同步失败(不影响卡片落盘)', { id: card.id, error: coverError });
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const [schemaInfo, dataInfo] = await Promise.all([stat(schemaPath), stat(dataPath)]);
|
|
723
|
+
const coverInfo = coverPath ? await stat(coverPath).catch(() => null) : null;
|
|
724
|
+
await writeImportSidecar(cardDir, {
|
|
725
|
+
appId,
|
|
726
|
+
workspaceId,
|
|
727
|
+
card,
|
|
728
|
+
files: { schema: schemaInfo.size, data: dataInfo.size, cover: coverInfo ? coverInfo.size : 0 },
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
return {
|
|
732
|
+
cover: coverInfo ? coverInfo.size : 0,
|
|
733
|
+
schema: schemaInfo.size,
|
|
734
|
+
data: dataInfo.size,
|
|
735
|
+
blank: true,
|
|
736
|
+
keptLocal,
|
|
737
|
+
pageIdRewritten,
|
|
738
|
+
coverSkipped,
|
|
739
|
+
coverError,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** 最小合法空白 schema:满足 validate_schema.py 的顶层必填(version/pageId/name/dictionary/view)。 */
|
|
744
|
+
function buildBlankSchema(card) {
|
|
745
|
+
return {
|
|
746
|
+
version: '1.0.0',
|
|
747
|
+
pageId: String(card.cardId),
|
|
748
|
+
name: card.title || card.id,
|
|
749
|
+
desc: '',
|
|
750
|
+
platform: 'mobile',
|
|
751
|
+
dictionary: { dictionaryList: [], inputParams: [], mocks: [] },
|
|
752
|
+
view: {
|
|
753
|
+
width: '375',
|
|
754
|
+
className: 'Root',
|
|
755
|
+
style: { properties: [] },
|
|
756
|
+
properties: [],
|
|
757
|
+
eventactions: [],
|
|
758
|
+
components: [],
|
|
759
|
+
},
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** 读本地 JSON;文件不存在或解析失败返回 null(不抛,交给调用方决定降级策略)。 */
|
|
764
|
+
async function readJsonIfValid(filePath) {
|
|
765
|
+
try {
|
|
766
|
+
return JSON.parse(await readFile(filePath, 'utf8'));
|
|
767
|
+
} catch {
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
|
773
|
+
|
|
774
|
+
/** 找本地已有的同名预览图(<cardId>.<ext>);没有返回 null。 */
|
|
775
|
+
async function findExistingCover(cardDir, id) {
|
|
776
|
+
let entries = [];
|
|
777
|
+
try {
|
|
778
|
+
entries = await readdir(cardDir);
|
|
779
|
+
} catch {
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
for (const name of entries) {
|
|
783
|
+
const ext = path.extname(name).toLowerCase();
|
|
784
|
+
if (IMAGE_EXTS.has(ext) && path.basename(name, ext) === id) return path.join(cardDir, name);
|
|
785
|
+
}
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* 导入来源可追溯(Phase E):把这张卡从哪来 / 何时导入 / 落盘在哪写进侧车 .import-meta.json。
|
|
791
|
+
* 侧车独立于 schema.json/data.json,绝不污染 DSL 产品数据;找不到 server 模块时静默跳过。
|
|
792
|
+
* 空白卡同样写侧车——pageId 已对齐远端 cardId,属于合法的远端来源卡,允许后续推送。
|
|
793
|
+
*/
|
|
794
|
+
async function writeImportSidecar(cardDir, { appId, workspaceId, card, files }) {
|
|
795
|
+
try {
|
|
796
|
+
const metaPath = path.resolve(workspace, 'server', 'core', 'cardImportMeta.js');
|
|
797
|
+
const { writeImportMeta, buildImportMeta } = await import(pathToFileURL(metaPath).href);
|
|
798
|
+
const meta = buildImportMeta({
|
|
799
|
+
baseUrl: finmallConfig.baseUrl,
|
|
800
|
+
appId,
|
|
801
|
+
workspaceId,
|
|
802
|
+
cardId: card.cardId,
|
|
803
|
+
cardCode: card.id,
|
|
804
|
+
cardName: card.title,
|
|
805
|
+
files,
|
|
806
|
+
});
|
|
807
|
+
writeImportMeta(cardDir, meta);
|
|
808
|
+
} catch (e) {
|
|
809
|
+
log.warn('写导入元数据侧车失败(不影响卡片落盘)', { error: e && e.message });
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function saveCover(page, card, cardDir) {
|
|
814
|
+
if (!card.coverUrl) throw new Error(`No cover url for ${card.id}.`);
|
|
815
|
+
const ext = imageExt(card.coverUrl);
|
|
816
|
+
const coverPath = path.join(cardDir, `${card.id}${ext}`);
|
|
817
|
+
const base64 = await withTimeout(
|
|
818
|
+
page.evaluate(async ({ src, timeoutMs }) => {
|
|
819
|
+
const controller = new AbortController();
|
|
820
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
821
|
+
try {
|
|
822
|
+
const response = await fetch(src, { credentials: 'include', signal: controller.signal });
|
|
823
|
+
if (!response.ok) throw new Error(`cover fetch failed ${response.status}`);
|
|
824
|
+
const buffer = await response.arrayBuffer();
|
|
825
|
+
let binary = '';
|
|
826
|
+
const bytes = new Uint8Array(buffer);
|
|
827
|
+
for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
828
|
+
return btoa(binary);
|
|
829
|
+
} finally {
|
|
830
|
+
clearTimeout(timer);
|
|
831
|
+
}
|
|
832
|
+
}, { src: card.coverUrl, timeoutMs: COVER_FETCH_TIMEOUT_MS }),
|
|
833
|
+
COVER_FETCH_TIMEOUT_MS + 2000,
|
|
834
|
+
'预览图下载'
|
|
835
|
+
);
|
|
836
|
+
await writeFile(coverPath, Buffer.from(base64, 'base64'));
|
|
837
|
+
return coverPath;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function imageExt(url) {
|
|
841
|
+
try {
|
|
842
|
+
const ext = path.extname(decodeURIComponent(new URL(url).pathname)).toLowerCase();
|
|
843
|
+
return ext && ext.length <= 8 ? ext : '.png';
|
|
844
|
+
} catch {
|
|
845
|
+
return '.png';
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function buildDataFromSchema(schema) {
|
|
850
|
+
const data = {};
|
|
851
|
+
for (const param of schema.dictionary?.inputParams || []) {
|
|
852
|
+
const fallback = parseDescFallback(param);
|
|
853
|
+
const defaultValue = param.defaultValue;
|
|
854
|
+
const emptyObject = defaultValue && typeof defaultValue === 'object' && !Array.isArray(defaultValue) && Object.keys(defaultValue).length === 0;
|
|
855
|
+
data[param.key] = clone(fallback !== undefined && (defaultValue === undefined || defaultValue === '' || emptyObject) ? fallback : defaultValue);
|
|
856
|
+
}
|
|
857
|
+
return data;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function parseDescFallback(param) {
|
|
861
|
+
if (!param.desc || typeof param.desc !== 'string') return undefined;
|
|
862
|
+
const text = param.desc.trim();
|
|
863
|
+
if (!text.startsWith('{')) return undefined;
|
|
864
|
+
try {
|
|
865
|
+
const parsed = JSON.parse(text);
|
|
866
|
+
return Object.prototype.hasOwnProperty.call(parsed, param.key) ? parsed[param.key] : undefined;
|
|
867
|
+
} catch {
|
|
868
|
+
return undefined;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function clone(value) {
|
|
873
|
+
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// 仅当作为脚本直接运行时执行 CLI 逻辑(被 Vite 中间件 import 时不触发)
|
|
877
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
878
|
+
if (isMain) {
|
|
879
|
+
const args = parseArgs(process.argv.slice(2));
|
|
880
|
+
const summary = await syncCards(args);
|
|
881
|
+
const blanks = summary.synced.filter((s) => s.blank);
|
|
882
|
+
log.box({
|
|
883
|
+
title: '同步结果',
|
|
884
|
+
level: summary.success ? 'ok' : 'warn',
|
|
885
|
+
lines: [
|
|
886
|
+
`请求:${args.cardCode || '(全部)'}`,
|
|
887
|
+
`已同步:${summary.synced.length} 张`,
|
|
888
|
+
`失败:${summary.failed.length} 张`,
|
|
889
|
+
`空白卡片:${blanks.length} 张`,
|
|
890
|
+
`重复 schema:${summary.duplicates.length} 项`,
|
|
891
|
+
...(blanks.length
|
|
892
|
+
? [`空白明细:${blanks.map((b) => `${b.id}${b.keptLocal ? '(保留本地)' : '(新建空白)'}`).join('、')}`]
|
|
893
|
+
: []),
|
|
894
|
+
...(summary.failed.length
|
|
895
|
+
? [`失败明细:${summary.failed.map((f) => f.id || '(unknown)').join('、')}`]
|
|
896
|
+
: []),
|
|
897
|
+
],
|
|
898
|
+
});
|
|
899
|
+
if (summary.failed.length) process.exitCode = 1;
|
|
900
|
+
}
|