cardbuddy 0.7.0 → 0.9.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 (36) hide show
  1. package/README.md +21 -0
  2. package/dist/cli.js +5 -0
  3. package/dist/preview-editor/assets/index-CrcrVs4F.css +1 -0
  4. package/dist/preview-editor/assets/index-DnpHHKWl.js +80 -0
  5. package/dist/preview-editor/index.html +2 -2
  6. package/dist/server-core/codeBridge.js +359 -0
  7. package/dist/server-core/index.js +27 -2
  8. package/dist/server-core/logStream.js +275 -0
  9. package/dist/standalone.js +33 -6
  10. package/dist/vendored/App.vue +28 -19
  11. package/dist/vendored/ansi.js +185 -0
  12. package/dist/vendored/api.js +164 -28
  13. package/dist/vendored/components/ApiCallPanel.vue +13 -1
  14. package/dist/vendored/components/ApiPayloadTree.vue +34 -1
  15. package/dist/vendored/components/CardGallery.vue +2 -2
  16. package/dist/vendored/components/ComponentPalette.vue +46 -39
  17. package/dist/vendored/components/ConfirmDialog.vue +2 -2
  18. package/dist/vendored/components/DebugPanel.vue +420 -69
  19. package/dist/vendored/components/EventEditor.vue +29 -3
  20. package/dist/vendored/components/LogValue.vue +2 -1
  21. package/dist/vendored/components/NodeInspector.vue +1 -1
  22. package/dist/vendored/components/PropControl.vue +6 -2
  23. package/dist/vendored/components/RemotePreview.vue +3 -1
  24. package/dist/vendored/components/StructureTree.vue +2 -0
  25. package/dist/vendored/components/setters/SetCode.vue +111 -3
  26. package/dist/vendored/styles/app.css +529 -96
  27. package/dist/vendored/styles/card-gallery.css +4 -11
  28. package/dist/vendored/styles/card-select.css +0 -15
  29. package/dist/vendored/styles/dropdown.css +3 -15
  30. package/dist/vendored/styles/json-tree.css +1 -1
  31. package/dist/vendored/styles/node-inspector.css +204 -131
  32. package/dist/vendored/styles/setters.css +91 -2
  33. package/dist/vendored/styles/structure-tree.css +27 -12
  34. package/package.json +1 -1
  35. package/dist/preview-editor/assets/index-BlscexDE.js +0 -79
  36. package/dist/preview-editor/assets/index-dlbSPb5W.css +0 -1
@@ -13,8 +13,8 @@
13
13
  RadioButton·CheckGroup 选择 / globalSet 等只写这里),不污染 rootScope、不触发保存
14
14
  高亮,事件代码里 `this.data.xxx` 也读这里,调试变更永不落盘。
15
15
 
16
- 注:「绑定错误」已移至底部状态栏(类 VSCode 面板),点击状态栏问题指示器在
17
- 底部弹出错误面板查看,不再作为此处的标签页。
16
+ 注:「变量绑定」已移至底部状态栏(类 VSCode 面板),点击状态栏指示器在
17
+ 底部弹出面板查看,不再作为此处的标签页。
18
18
  -->
19
19
  <template>
20
20
  <aside class="debug">
@@ -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>
@@ -130,16 +147,18 @@
130
147
  节点属性面板:组件结构 tab 双击节点弹出(可编辑写回)。
131
148
  事件编辑已并入本面板的「事件」页签(meta 驱动),不再有独立弹层。
132
149
  -->
133
- <NodeInspector
134
- v-if="inspectingComp"
135
- :key="inspectKey"
136
- :comp="inspectingComp"
137
- :comp-names="compNames"
138
- :rt="rt"
139
- @apply="onApply"
140
- @commit="onCommit"
141
- @close="inspectingComp = null"
142
- />
150
+ <Transition name="modal">
151
+ <NodeInspector
152
+ v-if="inspectingComp"
153
+ :key="inspectKey"
154
+ :comp="inspectingComp"
155
+ :comp-names="compNames"
156
+ :rt="rt"
157
+ @apply="onApply"
158
+ @commit="onCommit"
159
+ @close="inspectingComp = null"
160
+ />
161
+ </Transition>
143
162
 
144
163
  <!--
145
164
  事件编辑弹框:点击结构树「事件」徽标时单独弹出,与属性面板解耦。
@@ -164,26 +183,38 @@
164
183
  并持久化到 localStorage。展示 rt.state 实时 JSON(运行期交互态,只读),不污染绑定数据、
165
184
  不触发保存高亮、调试变更永不落盘。
166
185
  -->
186
+ <!--
187
+ 底部弹出面板(运行态 / 事件 / 变量绑定):Teleport 到 App 的 #app-workside-panels,
188
+ 宽度贯穿中右两栏、紧贴右缘(与共同底栏同一容器机制,贴近 VSCode 底部面板)。
189
+ defer:目标与 Teleport 同属本应用组件树,挂载时目标尚未插入 document,须等整个
190
+ 应用挂载完成后再解析(Vue 3.5+)。chrome-off 截图模式下禁用 Teleport,回落本面板内。
191
+ -->
192
+ <Teleport defer to="#app-workside-panels" :disabled="chromeOff">
167
193
  <section
168
194
  class="statepanel"
169
195
  ref="statePanel"
170
196
  v-show="statePanelOpen"
171
197
  >
172
- <div class="statepanel__head" @mousedown.prevent="startResize">
198
+ <!-- 顶缘拖拽条:与竖向分隔条同规格(命中 6px / 高亮 3px),仅分割边缘触发 -->
199
+ <div class="panelhead__edge" :class="{ 'is-dragging': panelDragging }" title="拖拽调整面板高度" @mousedown.prevent="startResize"></div>
200
+ <div class="statepanel__head">
173
201
  <span class="statepanel__title">运行态 <code>rt.state</code></span>
174
- <button class="statepanel__close" type="button" title="收起" @click="statePanelOpen = false">✕</button>
202
+ <button class="statepanel__close" type="button" title="收起" @click="statePanelOpen = false">
203
+ <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>
204
+ </button>
175
205
  </div>
176
206
  <div class="statepanel__tree">
177
207
  <JsonTree :data="rt.state" :rt="rt" :editable="false" />
178
208
  </div>
179
209
  </section>
210
+ </Teleport>
180
211
 
181
212
  <!--
182
213
  底部状态栏:Teleport 到 App 的 #app-workside-bar,作为「调试面板 + 接口调用面板」
183
214
  中右两栏共同的底栏(贴近 VSCode 布局)。defer:目标与 Teleport 同属本应用组件树,
184
215
  挂载时目标尚未插入 document,须等整个应用挂载完成后再解析(Vue 3.5+)。
185
216
  chrome-off 截图模式下禁用 Teleport,回落到本面板内渲染(随面板一起隐藏)。
186
- 左侧「问题」指示器展示绑定错误数,点击弹出/收起错误面板;右侧展示当前卡片与卡片总数。
217
+ 左侧三枚指示器(运行态 / 变量绑定 / 事件)点击弹出或收起对应底部面板;右侧展示当前卡片与卡片总数。
187
218
  -->
188
219
  <Teleport defer to="#app-workside-bar" :disabled="chromeOff">
189
220
  <section class="statusbar">
@@ -201,7 +232,7 @@
201
232
  class="statusbar__item statusbar__item--prob"
202
233
  :class="{ 'is-error': bindingErrors.length, 'is-ok': !bindingErrors.length }"
203
234
  type="button"
204
- @click="bindingErrors.length && toggleErrorPanel('binding')"
235
+ @click="toggleErrorPanel('binding')"
205
236
  :title="bindingErrors.length ? '查看绑定错误' : '无绑定错误'"
206
237
  >
207
238
  <span class="statusbar__icon">{{ bindingErrors.length ? '⚠' : '✓' }}</span>
@@ -211,34 +242,63 @@
211
242
  class="statusbar__item statusbar__item--prob"
212
243
  :class="eventStatusClass"
213
244
  type="button"
214
- :disabled="!eventCount"
215
- @click="eventCount && toggleErrorPanel('event')"
245
+ @click="toggleErrorPanel('event')"
216
246
  :title="eventStatusTitle"
217
247
  >
218
248
  <span class="statusbar__icon">{{ eventCount ? (eventErrors.length ? '⚠' : '☰') : '✓' }}</span>
219
- <span>{{ eventCount ? eventCount + ' 条事件' : '无事件' }}</span>
249
+ <span>{{ eventCount ? eventCount + ' 条事件记录' : '无事件记录' }}</span>
220
250
  </button>
221
- <!-- 本地开发服务连接状态:连接图标 + 状态文案,颜色与右上角「连接」按钮实时同步(复用 devSvcClass / devSvcTitle / devSvcLabel) -->
222
- <span
251
+ <!-- 本地开发服务连接状态:连接图标 + 状态文案,颜色与右上角「连接」按钮实时同步(复用 devSvcClass / devSvcTitle / devSvcLabel)。
252
+ 可点击:弹出 / 收起底部「服务日志」面板(实时镜像所连 cardbuddy 终端输出),展开态用 is-open 底线标识。 -->
253
+ <button
223
254
  class="statusbar__item statusbar__conn"
224
- :class="devSvcClass"
225
- :title="devSvcTitle"
255
+ :class="[devSvcClass, { 'is-open': errorPanel === 'svclog' }]"
256
+ type="button"
257
+ :title="devSvcTitle + '(点击查看服务日志)'"
258
+ @click="toggleErrorPanel('svclog')"
226
259
  >
227
260
  <svg class="statusbar__conn-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>
228
261
  <span>{{ devSvcLabel }}</span>
229
- </span>
262
+ </button>
230
263
  <span class="statusbar__spacer"></span>
231
264
  <span class="statusbar__item statusbar__item--right">{{ cardId }}</span>
232
265
  <span class="statusbar__item statusbar__item--right">{{ cardCount }} 张卡片</span>
233
266
  </section>
234
267
  </Teleport>
235
268
 
269
+ <Teleport defer to="#app-workside-panels" :disabled="chromeOff">
236
270
  <section class="errpanel" ref="errPanel" v-show="errorPanel">
237
- <div class="errpanel__head" @mousedown.prevent="startResize">
238
- <span class="errpanel__title">{{ errorPanel === 'event' ? '事件面板' : '绑定错误' }}</span>
271
+ <!-- 顶缘拖拽条:与竖向分隔条同规格(命中 6px / 高亮 3px),仅分割边缘触发 -->
272
+ <div class="panelhead__edge" :class="{ 'is-dragging': panelDragging }" title="拖拽调整面板高度" @mousedown.prevent="startResize"></div>
273
+ <div class="errpanel__head">
274
+ <span class="errpanel__title">{{ errorPanel === 'svclog' ? '服务日志' : errorPanel === 'event' ? '事件记录' : '绑定错误记录' }}</span>
275
+ <!-- 服务日志:标题后给一枚读数(行数 + 实时态),与事件面板 tab 同一位置语言 -->
276
+ <span v-if="errorPanel === 'svclog'" class="svclog__meta">
277
+ {{ svcLogs.length }} 行<span v-if="svcLogDropped"> · 已滚动淘汰 {{ svcLogDropped }}</span>
278
+ <i class="svclog__dot" :class="svcLogLive ? 'is-live' : 'is-off'"></i>{{ svcLogLive ? '实时' : '未连接' }}
279
+ </span>
280
+ <!-- 事件面板:日志 / 错误 tab 并入头部单行(一次只看一类);变量绑定面板无 tab -->
281
+ <div v-if="errorPanel === 'event'" class="errpanel__tabs">
282
+ <button
283
+ type="button"
284
+ class="errpanel__tab"
285
+ :class="{ 'errpanel__tab--active': eventTab === 'log' }"
286
+ @click="eventTab = 'log'"
287
+ >
288
+ 日志<span class="errpanel__tab-count">{{ logs.length }}</span>
289
+ </button>
290
+ <button
291
+ type="button"
292
+ class="errpanel__tab"
293
+ :class="{ 'errpanel__tab--active': eventTab === 'err' }"
294
+ @click="eventTab = 'err'"
295
+ >
296
+ 错误<span class="errpanel__tab-count" :class="{ 'is-hit': eventErrors.length }">{{ eventErrors.length }}</span>
297
+ </button>
298
+ </div>
239
299
  <span class="errpanel__actions">
240
300
  <button
241
- v-if="errorPanel === 'event'"
301
+ v-if="errorPanel === 'event' && eventTab === 'log'"
242
302
  class="errpanel__iconbtn errpanel__clear"
243
303
  type="button"
244
304
  :disabled="!logs.length"
@@ -246,47 +306,147 @@
246
306
  @click="clearEventLogs"
247
307
  >
248
308
  <!-- 垃圾桶图标:清空日志 -->
249
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none"
309
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
310
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#icon-trash" /></svg>
311
+ </button>
312
+ <button
313
+ v-if="errorPanel === 'svclog'"
314
+ class="errpanel__iconbtn"
315
+ :class="{ 'is-done': svcCopied }"
316
+ type="button"
317
+ :disabled="!svcLogs.length"
318
+ :title="svcCopied ? '已复制' : '复制全部日志(纯文本,去 ANSI)'"
319
+ @click="copySvcLogs"
320
+ >
321
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>
322
+ </button>
323
+ <button
324
+ v-if="errorPanel === 'svclog'"
325
+ class="errpanel__iconbtn svclog__follow"
326
+ :class="{ 'is-on': svcLogFollow }"
327
+ type="button"
328
+ :title="svcLogFollow ? '跟随最新输出(点击暂停)' : '已暂停跟随(点击回到最新)'"
329
+ @click="toggleSvcFollow"
330
+ >
331
+ <!-- 下箭头图标:跟随最新输出 -->
332
+ <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="12" y1="5" x2="12" y2="19" /><polyline points="19 12 12 19 5 12" /></svg>
333
+ </button>
334
+ <button
335
+ v-if="errorPanel === 'svclog'"
336
+ class="errpanel__iconbtn errpanel__clear"
337
+ type="button"
338
+ :disabled="!svcLogs.length"
339
+ title="清空面板(不影响服务端缓冲)"
340
+ @click="clearSvcLogs"
341
+ >
342
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
250
343
  stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#icon-trash" /></svg>
251
344
  </button>
252
- <button class="errpanel__iconbtn errpanel__close" type="button" title="关闭" @click="errorPanel = ''">✕</button>
345
+ <button class="errpanel__iconbtn errpanel__close" type="button" title="关闭" @click="errorPanel = ''">
346
+ <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>
347
+ </button>
253
348
  </span>
254
349
  </div>
255
- <div class="errpanel__body">
256
- <!-- 事件面板:日志 + 错误 两区块,用于捕获主动日志与执行错误 -->
257
- <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>
350
+ <div
351
+ class="errpanel__body"
352
+ :class="{ 'errpanel__body--term': errorPanel === 'svclog' && svcLogs.length }"
353
+ ref="errBody"
354
+ @scroll.passive="onErrBodyScroll"
355
+ >
356
+ <!-- 服务日志面板:本地服务(cardbuddy)终端输出的实时镜像。
357
+ 服务端按行镜像 stdout/stderr 并保留原始 ANSI,这里经 preview/ansi.js 还原配色,
358
+ 因此面板固定深色终端底(chalk 调色板取自深色令牌,浅底不可读)。 -->
359
+ <template v-if="errorPanel === 'svclog'">
360
+ <div v-if="!svcLogs.length" class="empty-state">
361
+ <span class="empty-state__badge" aria-hidden="true">
362
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
363
+ <polyline points="4 17 10 11 4 5" />
364
+ <line x1="12" y1="19" x2="20" y2="19" />
365
+ </svg>
366
+ </span>
367
+ <p class="debug__empty">{{ svcEmptyText }}</p>
368
+ <p class="empty-state__hint">{{ svcEmptyHint }}</p>
369
+ </div>
370
+ <div v-else class="svclog">
371
+ <div
372
+ v-for="row in svcLogs"
373
+ :key="row.seq || row.t"
374
+ class="svclog__line"
375
+ :class="'svclog__line--' + row.level"
376
+ ><span class="svclog__text" v-html="row.html"></span></div>
377
+ </div>
278
378
  </template>
279
- <!-- 绑定错误面板:仅错误列表 -->
379
+ <!-- 事件面板:按当前 tab 只渲染日志或错误一类 -->
380
+ <template v-else-if="errorPanel === 'event'">
381
+ <template v-if="eventTab === 'log'">
382
+ <!-- 空状态占位:与 API 调用面板同款(虚线徽章 + 线性图标 + 文案 + 弱提示) -->
383
+ <div v-if="!logs.length" class="empty-state">
384
+ <span class="empty-state__badge" aria-hidden="true">
385
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
386
+ <line x1="21" y1="6" x2="3" y2="6" />
387
+ <line x1="21" y1="12" x2="3" y2="12" />
388
+ <line x1="21" y1="18" x2="3" y2="18" />
389
+ </svg>
390
+ </span>
391
+ <p class="debug__empty">暂无日志</p>
392
+ <p class="empty-state__hint">卡片事件内 console.* 主动打印的日志会实时显示在这里</p>
393
+ </div>
394
+ <ul v-else class="debug__list debug__list--log">
395
+ <li v-for="(l, i) in logs" :key="'l' + i" class="log__item" :class="'log--' + l.level">
396
+ <span class="log__time">{{ fmtTime(l.t) }}</span>
397
+ <span class="log__tag" :class="'log__tag--' + l.level">{{ l.level }}</span>
398
+ <div class="log__content">
399
+ <template v-if="l.args && l.args.length">
400
+ <LogValue v-for="(a, ai) in l.args" :key="ai" :value="a" />
401
+ </template>
402
+ <span v-else class="log__empty">—</span>
403
+ </div>
404
+ </li>
405
+ </ul>
406
+ </template>
407
+ <template v-else>
408
+ <div v-if="!eventErrors.length" class="empty-state">
409
+ <span class="empty-state__badge" aria-hidden="true">
410
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
411
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
412
+ <polyline points="22 4 12 14.01 9 11.01" />
413
+ </svg>
414
+ </span>
415
+ <p class="debug__empty">无事件错误</p>
416
+ <p class="empty-state__hint">事件动作(customAction / 监听器)执行报错时会记录在这里</p>
417
+ </div>
418
+ <ul v-else class="debug__list">
419
+ <li v-for="(e, i) in eventErrors" :key="'e' + i" class="debug__item debug__item--err">
420
+ <span class="err__time">{{ fmtTime(e.t) }}</span>
421
+ <span class="debug__kind">{{ e.kind }}</span>
422
+ <span class="err__msg">{{ e.msg }}</span>
423
+ </li>
424
+ </ul>
425
+ </template>
426
+ </template>
427
+ <!-- 变量绑定面板:仅错误列表 -->
280
428
  <template v-else>
281
- <p v-if="!panelErrors.length" class="debug__empty">无错误 ✓</p>
429
+ <div v-if="!panelErrors.length" class="empty-state">
430
+ <span class="empty-state__badge" aria-hidden="true">
431
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
432
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
433
+ <polyline points="22 4 12 14.01 9 11.01" />
434
+ </svg>
435
+ </span>
436
+ <p class="debug__empty">无绑定错误</p>
437
+ <p class="empty-state__hint">绑定 / 表达式(bind、vshow、render、refresh)求值失败时会记录在这里</p>
438
+ </div>
282
439
  <ul v-else class="debug__list">
283
440
  <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 }}
441
+ <span class="err__time">{{ fmtTime(e.t) }}</span>
442
+ <span class="debug__kind">{{ e.kind }}</span>
443
+ <span class="err__msg">{{ e.msg }}</span>
285
444
  </li>
286
445
  </ul>
287
446
  </template>
288
447
  </div>
289
448
  </section>
449
+ </Teleport>
290
450
  </aside>
291
451
  </template>
292
452
 
@@ -305,7 +465,38 @@ import { nodeClipboard } from '@/runtime/clipboard-node.js'
305
465
  import { CodeJar } from 'codejar'
306
466
  import Prism from 'prismjs'
307
467
  import 'prismjs/components/prism-json'
308
- import { apiFetch } from '@/api.js'
468
+ import { apiFetch, subscribeServiceLog, fetchServiceLogs } from '@/api.js'
469
+ // 服务日志面板:终端 ANSI(SGR)→ HTML,1:1 还原 cardbuddy 的 chalk 配色
470
+ import { ansiToHtml, stripAnsi } from '@/ansi.js'
471
+
472
+ /** 服务日志在前端保留的最大行数(与服务端环形缓冲同量级,超出裁剪最旧的)。 */
473
+ const SVC_MAX_LINES = 800
474
+
475
+ /**
476
+ * 行级别判定:只认 logger.js 的行首标签(HH:MM:SS.mmm INFO/OK/WARN/ERR),
477
+ * 其余输出(console.* / 第三方 / vite)统一 info,避免把卡片内容里的「错误」字样误判。
478
+ */
479
+ function svcLevelOf(plain) {
480
+ const m = /^\d{2}:\d{2}:\d{2}\.\d{3}\s+(INFO|OK\s|WARN|ERR)\b/.exec(plain)
481
+ if (m) {
482
+ const tag = m[1].trim()
483
+ return tag === 'ERR' ? 'error' : tag === 'WARN' ? 'warn' : tag === 'OK' ? 'ok' : 'info'
484
+ }
485
+ return 'info'
486
+ }
487
+
488
+ /** 服务端日志行 { seq, t, text } → 面板行(ANSI 转 HTML 只做一次,避免每次重渲染重复解析)。 */
489
+ function toSvcRow(line) {
490
+ const text = String(line && line.text != null ? line.text : '')
491
+ const plain = stripAnsi(text)
492
+ return {
493
+ seq: Number(line && line.seq) || 0,
494
+ t: Number(line && line.t) || 0,
495
+ level: svcLevelOf(plain),
496
+ plain,
497
+ html: ansiToHtml(text),
498
+ }
499
+ }
309
500
 
310
501
  // copyText 已抽到 @/runtime/clipboard.js,供 DebugPanel 与 NodeInspector 共用
311
502
 
@@ -348,6 +539,9 @@ export default {
348
539
  devSvcClass: { type: String, default: 'app__btn--warn' },
349
540
  devSvcTitle: { type: String, default: '' },
350
541
  devSvcLabel: { type: String, default: '' },
542
+ // 本地服务连接态('' | unconfigured | connecting | open | closed):
543
+ // 服务日志面板据此给出空态文案(未配置 / 连接中 / 已断开)。
544
+ devSvcConn: { type: String, default: '' },
351
545
  // 纯净截图模式(?chrome=off):禁用状态栏 Teleport,使其留在本面板内随面板隐藏
352
546
  chromeOff: { type: Boolean, default: false },
353
547
  },
@@ -355,13 +549,30 @@ export default {
355
549
  return {
356
550
  copied: false,
357
551
  copyTimer: null,
358
- // 底部错误面板模式:''(收起) | 'binding'(绑定错误) | 'event'(事件执行错误)
552
+ // 底部面板模式:''(收起) | 'binding'(变量绑定错误) | 'event'(事件日志 / 执行错误)
359
553
  errorPanel: '',
554
+ // 事件面板内部 tab:'log'(主动日志) | 'err'(执行错误),打开面板时按是否有错误自动定位
555
+ eventTab: 'log',
556
+ // 服务日志面板(errorPanel === 'svclog'):本地服务终端输出的实时镜像
557
+ svcLogs: [],
558
+ // 已收到的最大 seq:断线重连后用 GET /__logs?since= 精确补齐缺口
559
+ svcLogSeq: 0,
560
+ // 清空面板时记录的水位线:此后取历史不会把已清掉的行再拉回来
561
+ svcLogFloor: 0,
562
+ svcLogLive: false,
563
+ // 自动跟随最新输出;用户向上翻阅即暂停,回到底部自动恢复
564
+ svcLogFollow: true,
565
+ svcLogDropped: 0,
566
+ svcLogLoading: false,
567
+ svcLogErr: '',
568
+ svcCopied: false,
360
569
  // 运行态面板:默认收起;点击状态栏「运行态」指示器切换
361
570
  statePanelOpen: false,
362
- // 运行态 / 绑定错误 / 事件 三个底部面板互斥,共用同一高度变量(px);
571
+ // 运行态 / 变量绑定 / 事件 三个底部面板互斥,共用同一高度变量(px);
363
572
  // 可被顶部抓手拖拽调整,并持久化到 localStorage(cardbuddy:debug-panel-height)
364
573
  panelH: this.loadPanelH(),
574
+ // 底部面板头部拖拽进行中:头部高亮与竖向分隔条 .is-dragging 同一语言
575
+ panelDragging: false,
365
576
  // 双击结构树节点后弹出的属性面板目标节点
366
577
  inspectingComp: null,
367
578
  // 换节点时强制重建面板(内部含 CodeJar 这类托管 DOM 的控件)
@@ -383,6 +594,24 @@ export default {
383
594
  }
384
595
  },
385
596
  computed: {
597
+ /** 服务日志空态标题:按连接态区分「没配 / 没连上 / 连上了但还没输出」 */
598
+ svcEmptyText() {
599
+ if (this.devSvcConn === 'unconfigured') return '未配置本地开发服务'
600
+ if (this.devSvcConn === 'open') return '暂无服务日志'
601
+ return '本地服务未连接'
602
+ },
603
+ svcEmptyHint() {
604
+ if (this.svcLogErr) {
605
+ return '取历史失败:' + this.svcLogErr + '(本地服务需升级到支持 /__logs 的 cardbuddy 版本)'
606
+ }
607
+ if (this.devSvcConn === 'unconfigured') {
608
+ return '在顶栏「连接」中配置本地开发服务地址后,这里会实时镜像该服务终端的彩色输出'
609
+ }
610
+ if (this.devSvcConn === 'open') {
611
+ return 'cardbuddy 终端的输出(含配色)会实时镜像到这里;试试同步、保存、校验或截图'
612
+ }
613
+ return '连接恢复后自动继续;服务端保留最近 ' + SVC_MAX_LINES + ' 行历史,重连不丢'
614
+ },
386
615
  // 依赖 revision,使 setData 后数据绑定实时刷新
387
616
  scopeText() {
388
617
  void this.rt.revision
@@ -420,8 +649,8 @@ export default {
420
649
  return 'is-ok'
421
650
  },
422
651
  eventStatusTitle() {
423
- if (this.eventErrors.length) return '查看事件面板(含错误)'
424
- if (this.logs.length) return '查看事件面板(含日志)'
652
+ if (this.eventErrors.length) return '查看事件(含错误)'
653
+ if (this.logs.length) return '查看事件(含日志)'
425
654
  return '无事件记录'
426
655
  },
427
656
  panelErrors() {
@@ -451,6 +680,12 @@ export default {
451
680
  },
452
681
  },
453
682
  watch: {
683
+ // 服务日志采用懒订阅:面板打开才建 SSE(浏览器对同源并发连接有限,不与
684
+ // cards-changed / code-bridge 常驻连接抢位),关闭即退订,历史由服务端缓冲补。
685
+ errorPanel(mode) {
686
+ if (mode === 'svclog') this.startSvcLog()
687
+ else this.stopSvcLog()
688
+ },
454
689
  // 切换卡片时退出 Raw 模式,避免编辑内容与新卡片数据不一致;
455
690
  // 同时关闭属性面板,避免展示旧卡片节点;并拉取导入来源元数据。
456
691
  cardId: {
@@ -494,9 +729,10 @@ export default {
494
729
  // DebugPanel 一般常驻,但保险起见卸载时销毁 CodeJar、清理可能残留的拖拽监听
495
730
  this.destroyJar()
496
731
  this.cancelResize()
732
+ this.stopSvcLog()
497
733
  },
498
734
  methods: {
499
- // 三个底部面板互斥:运行态 / 绑定错误 / 事件 不会同时出现。
735
+ // 三个底部面板互斥:运行态 / 变量绑定 / 事件 不会同时出现。
500
736
  // 点开运行态即关闭错误/事件面板。
501
737
  toggleStatePanel() {
502
738
  this.statePanelOpen = !this.statePanelOpen
@@ -506,10 +742,14 @@ export default {
506
742
  // 再次点击同一指示器则收起;点击另一指示器则切换列表
507
743
  this.errorPanel = this.errorPanel === kind ? '' : kind
508
744
  // 与运行态面板互斥:点开错误/事件面板即关闭运行态
509
- if (this.errorPanel) this.statePanelOpen = false
745
+ if (this.errorPanel) {
746
+ this.statePanelOpen = false
747
+ // 事件面板展开时自动落到更值得关注的一侧:有执行错误看错误,否则看日志
748
+ if (kind === 'event') this.eventTab = this.eventErrors.length ? 'err' : 'log'
749
+ }
510
750
  },
511
751
  /**
512
- * 拖拽调整底部面板(运行态 / 绑定错误 / 事件)高度:挂在面板头部(已有 .statepanel__head /
752
+ * 拖拽调整底部面板(运行态 / 变量绑定 / 事件)高度:挂在面板头部(已有 .statepanel__head /
513
753
  * .errpanel__head,无专门拖拽节点),与预览区宽度调节同一套 mousedown 机制。
514
754
  * 在头部按下后监听 document 的 mousemove / mouseup;向上拖(clientY 减小)增大面板高度,
515
755
  * 向下拖减小,符合底部停靠面板的直觉。高度夹在 [120px, 视窗 60%],并实时同步到两个面板
@@ -518,6 +758,7 @@ export default {
518
758
  */
519
759
  startResize(e) {
520
760
  if (e.target.closest('button')) return
761
+ this.panelDragging = true
521
762
  const startY = e.clientY
522
763
  const startH = this.panelH
523
764
  const minH = 120
@@ -537,6 +778,7 @@ export default {
537
778
  document.removeEventListener('mousemove', onMove)
538
779
  document.removeEventListener('mouseup', onUp)
539
780
  this._resizeMove = this._resizeUp = null
781
+ this.panelDragging = false
540
782
  this.savePanelH()
541
783
  }
542
784
  this._resizeMove = onMove
@@ -551,6 +793,7 @@ export default {
551
793
  if (this._resizeMove) document.removeEventListener('mousemove', this._resizeMove)
552
794
  if (this._resizeUp) document.removeEventListener('mouseup', this._resizeUp)
553
795
  this._resizeMove = this._resizeUp = null
796
+ this.panelDragging = false
554
797
  document.body.style.cursor = ''
555
798
  document.body.style.userSelect = ''
556
799
  },
@@ -704,6 +947,114 @@ export default {
704
947
  clearEventLogs() {
705
948
  this.rt.clearEventLogs()
706
949
  },
950
+ /** 打开服务日志:先补历史(含上次清空后的水位线),再建立 SSE 实时订阅。 */
951
+ async startSvcLog() {
952
+ this.svcLogFollow = true
953
+ await this.pullSvcLogHistory(0)
954
+ if (this._svcUnsub) return
955
+ this._svcUnsub = subscribeServiceLog(
956
+ (payload) => this.appendSvcLogs(payload && payload.lines),
957
+ (state) => {
958
+ this.svcLogLive = state === 'open'
959
+ // 断线重连成功后按 seq 补漏:缓冲仍在服务端,不会丢行也不会重复
960
+ if (state === 'open' && this.svcLogSeq) this.pullSvcLogHistory(this.svcLogSeq)
961
+ }
962
+ )
963
+ },
964
+ /** 关闭服务日志:退订 SSE(面板数据保留,下次打开按水位线增量补齐)。 */
965
+ stopSvcLog() {
966
+ if (this._svcUnsub) {
967
+ this._svcUnsub()
968
+ this._svcUnsub = null
969
+ }
970
+ this.svcLogLive = false
971
+ },
972
+ /**
973
+ * 拉取服务日志历史。
974
+ * @param {number} since 0 = 全量(取最新 SVC_MAX_LINES 行);>0 = 只取 seq 大于它的行
975
+ */
976
+ async pullSvcLogHistory(since) {
977
+ if (this.svcLogLoading) return
978
+ this.svcLogLoading = true
979
+ const from = Math.max(Number(since) || 0, this.svcLogFloor)
980
+ try {
981
+ const snap = await fetchServiceLogs({ limit: SVC_MAX_LINES, since: from })
982
+ this.svcLogErr = ''
983
+ if (snap && snap.enabled === false) {
984
+ this.svcLogErr = '服务端已关闭日志流(CARDBUDDY_STREAM_LOGS=0)'
985
+ } else {
986
+ this.svcLogDropped = (snap && snap.dropped) || 0
987
+ if (snap && snap.seq) this.svcLogSeq = Math.max(this.svcLogSeq, snap.seq)
988
+ const rows = ((snap && snap.lines) || []).map(toSvcRow)
989
+ // from > 0 是断线补漏:走同一条去重/裁剪/跟随逻辑,避免与实时流重叠
990
+ if (from) this.appendSvcLogs(rows)
991
+ else this.svcLogs = rows
992
+ this.scrollSvcToBottom(true)
993
+ }
994
+ } catch (e) {
995
+ this.svcLogErr = (e && e.message) || String(e)
996
+ } finally {
997
+ this.svcLogLoading = false
998
+ }
999
+ },
1000
+ /** 追加实时行:按 seq 去重(历史与实时流可能重叠)、裁剪上限、按需跟随滚动。 */
1001
+ appendSvcLogs(lines) {
1002
+ const list = Array.isArray(lines) ? lines : []
1003
+ if (!list.length) return
1004
+ const rows = []
1005
+ for (const l of list) {
1006
+ // 既接受服务端原始行 { seq, t, text },也接受已转换好的面板行(含 html)
1007
+ const row = l && l.html != null ? l : toSvcRow(l)
1008
+ const seq = Number(row.seq) || 0
1009
+ if (seq && seq <= this.svcLogSeq) continue
1010
+ if (seq) this.svcLogSeq = seq
1011
+ rows.push(row)
1012
+ }
1013
+ if (!rows.length) return
1014
+ const next = this.svcLogs.concat(rows)
1015
+ this.svcLogs = next.length > SVC_MAX_LINES ? next.slice(next.length - SVC_MAX_LINES) : next
1016
+ if (this.svcLogFollow) this.scrollSvcToBottom()
1017
+ },
1018
+ clearSvcLogs() {
1019
+ this.svcLogFloor = this.svcLogSeq
1020
+ this.svcLogs = []
1021
+ this.svcLogDropped = 0
1022
+ },
1023
+ /** 复制全部日志为纯文本(去掉 ANSI),便于贴到工单 / IM 里排障。 */
1024
+ async copySvcLogs() {
1025
+ const ok = await copyText(this.svcLogs.map((r) => r.plain).join('\n'))
1026
+ if (!ok) return
1027
+ this.svcCopied = true
1028
+ if (this._svcCopiedTimer) clearTimeout(this._svcCopiedTimer)
1029
+ this._svcCopiedTimer = setTimeout(() => {
1030
+ this.svcCopied = false
1031
+ }, 1200)
1032
+ },
1033
+ toggleSvcFollow() {
1034
+ this.svcLogFollow = !this.svcLogFollow
1035
+ if (this.svcLogFollow) this.scrollSvcToBottom(true)
1036
+ },
1037
+ scrollSvcToBottom(force) {
1038
+ if (!force && !this.svcLogFollow) return
1039
+ this.$nextTick(() => {
1040
+ const el = this.$refs.errBody
1041
+ if (el) el.scrollTop = el.scrollHeight
1042
+ })
1043
+ },
1044
+ /** 面板体滚动:距底 24px 内视为「在底部」并恢复跟随,否则暂停(用户正在往上翻)。 */
1045
+ onErrBodyScroll() {
1046
+ if (this.errorPanel !== 'svclog') return
1047
+ const el = this.$refs.errBody
1048
+ if (!el) return
1049
+ this.svcLogFollow = el.scrollHeight - el.scrollTop - el.clientHeight < 24
1050
+ },
1051
+ /** 日志 / 错误行首时间戳:HH:MM:SS 等宽对齐,单行多带一份定位信息 */
1052
+ fmtTime(t) {
1053
+ if (!t) return '--:--:--'
1054
+ const d = new Date(t)
1055
+ const p = (n) => String(n).padStart(2, '0')
1056
+ return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
1057
+ },
707
1058
  /** 拉取当前卡片的导入来源元数据(Phase E 来源 tab) */
708
1059
  maybeFetchMeta() {
709
1060
  if (this.activeTab === 'meta') this.fetchImportMeta()