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.
- package/README.md +21 -0
- package/dist/cli.js +5 -0
- package/dist/preview-editor/assets/index-CrcrVs4F.css +1 -0
- package/dist/preview-editor/assets/index-DnpHHKWl.js +80 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/server-core/codeBridge.js +359 -0
- package/dist/server-core/index.js +27 -2
- package/dist/server-core/logStream.js +275 -0
- package/dist/standalone.js +33 -6
- package/dist/vendored/App.vue +28 -19
- package/dist/vendored/ansi.js +185 -0
- package/dist/vendored/api.js +164 -28
- package/dist/vendored/components/ApiCallPanel.vue +13 -1
- package/dist/vendored/components/ApiPayloadTree.vue +34 -1
- package/dist/vendored/components/CardGallery.vue +2 -2
- package/dist/vendored/components/ComponentPalette.vue +46 -39
- package/dist/vendored/components/ConfirmDialog.vue +2 -2
- package/dist/vendored/components/DebugPanel.vue +420 -69
- package/dist/vendored/components/EventEditor.vue +29 -3
- package/dist/vendored/components/LogValue.vue +2 -1
- package/dist/vendored/components/NodeInspector.vue +1 -1
- package/dist/vendored/components/PropControl.vue +6 -2
- package/dist/vendored/components/RemotePreview.vue +3 -1
- package/dist/vendored/components/StructureTree.vue +2 -0
- package/dist/vendored/components/setters/SetCode.vue +111 -3
- package/dist/vendored/styles/app.css +529 -96
- package/dist/vendored/styles/card-gallery.css +4 -11
- package/dist/vendored/styles/card-select.css +0 -15
- package/dist/vendored/styles/dropdown.css +3 -15
- package/dist/vendored/styles/json-tree.css +1 -1
- package/dist/vendored/styles/node-inspector.css +204 -131
- package/dist/vendored/styles/setters.css +91 -2
- package/dist/vendored/styles/structure-tree.css +27 -12
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-BlscexDE.js +0 -79
- package/dist/preview-editor/assets/index-dlbSPb5W.css +0 -1
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server/core/logStream.js
|
|
3
|
+
* 服务进程日志流:终端输出镜像 + 环形缓冲 + 订阅推送,供编辑器底部「服务日志」面板实时消费。
|
|
4
|
+
*
|
|
5
|
+
* 为什么镜像 stdout/stderr 而不是只挂 logger.js:
|
|
6
|
+
* - logger.js 的 emit() 只覆盖 log.info/ok/warn/error;server/core 里仍有直接 console.*
|
|
7
|
+
* (renderStatus / cardSave / cardHistory),第三方依赖(vite SSR、playwright、chokidar)
|
|
8
|
+
* 与 Node 自身的告警也只写 stdout/stderr。镜像 write() 才能拿到「终端上看到的同一份内容」。
|
|
9
|
+
* - 镜像保留原始 ANSI(chalk 真彩 38;2;r;g;b),前端解析后即可 1:1 还原终端配色,
|
|
10
|
+
* 包括 logger.js 的 box() / rule() 框线;不做服务端转 HTML,避免把展示逻辑沉到后端。
|
|
11
|
+
*
|
|
12
|
+
* 数据形态:每条 = { seq, t, text }
|
|
13
|
+
* - seq:进程内单调递增序号。前端断线重连后可用 GET /__logs?since=<seq> 精确补齐缺口,
|
|
14
|
+
* 不必重拉整段历史。
|
|
15
|
+
* - t:服务端收到该行的时间(epoch ms)。logger 行自带 HH:MM:SS.mmm,但 console.* 与
|
|
16
|
+
* 第三方输出没有,统一补一个接收时刻便于排序与展示。
|
|
17
|
+
* - text:原始行内容(含 ANSI,已去掉行尾 \r)。
|
|
18
|
+
*
|
|
19
|
+
* 端点(见 createLogMiddleware,随 mountCoreMiddlewares 一起挂载):
|
|
20
|
+
* - GET /__logs?limit=300&since=<seq> → { ok, enabled, startedAt, pid, seq, dropped, lines }
|
|
21
|
+
* - POST /__logs/ingest body { text } → 外部进程(如启动器 tools/dev.mjs)注入日志行
|
|
22
|
+
* 实时推送复用 cardbuddy 既有的 SSE hub:standalone.js 里 subscribeLog() → notify({lines}, 'service-log')。
|
|
23
|
+
*
|
|
24
|
+
* 开关与安全:
|
|
25
|
+
* - CARDBUDDY_STREAM_LOGS=0 完全关闭(不镜像、端点返回 enabled:false)。
|
|
26
|
+
* - 服务只绑 127.0.0.1,但 CORS 反射任意来源,日志比卡片列表更敏感(远端地址、错误栈、cardId);
|
|
27
|
+
* logger.js 的 redact() 已对大段正文脱敏,配置了 DSL_DEV_TOKEN 时 /__logs 与其它 API 一样受
|
|
28
|
+
* tokenMiddleware 保护(EventSource 不能带 header,故 SSE 侧支持 ?token= 查询参数)。
|
|
29
|
+
*
|
|
30
|
+
* 稳健性铁律:镜像与推送都包在 try/catch 里,任何异常都不得影响原始 write() 与主流程。
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** 环形缓冲上限(条)。800 行 × 约 200 字符 ≈ 160KB 常驻,足够回溯一次操作链。 */
|
|
34
|
+
const MAX_LINES = Number(process.env.CARDBUDDY_LOG_BUFFER || 800)
|
|
35
|
+
/** 单行长度上限:超长行(如整段 JSON dump)截断,避免一行吃掉整个缓冲。 */
|
|
36
|
+
const MAX_LINE_CHARS = 4000
|
|
37
|
+
/** 批量推送间隔(ms):合并同一轮操作的多行,避免 SSE 逐行刷屏。 */
|
|
38
|
+
const FLUSH_MS = 100
|
|
39
|
+
/** 批量条数阈值:突发日志(截图 / 批量校验)达到即立刻推,不等定时器。 */
|
|
40
|
+
const FLUSH_BATCH = 40
|
|
41
|
+
/** /__logs/ingest 请求体上限(字节)。 */
|
|
42
|
+
const MAX_BODY = 256 * 1024
|
|
43
|
+
/** 未换行的残留缓冲上限:进度条类输出(只有 \r 没有 \n)不会无限堆积。 */
|
|
44
|
+
const MAX_PARTIAL = 8000
|
|
45
|
+
|
|
46
|
+
/** 是否启用日志流(进程启动时定一次,运行期不切换)。 */
|
|
47
|
+
export const logStreamEnabled = process.env.CARDBUDDY_STREAM_LOGS !== '0'
|
|
48
|
+
|
|
49
|
+
/** @type {{ seq: number, t: number, text: string }[]} */
|
|
50
|
+
const buffer = []
|
|
51
|
+
/** 因缓冲上限被淘汰的行数(前端据此提示「仅保留最近 N 行」)。 */
|
|
52
|
+
let dropped = 0
|
|
53
|
+
/** 已分配的最大 seq(0 表示尚无日志)。 */
|
|
54
|
+
let lastSeq = 0
|
|
55
|
+
const startedAt = Date.now()
|
|
56
|
+
|
|
57
|
+
/** @type {Set<(lines: {seq:number,t:number,text:string}[]) => void>} */
|
|
58
|
+
const subscribers = new Set()
|
|
59
|
+
/** @type {{seq:number,t:number,text:string}[]} */
|
|
60
|
+
let pending = []
|
|
61
|
+
let flushTimer = null
|
|
62
|
+
|
|
63
|
+
function flush() {
|
|
64
|
+
if (flushTimer) {
|
|
65
|
+
clearTimeout(flushTimer)
|
|
66
|
+
flushTimer = null
|
|
67
|
+
}
|
|
68
|
+
if (!pending.length) return
|
|
69
|
+
const batch = pending
|
|
70
|
+
pending = []
|
|
71
|
+
for (const cb of subscribers) {
|
|
72
|
+
try {
|
|
73
|
+
cb(batch)
|
|
74
|
+
} catch {
|
|
75
|
+
/* 单个订阅者异常不影响其它订阅者与主流程 */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function scheduleFlush() {
|
|
81
|
+
if (pending.length >= FLUSH_BATCH) return flush()
|
|
82
|
+
if (!flushTimer) flushTimer = setTimeout(flush, FLUSH_MS)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 归一化并收纳一行;返回入库的记录(未启用时返回 null)。 */
|
|
86
|
+
function record(line) {
|
|
87
|
+
if (!logStreamEnabled) return null
|
|
88
|
+
let text = String(line).replace(/\r$/, '')
|
|
89
|
+
if (!text) return null
|
|
90
|
+
if (text.length > MAX_LINE_CHARS) text = text.slice(0, MAX_LINE_CHARS) + '…(truncated)'
|
|
91
|
+
const entry = { seq: ++lastSeq, t: Date.now(), text }
|
|
92
|
+
buffer.push(entry)
|
|
93
|
+
if (buffer.length > MAX_LINES) {
|
|
94
|
+
dropped += buffer.length - MAX_LINES
|
|
95
|
+
buffer.splice(0, buffer.length - MAX_LINES)
|
|
96
|
+
}
|
|
97
|
+
pending.push(entry)
|
|
98
|
+
scheduleFlush()
|
|
99
|
+
return entry
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 注入一行日志(镜像与 ingest 端点共用入口)。
|
|
104
|
+
* @param {string} line
|
|
105
|
+
*/
|
|
106
|
+
export function pushLogLine(line) {
|
|
107
|
+
try {
|
|
108
|
+
return record(line)
|
|
109
|
+
} catch {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 注入一段可能含多行的文本(ingest 端点 / 外部进程转发用)。
|
|
116
|
+
* @param {string} text
|
|
117
|
+
* @returns {number} 实际入库行数
|
|
118
|
+
*/
|
|
119
|
+
export function pushLogText(text) {
|
|
120
|
+
const raw = String(text || '')
|
|
121
|
+
if (!raw) return 0
|
|
122
|
+
let n = 0
|
|
123
|
+
for (const line of raw.split('\n')) if (record(line)) n++
|
|
124
|
+
return n
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 取历史快照。
|
|
129
|
+
* @param {{ limit?: number, since?: number }} [opts]
|
|
130
|
+
* - since:只返回 seq > since 的行(断线补漏);
|
|
131
|
+
* - limit:最多返回多少行(默认 300,取最新的)。
|
|
132
|
+
*/
|
|
133
|
+
export function getLogSnapshot({ limit = 300, since = 0 } = {}) {
|
|
134
|
+
const max = Math.max(1, Math.min(MAX_LINES, Number(limit) || 300))
|
|
135
|
+
const from = Number(since) || 0
|
|
136
|
+
const rows = from > 0 ? buffer.filter((e) => e.seq > from) : buffer
|
|
137
|
+
const lines = rows.length > max ? rows.slice(rows.length - max) : rows
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
enabled: logStreamEnabled,
|
|
141
|
+
startedAt,
|
|
142
|
+
pid: process.pid,
|
|
143
|
+
seq: lastSeq,
|
|
144
|
+
dropped,
|
|
145
|
+
buffered: buffer.length,
|
|
146
|
+
truncated: rows.length > lines.length,
|
|
147
|
+
lines,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 清空缓冲(面板「清空」可选调用;seq 不回退,since 语义保持单调)。 */
|
|
152
|
+
export function clearLogBuffer() {
|
|
153
|
+
buffer.length = 0
|
|
154
|
+
pending = []
|
|
155
|
+
dropped = 0
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 订阅新日志(批量回调)。
|
|
160
|
+
* @param {(lines: {seq:number,t:number,text:string}[]) => void} cb
|
|
161
|
+
* @returns {() => void} 取消订阅
|
|
162
|
+
*/
|
|
163
|
+
export function subscribeLog(cb) {
|
|
164
|
+
if (typeof cb !== 'function') return () => {}
|
|
165
|
+
subscribers.add(cb)
|
|
166
|
+
return () => subscribers.delete(cb)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 当前订阅者数量(诊断用)。 */
|
|
170
|
+
export function logSubscriberCount() {
|
|
171
|
+
return subscribers.size
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let mirrorInstalled = false
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 镜像本进程 stdout/stderr 到日志流。幂等;CLI 入口尽早调用即可覆盖启动期日志。
|
|
178
|
+
*
|
|
179
|
+
* 实现要点:
|
|
180
|
+
* - 包装 write() 而非监听 'data':后者只在管道/TTY 的可读侧成立,且拿不到本进程自己的写入。
|
|
181
|
+
* - 按 \n 切行并保留跨 chunk 的半行(partial);一行日志可能被拆成多个 chunk 写入。
|
|
182
|
+
* - 纯 \r 刷新(进度条)不产行,partial 超过 MAX_PARTIAL 时强制截断入库,防止无界增长。
|
|
183
|
+
* - 原始 write() 一定被调用并原样返回,镜像失败也不影响终端输出。
|
|
184
|
+
*/
|
|
185
|
+
export function installLogMirror() {
|
|
186
|
+
if (mirrorInstalled || !logStreamEnabled) return false
|
|
187
|
+
mirrorInstalled = true
|
|
188
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
189
|
+
if (!stream || typeof stream.write !== 'function') continue
|
|
190
|
+
const original = stream.write.bind(stream)
|
|
191
|
+
let partial = ''
|
|
192
|
+
stream.write = function patchedWrite(chunk, ...rest) {
|
|
193
|
+
try {
|
|
194
|
+
const text = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
|
|
195
|
+
partial += text
|
|
196
|
+
let idx
|
|
197
|
+
while ((idx = partial.indexOf('\n')) >= 0) {
|
|
198
|
+
const line = partial.slice(0, idx)
|
|
199
|
+
partial = partial.slice(idx + 1)
|
|
200
|
+
record(line)
|
|
201
|
+
}
|
|
202
|
+
// 只允许最后一个 \r 之后的内容作为「当前进度行」留存,其余直接丢弃并记一行,
|
|
203
|
+
// 避免进度条把 partial 撑爆(同时保留最终状态可见)。
|
|
204
|
+
const cr = partial.lastIndexOf('\r')
|
|
205
|
+
if (cr >= 0) partial = partial.slice(cr + 1)
|
|
206
|
+
if (partial.length > MAX_PARTIAL) {
|
|
207
|
+
record(partial.slice(0, MAX_PARTIAL))
|
|
208
|
+
partial = ''
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
/* 镜像绝不抛出:日志流故障不能拖垮服务 */
|
|
212
|
+
}
|
|
213
|
+
return original(chunk, ...rest)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return true
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 日志流 HTTP 端点:GET /__logs(历史/补漏)、POST /__logs/ingest(外部进程注入)。
|
|
221
|
+
* 挂在 mountCoreMiddlewares 里,因此 Vite 插件形态与 cardbuddy 独立服务形态同时可用。
|
|
222
|
+
*/
|
|
223
|
+
export function createLogMiddleware() {
|
|
224
|
+
const sendJson = (res, status, payload) => {
|
|
225
|
+
res.statusCode = status
|
|
226
|
+
res.setHeader('Content-Type', 'application/json')
|
|
227
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
228
|
+
res.end(JSON.stringify(payload))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const readBody = (req, limit = MAX_BODY) =>
|
|
232
|
+
new Promise((resolve, reject) => {
|
|
233
|
+
let body = ''
|
|
234
|
+
req.on('data', (c) => {
|
|
235
|
+
body += c
|
|
236
|
+
if (body.length > limit) {
|
|
237
|
+
reject(new Error('body too large'))
|
|
238
|
+
req.destroy()
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
req.on('end', () => resolve(body))
|
|
242
|
+
req.on('error', reject)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
return async function logMiddleware(req, res, next) {
|
|
246
|
+
const raw = (req.url || '').split('?')[0]
|
|
247
|
+
if (raw !== '/__logs' && raw !== '/__logs/ingest') return next()
|
|
248
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
249
|
+
|
|
250
|
+
if (raw === '/__logs' && method === 'GET') {
|
|
251
|
+
if (!logStreamEnabled) return sendJson(res, 200, { ok: true, enabled: false, lines: [] })
|
|
252
|
+
const q = new URLSearchParams((req.url || '').split('?')[1] || '')
|
|
253
|
+
return sendJson(
|
|
254
|
+
res,
|
|
255
|
+
200,
|
|
256
|
+
getLogSnapshot({ limit: Number(q.get('limit')) || 300, since: Number(q.get('since')) || 0 })
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (raw === '/__logs/ingest' && method === 'POST') {
|
|
261
|
+
if (!logStreamEnabled) return sendJson(res, 200, { ok: true, enabled: false, accepted: 0 })
|
|
262
|
+
let payload
|
|
263
|
+
try {
|
|
264
|
+
payload = JSON.parse((await readBody(req)) || '{}')
|
|
265
|
+
} catch {
|
|
266
|
+
return sendJson(res, 400, { ok: false, error: 'invalid json' })
|
|
267
|
+
}
|
|
268
|
+
const text = typeof payload.text === 'string' ? payload.text : ''
|
|
269
|
+
const accepted = pushLogText(text)
|
|
270
|
+
return sendJson(res, 200, { ok: true, accepted, seq: lastSeq })
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
274
|
+
}
|
|
275
|
+
}
|
package/dist/standalone.js
CHANGED
|
@@ -22,7 +22,7 @@ import vue from '@vitejs/plugin-vue'
|
|
|
22
22
|
import chokidar from 'chokidar'
|
|
23
23
|
import chalk from 'chalk'
|
|
24
24
|
|
|
25
|
-
import { mountCoreMiddlewares } from './core.js'
|
|
25
|
+
import { mountCoreMiddlewares, installLogMirror, subscribeLog } from './core.js'
|
|
26
26
|
import { loadConfig } from './config.js'
|
|
27
27
|
|
|
28
28
|
const MIME_TYPES = {
|
|
@@ -259,11 +259,17 @@ function corsMiddleware(req, res, next) {
|
|
|
259
259
|
next()
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
/**
|
|
262
|
+
/**
|
|
263
|
+
* 可选 token 鉴权。
|
|
264
|
+
* 浏览器原生 EventSource 无法自定义请求头,故 SSE(/__events,服务日志与卡片变更推送都走它)
|
|
265
|
+
* 额外允许经查询参数携带:?token=<t>(别名 ?access_token=<t>)。其余端点仍以 header 为准。
|
|
266
|
+
*/
|
|
263
267
|
function tokenMiddleware(token) {
|
|
264
268
|
return (req, res, next) => {
|
|
265
269
|
if (req.method === 'OPTIONS') return next()
|
|
266
|
-
|
|
270
|
+
const query = new URLSearchParams((req.url || '').split('?')[1] || '')
|
|
271
|
+
const provided = req.headers['x-dev-token'] || query.get('token') || query.get('access_token')
|
|
272
|
+
if (provided !== token) {
|
|
267
273
|
res.statusCode = 403
|
|
268
274
|
res.setHeader('Content-Type', 'application/json')
|
|
269
275
|
return res.end(JSON.stringify({ error: 'invalid x-dev-token' }))
|
|
@@ -290,8 +296,8 @@ function createEventsEndpoint() {
|
|
|
290
296
|
clients.delete(res)
|
|
291
297
|
})
|
|
292
298
|
}
|
|
293
|
-
const notify = (data) => {
|
|
294
|
-
const payload = `event:
|
|
299
|
+
const notify = (data, event = 'cards-changed') => {
|
|
300
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
|
295
301
|
for (const res of clients) res.write(payload)
|
|
296
302
|
}
|
|
297
303
|
return { handler, notify }
|
|
@@ -395,9 +401,30 @@ export async function startServer(userConfig = {}) {
|
|
|
395
401
|
const events = createEventsEndpoint()
|
|
396
402
|
app.use(events.handler)
|
|
397
403
|
|
|
404
|
+
// 服务日志流:镜像本进程 stdout/stderr(含 chalk ANSI)→ 环形缓冲 → SSE 批量推送。
|
|
405
|
+
// 编辑器底部「服务日志」面板订阅 event: service-log;历史与断线补漏走 GET /__logs。
|
|
406
|
+
// installLogMirror 幂等,CLI 入口已先行安装,这里兜住以编程方式调用 startServer 的场景。
|
|
407
|
+
installLogMirror()
|
|
408
|
+
subscribeLog((lines) => events.notify({ lines }, 'service-log'))
|
|
409
|
+
|
|
398
410
|
const vite = await createEmbeddedVite(config)
|
|
399
411
|
// upstream(FINMALL_BASE_URL)透传给资源代理中间件,使其能回源 FinMall 取站内资源
|
|
400
|
-
mountCoreMiddlewares(app, {
|
|
412
|
+
mountCoreMiddlewares(app, {
|
|
413
|
+
root: config.root,
|
|
414
|
+
viteServer: vite,
|
|
415
|
+
upstream: config.upstream,
|
|
416
|
+
notify: events.notify,
|
|
417
|
+
// 代码桥单文件监听注入 chokidar 版:原子保存(改名覆盖)由 add 兜住,awaitWriteFinish 防半截读
|
|
418
|
+
watchFile: (file, onChange) => {
|
|
419
|
+
const watcher = chokidar.watch(file, {
|
|
420
|
+
ignoreInitial: true,
|
|
421
|
+
awaitWriteFinish: { stabilityThreshold: 120, pollInterval: 40 },
|
|
422
|
+
})
|
|
423
|
+
watcher.on('change', onChange)
|
|
424
|
+
watcher.on('add', onChange)
|
|
425
|
+
return watcher
|
|
426
|
+
},
|
|
427
|
+
})
|
|
401
428
|
|
|
402
429
|
watchCards(config.root, events.notify)
|
|
403
430
|
|
package/dist/vendored/App.vue
CHANGED
|
@@ -328,7 +328,9 @@
|
|
|
328
328
|
</div>
|
|
329
329
|
</div>
|
|
330
330
|
|
|
331
|
-
|
|
331
|
+
|
|
332
|
+
<!-- 左分隔条:预览列 | 工作区 接缝。底面板 / 状态栏的左缘即此缝(随预览宽度联动),
|
|
333
|
+
故定位在 .app__stage 通到底部,全程可拖;右分隔条才收进两栏容器内。 -->
|
|
332
334
|
<div
|
|
333
335
|
v-show="!chromeOff"
|
|
334
336
|
class="splitter"
|
|
@@ -337,22 +339,24 @@
|
|
|
337
339
|
title="拖拽调整预览区域宽度"
|
|
338
340
|
></div>
|
|
339
341
|
|
|
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
342
|
<!-- 中右工作区:上为「调试面板 + 接口调用面板」两栏,下为贯穿两栏的共同状态栏。
|
|
351
|
-
|
|
352
|
-
|
|
343
|
+
状态栏与底部弹出面板(运行态 / 事件 / 变量绑定)均由 DebugPanel 内 Teleport 注入
|
|
344
|
+
#app-workside-bar / #app-workside-panels,宽度贯穿中右两栏;Teleport 目标须在 DOM 中
|
|
345
|
+
先于面板挂载,故两个空容器写在前面,以 flex order 视觉置底。 -->
|
|
353
346
|
<div class="workside" v-show="!chromeOff">
|
|
354
347
|
<div id="app-workside-bar" class="workside__bar"></div>
|
|
348
|
+
<div id="app-workside-panels" class="workside__panels"></div>
|
|
355
349
|
<div class="workside__cols">
|
|
350
|
+
<!-- 拖拽分隔条:置于两栏容器内(定位上下文),只贯穿列区高度,不穿过底部弹出面板与状态栏 -->
|
|
351
|
+
<!-- 中右分隔条:调试面板 | 接口调用面板 接缝,拖动调整右栏宽度。
|
|
352
|
+
约束:中栏最小宽度 ≥ 屏宽 1/3,右栏最小宽度 ≥ 320px(见 startApiDrag) -->
|
|
353
|
+
<div
|
|
354
|
+
v-show="!chromeOff && apiPanelOpen"
|
|
355
|
+
class="splitter splitter--api"
|
|
356
|
+
:class="{ 'is-dragging': apiDragging }"
|
|
357
|
+
@mousedown.prevent="startApiDrag"
|
|
358
|
+
title="拖拽调整接口调用面板宽度"
|
|
359
|
+
></div>
|
|
356
360
|
<DebugPanel
|
|
357
361
|
:chrome-off="chromeOff"
|
|
358
362
|
:rt="rt"
|
|
@@ -379,6 +383,7 @@
|
|
|
379
383
|
:dev-svc-class="devSvcClass"
|
|
380
384
|
:dev-svc-title="devSvcTitle"
|
|
381
385
|
:dev-svc-label="devSvcLabel"
|
|
386
|
+
:dev-svc-conn="devSvcConn"
|
|
382
387
|
/>
|
|
383
388
|
|
|
384
389
|
<!-- 右侧常驻「接口调用」面板:与预览列 / 调试面板呈左中右布局,
|
|
@@ -399,8 +404,9 @@
|
|
|
399
404
|
@close="remotePreviewOpen = false"
|
|
400
405
|
/>
|
|
401
406
|
<!-- 登录凭据弹窗:远程同步检测到缺凭据时弹出,填写账号密码(MD5 后)写入 .local/credentials.json -->
|
|
407
|
+
<Transition name="modal">
|
|
402
408
|
<div v-if="loginModal" class="login-mask" @click.self="closeLoginModal">
|
|
403
|
-
<div class="login-modal" role="dialog" aria-modal="true">
|
|
409
|
+
<div class="login-modal modal-card" role="dialog" aria-modal="true">
|
|
404
410
|
<div class="login-modal__title">FinMall 登录凭据</div>
|
|
405
411
|
<div class="login-modal__desc">
|
|
406
412
|
本地登录凭据缺失,请填写账号与密码。密码仅做 MD5 后保存,不落明文。
|
|
@@ -448,12 +454,15 @@
|
|
|
448
454
|
</div>
|
|
449
455
|
</div>
|
|
450
456
|
</div>
|
|
457
|
+
</Transition>
|
|
451
458
|
<!-- 组件选择面板:点击结构树每行的「+」后弹出,选择类型即插入对应节点 -->
|
|
452
|
-
<
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
459
|
+
<Transition name="modal">
|
|
460
|
+
<ComponentPalette
|
|
461
|
+
v-if="addNodePanelOpen"
|
|
462
|
+
@pick="onPickComponent"
|
|
463
|
+
@close="cancelAddNode"
|
|
464
|
+
/>
|
|
465
|
+
</Transition>
|
|
457
466
|
<!-- 二次确认弹窗:版本推送 / 当前同步 / 全量同步 等写/覆盖类操作前弹出,确认后才执行 -->
|
|
458
467
|
<ConfirmDialog
|
|
459
468
|
:visible="confirm.show"
|
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|