cardbuddy 0.7.0 → 0.8.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.
@@ -43,7 +43,9 @@
43
43
  <p v-if="rawError" class="debug__raw-err">{{ rawError }}</p>
44
44
  </div>
45
45
  <div class="debug__scope-foot">
46
- <button class="debug__copy" type="button" @click="saveRaw">应用</button>
46
+ <button class="debug__copy debug__copy--primary" type="button" @click="saveRaw">
47
+ <svg class="debug__copy-icn" viewBox="0 0 16 16" aria-hidden="true"><use href="#icon-check" /></svg>应用
48
+ </button>
47
49
  <button class="debug__copy" type="button" @click="exitRaw">取消</button>
48
50
  </div>
49
51
  </template>
@@ -54,11 +56,19 @@
54
56
  </div>
55
57
  <div class="debug__scope-foot">
56
58
  <span v-if="copied" class="debug__copied">已复制 ✓</span>
57
- <button class="debug__copy" type="button" @click="enterRaw">Raw</button>
58
- <button class="debug__copy" type="button" @click="copyScope">复制</button>
59
+ <button class="debug__copy" type="button" title="切换为 Raw JSON 编辑" @click="enterRaw">
60
+ <svg class="debug__copy-icn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" /></svg>Raw
61
+ </button>
62
+ <button class="debug__copy" type="button" @click="copyScope">
63
+ <svg class="debug__copy-icn" 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-sm" /></svg>复制
64
+ </button>
59
65
  <span class="debug__sep"></span>
60
- <button class="debug__copy debug__copy--primary" type="button" @click="onCommit">应用</button>
61
- <button class="debug__copy" type="button" @click="$emit('reset-import')" title="丢弃内存编辑,从磁盘重新载入(导入优先于编辑)">重置</button>
66
+ <button class="debug__copy debug__copy--primary" type="button" @click="onCommit">
67
+ <svg class="debug__copy-icn" viewBox="0 0 16 16" aria-hidden="true"><use href="#icon-check" /></svg>应用
68
+ </button>
69
+ <button class="debug__copy debug__copy--warn" type="button" @click="$emit('reset-import')" title="丢弃内存编辑,从磁盘重新载入(导入优先于编辑)">
70
+ <svg class="debug__copy-icn" viewBox="0 0 16 16" aria-hidden="true"><use href="#icon-reset" /></svg>重置
71
+ </button>
62
72
  </div>
63
73
  </template>
64
74
  </div>
@@ -70,7 +80,14 @@
70
80
 
71
81
  <template v-else-if="activeTab === 'meta'">
72
82
  <div class="debug__meta">
73
- <p v-if="!importMeta" class="debug__empty">该卡尚未经「同步远程」导入,无导入来源元数据。<br />本地新建卡 / 未同步卡均如此。</p>
83
+ <!-- 空状态占位:与 API 调用面板同款(虚线徽章 + 线性图标 + 文案 + 弱提示) -->
84
+ <div v-if="!importMeta" class="empty-state">
85
+ <span class="empty-state__badge" aria-hidden="true">
86
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#icon-cloud" /></svg>
87
+ </span>
88
+ <p class="debug__empty">暂无卡片来源</p>
89
+ <p class="empty-state__hint">该卡尚未经「同步远程」导入,无导入来源元数据;本地新建卡 / 未同步卡均如此</p>
90
+ </div>
74
91
  <dl v-else class="debug__meta-list">
75
92
  <div class="debug__meta-row">
76
93
  <dt>导入接口</dt>
@@ -164,19 +181,31 @@
164
181
  并持久化到 localStorage。展示 rt.state 实时 JSON(运行期交互态,只读),不污染绑定数据、
165
182
  不触发保存高亮、调试变更永不落盘。
166
183
  -->
184
+ <!--
185
+ 底部弹出面板(运行态 / 事件 / 绑定错误):Teleport 到 App 的 #app-workside-panels,
186
+ 宽度贯穿中右两栏、紧贴右缘(与共同底栏同一容器机制,贴近 VSCode 底部面板)。
187
+ defer:目标与 Teleport 同属本应用组件树,挂载时目标尚未插入 document,须等整个
188
+ 应用挂载完成后再解析(Vue 3.5+)。chrome-off 截图模式下禁用 Teleport,回落本面板内。
189
+ -->
190
+ <Teleport defer to="#app-workside-panels" :disabled="chromeOff">
167
191
  <section
168
192
  class="statepanel"
169
193
  ref="statePanel"
170
194
  v-show="statePanelOpen"
171
195
  >
172
- <div class="statepanel__head" @mousedown.prevent="startResize">
196
+ <!-- 顶缘拖拽条:与竖向分隔条同规格(命中 6px / 高亮 3px),仅分割边缘触发 -->
197
+ <div class="panelhead__edge" :class="{ 'is-dragging': panelDragging }" title="拖拽调整面板高度" @mousedown.prevent="startResize"></div>
198
+ <div class="statepanel__head">
173
199
  <span class="statepanel__title">运行态 <code>rt.state</code></span>
174
- <button class="statepanel__close" type="button" title="收起" @click="statePanelOpen = false">✕</button>
200
+ <button class="statepanel__close" type="button" title="收起" @click="statePanelOpen = false">
201
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" 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>
202
+ </button>
175
203
  </div>
176
204
  <div class="statepanel__tree">
177
205
  <JsonTree :data="rt.state" :rt="rt" :editable="false" />
178
206
  </div>
179
207
  </section>
208
+ </Teleport>
180
209
 
181
210
  <!--
182
211
  底部状态栏:Teleport 到 App 的 #app-workside-bar,作为「调试面板 + 接口调用面板」
@@ -201,7 +230,7 @@
201
230
  class="statusbar__item statusbar__item--prob"
202
231
  :class="{ 'is-error': bindingErrors.length, 'is-ok': !bindingErrors.length }"
203
232
  type="button"
204
- @click="bindingErrors.length && toggleErrorPanel('binding')"
233
+ @click="toggleErrorPanel('binding')"
205
234
  :title="bindingErrors.length ? '查看绑定错误' : '无绑定错误'"
206
235
  >
207
236
  <span class="statusbar__icon">{{ bindingErrors.length ? '⚠' : '✓' }}</span>
@@ -211,8 +240,7 @@
211
240
  class="statusbar__item statusbar__item--prob"
212
241
  :class="eventStatusClass"
213
242
  type="button"
214
- :disabled="!eventCount"
215
- @click="eventCount && toggleErrorPanel('event')"
243
+ @click="toggleErrorPanel('event')"
216
244
  :title="eventStatusTitle"
217
245
  >
218
246
  <span class="statusbar__icon">{{ eventCount ? (eventErrors.length ? '⚠' : '☰') : '✓' }}</span>
@@ -233,12 +261,34 @@
233
261
  </section>
234
262
  </Teleport>
235
263
 
264
+ <Teleport defer to="#app-workside-panels" :disabled="chromeOff">
236
265
  <section class="errpanel" ref="errPanel" v-show="errorPanel">
237
- <div class="errpanel__head" @mousedown.prevent="startResize">
266
+ <!-- 顶缘拖拽条:与竖向分隔条同规格(命中 6px / 高亮 3px),仅分割边缘触发 -->
267
+ <div class="panelhead__edge" :class="{ 'is-dragging': panelDragging }" title="拖拽调整面板高度" @mousedown.prevent="startResize"></div>
268
+ <div class="errpanel__head">
238
269
  <span class="errpanel__title">{{ errorPanel === 'event' ? '事件面板' : '绑定错误' }}</span>
270
+ <!-- 事件面板:日志 / 错误 tab 并入头部单行(一次只看一类);绑定错误面板无 tab -->
271
+ <div v-if="errorPanel === 'event'" class="errpanel__tabs">
272
+ <button
273
+ type="button"
274
+ class="errpanel__tab"
275
+ :class="{ 'errpanel__tab--active': eventTab === 'log' }"
276
+ @click="eventTab = 'log'"
277
+ >
278
+ 日志<span class="errpanel__tab-count">{{ logs.length }}</span>
279
+ </button>
280
+ <button
281
+ type="button"
282
+ class="errpanel__tab"
283
+ :class="{ 'errpanel__tab--active': eventTab === 'err' }"
284
+ @click="eventTab = 'err'"
285
+ >
286
+ 错误<span class="errpanel__tab-count" :class="{ 'is-hit': eventErrors.length }">{{ eventErrors.length }}</span>
287
+ </button>
288
+ </div>
239
289
  <span class="errpanel__actions">
240
290
  <button
241
- v-if="errorPanel === 'event'"
291
+ v-if="errorPanel === 'event' && eventTab === 'log'"
242
292
  class="errpanel__iconbtn errpanel__clear"
243
293
  type="button"
244
294
  :disabled="!logs.length"
@@ -246,47 +296,86 @@
246
296
  @click="clearEventLogs"
247
297
  >
248
298
  <!-- 垃圾桶图标:清空日志 -->
249
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none"
299
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
250
300
  stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#icon-trash" /></svg>
251
301
  </button>
252
- <button class="errpanel__iconbtn errpanel__close" type="button" title="关闭" @click="errorPanel = ''">✕</button>
302
+ <button class="errpanel__iconbtn errpanel__close" type="button" title="关闭" @click="errorPanel = ''">
303
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" 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>
304
+ </button>
253
305
  </span>
254
306
  </div>
255
307
  <div class="errpanel__body">
256
- <!-- 事件面板:日志 + 错误 两区块,用于捕获主动日志与执行错误 -->
308
+ <!-- 事件面板:按当前 tab 只渲染日志或错误一类 -->
257
309
  <template v-if="errorPanel === 'event'">
258
- <div class="ep__sec-title">日志 ({{ logs.length }})</div>
259
- <p v-if="!logs.length" class="debug__empty">无日志 ✓</p>
260
- <ul v-else class="debug__list debug__list--log">
261
- <li v-for="(l, i) in logs" :key="'l' + i" class="log__item" :class="'log--' + l.level">
262
- <span class="log__tag" :class="'log__tag--' + l.level">{{ l.level }}</span>
263
- <div class="log__content">
264
- <template v-if="l.args && l.args.length">
265
- <LogValue v-for="(a, ai) in l.args" :key="ai" :value="a" />
266
- </template>
267
- <span v-else class="log__empty">—</span>
268
- </div>
269
- </li>
270
- </ul>
271
- <div class="ep__sec-title">错误 ({{ eventErrors.length }})</div>
272
- <p v-if="!eventErrors.length" class="debug__empty">无错误 ✓</p>
273
- <ul v-else class="debug__list">
274
- <li v-for="(e, i) in eventErrors" :key="'e' + i" class="debug__item debug__item--err">
275
- <span class="debug__kind">[{{ e.kind }}]</span> {{ e.msg }}
276
- </li>
277
- </ul>
310
+ <template v-if="eventTab === 'log'">
311
+ <!-- 空状态占位:与 API 调用面板同款(虚线徽章 + 线性图标 + 文案 + 弱提示) -->
312
+ <div v-if="!logs.length" class="empty-state">
313
+ <span class="empty-state__badge" aria-hidden="true">
314
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
315
+ <line x1="21" y1="6" x2="3" y2="6" />
316
+ <line x1="21" y1="12" x2="3" y2="12" />
317
+ <line x1="21" y1="18" x2="3" y2="18" />
318
+ </svg>
319
+ </span>
320
+ <p class="debug__empty">暂无日志</p>
321
+ <p class="empty-state__hint">卡片事件内 console.* 主动打印的日志会实时显示在这里</p>
322
+ </div>
323
+ <ul v-else class="debug__list debug__list--log">
324
+ <li v-for="(l, i) in logs" :key="'l' + i" class="log__item" :class="'log--' + l.level">
325
+ <span class="log__time">{{ fmtTime(l.t) }}</span>
326
+ <span class="log__tag" :class="'log__tag--' + l.level">{{ l.level }}</span>
327
+ <div class="log__content">
328
+ <template v-if="l.args && l.args.length">
329
+ <LogValue v-for="(a, ai) in l.args" :key="ai" :value="a" />
330
+ </template>
331
+ <span v-else class="log__empty">—</span>
332
+ </div>
333
+ </li>
334
+ </ul>
335
+ </template>
336
+ <template v-else>
337
+ <div v-if="!eventErrors.length" class="empty-state">
338
+ <span class="empty-state__badge" aria-hidden="true">
339
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
340
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
341
+ <polyline points="22 4 12 14.01 9 11.01" />
342
+ </svg>
343
+ </span>
344
+ <p class="debug__empty">无事件错误</p>
345
+ <p class="empty-state__hint">事件动作(customAction / 监听器)执行报错时会记录在这里</p>
346
+ </div>
347
+ <ul v-else class="debug__list">
348
+ <li v-for="(e, i) in eventErrors" :key="'e' + i" class="debug__item debug__item--err">
349
+ <span class="err__time">{{ fmtTime(e.t) }}</span>
350
+ <span class="debug__kind">{{ e.kind }}</span>
351
+ <span class="err__msg">{{ e.msg }}</span>
352
+ </li>
353
+ </ul>
354
+ </template>
278
355
  </template>
279
356
  <!-- 绑定错误面板:仅错误列表 -->
280
357
  <template v-else>
281
- <p v-if="!panelErrors.length" class="debug__empty">无错误 ✓</p>
358
+ <div v-if="!panelErrors.length" class="empty-state">
359
+ <span class="empty-state__badge" aria-hidden="true">
360
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
361
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
362
+ <polyline points="22 4 12 14.01 9 11.01" />
363
+ </svg>
364
+ </span>
365
+ <p class="debug__empty">无绑定错误</p>
366
+ <p class="empty-state__hint">绑定 / 表达式(bind、vshow、render、refresh)求值失败时会记录在这里</p>
367
+ </div>
282
368
  <ul v-else class="debug__list">
283
369
  <li v-for="(e, i) in panelErrors" :key="i" class="debug__item debug__item--err">
284
- <span class="debug__kind">[{{ e.kind }}]</span> {{ e.msg }}
370
+ <span class="err__time">{{ fmtTime(e.t) }}</span>
371
+ <span class="debug__kind">{{ e.kind }}</span>
372
+ <span class="err__msg">{{ e.msg }}</span>
285
373
  </li>
286
374
  </ul>
287
375
  </template>
288
376
  </div>
289
377
  </section>
378
+ </Teleport>
290
379
  </aside>
291
380
  </template>
292
381
 
@@ -357,11 +446,15 @@ export default {
357
446
  copyTimer: null,
358
447
  // 底部错误面板模式:''(收起) | 'binding'(绑定错误) | 'event'(事件执行错误)
359
448
  errorPanel: '',
449
+ // 事件面板内部 tab:'log'(主动日志) | 'err'(执行错误),打开面板时按是否有错误自动定位
450
+ eventTab: 'log',
360
451
  // 运行态面板:默认收起;点击状态栏「运行态」指示器切换
361
452
  statePanelOpen: false,
362
453
  // 运行态 / 绑定错误 / 事件 三个底部面板互斥,共用同一高度变量(px);
363
454
  // 可被顶部抓手拖拽调整,并持久化到 localStorage(cardbuddy:debug-panel-height)
364
455
  panelH: this.loadPanelH(),
456
+ // 底部面板头部拖拽进行中:头部高亮与竖向分隔条 .is-dragging 同一语言
457
+ panelDragging: false,
365
458
  // 双击结构树节点后弹出的属性面板目标节点
366
459
  inspectingComp: null,
367
460
  // 换节点时强制重建面板(内部含 CodeJar 这类托管 DOM 的控件)
@@ -506,7 +599,11 @@ export default {
506
599
  // 再次点击同一指示器则收起;点击另一指示器则切换列表
507
600
  this.errorPanel = this.errorPanel === kind ? '' : kind
508
601
  // 与运行态面板互斥:点开错误/事件面板即关闭运行态
509
- if (this.errorPanel) this.statePanelOpen = false
602
+ if (this.errorPanel) {
603
+ this.statePanelOpen = false
604
+ // 事件面板展开时自动落到更值得关注的一侧:有执行错误看错误,否则看日志
605
+ if (kind === 'event') this.eventTab = this.eventErrors.length ? 'err' : 'log'
606
+ }
510
607
  },
511
608
  /**
512
609
  * 拖拽调整底部面板(运行态 / 绑定错误 / 事件)高度:挂在面板头部(已有 .statepanel__head /
@@ -518,6 +615,7 @@ export default {
518
615
  */
519
616
  startResize(e) {
520
617
  if (e.target.closest('button')) return
618
+ this.panelDragging = true
521
619
  const startY = e.clientY
522
620
  const startH = this.panelH
523
621
  const minH = 120
@@ -537,6 +635,7 @@ export default {
537
635
  document.removeEventListener('mousemove', onMove)
538
636
  document.removeEventListener('mouseup', onUp)
539
637
  this._resizeMove = this._resizeUp = null
638
+ this.panelDragging = false
540
639
  this.savePanelH()
541
640
  }
542
641
  this._resizeMove = onMove
@@ -551,6 +650,7 @@ export default {
551
650
  if (this._resizeMove) document.removeEventListener('mousemove', this._resizeMove)
552
651
  if (this._resizeUp) document.removeEventListener('mouseup', this._resizeUp)
553
652
  this._resizeMove = this._resizeUp = null
653
+ this.panelDragging = false
554
654
  document.body.style.cursor = ''
555
655
  document.body.style.userSelect = ''
556
656
  },
@@ -704,6 +804,13 @@ export default {
704
804
  clearEventLogs() {
705
805
  this.rt.clearEventLogs()
706
806
  },
807
+ /** 日志 / 错误行首时间戳:HH:MM:SS 等宽对齐,单行多带一份定位信息 */
808
+ fmtTime(t) {
809
+ if (!t) return '--:--:--'
810
+ const d = new Date(t)
811
+ const p = (n) => String(n).padStart(2, '0')
812
+ return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
813
+ },
707
814
  /** 拉取当前卡片的导入来源元数据(Phase E 来源 tab) */
708
815
  maybeFetchMeta() {
709
816
  if (this.activeTab === 'meta') this.fetchImportMeta()
@@ -11,7 +11,7 @@
11
11
  - 编辑即时(防抖)重建预览(emit apply),「应用」把改动暂存进内存工作副本(currentSchema),落盘由顶栏「保存到本地」负责;不再 emit write-back。
12
12
  -->
13
13
  <template>
14
- <div class="ee-overlay" @click.self="onClose">
14
+ <div class="ee-overlay">
15
15
  <div class="ee-panel">
16
16
  <div class="ee-head">
17
17
  <span class="ee-title">
@@ -29,7 +29,7 @@
29
29
  v-for="evt in eventModel.eventOptions"
30
30
  :key="evt.eventKey"
31
31
  class="ee-evt"
32
- :class="{ 'is-on': isOn(evt) }"
32
+ :class="{ 'is-on': isOn(evt), 'is-sel': activeKey === evt.eventKey }"
33
33
  @click="onSelectEvent(evt)"
34
34
  >
35
35
  <span class="ee-evt-name" :title="evt.eventDesc || evt.eventName || evt.eventKey">{{ evt.eventName || evt.eventKey }}</span>
@@ -43,6 +43,24 @@
43
43
  ><span class="ee-switch__dot"></span></button>
44
44
  </div>
45
45
  </div>
46
+ <!-- 帮助图标:与事件项左右靠边、垂直居中;点击弹出当前动作的使用说明 -->
47
+ <button
48
+ class="ee-help"
49
+ type="button"
50
+ :title="helpOpen ? '关闭使用说明' : '使用说明'"
51
+ @click.stop="helpOpen = !helpOpen"
52
+ >
53
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
54
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
55
+ <circle cx="12" cy="12" r="10"></circle>
56
+ <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
57
+ <line x1="12" y1="17" x2="12.01" y2="17"></line>
58
+ </svg>
59
+ </button>
60
+ <div v-if="helpOpen" class="ee-help__mask" @click="helpOpen = false"></div>
61
+ <div v-if="helpOpen" class="ee-help__pop" @click.stop>
62
+ <p class="ee-help__text">{{ helpText }}</p>
63
+ </div>
46
64
  </aside>
47
65
 
48
66
  <section class="ee-detail">
@@ -58,7 +76,6 @@
58
76
  @change="onActionType(activeRow, $event)"
59
77
  />
60
78
  </div>
61
- <p v-if="actionDesc(activeRow.actionType)" class="ee-evt-desc">{{ actionDesc(activeRow.actionType) }}</p>
62
79
  </div>
63
80
 
64
81
  <div class="ee-detail-body">
@@ -108,6 +125,7 @@ export default {
108
125
  return {
109
126
  eventModel: null,
110
127
  activeKey: '',
128
+ helpOpen: false,
111
129
  _t: null,
112
130
  _snapshot: null,
113
131
  _dirty: false,
@@ -127,6 +145,11 @@ export default {
127
145
  if (!this.eventModel || !this.activeKey) return null
128
146
  return this.eventModel.rowForEvent(this.activeKey) || null
129
147
  },
148
+ /** 帮助弹层文案:当前动作类型的使用说明;无选中动作时给通用引导 */
149
+ helpText() {
150
+ const d = this.activeRow ? this.actionDesc(this.activeRow.actionType) : ''
151
+ return d || '先在上方开启事件,再选择动作类型并填写参数;编辑即时预览,「应用」写入历史。'
152
+ },
130
153
  },
131
154
  watch: {
132
155
  comp: {
@@ -141,6 +164,7 @@ export default {
141
164
  const sn = this.schemaNode
142
165
  this.eventModel = sn ? reactive(new EventEditModel(sn)) : null
143
166
  this.activeKey = ''
167
+ this.helpOpen = false
144
168
  this._snapshot = sn ? this.snapshotEventActions(sn) : null
145
169
  this._dirty = false
146
170
  this._written = false
@@ -7,7 +7,8 @@
7
7
  -->
8
8
  <template>
9
9
  <span v-if="!complex" class="log__val" :class="'jt__val--' + typeClass">{{ display }}</span>
10
- <div v-else class="log__json"><ApiPayloadTree :value="value" :max-depth="14" /></div>
10
+ <!-- 日志里的对象/数组参数:根节点默认一行摘要、点击展开,保证日志行密度 -->
11
+ <div v-else class="log__json"><ApiPayloadTree :value="value" :max-depth="14" root-collapsed /></div>
11
12
  </template>
12
13
 
13
14
  <script>
@@ -233,9 +233,13 @@ export default {
233
233
  .pc-body { line-height: 0; }
234
234
  .pc-srcs { display: flex; gap: 4px; line-height: 1.5; }
235
235
  .pc-src {
236
- flex: 1; font-size: 12px; padding: 3px 0; border: 1px solid var(--border);
236
+ flex: 1; height: 24px; font-size: 12px; padding: 0 10px; border: 1px solid var(--border);
237
237
  background: transparent; color: var(--text-dim); border-radius: 6px; cursor: pointer;
238
+ transition: background 0.12s, color 0.12s, border-color 0.12s;
238
239
  }
239
- .pc-src.is-active { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
240
+ .pc-src:hover { background: var(--bg-hover); color: var(--text); }
241
+ /* 选中态与分段控件 .st-seg 同语言:浅底 + 同色文字 + 发丝描边,不做实心填充 */
242
+ .pc-src.is-active { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-border); }
243
+ .pc-src.is-active:hover { background: var(--accent-soft); color: var(--accent); }
240
244
  .pc--readonly .pc-src { opacity: .6; cursor: not-allowed; }
241
245
  </style>
@@ -12,9 +12,40 @@
12
12
  -->
13
13
  <template>
14
14
  <div class="st-code">
15
- <div ref="editor" class="st-code__editor" :class="{ 'is-disabled': disabled }"></div>
15
+ <div
16
+ ref="editor"
17
+ class="st-code__editor"
18
+ :class="{ 'is-disabled': disabled, 'is-bridged': !!bridge }"
19
+ ></div>
20
+ <!-- 右上角语言标签:弱化的只读标识,不参与交互 -->
21
+ <span class="st-code__lang" aria-hidden="true">JS</span>
16
22
  <div class="st-code__foot">
17
23
  <span class="st-code__hint">{{ lines }} 行 · {{ chars }} 字符</span>
24
+ <!-- VSCode 代码桥:生成临时 js 用外部编辑器编辑,保存实时回写;断开即删临时文件 -->
25
+ <span class="st-code__foot-actions">
26
+ <span v-if="bridge" class="st-code__bridge-tag" :title="bridge.file">VSCode 同步中</span>
27
+ <button
28
+ type="button"
29
+ class="st-code__vscode"
30
+ :class="{ 'is-on': !!bridge }"
31
+ :title="
32
+ bridge
33
+ ? '断开 VSCode 桥接并删除临时文件'
34
+ : '在 VSCode 中编辑:保存实时同步回编辑区,断开后清除临时文件'
35
+ "
36
+ :disabled="disabled"
37
+ @click="toggleBridge"
38
+ >
39
+ <!-- 图标左侧小字:未桥接时是动作说明;桥接后由状态徽标接替,避免两段文字挤一行 -->
40
+ <span v-if="!bridge" class="st-code__vscode-label">在编辑器中打开</span>
41
+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
42
+ <path
43
+ fill="currentColor"
44
+ d="M23.15 2.587 18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z"
45
+ />
46
+ </svg>
47
+ </button>
48
+ </span>
18
49
  </div>
19
50
  </div>
20
51
  </template>
@@ -24,13 +55,20 @@ import { CodeJar } from 'codejar'
24
55
  import Prism from 'prismjs'
25
56
  import 'prismjs/components/prism-javascript'
26
57
  import { setterProps, setterEmits } from './props.js'
58
+ import {
59
+ codeBridgeOpen,
60
+ codeBridgeFetch,
61
+ codeBridgeHeartbeat,
62
+ codeBridgeClose,
63
+ subscribeCodeBridge,
64
+ } from '../../api.js'
27
65
 
28
66
  export default {
29
67
  name: 'SetCode',
30
68
  props: setterProps,
31
69
  emits: setterEmits,
32
70
  data() {
33
- return { code: '' }
71
+ return { code: '', bridge: null }
34
72
  },
35
73
  computed: {
36
74
  codeKey() {
@@ -49,6 +87,8 @@ export default {
49
87
  },
50
88
  beforeUnmount() {
51
89
  if (this._t) clearTimeout(this._t)
90
+ // 组件卸载(抽屉关闭 / 切节点 / key 重建)即结束桥接会话,服务端删除临时文件
91
+ this.teardownBridge(true)
52
92
  if (this._jar && this._jar.destroy) this._jar.destroy()
53
93
  },
54
94
  methods: {
@@ -75,7 +115,7 @@ export default {
75
115
  this._jar.onUpdate((code) => this.onCode(code))
76
116
  },
77
117
  onCode(code) {
78
- if (this.disabled) return
118
+ if (this.disabled || this.bridge) return
79
119
  this.code = code
80
120
  // 保留 compiled 等未知键,只覆写 codeKey
81
121
  const base = this.value && typeof this.value === 'object' ? Object.assign({}, this.value) : {}
@@ -85,6 +125,74 @@ export default {
85
125
  if (this._t) clearTimeout(this._t)
86
126
  this._t = setTimeout(() => this.$emit('change'), 400)
87
127
  },
128
+ /* ---------------- VSCode 代码桥 ---------------- */
129
+ toggleBridge() {
130
+ if (this.bridge) this.teardownBridge(true)
131
+ else this.setupBridge()
132
+ },
133
+ async setupBridge() {
134
+ if (this.disabled || this.bridge) return
135
+ const label = this.itemInfo && this.itemInfo.itemKey ? String(this.itemInfo.itemKey) : ''
136
+ try {
137
+ const info = await codeBridgeOpen(this.code, label)
138
+ this.bridge = { id: info.id, file: info.file }
139
+ } catch (e) {
140
+ console.error('[VSCode 桥接建立失败]', e)
141
+ return
142
+ }
143
+ // 桥接期间 Web 编辑区只读:外部编辑器为唯一写入源,避免双向冲突
144
+ this.setEditorEditable(false)
145
+ this._hb = setInterval(() => {
146
+ if (!this.bridge) return
147
+ codeBridgeHeartbeat(this.bridge.id).catch(() => {})
148
+ }, 5000)
149
+ this._bridgeUnsub = subscribeCodeBridge((payload) => {
150
+ if (!this.bridge || !payload || payload.id !== this.bridge.id) return
151
+ // code-bridge-closed 带 reason(ttl / client):服务端已清理,本地同步收 UI
152
+ if (payload.reason) return this.teardownBridge(false)
153
+ this.pullBridgeCode()
154
+ })
155
+ },
156
+ async pullBridgeCode() {
157
+ if (!this.bridge) return
158
+ let r
159
+ try {
160
+ r = await codeBridgeFetch(this.bridge.id)
161
+ } catch (e) {
162
+ return // 会话已失效:等 closed 事件或 TTL 收尾
163
+ }
164
+ const code = String(r.code == null ? '' : r.code)
165
+ if (code === this.code) return
166
+ this.applyExternalCode(code)
167
+ },
168
+ applyExternalCode(code) {
169
+ this.code = code
170
+ // callOnUpdate=false:不触发 onUpdate,切断「回写 → input → 再写临时文件」回声环
171
+ if (this._jar) this._jar.updateCode(code, false)
172
+ const base = this.value && typeof this.value === 'object' ? Object.assign({}, this.value) : {}
173
+ base[this.codeKey] = code
174
+ this.$emit('input', base)
175
+ if (this._t) clearTimeout(this._t)
176
+ this._t = setTimeout(() => this.$emit('change'), 400)
177
+ },
178
+ teardownBridge(notifyServer) {
179
+ const b = this.bridge
180
+ this.bridge = null
181
+ if (this._hb) {
182
+ clearInterval(this._hb)
183
+ this._hb = null
184
+ }
185
+ if (this._bridgeUnsub) {
186
+ this._bridgeUnsub()
187
+ this._bridgeUnsub = null
188
+ }
189
+ this.setEditorEditable(true)
190
+ if (b && notifyServer) codeBridgeClose(b.id).catch(() => {})
191
+ },
192
+ setEditorEditable(on) {
193
+ const el = this.$refs.editor
194
+ if (el) el.setAttribute('contenteditable', on ? 'true' : 'false')
195
+ },
88
196
  },
89
197
  }
90
198
  </script>