cardbuddy 0.6.0 → 0.7.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.
@@ -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' }">
6
+ <div class="app" :class="{ 'app--chrome-off': chromeOff }" :style="{ '--preview-width': previewWidth + 'px', '--api-panel-width': apiPanelWidth + '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="" />
@@ -74,23 +74,23 @@
74
74
  <span>{{ copiedSchema ? '已复制 ✓' : '复制' }}</span>
75
75
  </button>
76
76
  </div>
77
- <!-- 远端:合并「远端保存 / 远端预览 / 全量同步 / 选中卡片同步」为一个分组,组名「远端」,云图标 -->
77
+ <!-- 远端:合并「远端保存 / 远端预览 / 切图上传 / 全量同步 / 选中卡片同步」为一个分组,组名「远端」,云图标 -->
78
78
  <Dropdown
79
79
  class="app__remote-menu"
80
80
  size="md"
81
81
  placement="bottom-end"
82
- :disabled="savingRemote || syncingRemote"
83
- :title="savingRemote ? '正在保存至远端…' : (syncingRemote ? '同步中…' : '远端操作:保存到 FinMall / 真机预览 / 全量或选中卡片同步')"
82
+ :disabled="savingRemote || syncingRemote || uploadingSlices"
83
+ :title="savingRemote ? '正在保存至远端…' : (syncingRemote ? '同步中…' : (uploadingSlices ? '正在上传切图…' : '远端操作:保存到 FinMall / 真机预览 / 切图上传 / 全量或选中卡片同步'))"
84
84
  data-testid="remote-group"
85
85
  >
86
86
  <template #trigger="{ open }">
87
87
  <button
88
88
  type="button"
89
89
  class="app__btn app__btn--remote"
90
- :class="{ 'app__btn--warn': savedRemote && verifiedRemote === false, 'app__remote-trigger--active': open, 'is-processing': savingRemote || syncingRemote }"
91
- :disabled="savingRemote || syncingRemote"
90
+ :class="{ 'app__btn--warn': savedRemote && verifiedRemote === false, 'app__remote-trigger--active': open, 'is-processing': savingRemote || syncingRemote || uploadingSlices }"
91
+ :disabled="savingRemote || syncingRemote || uploadingSlices"
92
92
  >
93
- <svg v-if="savingRemote || syncingRemote" class="app__icon app__icon--spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-loading" /></svg>
93
+ <svg v-if="savingRemote || syncingRemote || uploadingSlices" class="app__icon app__icon--spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-loading" /></svg>
94
94
  <svg v-else-if="savedRemote && verifiedRemote !== false" 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-cloud-check" /></svg>
95
95
  <svg v-else-if="savedRemote && verifiedRemote === false" 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-cloud-alert" /></svg>
96
96
  <svg v-else 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-cloud" /></svg>
@@ -115,8 +115,30 @@
115
115
  <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-refresh" /></svg>
116
116
  <span>全量同步</span>
117
117
  </li>
118
+ <!-- 切图上传:仅点击时执行,把「当前卡片引用到的」src/slices 切图上传到 FinMall 图片库
119
+ (CC0000.do),并把该卡 schema/data 里的 /slices/ 引用改写为返回的 @filestore/ 线上地址。
120
+ 没有任何自动上传路径;共享切图若已上传过(sha256 命中 src/slices/manifest.json)直接复用地址。
121
+ 不要求 hasCardSource:本地自建卡同样需要先把切图传上去,才能推版本 / 出真机预览。 -->
122
+ <li class="app__remote-item app__remote-item--upload" :class="{ 'is-disabled': !preview || !selectedId || uploadingSlices }" :title="uploadingSlices ? '正在上传切图…' : '切图上传:把当前卡片引用的本地切图(src/slices)上传到 FinMall 图片库,并把卡片里的 /slices/ 引用替换为线上地址(@filestore/…);本地切图文件保留'" data-testid="slice-upload" role="menuitem" @click="chooseRsave('slices'); close()">
123
+ <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-image-upload" /></svg>
124
+ <span>切图上传</span>
125
+ </li>
118
126
  </ul></template>
119
127
  </Dropdown>
128
+ <!-- 接口调用面板开关:右侧常驻面板的展开/收起,状态持久化 -->
129
+ <button
130
+ class="app__btn app__btn--icon"
131
+ :class="{ 'app__btn--active': apiPanelOpen }"
132
+ type="button"
133
+ :title="apiPanelOpen ? '收起接口调用面板' : '打开接口调用面板'"
134
+ :aria-pressed="apiPanelOpen"
135
+ @click="toggleApiPanel"
136
+ >
137
+ <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">
138
+ <rect x="3" y="4" width="18" height="16" rx="2" />
139
+ <line x1="15" y1="4" x2="15" y2="20" />
140
+ </svg>
141
+ </button>
120
142
  <!-- 主题切换:深色(当前)/ 浅色,复用同名 CSS 变量整体换肤,状态持久化 -->
121
143
  <button
122
144
  class="theme-toggle"
@@ -315,33 +337,59 @@
315
337
  title="拖拽调整预览区域宽度"
316
338
  ></div>
317
339
 
318
- <DebugPanel
319
- v-show="!chromeOff"
320
- :rt="rt"
321
- :preview="preview"
322
- :schema="currentSchema"
323
- :card-id="selectedId"
324
- :reload-token="reloadToken"
325
- :card-count="cardCount"
326
- :selected-node-id="selectedNodeId"
327
- :move-collapse-token="moveCollapseToken"
328
- :active-tab="activeTab"
329
- @schema-changed="onSchemaChanged"
330
- @schema-commit="onSchemaCommit"
331
- @select-node="onSelectNode"
332
- @update:active-tab="activeTab = $event"
333
- @add-node="onAddNode"
334
- @move-node="onMoveNode"
335
- @hover-node="onHoverNode"
336
- @copy-selected="copySelectedNode"
337
- @paste="pasteNode"
338
- @duplicate="duplicateNode"
339
- @delete-selected="deleteSelectedNode"
340
- @reset-import="onRefresh"
341
- :dev-svc-class="devSvcClass"
342
- :dev-svc-title="devSvcTitle"
343
- :dev-svc-label="devSvcLabel"
344
- />
340
+ <!-- 拖拽分隔条:位于调试面板与右侧「接口调用」面板之间,拖动调整右栏宽度。
341
+ 约束:中栏(调试面板)最小宽度 ≥ 屏宽 1/3,右栏最小宽度 ≥ 320px(见 startApiDrag) -->
342
+ <div
343
+ v-show="!chromeOff && apiPanelOpen"
344
+ class="splitter splitter--api"
345
+ :class="{ 'is-dragging': apiDragging }"
346
+ @mousedown.prevent="startApiDrag"
347
+ title="拖拽调整接口调用面板宽度"
348
+ ></div>
349
+
350
+ <!-- 中右工作区:上为「调试面板 + 接口调用面板」两栏,下为贯穿两栏的共同状态栏。
351
+ 状态栏由 DebugPanel 内 Teleport 注入 #app-workside-bar;Teleport 目标须在 DOM 中
352
+ 先于面板挂载,故 .workside__bar 写在前面,以 flex order 视觉置底。 -->
353
+ <div class="workside" v-show="!chromeOff">
354
+ <div id="app-workside-bar" class="workside__bar"></div>
355
+ <div class="workside__cols">
356
+ <DebugPanel
357
+ :chrome-off="chromeOff"
358
+ :rt="rt"
359
+ :preview="preview"
360
+ :schema="currentSchema"
361
+ :card-id="selectedId"
362
+ :reload-token="reloadToken"
363
+ :card-count="cardCount"
364
+ :selected-node-id="selectedNodeId"
365
+ :move-collapse-token="moveCollapseToken"
366
+ :active-tab="activeTab"
367
+ @schema-changed="onSchemaChanged"
368
+ @schema-commit="onSchemaCommit"
369
+ @select-node="onSelectNode"
370
+ @update:active-tab="activeTab = $event"
371
+ @add-node="onAddNode"
372
+ @move-node="onMoveNode"
373
+ @hover-node="onHoverNode"
374
+ @copy-selected="copySelectedNode"
375
+ @paste="pasteNode"
376
+ @duplicate="duplicateNode"
377
+ @delete-selected="deleteSelectedNode"
378
+ @reset-import="onRefresh"
379
+ :dev-svc-class="devSvcClass"
380
+ :dev-svc-title="devSvcTitle"
381
+ :dev-svc-label="devSvcLabel"
382
+ />
383
+
384
+ <!-- 右侧常驻「接口调用」面板:与预览列 / 调试面板呈左中右布局,
385
+ 展开/收起由顶栏按钮与面板内 ✕ 控制,初始状态持久化 -->
386
+ <ApiCallPanel
387
+ v-if="apiPanelOpen"
388
+ :rt="rt"
389
+ @close="apiPanelOpen = false"
390
+ />
391
+ </div>
392
+ </div>
345
393
  </main>
346
394
  <!-- 远端预览二维码弹层:点击顶栏「远端预览」按钮打开,手机(FinMall App)扫码真机查看 -->
347
395
  <RemotePreview
@@ -413,7 +461,7 @@
413
461
  :message="confirm.message"
414
462
  :confirm-text="confirm.confirmText"
415
463
  :cancel-text="confirm.cancelText"
416
- :danger="confirm.danger"
464
+ :severity="confirm.severity"
417
465
  @confirm="onConfirmDialog"
418
466
  @cancel="onCancelDialog"
419
467
  />
@@ -444,6 +492,7 @@ import {
444
492
  } from '@/runtime/clipboard-node.js'
445
493
  import RenderTree from '@/components/RenderTree.vue'
446
494
  import DebugPanel from '@/components/DebugPanel.vue'
495
+ import ApiCallPanel from '@/components/ApiCallPanel.vue'
447
496
  import CardSelect from '@/components/CardSelect.vue'
448
497
  import AlertCardLayer from '@/components/AlertCardLayer.vue'
449
498
  import DeviceOverlay from '@/components/DeviceOverlay.vue'
@@ -482,7 +531,7 @@ if (typeof window !== 'undefined' && import.meta.env.DEV) window.__rt = cardRunt
482
531
 
483
532
  export default {
484
533
  name: 'App',
485
- components: { RenderTree, DebugPanel, CardSelect, AlertCardLayer, DeviceOverlay, PickerOverlay, DatePickerOverlay, SelectionOverlay, HoverGuides, RemotePreview, AppToast, ConfirmDialog, CardGallery, ComponentPalette, Switch },
534
+ components: { RenderTree, DebugPanel, ApiCallPanel, CardSelect, AlertCardLayer, DeviceOverlay, PickerOverlay, DatePickerOverlay, SelectionOverlay, HoverGuides, RemotePreview, AppToast, ConfirmDialog, CardGallery, ComponentPalette, Switch },
486
535
  provide() {
487
536
  return {
488
537
  adapter,
@@ -536,9 +585,12 @@ export default {
536
585
  localSchema: null,
537
586
  localData: null,
538
587
  selectedNodeId: '',
539
- // 当前激活的调试面板 tab(接口调用/绑定数据/组件结构/卡片来源)。
588
+ // 当前激活的调试面板 tab(绑定数据/组件结构/卡片来源)。
540
589
  // 画布 Ctrl/⌘ 点击会自动切到「组件结构」tab 并在节点树聚焦选中,无需预先停在结构树 tab。
541
590
  activeTab: 'scope',
591
+ // 右侧常驻「接口调用」面板:展开/收起由顶栏按钮与面板内 ✕ 控制,
592
+ // 初始状态持久化到 localStorage(cardbuddy:api-panel-open)
593
+ apiPanelOpen: true,
542
594
  // 组件结构树「添加」面板:addNodePanelOpen 控制弹层显隐;addNodeTarget 记录
543
595
  // 插入锚点与模式({ node, mode },来自结构树 add-node 事件):
544
596
  // node = 右键/选中 viewNode(null 或 Root → 视作根);mode 决定落位规则:
@@ -565,20 +617,22 @@ export default {
565
617
  remotePreviewOpen: false, // 「远端预览」二维码弹层开关
566
618
  syncTarget: null, // 'full' | 'selected' | null —— 标记上一次/正在进行的同步模式,用于按钮反馈
567
619
  syncTimer: null,
620
+ uploadingSlices: false, // 「切图上传」进行中(远端分组,仅用户点击触发,无自动上传)
568
621
  // 登录凭据弹窗(远程同步缺凭据时弹出,填写账号密码写入 .local/credentials.json)
569
622
  loginModal: false,
570
623
  loginName: '',
571
624
  loginPassword: '',
572
625
  loginSubmitting: false,
573
626
  loginError: '',
574
- // 二次确认弹窗(版本推送 / 当前同步 / 全量同步 等写/覆盖类操作前弹出,确认后才执行 onConfirm)
627
+ // 二次确认弹窗(版本推送 / 当前同步 / 全量同步 等写/覆盖类操作前弹出,确认后才执行 onConfirm)。
628
+ // severity 三档:primary 常规 / warn 警告(橙黄+警告三角,覆盖类操作:版本推送、全量同步)/ danger 危险(红+禁止,仅明确负面结果如删除,暂无内置操作使用)
575
629
  confirm: {
576
630
  show: false,
577
631
  title: '',
578
632
  message: '',
579
633
  confirmText: '继续',
580
634
  cancelText: '取消',
581
- danger: false,
635
+ severity: 'primary',
582
636
  onConfirm: null,
583
637
  },
584
638
  // 编辑历史(撤销/重做):每个快照为 { schema, data },data 即 rt.rootScope(数据绑定),
@@ -599,6 +653,7 @@ export default {
599
653
  ],
600
654
  // —— 预览区宽度可调(拖拽分隔条 / 预设尺寸,画布按实际像素宽度布局,无 transform 缩放)——
601
655
  _previewWidthPx: 372, // 预览列宽度(像素,持久化到 localStorage);第一次打开未主动设置时默认 372px
656
+ _apiPanelWidthPx: 360, // 右侧「接口调用」面板宽度(像素,持久化到 localStorage);默认 360px
602
657
  presetSizes: [ // 预设尺寸下拉选项(仅宽度;高度随列高动态一致)
603
658
  { key: 375, value: '375' },
604
659
  { key: 430, value: '430' },
@@ -607,6 +662,7 @@ export default {
607
662
  stageWidth: typeof window !== 'undefined' ? window.innerWidth : 1280, // 舞台实际宽度(ResizeObserver 实测后更新)
608
663
  stageHeight: typeof window !== 'undefined' ? window.innerHeight - 52 : 748, // 舞台实际高度(contain 缩放的“高度上限”基准)
609
664
  dragging: false, // 是否正在拖拽分隔条
665
+ apiDragging: false, // 是否正在拖拽「接口调用」面板分隔条
610
666
  _stageObserver: null,
611
667
  _dragMove: null,
612
668
  _dragUp: null,
@@ -745,6 +801,15 @@ export default {
745
801
  canvasW() {
746
802
  return Math.round(this.previewWidth || 375)
747
803
  },
804
+ // 右侧「接口调用」面板宽度(像素):限制在 [320, 舞台宽度 - 预览列宽 - 中栏最小宽(屏宽 1/3)],
805
+ // 保证中栏(调试面板)最小宽度不小于三分之一屏幕宽度、右栏不小于 320px
806
+ apiPanelWidth() {
807
+ const minW = 320 // 右栏最小宽度
808
+ const midMin = Math.floor((typeof window !== 'undefined' ? window.innerWidth : 1280) / 3)
809
+ const maxW = Math.max(minW, Math.floor((this.stageWidth || 0) - this.previewWidth - midMin))
810
+ const w = this._apiPanelWidthPx || 360
811
+ return Math.max(minW, Math.min(maxW, w))
812
+ },
748
813
  // 缩放外框占满整列(宽高交由 CSS flex 填满;画布不再做 transform 缩放,直接按实际尺寸布局)
749
814
  frameStyle() {
750
815
  return {}
@@ -763,6 +828,14 @@ export default {
763
828
  theme(val) {
764
829
  document.documentElement.dataset.theme = val
765
830
  },
831
+ // 「接口调用」面板展开/收起状态变化(顶栏按钮 toggleApiPanel 或面板内 ✕ 关闭)时统一持久化
832
+ apiPanelOpen(val) {
833
+ try {
834
+ localStorage.setItem('cardbuddy:api-panel-open', val ? '1' : '0')
835
+ } catch (e) {
836
+ /* localStorage 不可用时忽略 */
837
+ }
838
+ },
766
839
  },
767
840
  beforeUnmount() {
768
841
  if (this.viewportObserver) this.viewportObserver.disconnect()
@@ -784,6 +857,7 @@ export default {
784
857
  if (this.$refs.phoneViewport) this.viewportObserver.observe(this.$refs.phoneViewport)
785
858
  // 预览区宽度可调:实测舞台尺寸,驱动 previewWidth/frameStyle 自适应窗口变化
786
859
  this.loadPreviewWidth()
860
+ this.loadApiPanelWidth()
787
861
  this._stageObserver = new ResizeObserver((entries) => {
788
862
  for (const entry of entries) {
789
863
  this.stageWidth = Math.round(entry.contentRect.width)
@@ -809,6 +883,13 @@ export default {
809
883
  /* localStorage 不可用时忽略 */
810
884
  }
811
885
  document.documentElement.dataset.theme = this.theme
886
+ // 右侧「接口调用」面板:恢复本地持久化的展开/收起状态(默认展开)
887
+ try {
888
+ const v = localStorage.getItem('cardbuddy:api-panel-open')
889
+ if (v === '0') this.apiPanelOpen = false
890
+ } catch (e) {
891
+ /* localStorage 不可用时忽略 */
892
+ }
812
893
  // 实时拉取卡片列表(不再依赖启动快照),再载入首张卡
813
894
  await this.fetchCardList()
814
895
  // 深链:若 URL 带 ?card=<id> 且命中列表,则预选该卡(供「远端预览」二维码扫码直达)
@@ -956,6 +1037,50 @@ export default {
956
1037
  this._previewWidthPx = Math.max(minW, Math.min(maxW, w))
957
1038
  this.savePreviewWidth()
958
1039
  },
1040
+ // —— 接口调用面板宽度调节 ——
1041
+ // 拖拽分隔条:拖动改变右栏宽度(向左拖加宽、向右拖收窄),松手持久化。
1042
+ // 约束:右栏 [320px, 舞台宽 - 预览列宽 - 中栏最小宽(屏宽 1/3)],与 apiPanelWidth computed 一致
1043
+ startApiDrag() {
1044
+ if (this.chromeOff || !this.apiPanelOpen) return
1045
+ this.apiDragging = true
1046
+ document.body.style.cursor = 'col-resize'
1047
+ document.body.style.userSelect = 'none'
1048
+ const stage = this.$refs.stageEl
1049
+ this._apiDragMove = (ev) => {
1050
+ const rect = stage.getBoundingClientRect()
1051
+ const minW = 320 // 右栏最小宽度
1052
+ const midMin = Math.floor(window.innerWidth / 3) // 中栏最小宽度:屏宽 1/3
1053
+ const maxW = Math.max(minW, Math.floor(rect.width - this.previewWidth - midMin))
1054
+ const w = Math.max(minW, Math.min(maxW, rect.right - ev.clientX))
1055
+ this._apiPanelWidthPx = w
1056
+ }
1057
+ this._apiDragUp = () => {
1058
+ this.apiDragging = false
1059
+ document.body.style.cursor = ''
1060
+ document.body.style.userSelect = ''
1061
+ document.removeEventListener('mousemove', this._apiDragMove)
1062
+ document.removeEventListener('mouseup', this._apiDragUp)
1063
+ this.saveApiPanelWidth()
1064
+ }
1065
+ document.addEventListener('mousemove', this._apiDragMove)
1066
+ document.addEventListener('mouseup', this._apiDragUp)
1067
+ },
1068
+ // 读取上次保存的接口调用面板宽度(像素);首次打开(无记录)时保持默认 360px
1069
+ loadApiPanelWidth() {
1070
+ try {
1071
+ const v = localStorage.getItem('cardbuddy:api-panel-width')
1072
+ if (v != null) {
1073
+ const px = parseInt(v, 10)
1074
+ if (!Number.isNaN(px) && px >= 320) this._apiPanelWidthPx = px
1075
+ }
1076
+ } catch (e) { /* localStorage 不可用时忽略 */ }
1077
+ },
1078
+ // 持久化接口调用面板宽度(像素,取夹取后的实际值)到 localStorage
1079
+ saveApiPanelWidth() {
1080
+ try {
1081
+ localStorage.setItem('cardbuddy:api-panel-width', String(Math.round(this.apiPanelWidth)))
1082
+ } catch (e) { /* 忽略 */ }
1083
+ },
959
1084
  /** 背景下拉:选中某项 */
960
1085
  chooseBg(key) {
961
1086
  this.canvasBg = key
@@ -1020,6 +1145,10 @@ export default {
1020
1145
  /* localStorage 不可用时忽略 */
1021
1146
  }
1022
1147
  },
1148
+ /** 展开/收起右侧常驻「接口调用」面板(默认展开);持久化由 apiPanelOpen 监听器统一处理 */
1149
+ toggleApiPanel() {
1150
+ this.apiPanelOpen = !this.apiPanelOpen
1151
+ },
1023
1152
  /**
1024
1153
  * 整卡校验徽标(Q3:仅 ok/error,不展开 bindingErrors 明细)。
1025
1154
  * 调 /__validate(契约硬校验)与 /__render-status(结构合法 + 渲染不抛错)两个既有端点,
@@ -2010,6 +2139,7 @@ export default {
2010
2139
  title: '全量同步',
2011
2140
  message: '即将从远端 FinMall 拉取完整卡片列表,并逐张覆盖本地同名卡片。本地独有卡片会保留,但被覆盖卡片的未保存改动将丢失,且无法撤销。是否继续?',
2012
2141
  confirmText: '继续同步',
2142
+ severity: 'warn',
2013
2143
  onConfirm: () => this.syncRemote(),
2014
2144
  })
2015
2145
  return
@@ -2022,7 +2152,7 @@ export default {
2022
2152
  onConfirm: () => this.syncSelected(),
2023
2153
  })
2024
2154
  },
2025
- /** 「远端」分组菜单项:保存到远端 / 远端预览(保存到远端先二次确认) */
2155
+ /** 「远端」分组菜单项:版本推送 / 在线预览 / 切图上传(写覆盖类操作先二次确认) */
2026
2156
  chooseRsave(target) {
2027
2157
  if (target === 'save') {
2028
2158
  if (!this.preview || !this.selectedId || this.savingRemote || !this.hasCardSource) return
@@ -2030,23 +2160,38 @@ export default {
2030
2160
  title: '版本推送',
2031
2161
  message: `即将把本地卡片「${this.selectedId}」的当前版本推送并覆盖到远端 FinMall。线上版本会被立即更新,且该操作不可撤销。是否继续?`,
2032
2162
  confirmText: '继续推送',
2033
- danger: true,
2163
+ severity: 'warn',
2034
2164
  onConfirm: () => this.saveToRemote(),
2035
2165
  })
2036
2166
  return
2037
2167
  }
2168
+ if (target === 'slices') {
2169
+ if (!this.preview || !this.selectedId || this.uploadingSlices) return
2170
+ this.requestConfirm({
2171
+ title: '切图上传',
2172
+ message:
2173
+ `即将把当前卡片「${this.selectedId}」引用的本地切图(src/slices/)上传到 FinMall 图片库,` +
2174
+ `并把卡片里的 /slices/ 引用替换为线上地址(@filestore/…)。本地切图文件保留不动;` +
2175
+ `内容未变的切图会直接复用已有线上地址,不重复上传。` +
2176
+ (this.historyDirty ? '\n当前卡片有未保存的改动,会先保存到本地再执行,避免被随后的重载丢弃。' : '') +
2177
+ '\n是否继续?',
2178
+ confirmText: '继续上传',
2179
+ onConfirm: () => this.uploadCardSlices(),
2180
+ })
2181
+ return
2182
+ }
2038
2183
  if (!this.preview || !this.selectedId || !this.hasCardSource) return
2039
2184
  this.remotePreviewOpen = true
2040
2185
  },
2041
2186
  /** 请求二次确认:统一在写/覆盖类操作前弹出,确认后执行 onConfirm 回调 */
2042
- requestConfirm({ title, message, confirmText, cancelText, danger, onConfirm }) {
2187
+ requestConfirm({ title, message, confirmText, cancelText, severity, onConfirm }) {
2043
2188
  this.confirm = {
2044
2189
  show: true,
2045
2190
  title,
2046
2191
  message,
2047
2192
  confirmText: confirmText || '继续',
2048
2193
  cancelText: cancelText || '取消',
2049
- danger: !!danger,
2194
+ severity: ['primary', 'warn', 'danger'].includes(severity) ? severity : 'primary',
2050
2195
  onConfirm,
2051
2196
  }
2052
2197
  },
@@ -2217,6 +2362,81 @@ export default {
2217
2362
  this.savingRemote = false
2218
2363
  }
2219
2364
  },
2365
+ /**
2366
+ * 把「当前卡片」引用的本地切图上传到 FinMall 图片库(CC0000.do),并把卡片里的
2367
+ * `/slices/<name>` 引用改写为返回的线上地址(`@filestore/...`)。
2368
+ * 经 dev-server 的 /__slice-upload 端点触发(端点内部复用 finmall-card-sync 的 uploadSlices,
2369
+ * 与「同步」「版本推送」共用同一后台 FinMall 会话)。纯前端无法直接调 FinMall:
2370
+ * 跨域 + 需 token(token 只在页面上下文内读取,不回传 Node),故必须经此中转。
2371
+ *
2372
+ * 纪律:
2373
+ * - **只在用户点击「远端 → 切图上传」时执行**,保存 / 推送 / 同步都不会顺带上传切图;
2374
+ * - 上传与改写范围都只限当前卡;共享切图若已被别的卡上传过(sha256 命中
2375
+ * src/slices/manifest.json),直接复用线上地址,不重复上传;
2376
+ * - 本地 src/slices 真源保留不动(映射清单记录 本地文件 ↔ 线上地址)。
2377
+ */
2378
+ async uploadCardSlices() {
2379
+ if (!this.preview || !this.selectedId || this.uploadingSlices) return
2380
+ const id = this.selectedId
2381
+ this.uploadingSlices = true
2382
+ try {
2383
+ // 端点改写的是磁盘上的 schema.json;若编辑器里有未保存改动,先落盘再上传,
2384
+ // 否则随后从磁盘重载会把未保存编辑静默丢掉。
2385
+ if (this.historyDirty) {
2386
+ const saved = await this.saveCard()
2387
+ if (!saved) throw new Error('本地保存失败,已中止切图上传')
2388
+ }
2389
+ // 标记本窗口即将落盘(端点会改写 src/cards/<id>/schema.json),吸收 watcher 经 SSE 推回的
2390
+ // cards-changed 回声,避免误弹「卡片已在编辑器外被修改」提示。
2391
+ this.selfSaveId = id
2392
+ this.selfSaveAt = Date.now()
2393
+ const res = await apiFetch('/__slice-upload', {
2394
+ method: 'POST',
2395
+ headers: { 'Content-Type': 'application/json' },
2396
+ body: JSON.stringify({ cardId: id }),
2397
+ })
2398
+ if (!res.ok) {
2399
+ const err = await res.json().catch(() => ({}))
2400
+ throw new Error(err.message || err.error || 'HTTP ' + res.status)
2401
+ }
2402
+ const r = await res.json()
2403
+ if (r.needCredentials) {
2404
+ // 凭据缺失:弹出登录表单,由用户填写账号密码后再点一次「切图上传」
2405
+ this.openLoginModal()
2406
+ return
2407
+ }
2408
+ // 上传可能耗时数秒(逐张传),落盘发生在响应前一刻:再刷一次自保存时间戳,
2409
+ // 让 2.5s 抑制窗口覆盖到这次回声(否则慢上传会漏抑制)。
2410
+ this.selfSaveAt = Date.now()
2411
+ if (!r.success) {
2412
+ const detail = (r.failed || []).map((f) => `${f.name}: ${f.error}`).join('; ')
2413
+ throw new Error(detail || '切图上传失败')
2414
+ }
2415
+ // 磁盘上的引用已被改写为线上地址,重新载入让预览即时生效。
2416
+ await this.onRefresh()
2417
+ const replaced = (r.rewritten || []).reduce((n, item) => n + item.count, 0)
2418
+ if (!r.referenced) {
2419
+ showAppToast('当前卡片没有引用本地切图,无需上传', 'ok')
2420
+ } else {
2421
+ showAppToast(
2422
+ `切图上传完成:新上传 ${r.uploaded.length} 张、复用 ${r.skipped.length} 张,替换引用 ${replaced} 处`,
2423
+ 'ok'
2424
+ )
2425
+ }
2426
+ if (r.dangling && r.dangling.length) {
2427
+ showAppToast(`有 ${r.dangling.length} 处引用的切图在 src/slices 下不存在:${r.dangling.join('、')}`, 'err')
2428
+ }
2429
+ } catch (e) {
2430
+ console.error('[切图上传失败]', e)
2431
+ // 远端操作接口报错:仅弹出错误提示,不写入事件/绑定错误记录(避免污染事件面板)
2432
+ showAppToast('切图上传失败:' + (e && e.message), 'err')
2433
+ // 未落盘:清除自保存标记,避免误吞后续真正的外部改动
2434
+ this.selfSaveId = ''
2435
+ this.selfSaveAt = 0
2436
+ } finally {
2437
+ this.uploadingSlices = false
2438
+ }
2439
+ },
2220
2440
  /** 实时拉取卡片列表(GET /__cards),替换下拉框选项,不缓存 */
2221
2441
  async fetchCardList() {
2222
2442
  try {
@@ -2309,7 +2529,6 @@ export default {
2309
2529
  message: `卡片「${name}」的 schema / data 在编辑器外发生了变更(例如 IDE 或其他工具覆盖了文件)。\n当前编辑器存在未保存的改动,是否放弃本地未保存改动并重新载入磁盘版本?`,
2310
2530
  confirmText: '重新载入',
2311
2531
  cancelText: '保留我的改动',
2312
- danger: false,
2313
2532
  onConfirm: () => this.loadCard(id),
2314
2533
  })
2315
2534
  },
@@ -12,11 +12,16 @@
12
12
  <symbol id="icon-cloud-alert" viewBox="0 0 24 24"><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"></path><line x1="12" y1="13" x2="12" y2="16"></line><line x1="12" y1="18" x2="12.01" y2="18"></line></symbol>
13
13
  <symbol id="icon-cloud" viewBox="0 0 24 24"><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"></path></symbol>
14
14
  <symbol id="icon-upload-cloud" viewBox="0 0 24 24"><path d="M12 13v8"></path><path d="M8 17l4-4 4 4"></path><path d="M20 16.5A4.5 4.5 0 0 0 17.5 8h-1.26A7 7 0 1 0 4 14.9"></path></symbol>
15
+ <!-- 切图上传(image-upload):图片框 + 框内上箭头,语义「把本地切图传到远端图床」,
16
+ 与 icon-upload-cloud(版本推送)区分开:一个传素材,一个传卡片版本。 -->
17
+ <symbol id="icon-image-upload" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><path d="M12 19v-7"></path><path d="M9 15l3-3 3 3"></path></symbol>
15
18
  <symbol id="icon-grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"></rect><rect x="14" y="3" width="7" height="7" rx="1"></rect><rect x="3" y="14" width="7" height="7" rx="1"></rect><path d="M14 14h3v3M21 14v7h-7v-3"></path></symbol>
16
19
  <symbol id="icon-sync-box" viewBox="0 0 24 24"><rect x="3" y="5" width="14" height="14" rx="2" ry="2"></rect><path d="M21 7v10a2 2 0 0 1-2 2H7"></path></symbol>
17
20
  <symbol id="icon-locate" viewBox="0 0 24 24"><circle cx="12" cy="12" r="7"></circle><line x1="12" y1="1" x2="12" y2="5"></line><line x1="12" y1="19" x2="12" y2="23"></line><line x1="1" y1="12" x2="5" y2="12"></line><line x1="19" y1="12" x2="23" y2="12"></line></symbol>
18
21
  <symbol id="icon-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></symbol>
19
22
  <symbol id="icon-no-entry" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"></circle><line x1="6" y1="6" x2="18" y2="18"></line></symbol>
23
+ <!-- 警告三角(alert-triangle):确认框 warn 级标题图标(警告操作,橙黄档);danger 级用 icon-no-entry -->
24
+ <symbol id="icon-alert-triangle" viewBox="0 0 24 24"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></symbol>
20
25
  <symbol id="icon-copy-sm" viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></symbol>
21
26
  <symbol id="icon-trash" viewBox="0 0 24 24"><path d="M3 6h18"></path><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"></path><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></symbol>
22
27
  <!-- 连接(link):本地开发服务「连接前后端」语义图标,呼应主题【连接】 -->
@@ -0,0 +1,115 @@
1
+ <!--
2
+ 接口调用面板:右侧常驻独立面板(由 App 顶栏按钮 / 面板内 ✕ 控制展开收起)。
3
+ 展示 ynet.callJSAPI / postMessage 的调用流水,内容原属调试面板的「接口调用」tab,
4
+ 拆出后调试面板聚焦「绑定数据 / 组件结构 / 卡片来源」,接口流水可常驻可视。
5
+ -->
6
+ <template>
7
+ <aside class="apipanel">
8
+ <div class="apipanel__head">
9
+ <span class="apipanel__title">
10
+ API调用
11
+ <span v-if="apiCalls.length" class="debug__count">{{ apiCalls.length }}</span>
12
+ </span>
13
+ <span class="apipanel__spacer"></span>
14
+ <button
15
+ class="apipanel__btn"
16
+ type="button"
17
+ :disabled="!apiCalls.length"
18
+ title="清空日志"
19
+ @click="clearApiLog"
20
+ >
21
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none"
22
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#icon-trash" /></svg>
23
+ </button>
24
+ <button
25
+ class="apipanel__btn"
26
+ type="button"
27
+ title="收起接口调用面板"
28
+ @click="$emit('close')"
29
+ >
30
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none"
31
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="5" x2="19" y2="19" /><line x1="19" y1="5" x2="5" y2="19" /></svg>
32
+ </button>
33
+ </div>
34
+ <div class="apipanel__body">
35
+ <p v-if="!apiCalls.length" class="debug__empty">暂无接口调用</p>
36
+ <ul class="debug__list">
37
+ <li v-for="(c, i) in apiCalls" :key="i" class="debug__item debug__item--api">
38
+ <div class="debug__api-row">
39
+ <span class="debug__kind">{{ c.api }}</span>
40
+ <button
41
+ class="debug__copy-ic"
42
+ type="button"
43
+ :title="copiedIndex === i ? '已复制 ✓' : '复制数据(不含方法名)'"
44
+ @click="copyApiPayload(c, i)"
45
+ >
46
+ <svg v-if="copiedIndex !== i" width="15" height="15" viewBox="0 0 24 24" fill="none"
47
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#icon-copy-sm" /></svg>
48
+ <span v-else class="debug__copy-ok">✓</span>
49
+ </button>
50
+ </div>
51
+ <!-- 返回数据:结构化折叠树(Chrome 控制台风格) -->
52
+ <ApiPayloadTree :value="c.payload" />
53
+ </li>
54
+ </ul>
55
+ </div>
56
+ </aside>
57
+ </template>
58
+
59
+ <script>
60
+ import ApiPayloadTree from './ApiPayloadTree.vue'
61
+ import { copyText } from '@/runtime/clipboard.js'
62
+
63
+ export default {
64
+ name: 'ApiCallPanel',
65
+ components: { ApiPayloadTree },
66
+ props: {
67
+ rt: { type: Object, required: true },
68
+ },
69
+ emits: ['close'],
70
+ data() {
71
+ return {
72
+ // 刚复制过数据的条目下标(用于图标变 ✓ 反馈),null=无
73
+ copiedIndex: null,
74
+ apiCopyTimer: null,
75
+ }
76
+ },
77
+ computed: {
78
+ // 依赖 apiRev,使接口调用流水在 pushApiCall / clearApiCalls 后实时刷新
79
+ // (apiCalls 本身是非响应式数组,模板直接读它不会触发重渲染)
80
+ //
81
+ // ⚠️ 必须 slice() 返回**新数组**,不能直接 return 原数组引用:
82
+ // Vue 3.4+ 引入了「computed 值稳定性」——重算后若新值与旧值 Object.is 相等,
83
+ // 就不再通知下游 effect。原数组是同一个引用,即使 apiRev 自增触发了重算,
84
+ // 渲染 effect 也收不到通知,界面纹丝不动。
85
+ apiCalls() {
86
+ void this.rt.apiRev
87
+ return this.rt.apiCalls.slice()
88
+ },
89
+ },
90
+ methods: {
91
+ /** 清空接口调用流水:调用运行时 clearApiCalls(清空数组 + 自增 apiRev),
92
+ * 因 apiCalls computed 返回 slice() 新引用,触发实时重渲染 */
93
+ clearApiLog() {
94
+ this.rt.clearApiCalls()
95
+ },
96
+ /** 复制某条接口调用的「返回数据」(即 payload 全文),不含方法名(api) */
97
+ async copyApiPayload(c, i) {
98
+ let text = ''
99
+ try {
100
+ text = JSON.stringify(c.payload, null, 2)
101
+ } catch {
102
+ text = String(c.payload)
103
+ }
104
+ const ok = await copyText(text)
105
+ if (ok) {
106
+ this.copiedIndex = i
107
+ if (this.apiCopyTimer) clearTimeout(this.apiCopyTimer)
108
+ this.apiCopyTimer = setTimeout(() => {
109
+ if (this.copiedIndex === i) this.copiedIndex = null
110
+ }, 1500)
111
+ }
112
+ },
113
+ },
114
+ }
115
+ </script>
@@ -56,6 +56,8 @@
56
56
  @click="choose(card)"
57
57
  >
58
58
  <div class="cg-tile__thumb">
59
+ <!-- 导入标记:仅含 .import-meta.json 侧车的卡(经远端同步导入)显示,悬浮于缩略图右上角 -->
60
+ <span v-if="card.imported" class="cg-tile__tag" title="导入卡片(含 .import-meta.json 来源信息)">导入</span>
59
61
  <img
60
62
  v-if="visibleIds.has(card.id) && !failed.has(card.id)"
61
63
  class="cg-tile__img"