cardbuddy 0.9.1 → 0.10.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.
Files changed (31) hide show
  1. package/README.md +17 -2
  2. package/dist/cli.js +3 -0
  3. package/dist/config.js +7 -0
  4. package/dist/portGuard.js +293 -0
  5. package/dist/preview-editor/assets/index-C_UKS0NF.css +1 -0
  6. package/dist/preview-editor/assets/{index-B9MOriNv.js → index-D1iIJ4sl.js} +14 -13
  7. package/dist/preview-editor/index.html +2 -2
  8. package/dist/server-core/assetProxy.js +3 -22
  9. package/dist/server-core/buildDataFromSchema.js +36 -4
  10. package/dist/server-core/cardListCache.js +0 -17
  11. package/dist/server-core/index.js +3 -5
  12. package/dist/server-core/logStream.js +1 -6
  13. package/dist/standalone.js +24 -8
  14. package/dist/vendored/App.vue +136 -46
  15. package/dist/vendored/components/ComponentPalette.vue +1 -1
  16. package/dist/vendored/components/DebugPanel.vue +735 -90
  17. package/dist/vendored/components/NumberStepper.vue +125 -0
  18. package/dist/vendored/components/ProgressSlider.vue +9 -1
  19. package/dist/vendored/components/StructureTree.vue +2 -2
  20. package/dist/vendored/runtime/clipboard-node.js +0 -5
  21. package/dist/vendored/styles/app.css +618 -210
  22. package/dist/vendored/styles/card-gallery.css +3 -3
  23. package/dist/vendored/styles/card-select.css +13 -6
  24. package/dist/vendored/styles/dropdown.css +1 -2
  25. package/dist/vendored/styles/json-tree.css +3 -3
  26. package/dist/vendored/styles/node-inspector.css +7 -294
  27. package/dist/vendored/styles/setters.css +48 -49
  28. package/dist/vendored/styles/structure-tree.css +6 -11
  29. package/package.json +1 -1
  30. package/dist/preview-editor/assets/index-CwD14gm3.css +0 -1
  31. package/dist/vendored/assets/logo.png +0 -0
@@ -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-B9MOriNv.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-CwD14gm3.css">
11
+ <script type="module" crossorigin src="/assets/index-D1iIJ4sl.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-C_UKS0NF.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="app"></div>
@@ -87,13 +87,9 @@ async function fetchUpstream(pathname, upstream) {
87
87
  }
88
88
 
89
89
  /**
90
- * 资源代理 + 落盘缓存(回源地址从传入的 upstream 取)。
91
- * @param {{ root: string, upstream: string }} opts
92
- */
93
- /**
94
- * 资源代理 + 落盘缓存中间件(与 Express / Vite middleware 容器解耦)。
95
- * 既可由 Vite 插件 configureServer 挂载,也可由 server/core 的 mountCoreMiddlewares
96
- * 直接挂载到 cardbuddy(8787),实现「Vite 零后端插件、8787 是唯一后端」。
90
+ * 资源代理 + 落盘缓存中间件(回源地址从传入的 upstream 取;与 Express / Vite middleware 容器解耦)。
91
+ * 由 server/core 的 mountCoreMiddlewares 挂载到 cardbuddy(8787),
92
+ * 实现「Vite 零后端插件、8787 是唯一后端」。
97
93
  * @param {{ root: string, upstream: string }} opts
98
94
  * @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
99
95
  */
@@ -137,18 +133,3 @@ export function createAssetProxyMiddleware({ root, upstream }) {
137
133
  return res.end(PLACEHOLDER)
138
134
  }
139
135
  }
140
-
141
- /**
142
- * Vite 插件适配层(保留以兼容纯 vite dev 场景):configureServer 阶段把
143
- * createAssetProxyMiddleware 挂到 server.middlewares。前后分离架构下已由 server/core
144
- * 的 mountCoreMiddlewares 挂载到 cardbuddy(8787),Vite 不再需要此插件。
145
- * @param {{ root: string, upstream: string }} opts
146
- */
147
- export function createAssetProxyPlugin({ root, upstream }) {
148
- return {
149
- name: 'finmall-asset-proxy',
150
- configureServer(server) {
151
- server.middlewares.use(createAssetProxyMiddleware({ root, upstream }))
152
- },
153
- }
154
- }
@@ -8,13 +8,21 @@
8
8
  * 仅当 defaultValue 为空 / 空对象 / 未定义时,回退到 param.desc 里的 JSON 兜底
9
9
  * (desc 以 '{' 开头且解析后含该 key 时生效)。
10
10
  *
11
+ * mock 覆盖(dictionary.mocks):
12
+ * 平台变量面板的「配置变量 mock」把设计期覆盖值写在 dictionary.mocks(key → 值),
13
+ * 与「默认值」inputParams[].defaultValue 分仓:defaultValue 是随卡发布的缺省数据,
14
+ * mocks 仅供设计期预览覆盖(编辑器画布),不进入 data.json。故默认(options.includeMocks
15
+ * falsy)只按 defaultValue 派生 —— 服务端写盘 / 同步落盘走这条;编辑器预览态传
16
+ * { includeMocks: true },在 defaultValue 之上按 key 叠加非空 mock 值。
17
+ *
11
18
  * 本文件是纯 ESM(无任何 node / 浏览器专有依赖),因此既能被服务端(Node)import,
12
19
  * 也能被预览前端(Vite 打包)直接 import,避免逻辑漂移。
13
20
  */
14
- export function buildDataFromSchema(schema) {
21
+ export function buildDataFromSchema(schema, options = {}) {
15
22
  // 稳健性:schema 缺失 / 非对象(如外部改坏 schema.json)时返回空对象,
16
23
  // 不抛错,避免 GET /__card 在损坏卡片上返回 500 堆栈。
17
24
  if (!schema || typeof schema !== 'object') return {}
25
+ const mocks = options.includeMocks ? readMocks(schema) : null
18
26
  const data = {}
19
27
  for (const param of schema.dictionary?.inputParams || []) {
20
28
  const fallback = parseDescFallback(param)
@@ -24,15 +32,39 @@ export function buildDataFromSchema(schema) {
24
32
  typeof defaultValue === 'object' &&
25
33
  !Array.isArray(defaultValue) &&
26
34
  Object.keys(defaultValue).length === 0
27
- data[param.key] = clone(
35
+ let value =
28
36
  fallback !== undefined && (defaultValue === undefined || defaultValue === '' || emptyObject)
29
37
  ? fallback
30
- : defaultValue,
31
- )
38
+ : defaultValue
39
+ // mock 覆盖优先于默认值;空 mock(未配置)不覆盖,回退 defaultValue / desc 兜底
40
+ if (mocks && hasMockValue(mocks, param.key)) value = mocks[param.key]
41
+ data[param.key] = clone(value)
32
42
  }
33
43
  return data
34
44
  }
35
45
 
46
+ /**
47
+ * 读取 dictionary.mocks(key → mock 值)。历史卡 / 同步脚本可能写成数组或缺省,
48
+ * 一律归一为 null(视为「没有 mock 表」),调用方无需再判形。
49
+ */
50
+ export function readMocks(schema) {
51
+ const mocks = schema && schema.dictionary && schema.dictionary.mocks
52
+ return mocks && typeof mocks === 'object' && !Array.isArray(mocks) ? mocks : null
53
+ }
54
+
55
+ /**
56
+ * 某 key 是否「配置了 mock」:undefined / null / 空串 / 空容器 视为未配置
57
+ * (与变量列表的 m 徽标同一判据,避免空壳 mock 被当成有效覆盖)。
58
+ */
59
+ export function hasMockValue(mocks, key) {
60
+ if (!mocks || !Object.prototype.hasOwnProperty.call(mocks, key)) return false
61
+ const value = mocks[key]
62
+ if (value === undefined || value === null || value === '') return false
63
+ if (Array.isArray(value)) return value.length > 0
64
+ if (typeof value === 'object') return Object.keys(value).length > 0
65
+ return true
66
+ }
67
+
36
68
  function parseDescFallback(param) {
37
69
  if (!param.desc || typeof param.desc !== 'string') return undefined
38
70
  const text = param.desc.trim()
@@ -13,7 +13,6 @@
13
13
  */
14
14
 
15
15
  const cardIndex = new Map(); // cardId(String) -> { cardId, code, cardName }
16
- let lastRefreshedAt = 0;
17
16
 
18
17
  /**
19
18
  * 写入/合并卡片列表。
@@ -27,7 +26,6 @@ export function refreshCardList(cards, { full = false } = {}) {
27
26
  const id = String(c.cardId);
28
27
  cardIndex.set(id, { cardId: id, code: c.code, cardName: c.cardName });
29
28
  }
30
- lastRefreshedAt = Date.now();
31
29
  }
32
30
 
33
31
  /** 卡片是否存在于缓存列表(按 cardId / pageId 查)。 */
@@ -35,16 +33,6 @@ export function hasCardId(cardId) {
35
33
  return cardIndex.has(String(cardId));
36
34
  }
37
35
 
38
- /** 取缓存中的卡片元信息,不存在返回 null。 */
39
- export function getCard(cardId) {
40
- return cardIndex.get(String(cardId)) || null;
41
- }
42
-
43
- /** 缓存是否已非空(至少同步过一次全量列表)。 */
44
- export function cacheSize() {
45
- return cardIndex.size;
46
- }
47
-
48
36
  /**
49
37
  * 推送前存在性校验:目标卡片是否确实在远端卡片列表中(按 cardId/pageId 查)。
50
38
  * - 缓存为空(本次 dev server 尚未全量同步过):放行,返回 allowed=true,避免误拦截首推。
@@ -61,8 +49,3 @@ export function checkPushAllowed(pageId) {
61
49
  reason: `卡片 pageId=${pageId} 不在远端卡片列表中,疑似已删除或 pageId 错位,请先「远程同步」刷新列表后再推送`,
62
50
  };
63
51
  }
64
-
65
- /** 上次刷新时间戳(ms),未刷新为 0。 */
66
- export function lastRefreshed() {
67
- return lastRefreshedAt;
68
- }
@@ -3,11 +3,9 @@
3
3
  *
4
4
  * 设计:
5
5
  * - 每个端点都是独立 `(req, res, next)` 中间件工厂(见同目录各文件),与 Express / Vite
6
- * 中间件容器解耦,因此可被两处复用:
7
- * 1) Vite 插件(server/cardServer.js,编辑器内核开发 same-origin 路径);
8
- * 2) 独立本地开发服务(packages/cardbuddy,公网静态编辑器指向本机路径)。
9
- * - mountCoreMiddlewares(app, opts) 把全部中间件挂到任意 Connect 风格容器(Vite 的
10
- * server.middlewares 或 connect 实例皆可),两处共用、改一处两处同步。
6
+ * 中间件容器解耦,由本地开发服务 packages/cardbuddy 统一装配(dev 与发布态同一份)。
7
+ * - mountCoreMiddlewares(app, opts) 把全部中间件挂到任意 Connect 风格容器(cardbuddy 的
8
+ * connect 实例,或 tools/regress-data.mjs 里的临时容器),改一处全链路同步。
11
9
  *
12
10
  * 注意:renderStatus / nodeModels 需要活着的 vite 实例做 ssrLoadModule,故 mountCoreMiddlewares
13
11
  * 接收 viteServer 参数透传;无 SSR 需求的场景可传 null(这两个端点会返回明确错误)。
@@ -166,11 +166,6 @@ export function subscribeLog(cb) {
166
166
  return () => subscribers.delete(cb)
167
167
  }
168
168
 
169
- /** 当前订阅者数量(诊断用)。 */
170
- export function logSubscriberCount() {
171
- return subscribers.size
172
- }
173
-
174
169
  let mirrorInstalled = false
175
170
 
176
171
  /**
@@ -218,7 +213,7 @@ export function installLogMirror() {
218
213
 
219
214
  /**
220
215
  * 日志流 HTTP 端点:GET /__logs(历史/补漏)、POST /__logs/ingest(外部进程注入)。
221
- * 挂在 mountCoreMiddlewares 里,因此 Vite 插件形态与 cardbuddy 独立服务形态同时可用。
216
+ * 挂在 mountCoreMiddlewares 里,随 cardbuddy 本地服务一并生效。
222
217
  */
223
218
  export function createLogMiddleware() {
224
219
  const sendJson = (res, status, payload) => {
@@ -24,6 +24,7 @@ import chalk from 'chalk'
24
24
 
25
25
  import { mountCoreMiddlewares, installLogMirror, subscribeLog } from './core.js'
26
26
  import { loadConfig } from './config.js'
27
+ import { ensurePortFree } from './portGuard.js'
27
28
 
28
29
  const MIME_TYPES = {
29
30
  '.html': 'text/html; charset=utf-8',
@@ -45,6 +46,13 @@ const MIME_TYPES = {
45
46
  '.map': 'application/json; charset=utf-8',
46
47
  }
47
48
 
49
+ // 被 dev.mjs 拉起时(DSL_LAUNCHER='dev'),来源标签由启动器用带背景色的 chip 标注,
50
+ // 这里让出前缀避免双重来源标签;直接运行 / dev:prod(stdio:inherit)则自带标签。
51
+ const srcTag =
52
+ process.env.DSL_LAUNCHER === 'dev'
53
+ ? ''
54
+ : chalk.magenta('▌ ') + chalk.magenta.bold('cardbuddy') + ' '
55
+
48
56
  function decodePathname(url) {
49
57
  if (!url) return '/'
50
58
  const raw = url.split('?')[0]
@@ -382,6 +390,21 @@ async function resolveTls(config) {
382
390
  */
383
391
  export async function startServer(userConfig = {}) {
384
392
  const config = { ...loadConfig(), ...userConfig }
393
+
394
+ // 日志镜像尽早安装(幂等,CLI 入口已先行装过一次):让端口接管等启动期日志也能进入
395
+ // 编辑器底部「服务日志」面板的环形缓冲。
396
+ installLogMirror()
397
+
398
+ // 端口前置检查:被「上一个 cardbuddy 实例」占用时主动结束它再接管(见 src/portGuard.js)。
399
+ // 放在最前面,避免白跑一遍 TLS / Vite 初始化后才以 EADDRINUSE 失败。
400
+ await ensurePortFree({
401
+ port: config.port,
402
+ tls: config.https,
403
+ token: config.token,
404
+ mode: config.portTakeover,
405
+ log: (msg) => console.log(srcTag + msg),
406
+ })
407
+
385
408
  const app = connect()
386
409
 
387
410
  app.use(corsMiddleware)
@@ -403,8 +426,7 @@ export async function startServer(userConfig = {}) {
403
426
 
404
427
  // 服务日志流:镜像本进程 stdout/stderr(含 chalk ANSI)→ 环形缓冲 → SSE 批量推送。
405
428
  // 编辑器底部「服务日志」面板订阅 event: service-log;历史与断线补漏走 GET /__logs。
406
- // installLogMirror 幂等,CLI 入口已先行安装,这里兜住以编程方式调用 startServer 的场景。
407
- installLogMirror()
429
+ // 镜像本体在函数开头安装,这里只负责把新增行转推给 SSE 订阅者。
408
430
  subscribeLog((lines) => events.notify({ lines }, 'service-log'))
409
431
 
410
432
  const vite = await createEmbeddedVite(config)
@@ -437,12 +459,6 @@ export async function startServer(userConfig = {}) {
437
459
  })
438
460
 
439
461
  const bind = `${scheme}://127.0.0.1:${config.port}`
440
- // 被 dev.mjs 拉起时(DSL_LAUNCHER='dev'),来源标签由启动器用带背景色的 chip 标注,
441
- // 这里让出前缀避免双重来源标签;直接运行 / dev:prod(stdio:inherit)则自带标签。
442
- const srcTag =
443
- process.env.DSL_LAUNCHER === 'dev'
444
- ? ''
445
- : chalk.magenta('▌ ') + chalk.magenta.bold('cardbuddy') + ' '
446
462
  console.log(
447
463
  srcTag +
448
464
  chalk.green(`本地开发服务已启动:${bind}`) +
@@ -36,6 +36,7 @@
36
36
  <svg class="app__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-grid" /></svg>
37
37
  </button>
38
38
  <div
39
+ ref="validateBadgeEl"
39
40
  class="app__badge"
40
41
  :class="'app__badge--' + validateBadge.state"
41
42
  :title="badgeTitle"
@@ -69,7 +70,7 @@
69
70
  <svg class="app__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-save" /></svg>
70
71
  <span>保存</span>
71
72
  </button>
72
- <button class="app__btn app__btn--op" :disabled="!preview" @click="copySchema" title="复制 schema(含数据绑定编辑后的数据)">
73
+ <button class="app__btn app__btn--op" :disabled="!preview" @click="copySchema" title="复制 schema(含变量 mock 编辑后的数据)">
73
74
  <svg class="app__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-copy" /></svg>
74
75
  <span>{{ copiedSchema ? '已复制 ✓' : '复制' }}</span>
75
76
  </button>
@@ -198,29 +199,38 @@
198
199
  >
199
200
  <svg class="app__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-link" /></svg>
200
201
  </button>
201
- <div v-if="devServiceOpen" class="app__dev-svc-panel" @click.stop>
202
- <div class="app__dev-svc-title">本地开发服务</div>
203
- <label class="app__dev-svc-row">
204
- <span>启用</span>
205
- <Switch v-model="devServiceForm.enabled" />
206
- </label>
207
- <label class="app__dev-svc-row app__dev-svc-row--col">
208
- <span>服务地址</span>
209
- <input type="text" v-model="devServiceForm.baseUrl" placeholder="http://127.0.0.1:8787" />
210
- </label>
211
- <label class="app__dev-svc-row app__dev-svc-row--col">
212
- <span>访问令牌(可选)</span>
213
- <input type="text" v-model="devServiceForm.token" placeholder="留空则不校验" />
214
- </label>
215
- <div class="app__dev-svc-actions">
216
- <button class="ni-btn ni-btn--ghost" type="button" @click="devServiceOpen = false">关闭</button>
217
- <button class="ni-btn" type="button" @click="saveDevService">保存</button>
202
+ <!-- 点遮罩关闭:与事件帮助气泡等轻量浮层同一交互 -->
203
+ <div v-if="devServiceOpen" class="app__dev-svc-mask" @click="devServiceOpen = false"></div>
204
+ <Transition name="pop">
205
+ <div v-if="devServiceOpen" class="app__dev-svc-panel" @click.stop>
206
+ <div class="app__dev-svc-head">
207
+ <span class="app__dev-svc-title">本地开发服务</span>
208
+ <button class="app__dev-svc-close" type="button" title="关闭" @click="devServiceOpen = false"></button>
209
+ </div>
210
+ <div class="app__dev-svc-body">
211
+ <label class="app__dev-svc-row">
212
+ <span>启用</span>
213
+ <Switch v-model="devServiceForm.enabled" />
214
+ </label>
215
+ <label class="app__dev-svc-row app__dev-svc-row--col">
216
+ <span>服务地址</span>
217
+ <input type="text" v-model="devServiceForm.baseUrl" placeholder="http://127.0.0.1:8787" />
218
+ </label>
219
+ <label class="app__dev-svc-row app__dev-svc-row--col">
220
+ <span>访问令牌(可选)</span>
221
+ <input type="text" v-model="devServiceForm.token" placeholder="留空则不校验" />
222
+ </label>
223
+ <p class="app__dev-svc-hint" v-if="apiBaseResolved === null">
224
+ <template v-if="devSvcBlockReason === 'disabled'">已填写服务地址,但「启用」开关未打开。请开启「启用」开关并点击保存,本地开发服务才会生效。</template>
225
+ <template v-else>请填写本机 cardbuddy 服务地址(默认 http://127.0.0.1:8787),打开「启用」开关并保存后,编辑功能才会生效。</template>
226
+ </p>
227
+ <div class="app__dev-svc-actions">
228
+ <button class="ni-btn ni-btn--ghost" type="button" @click="devServiceOpen = false">关闭</button>
229
+ <button class="ni-btn" type="button" @click="saveDevService">保存</button>
230
+ </div>
231
+ </div>
218
232
  </div>
219
- <p class="app__dev-svc-hint" v-if="apiBaseResolved === null">
220
- <template v-if="devSvcBlockReason === 'disabled'">已填写服务地址,但「启用」开关未打开。请开启「启用」开关并点击保存,本地开发服务才会生效。</template>
221
- <template v-else>请填写本机 cardbuddy 服务地址(默认 http://127.0.0.1:8787),打开「启用」开关并保存后,编辑功能才会生效。</template>
222
- </p>
223
- </div>
233
+ </Transition>
224
234
  </div>
225
235
  </div>
226
236
  <!-- revision 依赖锚点:setData 自增后驱动本组件重渲染 -->
@@ -536,7 +546,9 @@ import logoUrl from '@/assets/site-icon.png'
536
546
  import { apiFetch, resolveApiBase, subscribeCardsChanged, loadDevServiceConfig, saveDevServiceConfig } from '@/api.js'
537
547
  // 由 schema 单向派生 data 的共享实现(与 server/core 同源,避免逻辑漂移)。
538
548
  // 预览渲染注入的数据绑定、本地基线比较、历史快照还原均使用此函数,使 data 永不读回 data.json 文件。
539
- import { buildDataFromSchema } from '../server/core/buildDataFromSchema.js'
549
+ import { buildDataFromSchema, readMocks } from '../server/core/buildDataFromSchema.js'
550
+ // 编辑器预览态派生 data 时叠加 dictionary.mocks(设计期覆盖);服务端写盘仍只用 defaultValue。
551
+ const WITH_MOCKS = { includeMocks: true }
540
552
 
541
553
  // 编辑器自身「保存到本地 / 保存至远端」写盘后,cardbuddy 的 watcher 会把这次落盘经 SSE
542
554
  // 推回 cards-changed。这是「自己改自己」的回声,并非外部修改,用此窗口吸收,避免误弹外部重载
@@ -547,8 +559,8 @@ const SELF_SAVE_SUPPRESS_MS = 2500
547
559
  // 内部 rootScope 可经 setRootScope 整体替换而引用不变。
548
560
  const adapter = new Adapter()
549
561
  const cardRuntime = createCardRuntime({}, adapter)
550
- // 开发期暴露 runtime 单例句柄,供 E2E 回归(如 L2-12 绑定数据整体替换实时同步画布)
551
- // 直接驱动 rt.setRootScope —— 与 DebugPanel「绑定数据」Raw「应用」同源。生产构建不暴露。
562
+ // 开发期暴露 runtime 单例句柄,供 E2E 回归(如 L2-12 rootScope 整体替换实时同步画布)
563
+ // 直接驱动 rt.setRootScope —— 与 DebugPanel「变量 → 配置变量 mock」Raw「应用」同源。生产构建不暴露。
552
564
  if (typeof window !== 'undefined' && import.meta.env.DEV) window.__rt = cardRuntime
553
565
 
554
566
  export default {
@@ -607,9 +619,9 @@ export default {
607
619
  localSchema: null,
608
620
  localData: null,
609
621
  selectedNodeId: '',
610
- // 当前激活的调试面板 tab(绑定数据/组件结构/卡片来源)。
622
+ // 当前激活的调试面板 tab(变量/组件结构/卡片来源)。
611
623
  // 画布 Ctrl/⌘ 点击会自动切到「组件结构」tab 并在节点树聚焦选中,无需预先停在结构树 tab。
612
- activeTab: 'scope',
624
+ activeTab: 'vars',
613
625
  // 右侧常驻「接口调用」面板:展开/收起由顶栏按钮与面板内 ✕ 控制,
614
626
  // 初始状态持久化到 localStorage(cardbuddy:api-panel-open)
615
627
  apiPanelOpen: true,
@@ -666,7 +678,7 @@ export default {
666
678
  baseline: null,
667
679
  _commitTimer: null, // 编辑落定防抖计时器,避免连续编辑产生过多快照
668
680
  viewportHeight: 700, // 手机视口实际高度(高度自适应填充后由 ResizeObserver 实测更新)
669
- // 画布背景模式:checker(棋盘格) / white(白色) / gray(浅灰)
681
+ // 画布背景模式:checker(棋盘格) / white(白色) / gray(浅灰) / black(近黑,深色卡片对色用)
670
682
  canvasBg: 'gray',
671
683
  // 刷新按钮进行态:图标自转 + 禁用,避免连点与「点了没反应」
672
684
  refreshing: false,
@@ -674,6 +686,7 @@ export default {
674
686
  { key: 'checker', label: '棋盘' },
675
687
  { key: 'white', label: '白色' },
676
688
  { key: 'gray', label: '浅灰' },
689
+ { key: 'black', label: '黑色' },
677
690
  ],
678
691
  // —— 预览区宽度可调(拖拽分隔条 / 预设尺寸,画布按实际像素宽度布局,无 transform 缩放)——
679
692
  _previewWidthPx: 372, // 预览列宽度(像素,持久化到 localStorage);第一次打开未主动设置时默认 372px
@@ -934,9 +947,10 @@ export default {
934
947
  this.selectedId = initial
935
948
  await this.loadCard(this.selectedId)
936
949
  }
937
- // 数据绑定(rt.rootScope)深度监听:任意编辑实时同步进 schema.dictionary.inputParams.defaultValue,
950
+ // 数据绑定(rt.rootScope)深度监听:任意编辑实时回写 schema 的权威数据位 ——
951
+ // 已配置 mock 的 key 回写 dictionary.mocks[key],其余回写 inputParams[].defaultValue,
938
952
  // 保证「保存到本地」基于最新数据绑定;预览也经 rt 响应式实时刷新。
939
- // ⚠️ 此处【不】写历史——历史由「绑定数据」面板的「应用」按钮(schema-commit)落定,
953
+ // ⚠️ 此处【不】写历史——历史由「变量 → 配置变量 mock」视图的「应用」按钮(schema-commit)落定,
940
954
  // 与节点属性/事件面板一致:实时编辑只更新预览与内存,点「应用」才记一笔撤销记录。
941
955
  this.$watch(
942
956
  () => this.rt.rootScope,
@@ -1215,18 +1229,89 @@ export default {
1215
1229
  // 渲染端点异常不强行判错(避免 dev server 预热期 SSR 冷启动误报)
1216
1230
  const renderOk = r ? !!r.renderOk : true
1217
1231
  const ok = contractOk && renderOk
1232
+ const next = ok ? 'ok' : 'error'
1233
+ const changed = this.validateBadge.state !== next
1218
1234
  this.validateBadge = {
1219
- state: ok ? 'ok' : 'error',
1235
+ state: next,
1220
1236
  errors: (v.errors || []).length,
1221
1237
  warnings: (v.warnings || []).length,
1222
1238
  renderOk,
1223
1239
  ts: Date.now(),
1224
1240
  }
1241
+ if (changed && (next === 'ok' || next === 'error')) this.celebrateValidate(next)
1225
1242
  } catch (e) {
1226
- // 网络/服务不可用:保底不阻断编辑,标记 fail
1243
+ // 网络/服务不可用:保底不阻断编辑,标记 fail(fail 不触发动画)
1227
1244
  this.validateBadge = { ...this.validateBadge, state: 'fail' }
1228
1245
  }
1229
1246
  },
1247
+ /** 状态跳变时,在徽标局部触发庆祝动画:徽标弹跳 + 碎玻璃碎屑沿轮廓放射爆开,碎屑颜色跟随目标状态主色。仅局部,不铺屏。 */
1248
+ celebrateValidate(state = 'ok') {
1249
+ const el = this.$refs.validateBadgeEl
1250
+ if (!el) return
1251
+ if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
1252
+ // 徽标弹跳
1253
+ el.classList.remove('is-celebrating')
1254
+ void el.offsetWidth // 重排以重启动画
1255
+ el.classList.add('is-celebrating')
1256
+ setTimeout(() => el.classList.remove('is-celebrating'), 600)
1257
+ // 碎屑颜色跟随目标状态徽标主色(--ok / --danger / --warn / --text-faint)
1258
+ const r = el.getBoundingClientRect()
1259
+ const colorVar = { ok: '--ok', error: '--danger', pending: '--warn', fail: '--text-faint', nopython: '--text-faint' }[state] || '--ok'
1260
+ const fallback = { ok: '#52c41a', error: '#F5222D', pending: '#FAAD14', fail: '#8c8c8c', nopython: '#8c8c8c' }[state] || '#52c41a'
1261
+ const badgeColor = getComputedStyle(el).getPropertyValue(colorVar).trim() || fallback
1262
+ const box = document.createElement('div')
1263
+ box.className = 'app__confetti'
1264
+ box.style.left = r.left + r.width / 2 + 'px'
1265
+ box.style.top = r.top + r.height / 2 + 'px'
1266
+ // 碎玻璃碎片:从徽标矩形轮廓迸出,沿外法线方向飞散;不规则尖锐多边形 + 尺寸不一
1267
+ const count = 22
1268
+ const clips = [
1269
+ 'polygon(50% 0%, 0% 100%, 100% 100%)',
1270
+ 'polygon(62% 0%, 100% 40%, 45% 100%, 0% 52%)',
1271
+ 'polygon(0% 0%, 100% 18%, 82% 100%, 12% 70%)',
1272
+ 'polygon(22% 0%, 100% 8%, 88% 100%, 0% 76%)',
1273
+ 'polygon(0% 32%, 100% 0%, 76% 100%, 18% 86%)',
1274
+ 'polygon(8% 0%, 100% 22%, 70% 100%, 0% 64%)',
1275
+ 'polygon(0% 12%, 100% 0%, 100% 78%, 30% 100%, 12% 55%)',
1276
+ ]
1277
+ const halfW = r.width / 2
1278
+ const halfH = r.height / 2
1279
+ for (let i = 0; i < count; i++) {
1280
+ const bit = document.createElement('span')
1281
+ bit.className = 'app__confetti-bit'
1282
+ // 在徽标矩形轮廓上随机取一点作为迸发起点
1283
+ const edge = (Math.random() * 4) | 0
1284
+ let px, py
1285
+ if (edge === 0) { px = (Math.random() * 2 - 1) * halfW; py = -halfH }
1286
+ else if (edge === 1) { px = halfW; py = (Math.random() * 2 - 1) * halfH }
1287
+ else if (edge === 2) { px = (Math.random() * 2 - 1) * halfW; py = halfH }
1288
+ else { px = -halfW; py = (Math.random() * 2 - 1) * halfH }
1289
+ // 沿轮廓点的外法线方向飞出 + 微抖动
1290
+ const len = Math.hypot(px, py) || 1
1291
+ const a = Math.atan2(py / len, px / len) + (Math.random() - 0.5) * 0.5
1292
+ const dist = 16 + Math.random() * 26
1293
+ bit.style.setProperty('--sx', px.toFixed(1) + 'px')
1294
+ bit.style.setProperty('--sy', py.toFixed(1) + 'px')
1295
+ bit.style.setProperty('--tx', (Math.cos(a) * dist).toFixed(1) + 'px')
1296
+ bit.style.setProperty('--ty', (Math.sin(a) * dist).toFixed(1) + 'px')
1297
+ bit.style.setProperty('--rot', (Math.random() * 720 - 360).toFixed(0) + 'deg')
1298
+ bit.style.setProperty('--clip', clips[(Math.random() * clips.length) | 0])
1299
+ bit.style.setProperty('--c', badgeColor)
1300
+ // 每片独立透明度,半透明玻璃感
1301
+ bit.style.setProperty('--o', (0.68 + Math.random() * 0.27).toFixed(2))
1302
+ // 三层尺寸分布:长薄片 / 中等碎块 / 细微粉尘,强化碎裂参差感
1303
+ const roll = Math.random()
1304
+ let w, h
1305
+ if (roll < 0.35) { w = 1.5 + Math.random() * 2; h = 10 + Math.random() * 10 } // 细长薄片
1306
+ else if (roll < 0.75) { w = 4 + Math.random() * 5; h = w * (0.6 + Math.random() * 0.8) } // 中等碎块
1307
+ else { w = 2 + Math.random() * 2.5; h = w } // 细微粉尘
1308
+ bit.style.width = w.toFixed(0) + 'px'
1309
+ bit.style.height = h.toFixed(0) + 'px'
1310
+ box.appendChild(bit)
1311
+ }
1312
+ document.body.appendChild(box)
1313
+ setTimeout(() => box.remove(), 800)
1314
+ },
1230
1315
  /** 实时从磁盘读取并载入指定卡片(GET /__card/<id>),不读任何缓存 */
1231
1316
  async loadCard(id, opts = {}) {
1232
1317
  if (!id) return
@@ -1251,7 +1336,7 @@ export default {
1251
1336
  this.rt.clearApiCalls()
1252
1337
  // schema 被事件编辑器改后重建时保留当前数据绑定编辑,避免被磁盘 data(由 schema 派生)覆盖。
1253
1338
  // 数据绑定改为由 schema 单向派生,不再读回 data.json 文件。
1254
- if (!opts.keepScope) this.rt.setRootScope(buildDataFromSchema(fresh.schema))
1339
+ if (!opts.keepScope) this.rt.setRootScope(buildDataFromSchema(fresh.schema, WITH_MOCKS))
1255
1340
  this.currentCard = { schema: fresh.schema }
1256
1341
  this.currentSchema = fresh.schema
1257
1342
  // 重置并在载入后拉取导入来源元数据(Phase E):本地新建卡无 .import-meta.json → null,
@@ -1261,7 +1346,7 @@ export default {
1261
1346
  // 记录本地真实版本基线(磁盘当前内容),供「保存到本地」按钮判断与本地是否一致。
1262
1347
  // localData 由 schema 派生(与渲染注入同源),保证纯数据编辑也能触发脏标记。
1263
1348
  this.localSchema = this.cloneSchema(fresh.schema)
1264
- this.localData = this.cloneData(buildDataFromSchema(fresh.schema))
1349
+ this.localData = this.cloneData(buildDataFromSchema(fresh.schema, WITH_MOCKS))
1265
1350
  this.preview = buildPreview(fresh.schema)
1266
1351
  // Task #5:把 Property 树模型森林暴露到 window,供浏览器内 AI / skill 直接读取节点可编辑结构
1267
1352
  if (typeof window !== 'undefined') window.__cardModel = buildModelView(fresh.schema)
@@ -1339,7 +1424,7 @@ export default {
1339
1424
  if (!id || !snapshot) return
1340
1425
  const schema = snapshot.schema || snapshot
1341
1426
  // 快照只存 schema;数据绑定由其派生。若老快照带 data 则优先沿用(历史兼容)。
1342
- const data = snapshot.data != null ? snapshot.data : buildDataFromSchema(schema)
1427
+ const data = snapshot.data != null ? snapshot.data : buildDataFromSchema(schema, WITH_MOCKS)
1343
1428
  this.currentSchema = this.cloneSchema(schema)
1344
1429
  this.rt.setRootScope(this.cloneData(data))
1345
1430
  this.preview = buildPreview(this.currentSchema)
@@ -1355,7 +1440,7 @@ export default {
1355
1440
  commitHistory() {
1356
1441
  if (!this.currentSchema || !this.baseline) return
1357
1442
  const sameSchema = this.structuralEqual(this.currentSchema, this.baseline.schema)
1358
- const sameData = this.dataEqual(this.rt.rootScope, buildDataFromSchema(this.baseline.schema))
1443
+ const sameData = this.dataEqual(this.rt.rootScope, buildDataFromSchema(this.baseline.schema, WITH_MOCKS))
1359
1444
  if (sameSchema && sameData) return
1360
1445
  const previous = { schema: this.baseline.schema }
1361
1446
  this.past.push(previous)
@@ -1396,7 +1481,7 @@ export default {
1396
1481
  // 取末尾最多 30 个作为可回退的上一版本(最新在末尾;仅 schema,data 由 schema 派生)
1397
1482
  this.past = snaps.slice(-30).map((s) => ({
1398
1483
  schema: s.schema,
1399
- data: s.data != null ? s.data : buildDataFromSchema(s.schema),
1484
+ data: s.data != null ? s.data : buildDataFromSchema(s.schema, WITH_MOCKS),
1400
1485
  }))
1401
1486
  } catch (e) {
1402
1487
  console.warn('[加载编辑历史失败]', e)
@@ -1461,10 +1546,11 @@ export default {
1461
1546
  this.commitHistory()
1462
1547
  },
1463
1548
  /**
1464
- * 把数据绑定(rt.rootScope,即 data.json)的编辑实时同步回 schema 的
1465
- * 权威数据位置:schema.dictionary.inputParams[i].defaultValue。
1466
- * 这样「本地保存」时 schema.json 与 data.json 都携带最新数据绑定,
1467
- * 二者同时生效、不会互相覆盖(data.json 本身即由 inputParams.defaultValue 派生)。
1549
+ * 把数据绑定(rt.rootScope)的编辑实时同步回 schema 的权威数据位:
1550
+ * key 已在 dictionary.mocks 中 → 回写 mocks[key](设计期覆盖仓);
1551
+ * 否则回写 schema.dictionary.inputParams[i].defaultValue(随卡发布的缺省仓)。
1552
+ * 这样「本地保存」时 schema.json 携带最新两仓数据,而 data.json 仍只由 defaultValue 派生,
1553
+ * mock 不会泄漏进发布数据;两仓也不会互相覆盖。
1468
1554
  */
1469
1555
  syncDataToSchema() {
1470
1556
  const schema = this.currentSchema
@@ -1472,13 +1558,14 @@ export default {
1472
1558
  const root = this.rt && this.rt.rootScope
1473
1559
  if (!root || typeof root !== 'object') return
1474
1560
  const params = schema.dictionary.inputParams
1561
+ const mocks = readMocks(schema)
1475
1562
  for (const key of Object.keys(root)) {
1476
1563
  const param = params.find((p) => p && (p.key === key || p.name === key))
1477
1564
  if (!param) continue
1478
1565
  // 深拷贝,避免数据绑定与 schema 共享同一引用(setRootScope 重建后会失联)
1479
- param.defaultValue = JSON.parse(
1480
- JSON.stringify(root[key], (k, v) => (k === '_schemaNode' ? undefined : v))
1481
- )
1566
+ const value = JSON.parse(JSON.stringify(root[key], (k, v) => (k === '_schemaNode' ? undefined : v)))
1567
+ if (mocks && Object.prototype.hasOwnProperty.call(mocks, key)) mocks[key] = value
1568
+ else param.defaultValue = value
1482
1569
  }
1483
1570
  },
1484
1571
  /** 结构树选中节点 */
@@ -2283,7 +2370,10 @@ export default {
2283
2370
  // 写盘成功后,当前显示版本即成为本地真实版本:刷新本地基线,使顶栏
2284
2371
  // 「保存到本地」按钮在「当前版本 == 本地版本」时自动置灰。
2285
2372
  this.localSchema = this.cloneSchema(this.currentSchema)
2286
- this.localData = this.cloneData(buildDataFromSchema(this.currentSchema))
2373
+ // 基线数据必须与载入 / 历史快照同源(含 mocks):rt.rootScope 是含 mock 覆盖的,
2374
+ // 若此处漏传 WITH_MOCKS,只要存在 mock 数据,保存后 rootScope 与 localData 永远
2375
+ // 不等 → historyDirty 恒真,「保存到本地」按钮保存成功也保持高亮。
2376
+ this.localData = this.cloneData(buildDataFromSchema(this.currentSchema, WITH_MOCKS))
2287
2377
  this.savedCard = true
2288
2378
  // 本地保存后磁盘已更新,刷新整卡校验徽标(端点读磁盘)
2289
2379
  this.refreshValidateBadge()
@@ -157,7 +157,7 @@ export default {
157
157
  align-items: center;
158
158
  gap: 10px;
159
159
  padding: 10px 16px;
160
- border-bottom: 1px solid var(--border);
160
+ border-bottom: var(--hairline) solid var(--border);
161
161
  }
162
162
  .cp__title {
163
163
  font-size: 13px;