cardbuddy 0.8.0 → 0.9.1

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.
@@ -253,9 +253,11 @@
253
253
  <div class="phone__frame" :style="frameStyle">
254
254
  <!-- 画布层:直接按实际列宽/列高布局(width/height 100%),不再使用 transform 缩放 -->
255
255
  <div class="phone__scaler">
256
- <!-- 设备工具条(类 Chrome DevTools 设备模式):下拉选择画布背景 -->
257
- <div class="device-bar" v-show="!chromeOff">
258
- <!-- 背景选择下拉框:无边框模式(工具栏文字式控件) -->
256
+ <!-- 画布工具条:原 device-bar(顶 28px)+ preview-statusbar(底 28px)合并为一条 32px,
257
+ 竖向空间还给手机视口。左组=改变画布(背景 / 宽度预设),右组=画布现状与动作(尺寸读数 / 刷新);
258
+ 预览列窄于 300px 时收起读数与文案只留图标(188 预设下仍单行可用)。 -->
259
+ <div class="canvas-bar" :class="{ 'canvas-bar--tight': previewWidth < 300 }" v-show="!chromeOff">
260
+ <!-- 画布背景:触发器带当前底色小样,不展开下拉也可见当前模式 -->
259
261
  <Dropdown
260
262
  borderless
261
263
  size="xs"
@@ -263,19 +265,47 @@
263
265
  :value="canvasBg"
264
266
  :options="bgOptions.map((b) => ({ key: b.key, value: b.label + '背景' }))"
265
267
  @change="chooseBg"
266
- />
267
- <span class="device-bar__spacer"></span>
268
- <span class="device-bar__label">宽度</span>
269
- <!-- 预设尺寸下拉:与背景下拉同款;触发器实时显示当前画布宽度,展开为 375 / 188 两个预设,切换即动态改宽度(高度随列高自动一致) -->
268
+ >
269
+ <template #trigger>
270
+ <span class="canvas-bar__chip" :title="'画布背景:' + currentBg.label">
271
+ <i class="canvas-bar__swatch" :class="'canvas-bar__swatch--' + canvasBg"></i>
272
+ <span class="canvas-bar__txt">{{ currentBg.label }}背景</span>
273
+ <svg class="canvas-bar__chev" viewBox="0 0 16 16" aria-hidden="true"><use href="#icon-chevron" /></svg>
274
+ </span>
275
+ </template>
276
+ </Dropdown>
277
+ <!-- 宽度预设:触发器直接显示当前宽度,省去独立的「宽度」标签 -->
270
278
  <Dropdown
271
279
  borderless
272
280
  size="xs"
273
- placement="bottom-end"
281
+ placement="bottom-start"
274
282
  :value="canvasW"
275
- :label="canvasW + ' px'"
276
283
  :options="presetSizes"
277
284
  @change="setPresetWidth"
278
- />
285
+ >
286
+ <template #trigger>
287
+ <span class="canvas-bar__chip" title="画布宽度预设(高度随列高自适应)">
288
+ <svg class="canvas-bar__icn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="8 7 4 12 8 17" /><polyline points="16 7 20 12 16 17" /><line x1="4" y1="12" x2="20" y2="12" /></svg>
289
+ <span class="canvas-bar__txt">{{ canvasW }} px</span>
290
+ <svg class="canvas-bar__chev" viewBox="0 0 16 16" aria-hidden="true"><use href="#icon-chevron" /></svg>
291
+ </span>
292
+ </template>
293
+ </Dropdown>
294
+ <span class="canvas-bar__spacer"></span>
295
+ <span class="canvas-bar__size" title="画布实际尺寸(宽 × 高,高度随窗口自适应)">{{ canvasW }} × {{ viewportHeight }}</span>
296
+ <button
297
+ class="canvas-bar__iconbtn"
298
+ :class="{ 'is-loading': refreshing }"
299
+ type="button"
300
+ :disabled="!selectedId || refreshing"
301
+ title="刷新卡片,重新加载"
302
+ @click="refreshCard"
303
+ >
304
+ <svg class="canvas-bar__icn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
305
+ <path d="M21 12a9 9 0 1 1-2.64-6.36" />
306
+ <path d="M21 3v6h-6" />
307
+ </svg>
308
+ </button>
279
309
  </div>
280
310
  <!--
281
311
  固定视口:弹层(alertCard / alertDialog / toast)相对此容器 absolute inset:0 定位,
@@ -307,23 +337,6 @@
307
337
  <PickerOverlay />
308
338
  <DatePickerOverlay />
309
339
  </div>
310
- <!-- 预览区底部状态条:显示画布宽高(左)+ 刷新按钮(右),chromeOff 时隐藏 -->
311
- <div class="preview-statusbar" v-show="!chromeOff">
312
- <span class="device-bar__info">{{ canvasW }} × {{ viewportHeight }}</span>
313
- <span class="device-bar__spacer"></span>
314
- <button
315
- class="statusbar__refresh"
316
- type="button"
317
- :disabled="!selectedId"
318
- @click="refreshCard"
319
- title="刷新卡片,重新加载"
320
- >
321
- <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">
322
- <path d="M21 12a9 9 0 1 1-2.64-6.36" />
323
- <path d="M21 3v6h-6" />
324
- </svg>
325
- </button>
326
- </div>
327
340
  </div>
328
341
  </div>
329
342
  </div>
@@ -340,7 +353,7 @@
340
353
  ></div>
341
354
 
342
355
  <!-- 中右工作区:上为「调试面板 + 接口调用面板」两栏,下为贯穿两栏的共同状态栏。
343
- 状态栏与底部弹出面板(运行态 / 事件 / 绑定错误)均由 DebugPanel 内 Teleport 注入
356
+ 状态栏与底部弹出面板(运行态 / 事件 / 变量绑定)均由 DebugPanel 内 Teleport 注入
344
357
  #app-workside-bar / #app-workside-panels,宽度贯穿中右两栏;Teleport 目标须在 DOM 中
345
358
  先于面板挂载,故两个空容器写在前面,以 flex order 视觉置底。 -->
346
359
  <div class="workside" v-show="!chromeOff">
@@ -383,6 +396,7 @@
383
396
  :dev-svc-class="devSvcClass"
384
397
  :dev-svc-title="devSvcTitle"
385
398
  :dev-svc-label="devSvcLabel"
399
+ :dev-svc-conn="devSvcConn"
386
400
  />
387
401
 
388
402
  <!-- 右侧常驻「接口调用」面板:与预览列 / 调试面板呈左中右布局,
@@ -403,8 +417,9 @@
403
417
  @close="remotePreviewOpen = false"
404
418
  />
405
419
  <!-- 登录凭据弹窗:远程同步检测到缺凭据时弹出,填写账号密码(MD5 后)写入 .local/credentials.json -->
420
+ <Transition name="modal">
406
421
  <div v-if="loginModal" class="login-mask" @click.self="closeLoginModal">
407
- <div class="login-modal" role="dialog" aria-modal="true">
422
+ <div class="login-modal modal-card" role="dialog" aria-modal="true">
408
423
  <div class="login-modal__title">FinMall 登录凭据</div>
409
424
  <div class="login-modal__desc">
410
425
  本地登录凭据缺失,请填写账号与密码。密码仅做 MD5 后保存,不落明文。
@@ -452,12 +467,15 @@
452
467
  </div>
453
468
  </div>
454
469
  </div>
470
+ </Transition>
455
471
  <!-- 组件选择面板:点击结构树每行的「+」后弹出,选择类型即插入对应节点 -->
456
- <ComponentPalette
457
- v-if="addNodePanelOpen"
458
- @pick="onPickComponent"
459
- @close="cancelAddNode"
460
- />
472
+ <Transition name="modal">
473
+ <ComponentPalette
474
+ v-if="addNodePanelOpen"
475
+ @pick="onPickComponent"
476
+ @close="cancelAddNode"
477
+ />
478
+ </Transition>
461
479
  <!-- 二次确认弹窗:版本推送 / 当前同步 / 全量同步 等写/覆盖类操作前弹出,确认后才执行 -->
462
480
  <ConfirmDialog
463
481
  :visible="confirm.show"
@@ -650,6 +668,8 @@ export default {
650
668
  viewportHeight: 700, // 手机视口实际高度(高度自适应填充后由 ResizeObserver 实测更新)
651
669
  // 画布背景模式:checker(棋盘格) / white(白色) / gray(浅灰)
652
670
  canvasBg: 'gray',
671
+ // 刷新按钮进行态:图标自转 + 禁用,避免连点与「点了没反应」
672
+ refreshing: false,
653
673
  bgOptions: [
654
674
  { key: 'checker', label: '棋盘' },
655
675
  { key: 'white', label: '白色' },
@@ -801,7 +821,7 @@ export default {
801
821
  const w = this._previewWidthPx || minW
802
822
  return Math.max(minW, Math.min(maxW, w))
803
823
  },
804
- // 画布实际宽度(列宽,整数):device-bar__info 实时显示用,拖拽分隔条时随之变化
824
+ // 画布实际宽度(列宽,整数):canvas-bar__size 实时显示用,拖拽分隔条时随之变化
805
825
  canvasW() {
806
826
  return Math.round(this.previewWidth || 375)
807
827
  },
@@ -852,7 +872,7 @@ export default {
852
872
  window.removeEventListener('keydown', this.onKeydown)
853
873
  },
854
874
  mounted() {
855
- // 视口高度随窗口弹性填充,实测高度同步到 device-bar 信息条
875
+ // 视口高度随窗口弹性填充,实测高度同步到 canvas-bar 尺寸读数
856
876
  this.viewportObserver = new ResizeObserver((entries) => {
857
877
  for (const entry of entries) {
858
878
  this.viewportHeight = Math.round(entry.contentRect.height)
@@ -1137,8 +1157,13 @@ export default {
1137
1157
  * 底部状态条刷新按钮:重新从本地开发服务拉取当前卡片并重建预览。
1138
1158
  */
1139
1159
  async refreshCard() {
1140
- if (!this.selectedId) return
1141
- await this.loadCard(this.selectedId)
1160
+ if (!this.selectedId || this.refreshing) return
1161
+ this.refreshing = true
1162
+ try {
1163
+ await this.loadCard(this.selectedId)
1164
+ } finally {
1165
+ this.refreshing = false
1166
+ }
1142
1167
  },
1143
1168
  // 切换深/浅主题:写入 <html data-theme> 并持久化,刷新后保持
1144
1169
  toggleTheme() {
@@ -0,0 +1,185 @@
1
+ /**
2
+ * preview/ansi.js
3
+ * 终端 ANSI 转义 → HTML 的最小解析器,供底部「服务日志」面板 1:1 还原 cardbuddy 终端配色。
4
+ *
5
+ * 为什么自己写而不引 ansi_up / ansi-to-html:
6
+ * - 只需覆盖本项目实际产出的子集:chalk 的真彩 SGR(38;2;r;g;b)、256 色(38;5;n)、
7
+ * 基础 8/16 色、粗体/暗淡/斜体/下划线,以及需要丢弃的 CSI 擦除类与 OSC 序列;
8
+ * - 零依赖、零体积负担,且输出形态可控(style 内联 + 语义 class),便于与主题令牌对齐;
9
+ * - 解析器行为可单测(tools/tests/ansi.test.mjs),不受第三方版本变更影响。
10
+ *
11
+ * 安全:所有文本片段先做 HTML 转义再拼接,样式值只来自白名单数字(RGB 分量 / 关键字),
12
+ * 不存在把日志内容当 HTML 注入的路径(v-html 消费本模块输出)。
13
+ */
14
+
15
+ /** 匹配一段 ANSI 转义:CSI 序列 / OSC 序列 / 双字符 ESC 序列。 */
16
+ const ANSI_RE =
17
+ // eslint-disable-next-line no-control-regex
18
+ /\x1b\[[0-9;:?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g
19
+
20
+ /** 去掉全部 ANSI 转义,得到纯文本(用于复制、搜索、宽度计算)。 */
21
+ export function stripAnsi(input) {
22
+ return String(input == null ? '' : input).replace(ANSI_RE, '')
23
+ }
24
+
25
+ /** 基础 8 色(xterm 常规档),与终端默认调色板一致的近似值。 */
26
+ const BASIC = [
27
+ [0, 0, 0],
28
+ [170, 0, 0],
29
+ [0, 170, 0],
30
+ [170, 85, 0],
31
+ [0, 0, 170],
32
+ [170, 0, 170],
33
+ [0, 170, 170],
34
+ [170, 170, 170],
35
+ ]
36
+ /** 高亮 8 色(bold / 90-97 使用)。 */
37
+ const BRIGHT = [
38
+ [85, 85, 85],
39
+ [255, 85, 85],
40
+ [85, 255, 85],
41
+ [255, 255, 85],
42
+ [85, 85, 255],
43
+ [255, 85, 255],
44
+ [85, 255, 255],
45
+ [255, 255, 255],
46
+ ]
47
+
48
+ function clamp255(n) {
49
+ const v = Math.round(Number(n))
50
+ if (!Number.isFinite(v)) return 0
51
+ return v < 0 ? 0 : v > 255 ? 255 : v
52
+ }
53
+
54
+ /** 256 色索引 → RGB(0-7 基础 / 8-15 高亮 / 16-231 6×6×6 立方 / 232-255 灰阶)。 */
55
+ export function color256(n) {
56
+ const i = clamp255(n)
57
+ if (i < 8) return BASIC[i]
58
+ if (i < 16) return BRIGHT[i - 8]
59
+ if (i < 232) {
60
+ const k = i - 16
61
+ const step = (v) => (v ? v * 40 + 55 : 0)
62
+ return [step(Math.floor(k / 36)), step(Math.floor((k % 36) / 6)), step(k % 6)]
63
+ }
64
+ const g = 8 + (i - 232) * 10
65
+ return [g, g, g]
66
+ }
67
+
68
+ function escapeHtml(s) {
69
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
70
+ }
71
+
72
+ function rgbCss(rgb) {
73
+ return `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`
74
+ }
75
+
76
+ /** 新建一份 SGR 状态(全部为默认值)。 */
77
+ function newState() {
78
+ return { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false }
79
+ }
80
+
81
+ function isDefault(st) {
82
+ return !st.fg && !st.bg && !st.bold && !st.dim && !st.italic && !st.underline
83
+ }
84
+
85
+ /** 按状态生成 style 串;默认态返回空串(调用方据此决定是否包 span)。 */
86
+ function styleOf(st) {
87
+ const parts = []
88
+ // 终端惯例:粗体 + 基础色 → 走高亮档,还原 chalk.magenta.bold 之类的观感
89
+ let fg = st.fg
90
+ if (fg && fg.basic != null) fg = { rgb: st.bold ? BRIGHT[fg.basic] : BASIC[fg.basic] }
91
+ if (fg) parts.push(`color:${rgbCss(fg.rgb)}`)
92
+ if (st.bg) parts.push(`background-color:${rgbCss(st.bg.rgb)}`)
93
+ if (st.bold) parts.push('font-weight:600')
94
+ if (st.dim) parts.push('opacity:.72')
95
+ if (st.italic) parts.push('font-style:italic')
96
+ if (st.underline) parts.push('text-decoration:underline')
97
+ return parts.join(';')
98
+ }
99
+
100
+ /**
101
+ * 解析一段 SGR 参数(`\x1b[<params>m` 里的 params),就地修改状态。
102
+ * 支持:0 重置、1/2/3/4 与 22/23/24 关闭、30-37/90-97 前景、40-47/100-107 背景、
103
+ * 39/49 默认前景背景、38;5;n 与 38;2;r;g;b(含 48 背景版、冒号分隔变体)。
104
+ */
105
+ function applySgr(state, paramStr) {
106
+ const raw = String(paramStr || '')
107
+ // 冒号变体(38:2::r:g:b / 38:5:n)统一按分号处理,ITU-T T.416 形式在本项目不出现,取近似即可
108
+ const codes = (raw === '' ? '0' : raw).split(/[;:]/).map((c) => (c === '' ? 0 : Number(c)))
109
+ for (let i = 0; i < codes.length; i++) {
110
+ const c = codes[i]
111
+ if (!Number.isFinite(c)) continue
112
+ if (c === 0) {
113
+ Object.assign(state, newState())
114
+ } else if (c === 1) state.bold = true
115
+ else if (c === 2) state.dim = true
116
+ else if (c === 3) state.italic = true
117
+ else if (c === 4) state.underline = true
118
+ else if (c === 22) {
119
+ state.bold = false
120
+ state.dim = false
121
+ } else if (c === 23) state.italic = false
122
+ else if (c === 24) state.underline = false
123
+ else if (c === 39) state.fg = null
124
+ else if (c === 49) state.bg = null
125
+ else if (c >= 30 && c <= 37) state.fg = { basic: c - 30 }
126
+ else if (c >= 90 && c <= 97) state.fg = { rgb: BRIGHT[c - 90] }
127
+ else if (c >= 40 && c <= 47) state.bg = { basic: c - 40 }
128
+ else if (c >= 100 && c <= 107) state.bg = { rgb: BRIGHT[c - 100] }
129
+ else if (c === 38 || c === 48) {
130
+ const mode = codes[i + 1]
131
+ let rgb = null
132
+ let consumed = 0
133
+ if (mode === 5) {
134
+ rgb = color256(codes[i + 2])
135
+ consumed = 2
136
+ } else if (mode === 2) {
137
+ rgb = [clamp255(codes[i + 2]), clamp255(codes[i + 3]), clamp255(codes[i + 4])]
138
+ consumed = 4
139
+ }
140
+ if (rgb) {
141
+ if (c === 38) state.fg = { rgb }
142
+ else state.bg = { rgb }
143
+ i += consumed
144
+ }
145
+ }
146
+ // 其余(5 闪烁 / 7 反显 / 9 删除线 …)本项目不产出,忽略即可
147
+ }
148
+ }
149
+
150
+ /**
151
+ * 单行 ANSI → HTML。
152
+ * @param {string} line 原始行(可含 ANSI)
153
+ * @returns {string} 转义后的 HTML(默认态文本不包 span,避免无谓节点)
154
+ */
155
+ export function ansiToHtml(line) {
156
+ const text = String(line == null ? '' : line)
157
+ if (!text) return ''
158
+ if (text.indexOf('\x1b') < 0) return escapeHtml(text)
159
+ const state = newState()
160
+ let out = ''
161
+ let last = 0
162
+ let m
163
+ ANSI_RE.lastIndex = 0
164
+ while ((m = ANSI_RE.exec(text)) !== null) {
165
+ if (m.index > last) {
166
+ out += renderSegment(text.slice(last, m.index), state)
167
+ }
168
+ last = m.index + m[0].length
169
+ const seq = m[0]
170
+ // 只处理 SGR(…m);CSI 擦除/光标类与 OSC 一律丢弃(浏览器面板里没有光标语义)
171
+ if (seq[1] === '[' && /[m]$/.test(seq)) applySgr(state, seq.slice(2, -1))
172
+ }
173
+ if (last < text.length) out += renderSegment(text.slice(last), state)
174
+ return out
175
+ }
176
+
177
+ function renderSegment(chunk, state) {
178
+ if (!chunk) return ''
179
+ const esc = escapeHtml(chunk)
180
+ if (isDefault(state)) return esc
181
+ const style = styleOf(state)
182
+ return style ? `<span style="${style}">${esc}</span>` : esc
183
+ }
184
+
185
+ export default ansiToHtml
@@ -127,6 +127,49 @@ export async function apiFetch(path, opts = {}) {
127
127
  * @param {(state:'connecting'|'open'|'closed')=>void} [onState] 连接态变化回调,
128
128
  * 用于驱动顶栏「连接」图标实时反映服务端运行态(SSE readyState / Vite HMR ws 生命周期)。
129
129
  */
130
+ /**
131
+ * 共享 SSE 连接池:浏览器对同一 origin 的 HTTP/1.1 并发连接约 6 条,而 /__events 是长连接,
132
+ * 每条订阅各开一个 EventSource 会常驻占位(cards-changed + code-bridge 已占 2 条,服务日志
133
+ * 面板再占 1 条后,普通 API 请求只剩 3 条并发余量,截图 / 校验这类慢请求易排队)。
134
+ * 三类事件本就同源同端点,故按 base 复用一条连接、按事件名分发,引用计数归零才真正 close。
135
+ * @type {Map<string, { es: EventSource, states: Set<(s:string)=>void>, refs: number }>}
136
+ */
137
+ const ssePool = new Map()
138
+
139
+ function acquireSse(base) {
140
+ let entry = ssePool.get(base)
141
+ if (!entry) {
142
+ // EventSource 不能自定义请求头,配置了 token 时改经查询参数携带(服务端 tokenMiddleware 已支持)
143
+ const token = devServiceToken()
144
+ const es = new EventSource(base + '/__events' + (token ? '?token=' + encodeURIComponent(token) : ''))
145
+ entry = { es, states: new Set(), refs: 0 }
146
+ ssePool.set(base, entry)
147
+ es.onopen = () => {
148
+ for (const cb of entry.states) cb('open')
149
+ }
150
+ es.onerror = () => {
151
+ // 断链时 onerror 触发,浏览器会自动重连:
152
+ // readyState===0(CONNECTING) 表示正在重连;===2(CLOSED) 表示已彻底关闭(如显式 close)。
153
+ const state = es.readyState === 2 ? 'closed' : 'connecting'
154
+ for (const cb of entry.states) cb(state)
155
+ }
156
+ }
157
+ entry.refs++
158
+ return entry
159
+ }
160
+
161
+ function releaseSse(base, entry) {
162
+ entry.refs = Math.max(0, entry.refs - 1)
163
+ if (entry.refs === 0 && ssePool.get(base) === entry) {
164
+ ssePool.delete(base)
165
+ try {
166
+ entry.es.close()
167
+ } catch (_) {
168
+ /* ignore */
169
+ }
170
+ }
171
+ }
172
+
130
173
  /**
131
174
  * 通用 SSE 订阅工厂:监听 /__events 上的一组事件名,返回取消订阅函数。
132
175
  * base === ''(开发期同源代理)同样走 SSE:请求相对当前页面 host,由 Vite 转发到 8787,
@@ -140,8 +183,8 @@ function makeSseSubscriber(events, { hmrEvents = [] } = {}) {
140
183
  const base = resolveApiBase()
141
184
  const emit = (s) => { if (typeof onState === 'function') onState(s) }
142
185
  if (base !== null) {
143
- // 本地服务 / 同源代理:SSE(浏览器原生自动重连)
144
- const es = new EventSource(base + '/__events')
186
+ // 本地服务 / 同源代理:SSE(浏览器原生自动重连),同 base 复用一条连接
187
+ const entry = acquireSse(base)
145
188
  const handler = (e) => {
146
189
  try {
147
190
  cb(e.data ? JSON.parse(e.data) : {})
@@ -149,15 +192,15 @@ function makeSseSubscriber(events, { hmrEvents = [] } = {}) {
149
192
  cb({})
150
193
  }
151
194
  }
152
- for (const name of events) es.addEventListener(name, handler)
153
- es.onopen = () => emit('open')
154
- es.onerror = () => {
155
- // 断链时 onerror 触发,浏览器会自动重连:
156
- // readyState===0(CONNECTING) 表示正在重连;===2(CLOSED) 表示已彻底关闭(如显式 close)。
157
- emit(es.readyState === 2 ? 'closed' : 'connecting')
195
+ for (const name of events) entry.es.addEventListener(name, handler)
196
+ entry.states.add(emit)
197
+ // 复用已建立的连接时立即回写真实态,避免顶栏「连接」图标无谓闪「连接中」
198
+ emit(entry.es.readyState === 1 ? 'open' : entry.es.readyState === 2 ? 'closed' : 'connecting')
199
+ return () => {
200
+ for (const name of events) entry.es.removeEventListener(name, handler)
201
+ entry.states.delete(emit)
202
+ releaseSse(base, entry)
158
203
  }
159
- emit('connecting') // 刚创建即处于 CONNECTING,先置为「连接中」
160
- return () => es.close()
161
204
  }
162
205
  // 同源 dev server:沿用 Vite HMR(底层 WebSocket,同样具备连接态)
163
206
  if (import.meta.hot && hmrEvents.length) {
@@ -180,6 +223,29 @@ export const subscribeCardsChanged = makeSseSubscriber(['cards-changed'], { hmrE
180
223
  /** 订阅外部编辑器代码桥事件(code-bridge-changed / code-bridge-closed)。 */
181
224
  export const subscribeCodeBridge = makeSseSubscriber(['code-bridge-changed', 'code-bridge-closed'])
182
225
 
226
+ /**
227
+ * 订阅本地服务日志流(event: service-log)。
228
+ * 载荷 { lines: [{ seq, t, text }] },text 为含 ANSI 的原始终端行,由 preview/ansi.js 还原配色。
229
+ *
230
+ * 连接开销说明:每条订阅各占一个 EventSource(浏览器对同源的并发连接有限),
231
+ * 因此服务日志采用「面板打开才订阅、关闭即退订」的懒连接策略(见 DebugPanel),
232
+ * 不与常驻的 cards-changed / code-bridge 抢连接。
233
+ */
234
+ export const subscribeServiceLog = makeSseSubscriber(['service-log'])
235
+
236
+ /**
237
+ * 取服务日志历史 / 断线补漏(GET /__logs)。
238
+ * @param {{ limit?: number, since?: number }} [opts] since:只取 seq 大于该值的行
239
+ * @returns {Promise<{ ok:boolean, enabled:boolean, seq:number, dropped:number, buffered:number, startedAt:number, pid:number, lines:{seq:number,t:number,text:string}[] }>}
240
+ */
241
+ export async function fetchServiceLogs({ limit = 300, since = 0 } = {}) {
242
+ const q = new URLSearchParams({ limit: String(limit) })
243
+ if (since) q.set('since', String(since))
244
+ const res = await apiFetch('/__logs?' + q.toString())
245
+ if (!res.ok) throw new Error('HTTP ' + res.status)
246
+ return res.json()
247
+ }
248
+
183
249
  /**
184
250
  * 代码桥 REST 封装(端点见 server/core/codeBridge.js):
185
251
  * open 建立会话并拉起编辑器;fetch 拉取外部保存后的最新代码;
@@ -10,9 +10,9 @@
10
10
  搜索框按 id / 名称过滤;Esc 或点遮罩关闭;当前选中卡高亮。
11
11
  -->
12
12
  <template>
13
- <transition name="cg-fade">
13
+ <transition name="modal">
14
14
  <div v-if="visible" class="cg-mask" @click.self="close">
15
- <div class="cg-modal" role="dialog" aria-modal="true" aria-label="卡片网格预览">
15
+ <div class="cg-modal modal-card" role="dialog" aria-modal="true" aria-label="卡片网格预览">
16
16
  <header class="cg-modal__head">
17
17
  <div class="cg-modal__title">
18
18
  <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>
@@ -5,7 +5,7 @@
5
5
  -->
6
6
  <template>
7
7
  <div class="cp__mask" @click.self="$emit('close')">
8
- <div class="cp" role="dialog" aria-label="选择组件类型">
8
+ <div class="cp modal-card" role="dialog" aria-label="选择组件类型">
9
9
  <div class="cp__head">
10
10
  <span class="cp__title">添加组件</span>
11
11
  <span class="cp__hint">选择要插入的组件类型</span>
@@ -1,7 +1,7 @@
1
1
  <template>
2
- <transition name="confirm-fade">
2
+ <transition name="modal">
3
3
  <div v-if="visible" class="confirm-mask" @click.self="$emit('cancel')">
4
- <div class="confirm-modal" role="alertdialog" aria-modal="true" :aria-label="title">
4
+ <div class="confirm-modal modal-card" role="alertdialog" aria-modal="true" :aria-label="title">
5
5
  <div class="confirm-modal__title" :class="{ 'is-danger': severity === 'danger', 'is-warn': severity === 'warn' }">
6
6
  <svg v-if="severity === 'danger'" class="confirm-modal__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-no-entry" /></svg>
7
7
  <svg v-else-if="severity === 'warn'" class="confirm-modal__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-alert-triangle" /></svg>