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.
Files changed (64) hide show
  1. package/README.md +21 -6
  2. package/dist/cli.js +17 -6
  3. package/dist/config.js +20 -2
  4. package/dist/portGuard.js +293 -0
  5. package/dist/preview-editor/assets/index-B4VZgNSK.css +1 -0
  6. package/dist/preview-editor/assets/index-jl4YJzKv.js +180 -0
  7. package/dist/preview-editor/index.html +2 -2
  8. package/dist/server-core/_vendor/finmall-env.mjs +4 -4
  9. package/dist/server-core/ai-chat.js +3710 -0
  10. package/dist/server-core/assetProxy.js +3 -22
  11. package/dist/server-core/buildDataFromSchema.js +36 -4
  12. package/dist/server-core/cardFiles.js +95 -2
  13. package/dist/server-core/cardImage.js +14 -0
  14. package/dist/server-core/cardListCache.js +0 -17
  15. package/dist/server-core/cardSave.js +2 -0
  16. package/dist/server-core/cardValidate.js +3 -0
  17. package/dist/server-core/index.js +6 -5
  18. package/dist/server-core/logStream.js +1 -6
  19. package/dist/server-core/sliceUpload.js +8 -1
  20. package/dist/standalone.js +24 -8
  21. package/dist/vendored/App.vue +405 -125
  22. package/dist/vendored/api.js +4 -4
  23. package/dist/vendored/assets/svgIcons/icons.svg +28 -0
  24. package/dist/vendored/components/AiSettings.vue +392 -0
  25. package/dist/vendored/components/CardGallery.vue +51 -5
  26. package/dist/vendored/components/ChatComposer.vue +413 -0
  27. package/dist/vendored/components/ChatMessage.vue +383 -0
  28. package/dist/vendored/components/ChatPanel.vue +937 -0
  29. package/dist/vendored/components/ComponentPalette.vue +1 -1
  30. package/dist/vendored/components/DebugPanel.vue +732 -90
  31. package/dist/vendored/components/Dropdown.vue +4 -2
  32. package/dist/vendored/components/MarkdownBody.vue +64 -0
  33. package/dist/vendored/components/NewCardDialog.vue +132 -0
  34. package/dist/vendored/components/NumberStepper.vue +125 -0
  35. package/dist/vendored/components/ProgressSlider.vue +9 -1
  36. package/dist/vendored/components/RemotePreview.vue +1 -1
  37. package/dist/vendored/components/StructureTree.vue +2 -2
  38. package/dist/vendored/main.js +1 -0
  39. package/dist/vendored/runtime/adapter.js +1 -1
  40. package/dist/vendored/runtime/ai-api.js +165 -0
  41. package/dist/vendored/runtime/clipboard-node.js +0 -5
  42. package/dist/vendored/runtime/markdown.js +140 -0
  43. package/dist/vendored/skills/.manifest.json +1101 -0
  44. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +249 -0
  45. package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +678 -0
  46. package/dist/vendored/skills/dsl-card-generator/scripts/derive_data_json.py +81 -0
  47. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +105 -0
  48. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +624 -0
  49. package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +900 -0
  50. package/dist/vendored/skills/finmall-card-sync/scripts/slice_upload.mjs +699 -0
  51. package/dist/vendored/skills/shared/finmall-config.mjs +124 -0
  52. package/dist/vendored/styles/ai-chat.css +1850 -0
  53. package/dist/vendored/styles/app.css +725 -213
  54. package/dist/vendored/styles/card-gallery.css +48 -10
  55. package/dist/vendored/styles/card-select.css +13 -6
  56. package/dist/vendored/styles/dropdown.css +1 -2
  57. package/dist/vendored/styles/json-tree.css +3 -3
  58. package/dist/vendored/styles/node-inspector.css +13 -289
  59. package/dist/vendored/styles/setters.css +48 -49
  60. package/dist/vendored/styles/structure-tree.css +6 -11
  61. package/package.json +1 -1
  62. package/dist/preview-editor/assets/index-B9MOriNv.js +0 -80
  63. package/dist/preview-editor/assets/index-CwD14gm3.css +0 -1
  64. package/dist/vendored/assets/logo.png +0 -0
@@ -3,7 +3,7 @@
3
3
  右侧调试面板。
4
4
  -->
5
5
  <template>
6
- <div class="app" :class="{ 'app--chrome-off': chromeOff }" :style="{ '--preview-width': previewWidth + 'px', '--api-panel-width': apiPanelWidth + 'px' }">
6
+ <div class="app" :class="{ 'app--chrome-off': chromeOff }" :style="{ '--preview-width': previewWidth + 'px', '--api-panel-width': apiPanelWidth + 'px', '--debug-min': debugMinWidth + 'px' }">
7
7
  <header class="app__bar" v-show="!chromeOff">
8
8
  <strong class="app__logo">
9
9
  <img class="app__logo-icon" :src="logoUrl" alt="" />
@@ -35,7 +35,17 @@
35
35
  >
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
+ <!-- 新增空白卡片:紧邻网格预览的图标按钮;弹窗收 ID(必填、本地重名前置拦截)+ 描述(选填),创建成功切换到新卡 -->
39
+ <button
40
+ class="app__btn app__btn--icon"
41
+ type="button"
42
+ @click="openNewCard"
43
+ title="新增空白卡片"
44
+ >
45
+ <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-folder-plus" /></svg>
46
+ </button>
38
47
  <div
48
+ ref="validateBadgeEl"
39
49
  class="app__badge"
40
50
  :class="'app__badge--' + validateBadge.state"
41
51
  :title="badgeTitle"
@@ -69,7 +79,7 @@
69
79
  <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
80
  <span>保存</span>
71
81
  </button>
72
- <button class="app__btn app__btn--op" :disabled="!preview" @click="copySchema" title="复制 schema(含数据绑定编辑后的数据)">
82
+ <button class="app__btn app__btn--op" :disabled="!preview" @click="copySchema" title="复制 schema(含变量 mock 编辑后的数据)">
73
83
  <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
84
  <span>{{ copiedSchema ? '已复制 ✓' : '复制' }}</span>
75
85
  </button>
@@ -198,35 +208,64 @@
198
208
  >
199
209
  <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
210
  </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>
211
+ <!-- 点遮罩关闭:与事件帮助气泡等轻量浮层同一交互 -->
212
+ <div v-if="devServiceOpen" class="app__dev-svc-mask" @click="devServiceOpen = false"></div>
213
+ <Transition name="pop">
214
+ <div v-if="devServiceOpen" class="app__dev-svc-panel" @click.stop>
215
+ <div class="app__dev-svc-head">
216
+ <span class="app__dev-svc-title">本地开发服务</span>
217
+ <button class="app__dev-svc-close" type="button" title="关闭" @click="devServiceOpen = false"></button>
218
+ </div>
219
+ <div class="app__dev-svc-body">
220
+ <label class="app__dev-svc-row">
221
+ <span>启用</span>
222
+ <Switch v-model="devServiceForm.enabled" />
223
+ </label>
224
+ <label class="app__dev-svc-row app__dev-svc-row--col">
225
+ <span>服务地址</span>
226
+ <input type="text" v-model="devServiceForm.baseUrl" placeholder="http://127.0.0.1:8787" />
227
+ </label>
228
+ <label class="app__dev-svc-row app__dev-svc-row--col">
229
+ <span>访问令牌(可选)</span>
230
+ <input type="text" v-model="devServiceForm.token" placeholder="留空则不校验" />
231
+ </label>
232
+ <p class="app__dev-svc-hint" v-if="apiBaseResolved === null">
233
+ <template v-if="devSvcBlockReason === 'disabled'">已填写服务地址,但「启用」开关未打开。请开启「启用」开关并点击保存,本地开发服务才会生效。</template>
234
+ <template v-else>请填写本机 cardbuddy 服务地址(默认 http://127.0.0.1:8787),打开「启用」开关并保存后,编辑功能才会生效。</template>
235
+ </p>
236
+ <div class="app__dev-svc-actions">
237
+ <button class="ni-btn ni-btn--ghost" type="button" @click="devServiceOpen = false">关闭</button>
238
+ <button class="ni-btn" type="button" @click="saveDevService">保存</button>
239
+ </div>
240
+ </div>
218
241
  </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>
242
+ </Transition>
224
243
  </div>
225
244
  </div>
226
- <!-- revision 依赖锚点:setData 自增后驱动本组件重渲染 -->
227
245
  <span style="display: none">{{ rt.revision }}</span>
228
246
  </header>
229
247
 
248
+ <!-- AI 开发助手悬浮入口:右下角圆形按钮(尺寸 / 配色走规范 Token,图标取自 sprite) -->
249
+ <button
250
+ class="ai-fab"
251
+ type="button"
252
+ :class="{ 'ai-fab--active': aiChatOpen }"
253
+ :title="aiChatOpen ? 'AI 开发助手(已打开)' : 'AI 开发助手'"
254
+ :aria-expanded="aiChatOpen ? 'true' : 'false'"
255
+ data-testid="ai-fab"
256
+ @click="aiChatOpen = !aiChatOpen"
257
+ >
258
+ <svg class="ai-fab__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-sparkles" /></svg>
259
+ </button>
260
+
261
+ <!-- AI 开发助手对话面板:右侧抽屉,宽度可拖(320px ~ 视口 50%,默认 33%),对话记录按卡片落盘 -->
262
+ <ChatPanel
263
+ :visible="aiChatOpen"
264
+ :card-id="selectedId"
265
+ :card-name="selectedCardName"
266
+ @close="aiChatOpen = false"
267
+ />
268
+
230
269
  <!-- 部署态但未配置本地开发服务:顶部提示条(chrome=off 截图模式不显示) -->
231
270
  <div v-if="apiBaseResolved === null && !chromeOff" class="app__nosvc-banner">
232
271
  <template v-if="devSvcBlockReason === 'disabled'">
@@ -348,7 +387,7 @@
348
387
  v-show="!chromeOff"
349
388
  class="splitter"
350
389
  :class="{ 'is-dragging': dragging }"
351
- @mousedown.prevent="startDrag"
390
+ @pointerdown.prevent="startDrag"
352
391
  title="拖拽调整预览区域宽度"
353
392
  ></div>
354
393
 
@@ -359,7 +398,7 @@
359
398
  <div class="workside" v-show="!chromeOff">
360
399
  <div id="app-workside-bar" class="workside__bar"></div>
361
400
  <div id="app-workside-panels" class="workside__panels"></div>
362
- <div class="workside__cols">
401
+ <div class="workside__cols" ref="colsEl">
363
402
  <!-- 拖拽分隔条:置于两栏容器内(定位上下文),只贯穿列区高度,不穿过底部弹出面板与状态栏 -->
364
403
  <!-- 中右分隔条:调试面板 | 接口调用面板 接缝,拖动调整右栏宽度。
365
404
  约束:中栏最小宽度 ≥ 屏宽 1/3,右栏最小宽度 ≥ 320px(见 startApiDrag) -->
@@ -367,7 +406,7 @@
367
406
  v-show="!chromeOff && apiPanelOpen"
368
407
  class="splitter splitter--api"
369
408
  :class="{ 'is-dragging': apiDragging }"
370
- @mousedown.prevent="startApiDrag"
409
+ @pointerdown.prevent="startApiDrag"
371
410
  title="拖拽调整接口调用面板宽度"
372
411
  ></div>
373
412
  <DebugPanel
@@ -494,6 +533,14 @@
494
533
  :selected-id="selectedId"
495
534
  @select="onSelect"
496
535
  @close="closeGallery"
536
+ @delete="onGalleryDelete"
537
+ />
538
+ <!-- 新增空白卡片弹窗:创建成功后关弹窗 + 刷列表 + 切换到新卡;existing-ids 供 ID 重名前置判定 -->
539
+ <NewCardDialog
540
+ :visible="newCardOpen"
541
+ :existing-ids="cardIds"
542
+ @close="newCardOpen = false"
543
+ @created="onNewCardCreated"
497
544
  />
498
545
  <!-- App 级弱提示:顶栏异步操作(远端保存 / 同步)完成反馈,浮于 App 外壳顶部居中 -->
499
546
  <AppToast />
@@ -527,8 +574,10 @@ import RemotePreview from '@/components/RemotePreview.vue'
527
574
  import AppToast from '@/components/AppToast.vue'
528
575
  import ConfirmDialog from '@/components/ConfirmDialog.vue'
529
576
  import CardGallery from '@/components/CardGallery.vue'
577
+ import NewCardDialog from '@/components/NewCardDialog.vue'
530
578
  import ComponentPalette from '@/components/ComponentPalette.vue'
531
579
  import Switch from '@/components/Switch.vue'
580
+ import ChatPanel from '@/components/ChatPanel.vue'
532
581
  import { showAppToast } from '@/runtime/app-toast.js'
533
582
  import { clearAllAlerts } from '@/runtime/alert-card.js'
534
583
  import { installJsApiMock, clearDeviceUI } from '@/runtime/jsapi-mock.js'
@@ -536,7 +585,9 @@ import logoUrl from '@/assets/site-icon.png'
536
585
  import { apiFetch, resolveApiBase, subscribeCardsChanged, loadDevServiceConfig, saveDevServiceConfig } from '@/api.js'
537
586
  // 由 schema 单向派生 data 的共享实现(与 server/core 同源,避免逻辑漂移)。
538
587
  // 预览渲染注入的数据绑定、本地基线比较、历史快照还原均使用此函数,使 data 永不读回 data.json 文件。
539
- import { buildDataFromSchema } from '../server/core/buildDataFromSchema.js'
588
+ import { buildDataFromSchema, readMocks } from '../server/core/buildDataFromSchema.js'
589
+ // 编辑器预览态派生 data 时叠加 dictionary.mocks(设计期覆盖);服务端写盘仍只用 defaultValue。
590
+ const WITH_MOCKS = { includeMocks: true }
540
591
 
541
592
  // 编辑器自身「保存到本地 / 保存至远端」写盘后,cardbuddy 的 watcher 会把这次落盘经 SSE
542
593
  // 推回 cards-changed。这是「自己改自己」的回声,并非外部修改,用此窗口吸收,避免误弹外部重载
@@ -547,13 +598,20 @@ const SELF_SAVE_SUPPRESS_MS = 2500
547
598
  // 内部 rootScope 可经 setRootScope 整体替换而引用不变。
548
599
  const adapter = new Adapter()
549
600
  const cardRuntime = createCardRuntime({}, adapter)
550
- // 开发期暴露 runtime 单例句柄,供 E2E 回归(如 L2-12 绑定数据整体替换实时同步画布)
551
- // 直接驱动 rt.setRootScope —— 与 DebugPanel「绑定数据」Raw「应用」同源。生产构建不暴露。
601
+ // 开发期暴露 runtime 单例句柄,供 E2E 回归(如 L2-12 rootScope 整体替换实时同步画布)
602
+ // 直接驱动 rt.setRootScope —— 与 DebugPanel「变量 → 配置变量 mock」Raw「应用」同源。生产构建不暴露。
552
603
  if (typeof window !== 'undefined' && import.meta.env.DEV) window.__rt = cardRuntime
553
604
 
605
+ // 三栏(预览列 | 调试面板 | 接口调用面板)布局约束的单一来源:
606
+ // 拖拽夹取与 computed 都从这里取值,避免「两套口径」在极限位置互相打架导致接缝跳动 / 整行溢出。
607
+ const PREVIEW_MIN = 188 // 预览列硬下限(= 最小机型预设宽)
608
+ const API_MIN = 320 // 接口调用面板常规下限(空间不足时按 apiPanelBounds 优雅退让,不整行溢出)
609
+ const DEBUG_MIN_FLOOR = 200 // 中栏(调试面板)硬下限;软目标为屏宽 1/3
610
+ const API_ABS_MIN = 160 // 极窄窗口下面板的绝对下限,保证不撑破容器
611
+
554
612
  export default {
555
613
  name: 'App',
556
- components: { RenderTree, DebugPanel, ApiCallPanel, CardSelect, AlertCardLayer, DeviceOverlay, PickerOverlay, DatePickerOverlay, SelectionOverlay, HoverGuides, RemotePreview, AppToast, ConfirmDialog, CardGallery, ComponentPalette, Switch },
614
+ components: { RenderTree, DebugPanel, ApiCallPanel, CardSelect, AlertCardLayer, DeviceOverlay, PickerOverlay, DatePickerOverlay, SelectionOverlay, HoverGuides, RemotePreview, AppToast, ConfirmDialog, CardGallery, NewCardDialog, ComponentPalette, Switch, ChatPanel },
557
615
  provide() {
558
616
  return {
559
617
  adapter,
@@ -571,7 +629,9 @@ export default {
571
629
  appVersion: __APP_VERSION__,
572
630
  cardList: [], // 实时从 GET /__cards 拉取,不缓存启动快照
573
631
  cardCount: 0,
574
- galleryOpen: false, // 卡片网格预览弹窗开关
632
+ galleryOpen: false,
633
+ // 新增空白卡片弹窗开关(顶栏图标按钮触发)
634
+ newCardOpen: false, // 卡片网格预览弹窗开关
575
635
  selectedId: '',
576
636
  // 外部(IDE / 其他工具)改了当前卡、且平台无未保存改动时,自动重载后顶部短暂提示
577
637
  // (非阻塞,区别于有未保存改动时的阻塞确认框)。
@@ -607,9 +667,9 @@ export default {
607
667
  localSchema: null,
608
668
  localData: null,
609
669
  selectedNodeId: '',
610
- // 当前激活的调试面板 tab(绑定数据/组件结构/卡片来源)。
670
+ // 当前激活的调试面板 tab(变量/组件结构/卡片来源)。
611
671
  // 画布 Ctrl/⌘ 点击会自动切到「组件结构」tab 并在节点树聚焦选中,无需预先停在结构树 tab。
612
- activeTab: 'scope',
672
+ activeTab: 'vars',
613
673
  // 右侧常驻「接口调用」面板:展开/收起由顶栏按钮与面板内 ✕ 控制,
614
674
  // 初始状态持久化到 localStorage(cardbuddy:api-panel-open)
615
675
  apiPanelOpen: true,
@@ -666,7 +726,7 @@ export default {
666
726
  baseline: null,
667
727
  _commitTimer: null, // 编辑落定防抖计时器,避免连续编辑产生过多快照
668
728
  viewportHeight: 700, // 手机视口实际高度(高度自适应填充后由 ResizeObserver 实测更新)
669
- // 画布背景模式:checker(棋盘格) / white(白色) / gray(浅灰)
729
+ // 画布背景模式:checker(棋盘格) / white(白色) / gray(浅灰) / black(近黑,深色卡片对色用)
670
730
  canvasBg: 'gray',
671
731
  // 刷新按钮进行态:图标自转 + 禁用,避免连点与「点了没反应」
672
732
  refreshing: false,
@@ -674,6 +734,7 @@ export default {
674
734
  { key: 'checker', label: '棋盘' },
675
735
  { key: 'white', label: '白色' },
676
736
  { key: 'gray', label: '浅灰' },
737
+ { key: 'black', label: '黑色' },
677
738
  ],
678
739
  // —— 预览区宽度可调(拖拽分隔条 / 预设尺寸,画布按实际像素宽度布局,无 transform 缩放)——
679
740
  _previewWidthPx: 372, // 预览列宽度(像素,持久化到 localStorage);第一次打开未主动设置时默认 372px
@@ -684,15 +745,22 @@ export default {
684
745
  { key: 188, value: '188' },
685
746
  ],
686
747
  stageWidth: typeof window !== 'undefined' ? window.innerWidth : 1280, // 舞台实际宽度(ResizeObserver 实测后更新)
748
+ colsWidth: 0, // 中右两栏容器(.workside__cols)实测宽度:右栏宽度夹取的单一口径
687
749
  stageHeight: typeof window !== 'undefined' ? window.innerHeight - 52 : 748, // 舞台实际高度(contain 缩放的“高度上限”基准)
688
750
  dragging: false, // 是否正在拖拽分隔条
689
751
  apiDragging: false, // 是否正在拖拽「接口调用」面板分隔条
690
752
  _stageObserver: null,
691
- _dragMove: null,
692
- _dragUp: null,
693
753
  // —— 本地开发服务(前后端分离)配置入口 ——
694
754
  // devServiceForm:设置面板双向绑定的草稿;启用后前端所有 /__xxx 请求改走该地址。
695
755
  devServiceOpen: false,
756
+ // AI 面板开合跨刷新保持:dev server 重启 / 误刷新后不必重新找入口
757
+ aiChatOpen: (() => {
758
+ try {
759
+ return sessionStorage.getItem('cardbuddy:ai-chat-open') === '1'
760
+ } catch {
761
+ return false
762
+ }
763
+ })(),
696
764
  devServiceForm: loadDevServiceConfig(),
697
765
  // apiBaseResolved:当前生效的 API 基址;null 表示部署态未配置(必须用本地服务才能编辑)。
698
766
  apiBaseResolved: resolveApiBase(),
@@ -712,6 +780,10 @@ export default {
712
780
  currentBg() {
713
781
  return this.bgOptions.find((b) => b.key === this.canvasBg) || this.bgOptions[0]
714
782
  },
783
+ /** 本地已有卡片 ID(GET /__cards 实时扫描 src/cards 的结果):新增空白卡片时做重名前置判定 */
784
+ cardIds() {
785
+ return this.cardList.map((c) => c && c.id).filter(Boolean)
786
+ },
715
787
  // 撤销可用:存在可回退的上一版本;重做可用:存在被撤销后的版本
716
788
  canUndo() {
717
789
  return this.past.length > 0
@@ -812,27 +884,58 @@ export default {
812
884
  return ''
813
885
  },
814
886
  // 预览列宽度(像素):chromeOff 时占满整屏(截图/真机模式,非“宽度调整”场景);
815
- // 否则使用 _previewWidthPx 并限制在 [188, 可视宽度/2],最大不得超过画布左侧可视区域的一半
887
+ // 否则使用 _previewWidthPx 并夹到 previewBounds(拖拽与渲染共用同一口径,杜绝两套夹取打架)
816
888
  previewWidth() {
817
889
  if (this.chromeOff) return this.stageWidth
818
- const total = this.stageWidth || 1
819
- const minW = 188 // 最小宽度不得小于 188(预设最小尺寸)
820
- const maxW = Math.max(minW, Math.floor(total / 2)) // 最大不得超过可视宽度的一半
821
- const w = this._previewWidthPx || minW
822
- return Math.max(minW, Math.min(maxW, w))
890
+ const { min, max } = this.previewBounds
891
+ const w = this._previewWidthPx || PREVIEW_MIN
892
+ return Math.max(min, Math.min(max, w))
893
+ },
894
+ /** 视口宽(响应式:随 stageWidth / colsWidth 的实测更新一起重算) */
895
+ viewportWidth() {
896
+ // 依赖 stageWidth 以便窗口缩放时重算;取值仍用 innerWidth(vw 口径与 CSS 一致)
897
+ void this.stageWidth
898
+ return typeof window !== 'undefined' ? window.innerWidth : 1280
899
+ },
900
+ /** 中栏软目标宽:屏宽 1/3,且不低于硬下限(下发给 CSS `.debug` 的 min-width) */
901
+ debugSoftWidth() {
902
+ return Math.max(DEBUG_MIN_FLOOR, Math.floor(this.viewportWidth / 3))
903
+ },
904
+ /** 预览列宽度区间:[188, min(舞台宽/2, 舞台宽 - 右栏下限 - 中栏软目标)]。
905
+ * 只依赖舞台宽与视口宽(不反向依赖中右两栏实测),杜绝循环夹取。 */
906
+ previewBounds() {
907
+ const stage = this.stageWidth || 0
908
+ const byHalf = Math.floor(stage / 2)
909
+ const byWorkspace = stage - API_MIN - this.debugSoftWidth
910
+ return { min: PREVIEW_MIN, max: Math.max(PREVIEW_MIN, Math.min(byHalf, byWorkspace)) }
911
+ },
912
+ /** 中栏(调试面板)实际最小宽:软目标(屏宽 1/3)优先,空间不足时退让到硬下限 */
913
+ debugMinWidth() {
914
+ const cols = this.colsWidth || Math.max(0, (this.stageWidth || 0) - this.previewWidth)
915
+ return Math.max(DEBUG_MIN_FLOOR, Math.min(this.debugSoftWidth, Math.floor(cols) - API_MIN))
916
+ },
917
+ /** 右栏(接口调用面板)宽度区间:常规 [320, 两栏容器宽 - 中栏最小宽];
918
+ * 容器实在放不下 320 时(窄窗口 + 预览列已占位)整体退让到剩余空间,绝不撑破容器
919
+ * —— 撑破会让面板被推出视口、分隔条接缝与面板边缘脱节,表现为「拖到极限就跳」。 */
920
+ apiPanelBounds() {
921
+ const cols = this.colsWidth || Math.max(0, (this.stageWidth || 0) - this.previewWidth)
922
+ const max = Math.max(API_ABS_MIN, Math.floor(cols) - this.debugMinWidth)
923
+ return { min: Math.min(API_MIN, max), max }
823
924
  },
824
925
  // 画布实际宽度(列宽,整数):canvas-bar__size 实时显示用,拖拽分隔条时随之变化
825
926
  canvasW() {
826
927
  return Math.round(this.previewWidth || 375)
827
928
  },
828
- // 右侧「接口调用」面板宽度(像素):限制在 [320, 舞台宽度 - 预览列宽 - 中栏最小宽(屏宽 1/3)],
829
- // 保证中栏(调试面板)最小宽度不小于三分之一屏幕宽度、右栏不小于 320px
929
+ /** 当前卡片名(AI 开发助手头部展示;列表未加载完时回落 id) */
930
+ selectedCardName() {
931
+ const hit = this.cardList.find((c) => c.id === this.selectedId)
932
+ return (hit && hit.name) || this.selectedId || ''
933
+ },
934
+ // 右侧「接口调用」面板宽度(像素):夹到 apiPanelBounds,保证中栏不小于屏宽 1/3、右栏不小于 320px
830
935
  apiPanelWidth() {
831
- const minW = 320 // 右栏最小宽度
832
- const midMin = Math.floor((typeof window !== 'undefined' ? window.innerWidth : 1280) / 3)
833
- const maxW = Math.max(minW, Math.floor((this.stageWidth || 0) - this.previewWidth - midMin))
936
+ const { min, max } = this.apiPanelBounds
834
937
  const w = this._apiPanelWidthPx || 360
835
- return Math.max(minW, Math.min(maxW, w))
938
+ return Math.max(min, Math.min(max, w))
836
939
  },
837
940
  // 缩放外框占满整列(宽高交由 CSS flex 填满;画布不再做 transform 缩放,直接按实际尺寸布局)
838
941
  frameStyle() {
@@ -848,6 +951,13 @@ export default {
848
951
  clearHoverEl()
849
952
  }
850
953
  },
954
+ aiChatOpen(val) {
955
+ try {
956
+ sessionStorage.setItem('cardbuddy:ai-chat-open', val ? '1' : '0')
957
+ } catch {
958
+ /* sessionStorage 不可用时忽略 */
959
+ }
960
+ },
851
961
  // 主题切换:把 'dark' / 'light' 写到 <html data-theme>,驱动 CSS 变量整体换肤
852
962
  theme(val) {
853
963
  document.documentElement.dataset.theme = val
@@ -864,8 +974,6 @@ export default {
864
974
  beforeUnmount() {
865
975
  if (this.viewportObserver) this.viewportObserver.disconnect()
866
976
  if (this._stageObserver) this._stageObserver.disconnect()
867
- if (this._dragMove) document.removeEventListener('mousemove', this._dragMove)
868
- if (this._dragUp) document.removeEventListener('mouseup', this._dragUp)
869
977
  if (this.saveTimer) clearTimeout(this.saveTimer)
870
978
  if (this.syncTimer) clearTimeout(this.syncTimer)
871
979
  if (this.saveRemoteTimer) clearTimeout(this.saveRemoteTimer)
@@ -884,11 +992,17 @@ export default {
884
992
  this.loadApiPanelWidth()
885
993
  this._stageObserver = new ResizeObserver((entries) => {
886
994
  for (const entry of entries) {
995
+ // 舞台与两栏容器分别实测:右栏夹取要用 colsWidth(不含预览列),不能拿舞台宽反推
996
+ if (entry.target === this.$refs.colsEl) {
997
+ this.colsWidth = Math.round(entry.contentRect.width)
998
+ continue
999
+ }
887
1000
  this.stageWidth = Math.round(entry.contentRect.width)
888
1001
  this.stageHeight = Math.round(entry.contentRect.height)
889
1002
  }
890
1003
  })
891
1004
  if (this.$refs.stageEl) this._stageObserver.observe(this.$refs.stageEl)
1005
+ if (this.$refs.colsEl) this._stageObserver.observe(this.$refs.colsEl)
892
1006
  // 全局键盘快捷键:复制/粘贴/副本/删除/撤销/重做/取消选中(见 onKeydown)
893
1007
  window.addEventListener('keydown', this.onKeydown)
894
1008
  },
@@ -934,9 +1048,10 @@ export default {
934
1048
  this.selectedId = initial
935
1049
  await this.loadCard(this.selectedId)
936
1050
  }
937
- // 数据绑定(rt.rootScope)深度监听:任意编辑实时同步进 schema.dictionary.inputParams.defaultValue,
1051
+ // 数据绑定(rt.rootScope)深度监听:任意编辑实时回写 schema 的权威数据位 ——
1052
+ // 已配置 mock 的 key 回写 dictionary.mocks[key],其余回写 inputParams[].defaultValue,
938
1053
  // 保证「保存到本地」基于最新数据绑定;预览也经 rt 响应式实时刷新。
939
- // ⚠️ 此处【不】写历史——历史由「绑定数据」面板的「应用」按钮(schema-commit)落定,
1054
+ // ⚠️ 此处【不】写历史——历史由「变量 → 配置变量 mock」视图的「应用」按钮(schema-commit)落定,
940
1055
  // 与节点属性/事件面板一致:实时编辑只更新预览与内存,点「应用」才记一笔撤销记录。
941
1056
  this.$watch(
942
1057
  () => this.rt.rootScope,
@@ -1009,32 +1124,84 @@ export default {
1009
1124
  showAppToast('本地开发服务配置已保存', 'ok')
1010
1125
  this.fetchCardList()
1011
1126
  },
1012
- // —— 预览区宽度调节 ——
1013
- // 拖拽分隔条:拖动改变预览列宽度(直接写入 _previewWidthPx 像素,previewWidth/canvasW 自动联动),松手持久化
1014
- startDrag() {
1015
- if (this.chromeOff) return
1016
- this.dragging = true
1017
- document.body.style.cursor = 'col-resize'
1018
- document.body.style.userSelect = 'none'
1019
- const stage = this.$refs.stageEl
1020
- this._dragMove = (ev) => {
1021
- const rect = stage.getBoundingClientRect()
1022
- const minW = 188 // 最小宽度不得小于 188
1023
- const maxW = Math.max(minW, Math.floor(rect.width / 2)) // 最大不得超过可视宽度的一半
1024
- let w = ev.clientX - rect.left
1025
- w = Math.max(minW, Math.min(maxW, w))
1026
- this._previewWidthPx = w
1127
+ // —— 列宽拖拽(左:预览列 / 右:接口调用面板)——
1128
+ /**
1129
+ * 分隔条拖拽统一入口:Pointer Events + 指针捕获。
1130
+ * 曾经左条绑 mousedown、处理函数却按 pointer 写(ev.pointerId 为 undefined → 捕获失败,
1131
+ * pointermove 只在 6px 命中区内触发),表现为「左侧分隔条拖不动」;两条统一走这里。
1132
+ * 捕获后即使松手点在视口外,pointerup / pointercancel 仍回送本元素,不会残留拖拽态。
1133
+ * @param {PointerEvent} ev
1134
+ * @param {(e: PointerEvent) => void} onMove 每次移动的夹取写入
1135
+ * @param {() => void} onEnd 收尾(清光标 / 持久化)
1136
+ */
1137
+ beginColumnDrag(ev, onMove, onEnd) {
1138
+ const el = ev && ev.currentTarget
1139
+ if (!el) return
1140
+ const pointerId = ev.pointerId
1141
+ try {
1142
+ el.setPointerCapture(pointerId)
1143
+ } catch {
1144
+ /* 不支持捕获时退化为元素级监听 */
1027
1145
  }
1028
- this._dragUp = () => {
1029
- this.dragging = false
1146
+ const finish = () => {
1147
+ el.removeEventListener('pointermove', onMove)
1148
+ el.removeEventListener('pointerup', finish)
1149
+ el.removeEventListener('pointercancel', finish)
1150
+ window.removeEventListener('blur', finish)
1151
+ try {
1152
+ if (el.hasPointerCapture?.(pointerId)) el.releasePointerCapture(pointerId)
1153
+ } catch {
1154
+ /* 已释放 */
1155
+ }
1030
1156
  document.body.style.cursor = ''
1031
1157
  document.body.style.userSelect = ''
1032
- document.removeEventListener('mousemove', this._dragMove)
1033
- document.removeEventListener('mouseup', this._dragUp)
1034
- this.savePreviewWidth()
1158
+ onEnd()
1035
1159
  }
1036
- document.addEventListener('mousemove', this._dragMove)
1037
- document.addEventListener('mouseup', this._dragUp)
1160
+ document.body.style.cursor = 'col-resize'
1161
+ document.body.style.userSelect = 'none'
1162
+ el.addEventListener('pointermove', onMove)
1163
+ el.addEventListener('pointerup', finish)
1164
+ el.addEventListener('pointercancel', finish)
1165
+ window.addEventListener('blur', finish) // 窗口失焦兜底:切走 / 最小化不卡死拖拽态
1166
+ },
1167
+ // 左分隔条:拖动改变预览列宽度(写像素,previewWidth / canvasW 自动联动),松手持久化
1168
+ startDrag(ev) {
1169
+ if (this.chromeOff) return
1170
+ const stage = this.$refs.stageEl
1171
+ if (!stage) return
1172
+ const baseLeft = stage.getBoundingClientRect().left
1173
+ this.dragging = true
1174
+ this.beginColumnDrag(
1175
+ ev,
1176
+ (e) => {
1177
+ const { min, max } = this.previewBounds
1178
+ this._previewWidthPx = Math.max(min, Math.min(max, Math.round(e.clientX - baseLeft)))
1179
+ },
1180
+ () => {
1181
+ this.dragging = false
1182
+ this.savePreviewWidth()
1183
+ }
1184
+ )
1185
+ },
1186
+ // 右分隔条:拖动改变「接口调用」面板宽度(向左加宽 / 向右收窄),松手持久化。
1187
+ // 基准取两栏容器右缘(= 面板右缘),夹取用 apiPanelBounds,与渲染口径完全一致 → 极限处不再跳动
1188
+ startApiDrag(ev) {
1189
+ if (this.chromeOff || !this.apiPanelOpen) return
1190
+ const cols = this.$refs.colsEl
1191
+ if (!cols) return
1192
+ const baseRight = cols.getBoundingClientRect().right
1193
+ this.apiDragging = true
1194
+ this.beginColumnDrag(
1195
+ ev,
1196
+ (e) => {
1197
+ const { min, max } = this.apiPanelBounds
1198
+ this._apiPanelWidthPx = Math.max(min, Math.min(max, Math.round(baseRight - e.clientX)))
1199
+ },
1200
+ () => {
1201
+ this.apiDragging = false
1202
+ this.saveApiPanelWidth()
1203
+ }
1204
+ )
1038
1205
  },
1039
1206
  // 读取上次保存的预览列宽度(像素);首次打开(无记录)时保持默认 372px
1040
1207
  loadPreviewWidth() {
@@ -1055,40 +1222,10 @@ export default {
1055
1222
  // 预设尺寸切换:375 / 188 一键设定预览列宽度;高度随列高动态保持一致(由 CSS flex 自动填充)
1056
1223
  setPresetWidth(w) {
1057
1224
  if (this.chromeOff) return
1058
- const total = this.stageWidth || 1
1059
- const minW = 188
1060
- const maxW = Math.max(minW, Math.floor(total / 2)) // 最大不得超过可视宽度的一半
1061
- this._previewWidthPx = Math.max(minW, Math.min(maxW, w))
1225
+ const { min, max } = this.previewBounds
1226
+ this._previewWidthPx = Math.max(min, Math.min(max, w))
1062
1227
  this.savePreviewWidth()
1063
1228
  },
1064
- // —— 接口调用面板宽度调节 ——
1065
- // 拖拽分隔条:拖动改变右栏宽度(向左拖加宽、向右拖收窄),松手持久化。
1066
- // 约束:右栏 [320px, 舞台宽 - 预览列宽 - 中栏最小宽(屏宽 1/3)],与 apiPanelWidth computed 一致
1067
- startApiDrag() {
1068
- if (this.chromeOff || !this.apiPanelOpen) return
1069
- this.apiDragging = true
1070
- document.body.style.cursor = 'col-resize'
1071
- document.body.style.userSelect = 'none'
1072
- const stage = this.$refs.stageEl
1073
- this._apiDragMove = (ev) => {
1074
- const rect = stage.getBoundingClientRect()
1075
- const minW = 320 // 右栏最小宽度
1076
- const midMin = Math.floor(window.innerWidth / 3) // 中栏最小宽度:屏宽 1/3
1077
- const maxW = Math.max(minW, Math.floor(rect.width - this.previewWidth - midMin))
1078
- const w = Math.max(minW, Math.min(maxW, rect.right - ev.clientX))
1079
- this._apiPanelWidthPx = w
1080
- }
1081
- this._apiDragUp = () => {
1082
- this.apiDragging = false
1083
- document.body.style.cursor = ''
1084
- document.body.style.userSelect = ''
1085
- document.removeEventListener('mousemove', this._apiDragMove)
1086
- document.removeEventListener('mouseup', this._apiDragUp)
1087
- this.saveApiPanelWidth()
1088
- }
1089
- document.addEventListener('mousemove', this._apiDragMove)
1090
- document.addEventListener('mouseup', this._apiDragUp)
1091
- },
1092
1229
  // 读取上次保存的接口调用面板宽度(像素);首次打开(无记录)时保持默认 360px
1093
1230
  loadApiPanelWidth() {
1094
1231
  try {
@@ -1111,20 +1248,35 @@ export default {
1111
1248
  },
1112
1249
  onSelect(id) {
1113
1250
  this.loadingCard = true
1251
+ // 乐观置位:下拉立刻跟手。持久化交给 loadCard —— 载入成功才记,
1252
+ // 避免把一张打不开的卡记成「上次选中」,下次进页面直接白屏
1253
+ this.selectedId = id
1254
+ this.loadCard(id, { clearAll: true })
1255
+ },
1256
+ /**
1257
+ * 置为「当前卡」并持久化(下次打开页面自动恢复,沿用 cardbuddy: 前缀约定)。
1258
+ * 顶栏下拉、网格高亮、校验徽标、RenderTree 的 key、导入元数据的过期判定全读 selectedId,
1259
+ * 所以由 loadCard 统一置位:任何调用点(下拉选中、网格点卡、新增空白卡、删除后切下一张)
1260
+ * 都不会再出现「画布切过去了、下拉还停在旧卡」的错位。
1261
+ */
1262
+ selectCard(id) {
1114
1263
  this.selectedId = id
1115
- // 持久化当前选中卡片,下次打开页面自动恢复(沿用 cardbuddy: 前缀约定)
1116
1264
  try {
1117
1265
  localStorage.setItem('cardbuddy:selected-card', id)
1118
1266
  } catch (e) {
1119
1267
  /* localStorage 不可用时忽略 */
1120
1268
  }
1121
- this.loadCard(id, { clearAll: true })
1122
1269
  },
1123
1270
  /** 打开卡片网格预览弹窗 */
1124
1271
  openGallery() {
1125
1272
  if (!this.cardList.length) return
1126
1273
  this.galleryOpen = true
1127
1274
  },
1275
+ /** 打开「新增空白卡片」弹窗:先刷一次列表,重名判定才拿得到最新本地卡片(别的窗口 / 同步远程可能刚建过卡) */
1276
+ openNewCard() {
1277
+ this.newCardOpen = true
1278
+ this.fetchCardList()
1279
+ },
1128
1280
  /** 关闭卡片网格预览弹窗 */
1129
1281
  closeGallery() {
1130
1282
  this.galleryOpen = false
@@ -1215,18 +1367,89 @@ export default {
1215
1367
  // 渲染端点异常不强行判错(避免 dev server 预热期 SSR 冷启动误报)
1216
1368
  const renderOk = r ? !!r.renderOk : true
1217
1369
  const ok = contractOk && renderOk
1370
+ const next = ok ? 'ok' : 'error'
1371
+ const changed = this.validateBadge.state !== next
1218
1372
  this.validateBadge = {
1219
- state: ok ? 'ok' : 'error',
1373
+ state: next,
1220
1374
  errors: (v.errors || []).length,
1221
1375
  warnings: (v.warnings || []).length,
1222
1376
  renderOk,
1223
1377
  ts: Date.now(),
1224
1378
  }
1379
+ if (changed && (next === 'ok' || next === 'error')) this.celebrateValidate(next)
1225
1380
  } catch (e) {
1226
- // 网络/服务不可用:保底不阻断编辑,标记 fail
1381
+ // 网络/服务不可用:保底不阻断编辑,标记 fail(fail 不触发动画)
1227
1382
  this.validateBadge = { ...this.validateBadge, state: 'fail' }
1228
1383
  }
1229
1384
  },
1385
+ /** 状态跳变时,在徽标局部触发庆祝动画:徽标弹跳 + 碎玻璃碎屑沿轮廓放射爆开,碎屑颜色跟随目标状态主色。仅局部,不铺屏。 */
1386
+ celebrateValidate(state = 'ok') {
1387
+ const el = this.$refs.validateBadgeEl
1388
+ if (!el) return
1389
+ if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
1390
+ // 徽标弹跳
1391
+ el.classList.remove('is-celebrating')
1392
+ void el.offsetWidth // 重排以重启动画
1393
+ el.classList.add('is-celebrating')
1394
+ setTimeout(() => el.classList.remove('is-celebrating'), 600)
1395
+ // 碎屑颜色跟随目标状态徽标主色(--ok / --danger / --warn / --text-faint)
1396
+ const r = el.getBoundingClientRect()
1397
+ const colorVar = { ok: '--ok', error: '--danger', pending: '--warn', fail: '--text-faint', nopython: '--text-faint' }[state] || '--ok'
1398
+ const fallback = { ok: '#52c41a', error: '#F5222D', pending: '#FAAD14', fail: '#8c8c8c', nopython: '#8c8c8c' }[state] || '#52c41a'
1399
+ const badgeColor = getComputedStyle(el).getPropertyValue(colorVar).trim() || fallback
1400
+ const box = document.createElement('div')
1401
+ box.className = 'app__confetti'
1402
+ box.style.left = r.left + r.width / 2 + 'px'
1403
+ box.style.top = r.top + r.height / 2 + 'px'
1404
+ // 碎玻璃碎片:从徽标矩形轮廓迸出,沿外法线方向飞散;不规则尖锐多边形 + 尺寸不一
1405
+ const count = 22
1406
+ const clips = [
1407
+ 'polygon(50% 0%, 0% 100%, 100% 100%)',
1408
+ 'polygon(62% 0%, 100% 40%, 45% 100%, 0% 52%)',
1409
+ 'polygon(0% 0%, 100% 18%, 82% 100%, 12% 70%)',
1410
+ 'polygon(22% 0%, 100% 8%, 88% 100%, 0% 76%)',
1411
+ 'polygon(0% 32%, 100% 0%, 76% 100%, 18% 86%)',
1412
+ 'polygon(8% 0%, 100% 22%, 70% 100%, 0% 64%)',
1413
+ 'polygon(0% 12%, 100% 0%, 100% 78%, 30% 100%, 12% 55%)',
1414
+ ]
1415
+ const halfW = r.width / 2
1416
+ const halfH = r.height / 2
1417
+ for (let i = 0; i < count; i++) {
1418
+ const bit = document.createElement('span')
1419
+ bit.className = 'app__confetti-bit'
1420
+ // 在徽标矩形轮廓上随机取一点作为迸发起点
1421
+ const edge = (Math.random() * 4) | 0
1422
+ let px, py
1423
+ if (edge === 0) { px = (Math.random() * 2 - 1) * halfW; py = -halfH }
1424
+ else if (edge === 1) { px = halfW; py = (Math.random() * 2 - 1) * halfH }
1425
+ else if (edge === 2) { px = (Math.random() * 2 - 1) * halfW; py = halfH }
1426
+ else { px = -halfW; py = (Math.random() * 2 - 1) * halfH }
1427
+ // 沿轮廓点的外法线方向飞出 + 微抖动
1428
+ const len = Math.hypot(px, py) || 1
1429
+ const a = Math.atan2(py / len, px / len) + (Math.random() - 0.5) * 0.5
1430
+ const dist = 16 + Math.random() * 26
1431
+ bit.style.setProperty('--sx', px.toFixed(1) + 'px')
1432
+ bit.style.setProperty('--sy', py.toFixed(1) + 'px')
1433
+ bit.style.setProperty('--tx', (Math.cos(a) * dist).toFixed(1) + 'px')
1434
+ bit.style.setProperty('--ty', (Math.sin(a) * dist).toFixed(1) + 'px')
1435
+ bit.style.setProperty('--rot', (Math.random() * 720 - 360).toFixed(0) + 'deg')
1436
+ bit.style.setProperty('--clip', clips[(Math.random() * clips.length) | 0])
1437
+ bit.style.setProperty('--c', badgeColor)
1438
+ // 每片独立透明度,半透明玻璃感
1439
+ bit.style.setProperty('--o', (0.68 + Math.random() * 0.27).toFixed(2))
1440
+ // 三层尺寸分布:长薄片 / 中等碎块 / 细微粉尘,强化碎裂参差感
1441
+ const roll = Math.random()
1442
+ let w, h
1443
+ if (roll < 0.35) { w = 1.5 + Math.random() * 2; h = 10 + Math.random() * 10 } // 细长薄片
1444
+ else if (roll < 0.75) { w = 4 + Math.random() * 5; h = w * (0.6 + Math.random() * 0.8) } // 中等碎块
1445
+ else { w = 2 + Math.random() * 2.5; h = w } // 细微粉尘
1446
+ bit.style.width = w.toFixed(0) + 'px'
1447
+ bit.style.height = h.toFixed(0) + 'px'
1448
+ box.appendChild(bit)
1449
+ }
1450
+ document.body.appendChild(box)
1451
+ setTimeout(() => box.remove(), 800)
1452
+ },
1230
1453
  /** 实时从磁盘读取并载入指定卡片(GET /__card/<id>),不读任何缓存 */
1231
1454
  async loadCard(id, opts = {}) {
1232
1455
  if (!id) return
@@ -1242,6 +1465,9 @@ export default {
1242
1465
  this.rt.pushError('loadCard', 'schema 读取失败')
1243
1466
  return
1244
1467
  }
1468
+ // 载入成功即认定这张卡为当前卡:必须在 fetchImportMeta / refreshValidateBadge 之前,
1469
+ // 二者都以 selectedId 作「是否已切卡」的判据,晚置位会把新卡的结果当过期丢掉
1470
+ this.selectCard(id)
1245
1471
  // 切换卡片:清全部错误;刷新/重建预览:只清绑定错误,保留事件错误
1246
1472
  if (opts.clearAll) {
1247
1473
  this.rt.clearErrors()
@@ -1251,7 +1477,7 @@ export default {
1251
1477
  this.rt.clearApiCalls()
1252
1478
  // schema 被事件编辑器改后重建时保留当前数据绑定编辑,避免被磁盘 data(由 schema 派生)覆盖。
1253
1479
  // 数据绑定改为由 schema 单向派生,不再读回 data.json 文件。
1254
- if (!opts.keepScope) this.rt.setRootScope(buildDataFromSchema(fresh.schema))
1480
+ if (!opts.keepScope) this.rt.setRootScope(buildDataFromSchema(fresh.schema, WITH_MOCKS))
1255
1481
  this.currentCard = { schema: fresh.schema }
1256
1482
  this.currentSchema = fresh.schema
1257
1483
  // 重置并在载入后拉取导入来源元数据(Phase E):本地新建卡无 .import-meta.json → null,
@@ -1261,7 +1487,7 @@ export default {
1261
1487
  // 记录本地真实版本基线(磁盘当前内容),供「保存到本地」按钮判断与本地是否一致。
1262
1488
  // localData 由 schema 派生(与渲染注入同源),保证纯数据编辑也能触发脏标记。
1263
1489
  this.localSchema = this.cloneSchema(fresh.schema)
1264
- this.localData = this.cloneData(buildDataFromSchema(fresh.schema))
1490
+ this.localData = this.cloneData(buildDataFromSchema(fresh.schema, WITH_MOCKS))
1265
1491
  this.preview = buildPreview(fresh.schema)
1266
1492
  // Task #5:把 Property 树模型森林暴露到 window,供浏览器内 AI / skill 直接读取节点可编辑结构
1267
1493
  if (typeof window !== 'undefined') window.__cardModel = buildModelView(fresh.schema)
@@ -1339,7 +1565,7 @@ export default {
1339
1565
  if (!id || !snapshot) return
1340
1566
  const schema = snapshot.schema || snapshot
1341
1567
  // 快照只存 schema;数据绑定由其派生。若老快照带 data 则优先沿用(历史兼容)。
1342
- const data = snapshot.data != null ? snapshot.data : buildDataFromSchema(schema)
1568
+ const data = snapshot.data != null ? snapshot.data : buildDataFromSchema(schema, WITH_MOCKS)
1343
1569
  this.currentSchema = this.cloneSchema(schema)
1344
1570
  this.rt.setRootScope(this.cloneData(data))
1345
1571
  this.preview = buildPreview(this.currentSchema)
@@ -1355,7 +1581,7 @@ export default {
1355
1581
  commitHistory() {
1356
1582
  if (!this.currentSchema || !this.baseline) return
1357
1583
  const sameSchema = this.structuralEqual(this.currentSchema, this.baseline.schema)
1358
- const sameData = this.dataEqual(this.rt.rootScope, buildDataFromSchema(this.baseline.schema))
1584
+ const sameData = this.dataEqual(this.rt.rootScope, buildDataFromSchema(this.baseline.schema, WITH_MOCKS))
1359
1585
  if (sameSchema && sameData) return
1360
1586
  const previous = { schema: this.baseline.schema }
1361
1587
  this.past.push(previous)
@@ -1396,7 +1622,7 @@ export default {
1396
1622
  // 取末尾最多 30 个作为可回退的上一版本(最新在末尾;仅 schema,data 由 schema 派生)
1397
1623
  this.past = snaps.slice(-30).map((s) => ({
1398
1624
  schema: s.schema,
1399
- data: s.data != null ? s.data : buildDataFromSchema(s.schema),
1625
+ data: s.data != null ? s.data : buildDataFromSchema(s.schema, WITH_MOCKS),
1400
1626
  }))
1401
1627
  } catch (e) {
1402
1628
  console.warn('[加载编辑历史失败]', e)
@@ -1461,10 +1687,11 @@ export default {
1461
1687
  this.commitHistory()
1462
1688
  },
1463
1689
  /**
1464
- * 把数据绑定(rt.rootScope,即 data.json)的编辑实时同步回 schema 的
1465
- * 权威数据位置:schema.dictionary.inputParams[i].defaultValue。
1466
- * 这样「本地保存」时 schema.json 与 data.json 都携带最新数据绑定,
1467
- * 二者同时生效、不会互相覆盖(data.json 本身即由 inputParams.defaultValue 派生)。
1690
+ * 把数据绑定(rt.rootScope)的编辑实时同步回 schema 的权威数据位:
1691
+ * key 已在 dictionary.mocks 中 → 回写 mocks[key](设计期覆盖仓);
1692
+ * 否则回写 schema.dictionary.inputParams[i].defaultValue(随卡发布的缺省仓)。
1693
+ * 这样「本地保存」时 schema.json 携带最新两仓数据,而 data.json 仍只由 defaultValue 派生,
1694
+ * mock 不会泄漏进发布数据;两仓也不会互相覆盖。
1468
1695
  */
1469
1696
  syncDataToSchema() {
1470
1697
  const schema = this.currentSchema
@@ -1472,13 +1699,14 @@ export default {
1472
1699
  const root = this.rt && this.rt.rootScope
1473
1700
  if (!root || typeof root !== 'object') return
1474
1701
  const params = schema.dictionary.inputParams
1702
+ const mocks = readMocks(schema)
1475
1703
  for (const key of Object.keys(root)) {
1476
1704
  const param = params.find((p) => p && (p.key === key || p.name === key))
1477
1705
  if (!param) continue
1478
1706
  // 深拷贝,避免数据绑定与 schema 共享同一引用(setRootScope 重建后会失联)
1479
- param.defaultValue = JSON.parse(
1480
- JSON.stringify(root[key], (k, v) => (k === '_schemaNode' ? undefined : v))
1481
- )
1707
+ const value = JSON.parse(JSON.stringify(root[key], (k, v) => (k === '_schemaNode' ? undefined : v)))
1708
+ if (mocks && Object.prototype.hasOwnProperty.call(mocks, key)) mocks[key] = value
1709
+ else param.defaultValue = value
1482
1710
  }
1483
1711
  },
1484
1712
  /** 结构树选中节点 */
@@ -1653,6 +1881,16 @@ export default {
1653
1881
  clearHoverEl()
1654
1882
  this.scheduleCommit()
1655
1883
  },
1884
+ /** 页面是否存在非空文本选区:有则视为「文本复制/粘贴」意图,交还原生行为 */
1885
+ hasTextSelection() {
1886
+ if (typeof window === 'undefined' || !window.getSelection) return false
1887
+ const sel = window.getSelection()
1888
+ return !!sel && !sel.isCollapsed && sel.toString().trim() !== ''
1889
+ },
1890
+ /** 是否存在可用于节点操作的选区(选中了非根节点):节点复制/粘贴/副本的前提 */
1891
+ hasNodeSelection() {
1892
+ return !!this.selectedNodeId && this.selectedNodeId !== '_root'
1893
+ },
1656
1894
  /** 判断按键事件焦点是否在可编辑元素内(输入框/文本域/可选文本),此时不劫持快捷键 */
1657
1895
  isEditableTarget(e) {
1658
1896
  const t = e.target
@@ -1668,6 +1906,11 @@ export default {
1668
1906
  * Ctrl/⌘+Z 撤销 · Ctrl/⌘+Shift+Z / Ctrl+Y 重做 · Esc 取消选中。
1669
1907
  * 焦点在输入框/JSON 编辑器等可编辑元素内时,仅放行 Esc,其余交还原生行为
1670
1908
  * (避免误删节点或劫持文本撤销/复制)。
1909
+ * 节点复制/粘贴与系统文本复制/粘贴共存判据(按优先级):
1910
+ * 1) 焦点可编辑 → 一律原生(文本撤销/复制/粘贴);
1911
+ * 2) 存在非空文本选区 → 一律原生(用户意图是文本);
1912
+ * 3) 节点类操作还要求已选中非根节点(粘贴另要求节点剪贴板非空),
1913
+ * 否则不 preventDefault,把按键还给浏览器(如 ⌘D 书签)。
1671
1914
  */
1672
1915
  onKeydown(e) {
1673
1916
  if (this.chromeOff || !this.preview) return
@@ -1677,18 +1920,21 @@ export default {
1677
1920
 
1678
1921
  if (ctrl && (k === 'c' || k === 'C')) {
1679
1922
  if (editable) return
1923
+ if (this.hasTextSelection() || !this.hasNodeSelection()) return
1680
1924
  this.copySelectedNode()
1681
1925
  e.preventDefault()
1682
1926
  return
1683
1927
  }
1684
1928
  if (ctrl && (k === 'v' || k === 'V')) {
1685
1929
  if (editable) return
1930
+ if (this.hasTextSelection() || !this.hasNodeSelection() || !getClipboardNode()) return
1686
1931
  this.pasteNode()
1687
1932
  e.preventDefault()
1688
1933
  return
1689
1934
  }
1690
1935
  if (ctrl && (k === 'd' || k === 'D')) {
1691
1936
  if (editable) return
1937
+ if (this.hasTextSelection() || !this.hasNodeSelection()) return
1692
1938
  this.duplicateNode()
1693
1939
  e.preventDefault()
1694
1940
  return
@@ -2283,7 +2529,10 @@ export default {
2283
2529
  // 写盘成功后,当前显示版本即成为本地真实版本:刷新本地基线,使顶栏
2284
2530
  // 「保存到本地」按钮在「当前版本 == 本地版本」时自动置灰。
2285
2531
  this.localSchema = this.cloneSchema(this.currentSchema)
2286
- this.localData = this.cloneData(buildDataFromSchema(this.currentSchema))
2532
+ // 基线数据必须与载入 / 历史快照同源(含 mocks):rt.rootScope 是含 mock 覆盖的,
2533
+ // 若此处漏传 WITH_MOCKS,只要存在 mock 数据,保存后 rootScope 与 localData 永远
2534
+ // 不等 → historyDirty 恒真,「保存到本地」按钮保存成功也保持高亮。
2535
+ this.localData = this.cloneData(buildDataFromSchema(this.currentSchema, WITH_MOCKS))
2287
2536
  this.savedCard = true
2288
2537
  // 本地保存后磁盘已更新,刷新整卡校验徽标(端点读磁盘)
2289
2538
  this.refreshValidateBadge()
@@ -2479,6 +2728,37 @@ export default {
2479
2728
  console.error('[加载卡片列表失败]', e)
2480
2729
  }
2481
2730
  },
2731
+ /**
2732
+ * 网格预览删除卡片:格子删除钮 → 警告确认 → 这里真删(DELETE /__card/<id> 递归删本地目录)。
2733
+ * 删完刷列表;若删的正是当前卡,切到剩余首卡,避免工作台指向已不存在的目录(保存会 404)。
2734
+ */
2735
+ async onGalleryDelete(id) {
2736
+ try {
2737
+ const res = await apiFetch(`/__card/${encodeURIComponent(id)}`, { method: 'DELETE' })
2738
+ const r = await res.json().catch(() => null)
2739
+ if (!res.ok) throw new Error((r && (r.message || r.error)) || `HTTP ${res.status}`)
2740
+ showAppToast(`已删除本地卡片 ${id}`, 'ok')
2741
+ await this.fetchCardList()
2742
+ if (this.selectedId === id) {
2743
+ const next = this.cardList[0]
2744
+ if (next) await this.loadCard(next.id)
2745
+ else {
2746
+ this.selectedId = ''
2747
+ this.currentSchema = null
2748
+ this.preview = null
2749
+ }
2750
+ }
2751
+ } catch (e) {
2752
+ showAppToast(`删除卡片失败:${e.message}`, 'err')
2753
+ }
2754
+ },
2755
+ /** 新增空白卡片创建成功:关弹窗 → 刷列表 → 切换到新卡(空白卡无封面,网格显示占位图) */
2756
+ async onNewCardCreated(id) {
2757
+ this.newCardOpen = false
2758
+ showAppToast(`已创建空白卡片 ${id}`, 'ok')
2759
+ await this.fetchCardList()
2760
+ await this.loadCard(id)
2761
+ },
2482
2762
  /** 刷新当前卡片:从磁盘重新读取 schema/data 并强制重挂载 */
2483
2763
  async onRefresh() {
2484
2764
  const id = this.selectedId