cardbuddy 0.26.0 → 0.26.2

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.
@@ -8,8 +8,8 @@
8
8
  <link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16-Cg6Nrosk.png" />
9
9
  <link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32-B_1744sp.png" />
10
10
  <link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon-BQcES9QK.png" />
11
- <script type="module" crossorigin src="/assets/index-Bn5gP68-.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-CeoWrec1.css">
11
+ <script type="module" crossorigin src="/assets/index-DD2MA6i_.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-BGqgV1Dw.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="app"></div>
@@ -71,11 +71,8 @@ function logSyncSummary(summary, requestLabel) {
71
71
  for (const s of summary.synced) {
72
72
  const f = s.files || {}
73
73
  // 空白卡(远端无 schemaValue):标注落盘策略,避免误读成「抓到了完整 schema」。
74
- const tag = s.blank
75
- ? s.keptLocal
76
- ? ' [空白卡·保留本地]'
77
- : ' [空白卡·新建空白 schema]'
78
- : ''
74
+ // 全量覆盖语义下本地旧内容一律丢弃,不再有「保留本地」分支。
75
+ const tag = s.blank ? ' [空白卡·按远端重建]' : ''
79
76
  lines.push(
80
77
  `• ${s.id}${s.title ? `(${s.title})` : ''}${tag} schema=${fmtBytes(f.schema)} data=${fmtBytes(f.data)} cover=${fmtBytes(f.cover)}`
81
78
  )
@@ -45,7 +45,7 @@
45
45
  - **Input 回写只写 rt.state**:Input 的 value 绑定 $key 时,渲染层兜底 `rt.state[key] = v`(**只写 rt.state、绝不写 rt.rootScope**,故打字不触发"保存到本地"脏标记)。
46
46
  - **读写约定**:运行态变量(含用户输入/绑定派生值)一律用 this.state 读、写走 this.setData;this.data 仅用于读 Input 绑定键的实时输入值;持久卡片态存 this.state;任何应重渲染的改动后调 this.setData(...)。**调试交互绝不调 setRootData**——否则会污染"保存到本地"基线。
47
47
  - **事件代码必须 `var self = this` 起手(指针安全铁律)**:每个 customAction 的 `function.code` / `compiled` **第一行**写 `var self = this;`。**任何嵌套函数(addEventListener 回调、`Array.map/forEach/find` 回调、setTimeout、ynet.callJSAPI 回调等)里访问组件实例一律用 `self`,禁止用 `this`**——`this` 在回调里会被运行时重绑(指向全局 / 事件源 / undefined),直接 `this.setData` 会指向错误对象,导致静默失效或 `this is undefined` 报错。顶层(非嵌套)直接调用可用 `this`,但统一用 `self` 最稳、零歧义。**生成事件代码默认就按此写,不要等出错再改。** export-proven:holdFinancialProducts 根 `onReady` 写 `var self = this; ... this.addEventListener('globalSet', (e) => self.$set(e.data.key, e.data.value))`;recipes.md §4 / §7 同构。
48
- - **List/Grid item 事件改绑定数据必须 globalSet 透传(铁律)**:ListLayout/GridLayout 的 item(slotScope)事件里禁止直接改绑定数据(this.setData/setRootData/直接 state 赋值)——必须 `this.postMessage('globalSet', patch)`(patch 与 setData 入参逐字一致),并先在根节点 onReady `addEventListener('globalSet')` 注册监听、监听里用闭包 self 代 `setData(patch)`;其他节点事件可直接 setData。lint `r54` ERROR 拦截「item 直改」与「用 globalSet 但根无监听」。固定范式见 recipes.md §4 / recipe_snippets/globalSet.json。
48
+ - **List/Grid item 事件改绑定数据必须 globalSet 透传(铁律)**:ListLayout/GridLayout 的 item(slotScope)事件里禁止直接改绑定数据(this.setData/setRootData/直接 state 赋值)——必须 `this.postMessage('globalSet', patch)`(patch 与 setData 入参逐字一致),并先在根节点 onReady `addEventListener('globalSet')` 注册监听、监听里用闭包 self 代 `setData(patch)`;其他节点事件可直接 setData。lint `r54` ERROR 拦截「item 直改」与「用 globalSet 但根无监听」;判定覆盖 item 模板(`slotScope`)子树内的**所有节点**(不止 item 根),`that` / `self` / `_this` 别名与 `this` 等价计入。固定范式见 recipes.md §4 / recipe_snippets/globalSet.json。
49
49
  - **slotScope item 字段在事件脚本里直读(铁律 r55)**:slotScope:true 的 List/Grid item 事件脚本里,当前 item 字段**直接 `this.state.<field>` 读取**(如 `var q = this.state.query;`)——作用域展开是平台保证,**不是**需要兜底判断的不确定行为;禁止写 `if (x === undefined && this.state.$$)` 之类的「$$ 兜底」。事件脚本(customAction)里**禁止出现任何 `$$`**(含 `this.state.$$`、`$$.field`);`$$` 只在**表达式**里引用整个 item 时才允许(R50 另管 `$$.field` 冗余)。lint `r55-event-no-dollar-dollar` ERROR 拦截。
50
50
  - **调 JSAPI 直接裸调 ynet(铁律 r56)**:事件脚本里调 JSAPI 一律 `ynet.callJSAPI(name, params, cb)`——ynet 由平台注入为全局;**禁止 `window.ynet` 前缀**,也**禁止 `typeof window !== 'undefined' && window.ynet && window.ynet.callJSAPI` 之类存在性守卫**(把平台保证误当不确定行为的冗余错写;49 张导出卡全量裸调为证)。lint `r56-jsapi-no-window-guard` ERROR 拦截。
51
51
  - **生成代码必须严格带 `;`(语句结束符不省略)**:customAction 的 `function.code` / `compiled` 里**每条 JS 语句结尾都要写分号,禁止依赖 ASI(自动分号插入)省略**。`return` / 换行 / 对象字面量 / 数组字面量等场景省略分号会触发 ASI 陷阱(如 `return\n{...}` 被解析成 `return;` 直接返回 undefined);且导出卡(49+ 卡 `compiled` 全量带分号)风格不一致会让 `diff_card.mjs` 更难比对。**生成时默认每条语句结尾补 `;`,不要"省着写"。** export-proven:holdFinancialProducts / creditCardBill 等所有导出卡 `compiled` 均全量带分号。
@@ -101,11 +101,25 @@ Options:
101
101
  /* ------------------------------------------------------------------ */
102
102
  /* 节点遍历 */
103
103
  /* ------------------------------------------------------------------ */
104
- function walk(node, visitor, parent = null) {
104
+ /**
105
+ * slot.slotScope 判定:正常是布尔 true,但存量卡里有 2 处写成字符串 'true',一并兼容
106
+ */
107
+ function isSlotScope(slot) {
108
+ const v = (slot || {}).slotScope
109
+ return v === true || v === 'true'
110
+ }
111
+
112
+ /**
113
+ * @param {object} node
114
+ * @param {(node:object, parent:object|null, inSlotScope:boolean)=>void} visitor
115
+ * @param {object|null} parent 父节点(实参传递,不往 schema 上挂字段,避免污染 JSON.stringify)
116
+ * @param {boolean} inSlotScope 是否位于 ListLayout/GridLayout 的 item 模板(slot.slotScope === true)子树内
117
+ */
118
+ function walk(node, visitor, parent = null, inSlotScope = false) {
105
119
  if (!node || typeof node !== 'object') return
106
- visitor(node, parent)
107
- if (Array.isArray(node.components)) for (const c of node.components) walk(c, visitor, node)
108
- for (const s of node.slots || []) for (const c of s.components || []) walk(c, visitor, node)
120
+ visitor(node, parent, inSlotScope)
121
+ if (Array.isArray(node.components)) for (const c of node.components) walk(c, visitor, node, inSlotScope)
122
+ for (const s of node.slots || []) for (const c of s.components || []) walk(c, visitor, node, inSlotScope || isSlotScope(s))
109
123
  }
110
124
 
111
125
  /** 获取默认插槽直接子节点(兼容 Root.components 与普通节点 slots[0].components) */
@@ -1329,13 +1343,14 @@ export function lint(schema, data) {
1329
1343
  const f = fn.find((x) => x.itemKey === 'function') || {}
1330
1344
  return String((f.itemValue || {}).code || (f.itemValue || {}).compiled || '')
1331
1345
  }
1332
- const MUTATE_RE = /this\.(setData|setRootData)\(|this\.(state|data)\.[\w$.]+\s*=[^=]/
1333
- walk(view, (n, parent) => {
1334
- let scoped = false
1335
- for (let cur = n; cur; cur = cur.__lintParent) {
1336
- if (cur.__lintSlotScope) { scoped = true; break }
1337
- }
1338
- if (!scoped) return
1346
+ // 别名与 collectBindings 保持一致:脚本里常用 that/self/_this 承接 this(部分由低代码编译器生成)
1347
+ const ALIAS = '(?:this|self|that|_this)'
1348
+ const MUTATE_RE = new RegExp(
1349
+ `\\b${ALIAS}\\s*\\.\\s*(?:setData|setRootData)\\s*\\(`
1350
+ + `|\\b${ALIAS}\\s*\\.\\s*(?:state|data)(?:\\.[\\w$]+)+\\s*=[^=]`,
1351
+ )
1352
+ walk(view, (n, parent, inSlotScope) => {
1353
+ if (!inSlotScope) return
1339
1354
  if (n.className === 'ListLayout' || n.className === 'GridLayout') return
1340
1355
  const cid = n.componentId || '(root)'
1341
1356
  for (const ea of n.eventactions || []) {
@@ -1350,7 +1365,7 @@ export function lint(schema, data) {
1350
1365
  })
1351
1366
  }
1352
1367
  }
1353
- }, true)
1368
+ })
1354
1369
  // R54-b: 用了 globalSet 透传但根节点 onReady 没注册监听 → 消息石沉大海
1355
1370
  const allCode = []
1356
1371
  let rootOnReadyCode = ''
@@ -1,4 +1,4 @@
1
- import { mkdir, stat, writeFile, readFile, readdir } from 'node:fs/promises';
1
+ import { mkdir, stat, writeFile, readdir, rm } from 'node:fs/promises';
2
2
  import { createHash } from 'node:crypto';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -208,8 +208,9 @@ export async function syncCards(opts = {}) {
208
208
  const session = await ensurePlatformSession();
209
209
  const browser = session.browser;
210
210
  const summary = { success: true, synced: [], failed: [], duplicates: [] };
211
- // 重复 schema 检测:记录每张卡 schema 的 MD5,若不同 code 命中相同 schema 说明抓取仍串卡。
212
- const schemaHashes = new Map();
211
+ // duplicates 保留为空数组仅为兼容既有结果结构(前端 / CLI 都读这个字段)。
212
+ // 不再按「不同卡拿到字节相同的 schema」判串卡:同模板创建、内容清空、空白骨架
213
+ // 都会产生相同 schema,属合法状态;内容相似度推不出「抓错了卡」。
213
214
 
214
215
  try {
215
216
  const context = browser.contexts()[0];
@@ -247,12 +248,11 @@ export async function syncCards(opts = {}) {
247
248
  if (cardCodeSet && !cardCodeSet.has(card.id)) continue;
248
249
  log.info('同步中', { page: `${pageNo}/${totalPages}`, index, id: card.id, title: card.title });
249
250
  try {
250
- const files = await syncCard(page, card, { outRoot, appId, workspaceId, summary, schemaHashes, importMeta });
251
+ const files = await syncCard(page, card, { outRoot, appId, workspaceId, importMeta });
251
252
  // 空白卡不算失败,但要在结果里显式标记,避免与「抓到完整 schema」混为一谈。
252
253
  summary.synced.push({
253
254
  page: pageNo, index, id: card.id, title: card.title, files,
254
255
  blank: !!files.blank,
255
- keptLocal: !!files.keptLocal,
256
256
  });
257
257
  } catch (error) {
258
258
  summary.success = false;
@@ -1691,9 +1691,10 @@ async function fetchCardSchema(page, card, { appId, workspaceId }) {
1691
1691
  log.warn('远端响应体为空,按空白卡处理', { id: card.id, cardId: card.cardId });
1692
1692
  return null;
1693
1693
  }
1694
- // 防串卡:服务端会回显 cardId,与请求不一致说明拿到了别的卡片,宁可失败也不写盘。
1694
+ // 抓错卡的唯一直接证据:服务端回显的主键必须等于本次请求主键。
1695
+ // 这是主键级校验,保留为硬闸;内容相似、pageId 缺失等都不构成「串卡」证据。
1695
1696
  if (body.cardId && String(body.cardId) !== String(card.cardId)) {
1696
- throw new Error(`${card.id} 响应 cardId=${body.cardId} 与请求 cardId=${card.cardId} 不一致,已中止以防串卡。`);
1697
+ throw new Error(`${card.id} 响应主键不匹配:请求 cardId=${card.cardId},响应 cardId=${body.cardId},已中止本卡落盘`);
1697
1698
  }
1698
1699
  if (!body.schemaValue) {
1699
1700
  log.warn('远端无 schemaValue,判定为空白卡', { id: card.id, cardId: card.cardId });
@@ -1827,92 +1828,117 @@ function filestoreToUrl(value) {
1827
1828
  return platformConfig.filestoreUrl(value);
1828
1829
  }
1829
1830
 
1830
- async function syncCard(page, card, { outRoot, appId, workspaceId, summary, schemaHashes, importMeta }) {
1831
+ /**
1832
+ * 全量覆盖:同步 = 用远端状态重建整张卡,先清空卡目录内的既有产物再写入。
1833
+ *
1834
+ * 旧实现会「保留本地 schema/data、只改 pageId 一个字段」、本地已有封面就跳过下载,
1835
+ * 目录里的旧封面、旧侧车、多余文件也从不清理 —— 那不是覆盖,是打补丁。
1836
+ * 现在除 .history(本地编辑历史,唯一恢复点)外的所有条目一律删除:
1837
+ * schema.json / data.json / 所有封面 / .import-meta.json / 其它残留
1838
+ * 都会由本次远端数据重新生成,不继承任何本地状态。
1839
+ *
1840
+ * 调用时机必须在 fetchCardSchema 成功之后:远端数据没到手就清目录,
1841
+ * 一次网络失败会把本地卡毁成空目录。
1842
+ *
1843
+ * @returns {Promise<number>} 被清除的条目数
1844
+ */
1845
+ export async function resetCardDir(cardDir) {
1846
+ const entries = await readdir(cardDir, { withFileTypes: true }).catch(() => []);
1847
+ let removed = 0;
1848
+ for (const entry of entries) {
1849
+ if (entry.name === '.history') continue;
1850
+ await rm(path.join(cardDir, entry.name), { recursive: true, force: true });
1851
+ removed++;
1852
+ }
1853
+ return removed;
1854
+ }
1855
+
1856
+ /**
1857
+ * 同步落盘:把 schema.pageId 规范为本次列表给出的 cardId。
1858
+ *
1859
+ * 这不是「只改一个字段」的补丁:卡片目录已由 resetCardDir 全量重建,
1860
+ * 此处处理的是远端数据自身的问题 —— IDC10012 返回的 schemaValue 里可能带着
1861
+ * 模板或旧实例的 pageId(实测 creditCardPayment:schema.pageId=10000818,
1862
+ * 而 IDC10001 列表 cardId=10000874)。保存链路 saveCardToRemote 用 schema.pageId
1863
+ * 作为 IDC10010 的 body.cardId,推送闸门 server/core/cardSave.js 又要求它等于
1864
+ * 侧车 cardId;留着旧值会让这张卡永远推不上去(409 pageId_mismatch)。
1865
+ * 因此以「这张卡本次的身份」即列表 cardId 为准,与 buildBlankSchema 一致。
1866
+ *
1867
+ * 抓错卡的判定不使用本函数:唯一主键级证据是 fetchCardSchema 里
1868
+ *「IDC10012 回显 cardId ≠ 请求 cardId」,内容相似与 pageId 差异都不构成串卡证据。
1869
+ *
1870
+ * @returns {{rewritten:boolean, from:string, to:string}}
1871
+ */
1872
+ export function alignSchemaPageId(schema, card) {
1873
+ const from = schema.pageId == null ? '' : String(schema.pageId).trim();
1874
+ const cardId = card.cardId == null ? '' : String(card.cardId).trim();
1875
+ if (!cardId || from === cardId) return { rewritten: false, from, to: from };
1876
+ schema.pageId = cardId;
1877
+ return { rewritten: true, from, to: cardId };
1878
+ }
1879
+
1880
+ async function syncCard(page, card, { outRoot, appId, workspaceId, importMeta }) {
1831
1881
  const cardDir = path.join(outRoot, card.id);
1832
1882
  await mkdir(cardDir, { recursive: true });
1833
1883
  const schema = await fetchCardSchema(page, card, { appId, workspaceId });
1834
- // 空白卡(远端从未保存过 schema):合法状态,走专门的落盘分支,不再整批失败。
1884
+ // 全量覆盖:远端数据到手后才重建目录(拉取失败时本地卡片保持原样)。
1885
+ await resetCardDir(cardDir);
1886
+ // 空白卡(远端从未保存过 schema):合法状态,同样按远端状态全量重建。
1835
1887
  if (!schema) {
1836
1888
  return syncBlankCard(page, card, { cardDir, appId, workspaceId, importMeta });
1837
1889
  }
1838
- // 推送前一致性闸门(基于实测不变量:IDC10001 列表 cardId ≡ schema 内 pageId)。
1839
- // 若二者不等,说明抓取的 schema 与该实例错位(串卡 / 模板错挂),落盘后推送会按错 pageId 打错卡。
1840
- // 直接抛错由调用方记入 failed 并中止本卡落盘,避免错位 pageId 进入本地与远端。
1841
- if (card.cardId != null && String(schema.pageId) !== String(card.cardId)) {
1842
- throw new Error(`${card.id} pageId 错位:列表 cardId=${card.cardId} ≠ schema.pageId=${schema.pageId},疑似串卡,已拒绝落盘`);
1843
- }
1844
- const coverPath = await saveCover(page, card, cardDir);
1845
- // 注意:schema 内部的 pageId 一律以「远端拉取」的为准,原样落盘,不做覆盖。
1846
- // 远端返回的 pageId 是远端编辑器维护的权威页面/模板 ID,落盘后随 schema 一并保留,
1847
- // 供保存(IDC10010)/预览(IDC10015/10020/10021)链路按此主键打到线上。
1848
- // 重复 schema 检测:若不同 code 抓到了字节完全相同的 schema,说明仍串卡,
1849
- // 记录到 duplicates 并把整体结果标为失败,便于重抓时立即发现而非事后排查。
1850
- const schemaHash = createHash('md5').update(JSON.stringify(schema)).digest('hex');
1851
- const dupOf = schemaHashes.get(schemaHash);
1852
- if (dupOf && dupOf !== card.id) {
1853
- summary.duplicates.push({ id: card.id, dupOf, hash: schemaHash.slice(0, 8) });
1854
- summary.success = false;
1855
- log.warn('检测到重复 schema(疑似串卡)', { id: card.id, dupOf, hash: schemaHash.slice(0, 8) });
1890
+ // 目录已重建,这里处理的是远端数据自带的旧实例 pageId(详见函数注释)。
1891
+ const { rewritten, from, to } = alignSchemaPageId(schema, card);
1892
+ if (rewritten) {
1893
+ log.info('schema.pageId 已按列表 cardId 规范', { id: card.id, from: from || '(空)', to });
1856
1894
  }
1857
- schemaHashes.set(schemaHash, card.id);
1895
+ // 先写核心文件,再取封面:封面下载失败不至于把卡目录留成空壳。
1858
1896
  const schemaPath = path.join(cardDir, 'schema.json');
1859
1897
  await writeFile(schemaPath, JSON.stringify(schema, null, 2), 'utf8');
1860
1898
  const dataPath = path.join(cardDir, 'data.json');
1861
1899
  await writeFile(dataPath, JSON.stringify(buildDataFromSchema(schema), null, 2), 'utf8');
1862
- const [cover, schemaInfo, data] = await Promise.all([
1863
- stat(coverPath), stat(schemaPath), stat(dataPath),
1900
+ let coverPath = null;
1901
+ if (card.coverUrl) {
1902
+ try {
1903
+ coverPath = await saveCover(page, card, cardDir);
1904
+ } catch (e) {
1905
+ log.warn('预览图下载失败(不影响卡片全量覆盖落盘)', { id: card.id, error: String(e && e.message) });
1906
+ }
1907
+ }
1908
+ const [schemaInfo, data, cover] = await Promise.all([
1909
+ stat(schemaPath), stat(dataPath), coverPath ? stat(coverPath).catch(() => null) : Promise.resolve(null),
1864
1910
  ]);
1865
1911
  await writeImportSidecar(cardDir, {
1866
1912
  appId,
1867
1913
  workspaceId,
1868
1914
  card,
1869
1915
  importMeta,
1870
- files: { schema: schemaInfo.size, data: data.size, cover: cover.size },
1916
+ files: { schema: schemaInfo.size, data: data.size, cover: cover ? cover.size : 0 },
1871
1917
  });
1872
- return { cover: cover.size, schema: schemaInfo.size, data: data.size };
1918
+ return { cover: cover ? cover.size : 0, schema: schemaInfo.size, data: data.size };
1873
1919
  }
1874
1920
 
1875
1921
  /**
1876
1922
  * 空白卡落盘:远端从未保存过 schema(IDC10012 无 schemaValue),但卡片实例真实存在。
1877
- * 规则(用户确认):
1878
- * 1) 本地已有同名卡片 → 以本地 schema.json / data.json 为准,远端空白绝不覆盖;
1879
- * 2) 本地已有同名预览图 → 跳过下载;否则照常同步;
1880
- * 3) pageId 必须与远端列表返回的 cardId 一致(保存 IDC10010 / 预览 10015·10020·10021
1881
- * 全链路按这个数字主键定位线上卡片,错位会打到别的卡上)。
1882
- * 本地无任何卡片时,落一份最小合法空白 schema,保证目录可被编辑器/预览正常加载。
1923
+ * 全量覆盖语义:目录已由 resetCardDir 清空,这里不读本地、不保留任何旧内容,
1924
+ * 直接按远端状态落一份最小合法空白 schema + 空 data,pageId 取列表 cardId
1925
+ * (保存 IDC10010 / 预览 10015·10020·10021 全链路按这个数字主键定位线上卡片)。
1926
+ * 远端「空白」就是本地「空白」,不再拿本地旧卡去填。
1883
1927
  */
1884
1928
  async function syncBlankCard(page, card, { cardDir, appId, workspaceId, importMeta }) {
1885
1929
  const schemaPath = path.join(cardDir, 'schema.json');
1886
1930
  const dataPath = path.join(cardDir, 'data.json');
1887
- const localSchema = await readJsonIfValid(schemaPath);
1888
- const localData = await readJsonIfValid(dataPath);
1889
- const keptLocal = !!localSchema;
1890
-
1891
- // 本地有实体卡片:保留本地内容;否则建最小空白骨架。
1892
- const schema = localSchema || buildBlankSchema(card);
1893
- const data = localData || (keptLocal ? buildDataFromSchema(schema) : {});
1894
-
1895
- // pageId 对齐远端 cardId:只改这一个字段,其余本地内容原样保留。
1896
- const pageIdRewritten = String(schema.pageId ?? '') !== String(card.cardId);
1897
- if (pageIdRewritten) schema.pageId = String(card.cardId);
1898
-
1899
- if (!keptLocal || pageIdRewritten) {
1900
- await writeFile(schemaPath, JSON.stringify(schema, null, 2), 'utf8');
1901
- }
1902
- if (!localData) {
1903
- await writeFile(dataPath, JSON.stringify(data, null, 2), 'utf8');
1904
- }
1931
+ const schema = buildBlankSchema(card);
1932
+ await writeFile(schemaPath, JSON.stringify(schema, null, 2), 'utf8');
1933
+ await writeFile(dataPath, JSON.stringify({}, null, 2), 'utf8');
1905
1934
 
1906
- // 预览图:本地已有同名图则跳过,否则下载(无可用图源时静默跳过,不算失败)。
1907
- let coverPath = await findExistingCover(cardDir, card.id);
1908
- let coverSkipped = !!coverPath;
1909
- let coverError = '';
1910
- if (!coverPath && card.coverUrl) {
1935
+ // 预览图一律按远端重新下载;无图源或下载失败不阻断卡片落盘。
1936
+ let coverPath = null;
1937
+ if (card.coverUrl) {
1911
1938
  try {
1912
1939
  coverPath = await saveCover(page, card, cardDir);
1913
1940
  } catch (e) {
1914
- coverError = String(e && e.message);
1915
- log.warn('空白卡预览图同步失败(不影响卡片落盘)', { id: card.id, error: coverError });
1941
+ log.warn('空白卡预览图同步失败(不影响卡片落盘)', { id: card.id, error: String(e && e.message) });
1916
1942
  }
1917
1943
  }
1918
1944
 
@@ -1931,10 +1957,6 @@ async function syncBlankCard(page, card, { cardDir, appId, workspaceId, importMe
1931
1957
  schema: schemaInfo.size,
1932
1958
  data: dataInfo.size,
1933
1959
  blank: true,
1934
- keptLocal,
1935
- pageIdRewritten,
1936
- coverSkipped,
1937
- coverError,
1938
1960
  };
1939
1961
  }
1940
1962
 
@@ -2002,32 +2024,6 @@ function buildBlankSchema(card) {
2002
2024
  };
2003
2025
  }
2004
2026
 
2005
- /** 读本地 JSON;文件不存在或解析失败返回 null(不抛,交给调用方决定降级策略)。 */
2006
- async function readJsonIfValid(filePath) {
2007
- try {
2008
- return JSON.parse(await readFile(filePath, 'utf8'));
2009
- } catch {
2010
- return null;
2011
- }
2012
- }
2013
-
2014
- const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
2015
-
2016
- /** 找本地已有的同名预览图(<cardId>.<ext>);没有返回 null。 */
2017
- async function findExistingCover(cardDir, id) {
2018
- let entries = [];
2019
- try {
2020
- entries = await readdir(cardDir);
2021
- } catch {
2022
- return null;
2023
- }
2024
- for (const name of entries) {
2025
- const ext = path.extname(name).toLowerCase();
2026
- if (IMAGE_EXTS.has(ext) && path.basename(name, ext) === id) return path.join(cardDir, name);
2027
- }
2028
- return null;
2029
- }
2030
-
2031
2027
  /**
2032
2028
  * 导入来源可追溯(Phase E):把这张卡从哪来 / 何时导入 / 落盘在哪写进侧车 .import-meta.json。
2033
2029
  * 侧车独立于 schema.json/data.json,绝不污染 DSL 产品数据;找不到 server 模块时静默跳过。
@@ -2137,9 +2133,8 @@ if (isMain) {
2137
2133
  `已同步:${summary.synced.length} 张`,
2138
2134
  `失败:${summary.failed.length} 张`,
2139
2135
  `空白卡片:${blanks.length} 张`,
2140
- `重复 schema:${summary.duplicates.length} 项`,
2141
2136
  ...(blanks.length
2142
- ? [`空白明细:${blanks.map((b) => `${b.id}${b.keptLocal ? '(保留本地)' : '(新建空白)'}`).join('、')}`]
2137
+ ? [`空白明细:${blanks.map((b) => `${b.id}(按远端重建空白)`).join('、')}`]
2143
2138
  : []),
2144
2139
  ...(summary.failed.length
2145
2140
  ? [`失败明细:${summary.failed.map((f) => f.id || '(unknown)').join('、')}`]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cardbuddy",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
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": {