cardbuddy 0.6.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.
Files changed (36) hide show
  1. package/README.md +21 -0
  2. package/dist/preview-editor/assets/index-BQKzMPBq.js +79 -0
  3. package/dist/preview-editor/assets/index-D3zp47Dg.css +1 -0
  4. package/dist/preview-editor/index.html +2 -2
  5. package/dist/server-core/cardFiles.js +9 -4
  6. package/dist/server-core/codeBridge.js +359 -0
  7. package/dist/server-core/index.js +14 -2
  8. package/dist/server-core/sliceUpload.js +97 -0
  9. package/dist/standalone.js +18 -3
  10. package/dist/vendored/App.vue +268 -45
  11. package/dist/vendored/api.js +102 -32
  12. package/dist/vendored/assets/svgIcons/icons.svg +5 -0
  13. package/dist/vendored/components/ApiCallPanel.vue +127 -0
  14. package/dist/vendored/components/ApiPayloadTree.vue +34 -1
  15. package/dist/vendored/components/CardGallery.vue +2 -0
  16. package/dist/vendored/components/CardSelect.vue +6 -0
  17. package/dist/vendored/components/ComponentPalette.vue +45 -38
  18. package/dist/vendored/components/ConfirmDialog.vue +20 -5
  19. package/dist/vendored/components/DebugPanel.vue +168 -127
  20. package/dist/vendored/components/EventEditor.vue +27 -3
  21. package/dist/vendored/components/JsonTree.vue +35 -4
  22. package/dist/vendored/components/LogValue.vue +2 -1
  23. package/dist/vendored/components/PropControl.vue +6 -2
  24. package/dist/vendored/components/StructureTree.vue +53 -2
  25. package/dist/vendored/components/setters/SetCode.vue +111 -3
  26. package/dist/vendored/styles/app.css +507 -132
  27. package/dist/vendored/styles/card-gallery.css +23 -2
  28. package/dist/vendored/styles/card-select.css +15 -0
  29. package/dist/vendored/styles/dropdown.css +3 -0
  30. package/dist/vendored/styles/json-tree.css +1 -1
  31. package/dist/vendored/styles/node-inspector.css +176 -119
  32. package/dist/vendored/styles/setters.css +91 -2
  33. package/dist/vendored/styles/structure-tree.css +158 -15
  34. package/package.json +1 -1
  35. package/dist/preview-editor/assets/index-BYB0hngG.js +0 -72
  36. package/dist/preview-editor/assets/index-CpBZEQDy.css +0 -1
@@ -0,0 +1,359 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import crypto from 'node:crypto'
5
+ import { spawn } from 'node:child_process'
6
+ import { log } from './logger.js'
7
+
8
+ /**
9
+ * 外部编辑器代码桥(VSCode / DSL_EDITOR_CMD 指定的任意编辑器,如 cursor)。
10
+ *
11
+ * 闭环:
12
+ * 1) 前端 POST /__code-bridge { code, label } → 后端在 os.tmpdir()/cardbuddy-code-bridge/
13
+ * 下写 <session>.js 并拉起编辑器,返回 { id, file };
14
+ * 2) 后端 chokidar 单文件监听:保存事件防抖 + 内容哈希去重(忽略服务端自写回声),
15
+ * 有实质变化经 SSE 推 code-bridge-changed { id }(内容走 GET 拉取,避免大 payload);
16
+ * 3) 前端 GET /__code-bridge/<id> 取回代码写回 CodeJar(updateCode 不触发 onUpdate,断回声环);
17
+ * 4) 会话结束 = 前端 DELETE(面板关闭 / 手动断开 / 组件卸载)或心跳 TTL 兜底
18
+ * (前端 5s 心跳,20s 无心跳即删文件,覆盖关浏览器 / 崩溃);启动时清扫 >24h 残留。
19
+ *
20
+ * 「关闭」语义说明:VSCode CLI 把文件交给已运行实例后立即返回,标签页关闭事件不装扩展
21
+ * 拿不到,故清除时机取「会话结束 + TTL」,而非 VSCode 关标签瞬间。
22
+ *
23
+ * 安全:与其余 /__ 端点同信任级(仅 127.0.0.1 + 可选 DSL_DEV_TOKEN);临时文件名由服务端
24
+ * 生成(hex 白名单),用户代码内容不进入命令行;DSL_EDITOR_CMD 为开发者自配环境变量,
25
+ * 按 shell 语义执行(文档注明)。
26
+ */
27
+
28
+ export const BRIDGE_DIR_NAME = 'cardbuddy-code-bridge'
29
+ export const SESSION_ID_RE = /^[a-f0-9]{8}$/
30
+ export const HEARTBEAT_TTL_MS = 20000
31
+ const REAP_INTERVAL_MS = 5000
32
+ const WATCH_DEBOUNCE_MS = 150
33
+ const STALE_FILE_MS = 24 * 60 * 60 * 1000
34
+ const MAX_BODY = 2 * 1024 * 1024
35
+
36
+ /** 桥接临时目录:os.tmpdir() 下独立子目录,不污染仓库 */
37
+ export function bridgeDir() {
38
+ return path.join(os.tmpdir(), BRIDGE_DIR_NAME)
39
+ }
40
+
41
+ /** 回写归一:Windows 编辑器可能以 CRLF 落盘,schema 代码串统一 LF,避免无谓 churn */
42
+ export function normalizeEol(s) {
43
+ if (s == null) return ''
44
+ return String(s).replace(/\r\n/g, '\n').replace(/\r/g, '\n')
45
+ }
46
+
47
+ export function hashContent(s) {
48
+ return crypto.createHash('sha1').update(String(s), 'utf8').digest('hex')
49
+ }
50
+
51
+ /**
52
+ * 编辑器拉起方式(纯函数,便于单测):
53
+ * - DSL_EDITOR_CMD 优先:整串按 shell 语义执行 + 追加带引号文件路径(支持 cursor / 自定义命令带参数);
54
+ * - 否则平台默认 VSCode:mac 走 `open -a`;linux 直接 `code`;win 走 shell 语义的 `code "<file>"`
55
+ * (PATH 里的 code 是 code.cmd,裸 spawn 执行不了 .cmd;而 ComSpec + 参数数组会被 node 的
56
+ * Windows 引号转义与 cmd /s /c 剥首尾引号规则叠加拆坏,实测命令变成 '\"code\"' 报 9009);
57
+ * - 返回 null 表示平台不支持(由 resolveEditorChain 继续降级 vscode:// 协议)。
58
+ */
59
+ export function resolveEditorSpec(file, env = process.env, platform = process.platform) {
60
+ const custom = String(env.DSL_EDITOR_CMD || '').trim()
61
+ if (custom) return { kind: 'custom', command: `${custom} "${file}"`, shell: true }
62
+ if (platform === 'darwin') {
63
+ return { kind: 'vscode', cmd: 'open', args: ['-a', 'Visual Studio Code', file], shell: false }
64
+ }
65
+ if (platform === 'win32') {
66
+ return { kind: 'vscode', command: `code "${file}"`, shell: true }
67
+ }
68
+ if (platform === 'linux') {
69
+ return { kind: 'vscode', cmd: 'code', args: [file], shell: false }
70
+ }
71
+ return null
72
+ }
73
+
74
+ /** VSCode 未安装 / CLI 缺失时的协议兜底(安装器默认注册 vscode:// 处理器) */
75
+ export function resolveProtocolSpec(file, env = process.env, platform = process.platform) {
76
+ const url = 'vscode://file/' + String(file).split(path.sep).join('/')
77
+ if (platform === 'darwin') return { cmd: 'open', args: [url], shell: false }
78
+ if (platform === 'win32') {
79
+ return { command: `start "" "${url}"`, shell: true }
80
+ }
81
+ if (platform === 'linux') return { cmd: 'xdg-open', args: [url], shell: false }
82
+ return null
83
+ }
84
+
85
+ /**
86
+ * 拉起方式降级链(跨平台兼容的核心):
87
+ * - DSL_EDITOR_CMD 自定义 → 单级;
88
+ * - win:`code "<file>"`(shell 语义)→ vscode:// 协议;
89
+ * - mac:`open -a "Visual Studio Code"` → `code` CLI(App 被改名/移动但装过 CLI 时兜一层)→ vscode:// 协议;
90
+ * - linux:`code` → xdg-open vscode://;
91
+ * 任一级 spawn ENOENT 或非 0 退出即尝试下一级。
92
+ */
93
+ export function resolveEditorChain(file, env = process.env, platform = process.platform) {
94
+ const custom = String(env.DSL_EDITOR_CMD || '').trim()
95
+ if (custom) return [{ tag: 'custom', spec: { command: `${custom} "${file}"`, shell: true } }]
96
+ const chain = []
97
+ const primary = resolveEditorSpec(file, env, platform)
98
+ if (primary) chain.push({ tag: primary.kind, spec: primary })
99
+ if (platform === 'darwin') chain.push({ tag: 'vscode-cli', spec: { command: `code "${file}"`, shell: true } })
100
+ const proto = resolveProtocolSpec(file, env, platform)
101
+ if (proto) chain.push({ tag: 'protocol', spec: proto })
102
+ return chain
103
+ }
104
+
105
+ function launchEditor(file) {
106
+ const chain = resolveEditorChain(file)
107
+ const attempt = (i) => {
108
+ if (i >= chain.length) return false
109
+ const { tag, spec } = chain[i]
110
+ const next = () => {
111
+ if (i + 1 >= chain.length) return false
112
+ if (!attempt(i + 1)) return false
113
+ log.ok('代码桥降级下一级拉起方式', { from: tag, to: chain[i + 1].tag, file })
114
+ return true
115
+ }
116
+ try {
117
+ const child = spec.shell
118
+ ? spawn(spec.command, { shell: true, stdio: 'ignore' })
119
+ : spawn(spec.cmd, spec.args, { shell: false, stdio: 'ignore' })
120
+ let handed = false
121
+ const fail = (detail) => {
122
+ if (handed) return
123
+ handed = true
124
+ log.warn(`代码桥拉起编辑器失败(${tag})`, detail)
125
+ next()
126
+ }
127
+ child.on('error', (e) => fail({ message: String(e.message) }))
128
+ // shell 语义下 spawn 本身不会 ENOENT(shell 一定在),内部命令缺失只体现在退出码上
129
+ child.on('exit', (code) => {
130
+ if (code === 0 || code === null) return
131
+ fail({ code })
132
+ })
133
+ return true
134
+ } catch (e) {
135
+ log.warn(`代码桥拉起编辑器异常(${tag})`, e)
136
+ return next()
137
+ }
138
+ }
139
+ return attempt(0)
140
+ }
141
+
142
+ /** 启动清扫:删除 >24h 的残留临时文件(会话表在内存,跨重启即孤儿) */
143
+ export function sweepStaleFiles(now = Date.now()) {
144
+ const dir = bridgeDir()
145
+ let removed = 0
146
+ try {
147
+ fs.mkdirSync(dir, { recursive: true })
148
+ for (const name of fs.readdirSync(dir)) {
149
+ if (!SESSION_ID_RE.test(name.replace(/\.js$/, ''))) continue
150
+ const full = path.join(dir, name)
151
+ try {
152
+ const st = fs.statSync(full)
153
+ if (now - st.mtimeMs > STALE_FILE_MS) {
154
+ fs.rmSync(full, { force: true })
155
+ removed++
156
+ }
157
+ } catch (_) { /* 忽略单文件竞态 */ }
158
+ }
159
+ } catch (e) {
160
+ log.warn('代码桥启动清扫失败', { message: String(e.message) })
161
+ }
162
+ return removed
163
+ }
164
+
165
+ /**
166
+ * 默认单文件监听(fs.watch + rename 重挂兜底原子保存)。
167
+ * cardbuddy 会注入 chokidar 版 watchFile(更稳);本兜底保证中间件零额外依赖,
168
+ * 使 server/core 可被根仓库(无 chokidar 依赖)直接 import / 单测。
169
+ */
170
+ export function defaultWatchFile(file, onChange) {
171
+ let watcher = null
172
+ let rearmTimer = null
173
+ let closed = false
174
+ const arm = () => {
175
+ if (closed) return
176
+ try {
177
+ watcher = fs.watch(file, (event) => {
178
+ onChange()
179
+ // 原子保存(临时文件改名覆盖)会让旧 inode 失效:重挂监听
180
+ if (event === 'rename') rearm()
181
+ })
182
+ watcher.on('error', () => rearm())
183
+ } catch (_) {
184
+ rearm()
185
+ }
186
+ }
187
+ const rearm = () => {
188
+ if (closed) return
189
+ try {
190
+ watcher && watcher.close()
191
+ } catch (_) { /* ignore */ }
192
+ if (rearmTimer) clearTimeout(rearmTimer)
193
+ rearmTimer = setTimeout(arm, 120)
194
+ }
195
+ arm()
196
+ return {
197
+ close() {
198
+ closed = true
199
+ if (rearmTimer) clearTimeout(rearmTimer)
200
+ try {
201
+ watcher && watcher.close()
202
+ } catch (_) { /* ignore */ }
203
+ },
204
+ }
205
+ }
206
+
207
+ /**
208
+ * @param {{ root?: string, notify?: (data: object, event?: string) => void, watchFile?: (file: string, cb: () => void) => { close: () => void } }} opts
209
+ * - watchFile:单文件监听能力注入(cardbuddy 注入 chokidar 版);缺省用 fs.watch 兜底。
210
+ * @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
211
+ */
212
+ export function createCodeBridgeMiddleware({ notify = null, watchFile = null } = {}) {
213
+ /** @type {Map<string, {id:string,file:string,label:string,lastHash:string,lastHeartbeat:number,watcher:object|null,timer:object|null}>} */
214
+ const sessions = new Map()
215
+ const swept = sweepStaleFiles()
216
+ if (swept) log.ok('代码桥清扫残留临时文件', { removed: swept })
217
+
218
+ const push = (data, event) => {
219
+ if (typeof notify === 'function') notify(data, event)
220
+ }
221
+
222
+ function closeSession(id, reason) {
223
+ const s = sessions.get(id)
224
+ if (!s) return false
225
+ sessions.delete(id)
226
+ if (s.timer) clearTimeout(s.timer)
227
+ try { s.watcher && s.watcher.close() } catch (_) { /* ignore */ }
228
+ try { fs.rmSync(s.file, { force: true }) } catch (e) {
229
+ log.warn('代码桥删除临时文件失败', { file: s.file, message: String(e.message) })
230
+ }
231
+ push({ id, reason }, 'code-bridge-closed')
232
+ log.ok('代码桥会话结束', { id, reason, file: s.file })
233
+ return true
234
+ }
235
+
236
+ const reaper = setInterval(() => {
237
+ const now = Date.now()
238
+ for (const [id, s] of sessions) {
239
+ if (now - s.lastHeartbeat > HEARTBEAT_TTL_MS) closeSession(id, 'ttl')
240
+ }
241
+ }, REAP_INTERVAL_MS)
242
+ reaper.unref && reaper.unref()
243
+
244
+ function onFileChange(session) {
245
+ if (session.timer) clearTimeout(session.timer)
246
+ session.timer = setTimeout(() => {
247
+ session.timer = null
248
+ let raw
249
+ try {
250
+ raw = fs.readFileSync(session.file, 'utf8')
251
+ } catch (_) {
252
+ return // 文件被外部删除:等 TTL / 前端断开收尾
253
+ }
254
+ const hash = hashContent(normalizeEol(raw))
255
+ if (hash === session.lastHash) return // 服务端自写回声 / 无实质变化
256
+ session.lastHash = hash
257
+ push({ id: session.id }, 'code-bridge-changed')
258
+ }, WATCH_DEBOUNCE_MS)
259
+ }
260
+
261
+ function openSession(code, label) {
262
+ const id = crypto.randomBytes(4).toString('hex')
263
+ const file = path.join(bridgeDir(), `${id}.js`)
264
+ const text = normalizeEol(code == null ? '' : code)
265
+ fs.mkdirSync(bridgeDir(), { recursive: true })
266
+ fs.writeFileSync(file, text, 'utf8')
267
+ const session = {
268
+ id,
269
+ file,
270
+ label: String(label || ''),
271
+ lastHash: hashContent(text),
272
+ lastHeartbeat: Date.now(),
273
+ watcher: null,
274
+ timer: null,
275
+ }
276
+ const watch = typeof watchFile === 'function' ? watchFile : defaultWatchFile
277
+ session.watcher = watch(file, () => onFileChange(session))
278
+ sessions.set(id, session)
279
+ const launched = launchEditor(file)
280
+ log.ok('代码桥会话建立', { id, file, label: session.label, launched })
281
+ return { id, file, launched }
282
+ }
283
+
284
+ const sendJson = (res, status, payload) => {
285
+ res.statusCode = status
286
+ res.setHeader('Content-Type', 'application/json')
287
+ res.setHeader('Cache-Control', 'no-store')
288
+ res.end(JSON.stringify(payload))
289
+ }
290
+
291
+ const readBody = (req, limit = MAX_BODY) =>
292
+ new Promise((resolve, reject) => {
293
+ let body = ''
294
+ req.on('data', (c) => {
295
+ body += c
296
+ if (body.length > limit) {
297
+ reject(new Error('body too large'))
298
+ req.destroy()
299
+ }
300
+ })
301
+ req.on('end', () => resolve(body))
302
+ req.on('error', reject)
303
+ })
304
+
305
+ return async function codeBridgeMiddleware(req, res, next) {
306
+ const pathname = decodeURIComponent((req.url || '').split('?')[0])
307
+ const method = (req.method || 'GET').toUpperCase()
308
+ if (!pathname.startsWith('/__code-bridge')) return next()
309
+
310
+ // POST /__code-bridge —— 建立会话
311
+ if (pathname === '/__code-bridge' && method === 'POST') {
312
+ let payload
313
+ try {
314
+ payload = JSON.parse((await readBody(req)) || '{}')
315
+ } catch (_) {
316
+ return sendJson(res, 400, { ok: false, error: 'invalid json' })
317
+ }
318
+ try {
319
+ const opened = openSession(payload.code, payload.label)
320
+ return sendJson(res, 200, { ok: true, ...opened })
321
+ } catch (e) {
322
+ log.error('代码桥建立会话失败', e)
323
+ return sendJson(res, 500, { ok: false, error: String(e.message) })
324
+ }
325
+ }
326
+
327
+ // 其余端点:/__code-bridge/<id>[/heartbeat]
328
+ const m = pathname.match(/^\/__code-bridge\/([a-f0-9]{8})(\/heartbeat)?$/)
329
+ if (!m) return sendJson(res, 404, { ok: false, error: 'unknown bridge route' })
330
+ const [, id, isHeartbeat] = m
331
+ const session = sessions.get(id)
332
+
333
+ if (isHeartbeat && method === 'POST') {
334
+ if (!session) return sendJson(res, 404, { ok: false, error: 'session not found', id })
335
+ session.lastHeartbeat = Date.now()
336
+ return sendJson(res, 200, { ok: true, id })
337
+ }
338
+
339
+ if (!isHeartbeat && method === 'GET') {
340
+ if (!session) return sendJson(res, 404, { ok: false, error: 'session not found', id })
341
+ let raw = ''
342
+ try {
343
+ raw = fs.readFileSync(session.file, 'utf8')
344
+ } catch (e) {
345
+ return sendJson(res, 410, { ok: false, error: 'temp file gone', id })
346
+ }
347
+ session.lastHeartbeat = Date.now()
348
+ const code = normalizeEol(raw)
349
+ return sendJson(res, 200, { ok: true, id, code, hash: hashContent(code) })
350
+ }
351
+
352
+ if (!isHeartbeat && method === 'DELETE') {
353
+ const closed = closeSession(id, 'client')
354
+ return sendJson(res, closed ? 200 : 404, { ok: closed, id })
355
+ }
356
+
357
+ return sendJson(res, 405, { ok: false, error: 'method not allowed' })
358
+ }
359
+ }
@@ -13,6 +13,7 @@
13
13
  * 接收 viteServer 参数透传;无 SSR 需求的场景可传 null(这两个端点会返回明确错误)。
14
14
  */
15
15
  import { createSyncMiddleware, createPreviewMiddleware, createLoginCredentialsMiddleware } from './cardSync.js'
16
+ import { createSliceUploadMiddleware } from './sliceUpload.js'
16
17
  import { createCardFilesMiddleware } from './cardFiles.js'
17
18
  import { createSaveMiddleware } from './cardSave.js'
18
19
  import { createImportMetaMiddleware } from './cardImportMeta.js'
@@ -26,12 +27,14 @@ import { createAssetProxyMiddleware } from './assetProxy.js'
26
27
  import { createSliceStaticMiddleware } from './sliceStatic.js'
27
28
  import { createCardImageMiddleware } from './cardImage.js'
28
29
  import { createOpenFolderMiddleware } from './cardOpenFolder.js'
30
+ import { createCodeBridgeMiddleware } from './codeBridge.js'
29
31
  import { buildDataFromSchema } from './buildDataFromSchema.js'
30
32
 
31
33
  export {
32
34
  createSyncMiddleware,
33
35
  createPreviewMiddleware,
34
36
  createLoginCredentialsMiddleware,
37
+ createSliceUploadMiddleware,
35
38
  createCardFilesMiddleware,
36
39
  createSaveMiddleware,
37
40
  createImportMetaMiddleware,
@@ -45,19 +48,26 @@ export {
45
48
  createSliceStaticMiddleware,
46
49
  createCardImageMiddleware,
47
50
  createOpenFolderMiddleware,
51
+ createCodeBridgeMiddleware,
48
52
  buildDataFromSchema,
49
53
  }
50
54
 
51
55
  /**
52
56
  * 把全部卡片中间件挂到 Connect 风格容器(需实现 .use(mw))。
53
57
  * @param {{ use: (mw: Function) => void }} app
54
- * @param {{ root: string, viteServer?: import('vite').ViteDevServer | null, upstream?: string }} opts
58
+ * @param {{ root: string, viteServer?: import('vite').ViteDevServer | null, upstream?: string, notify?: (data: object, event?: string) => void, watchFile?: (file: string, cb: () => void) => { close: () => void } }} opts
55
59
  * - upstream:FinMall 远端站点基址(来自 FINMALL_BASE_URL),资源代理回源用;空串表示不回源。
60
+ * - notify:SSE 推送入口(cardbuddy 的 /__events hub);代码桥经它推 bridge 事件,缺省时静默降级。
61
+ * - watchFile:代码桥单文件监听能力注入(chokidar 版);缺省代码桥用 fs.watch 兜底。
56
62
  */
57
- export function mountCoreMiddlewares(app, { root, viteServer = null, upstream = '' }) {
63
+ export function mountCoreMiddlewares(app, { root, viteServer = null, upstream = '', notify = null, watchFile = null }) {
58
64
  app.use(createSyncMiddleware({ root }))
59
65
  app.use(createPreviewMiddleware({ root }))
60
66
  app.use(createLoginCredentialsMiddleware({ root }))
67
+ // 切图上传(顶栏「远端 → 切图上传」):把当前卡引用的 src/slices 切图传到 FinMall 图片库,
68
+ // 写 src/slices/manifest.json 映射并把卡片里的 /slices/ 引用改写为 @filestore/ 线上地址。
69
+ // 仅在用户点击时触发,没有任何自动上传路径。
70
+ app.use(createSliceUploadMiddleware({ root }))
61
71
  app.use(createCardFilesMiddleware({ root }))
62
72
  app.use(createSaveMiddleware({ root }))
63
73
  app.use(createHistoryMiddleware({ root }))
@@ -70,6 +80,8 @@ export function mountCoreMiddlewares(app, { root, viteServer = null, upstream =
70
80
  app.use(createBboxMiddleware({ root }))
71
81
  // 在系统文件管理器中打开当前卡片本地目录(编辑器顶栏「打开目录」按钮触发,跨平台由后端完成)
72
82
  app.use(createOpenFolderMiddleware({ root }))
83
+ // 外部编辑器代码桥(VSCode / DSL_EDITOR_CMD):临时 js + 保存回写 + 会话 TTL 清理
84
+ app.use(createCodeBridgeMiddleware({ root, notify, watchFile }))
73
85
  // 卡片预览图静态服务:/__card-image/<id> → src/cards/<id>/<id>.<ext>(编辑器网格预览缩略图用,
74
86
  // 复用本地静态服务,避免实时无头截图)。
75
87
  app.use(createCardImageMiddleware({ root }))
@@ -0,0 +1,97 @@
1
+ import path from 'node:path'
2
+ import { pathToFileURL } from 'node:url'
3
+ import { log, fmtBytes } from './logger.js'
4
+
5
+ /**
6
+ * 顶栏「远端 → 切图上传」按钮的服务端处理:把卡片引用的 src/slices 切图上传到 FinMall
7
+ * 图片库(CC0000.do),写「本地文件 ↔ 线上地址」映射(src/slices/manifest.json),
8
+ * 并把该卡 schema.json / data.json 里的 `/slices/<name>` 引用改写为返回的 `@filestore/...`。
9
+ *
10
+ * 复用 finmall-card-sync 的 uploadSlices(与「同步」「版本推送」共用同一后台 FinMall 会话)。
11
+ * 纯前端无法直接调 FinMall:跨域 + 需 token(只在页面上下文内读取,不回传 Node),故必须经此中转。
12
+ *
13
+ * **只由用户点击触发**——没有任何自动上传路径:保存 / 推送 / 同步都不会顺带上传切图。
14
+ *
15
+ * @param {{ root: string }} opts
16
+ * @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
17
+ */
18
+ export function createSliceUploadMiddleware({ root }) {
19
+ return function sliceUploadMiddleware(req, res, next) {
20
+ const pathname = decodeURIComponent((req.url || '').split('?')[0])
21
+ if (pathname !== '/__slice-upload') return next()
22
+ if ((req.method || 'GET').toUpperCase() !== 'POST') return next()
23
+
24
+ let body = ''
25
+ req.on('data', (c) => {
26
+ body += c
27
+ if (body.length > 64 * 1024) req.destroy()
28
+ })
29
+ req.on('end', async () => {
30
+ let payload
31
+ try {
32
+ payload = JSON.parse(body || '{}')
33
+ } catch (e) {
34
+ res.statusCode = 400
35
+ res.setHeader('Content-Type', 'application/json')
36
+ return res.end(JSON.stringify({ error: 'invalid json', message: String(e.message) }))
37
+ }
38
+ if (!payload.cardId) {
39
+ res.statusCode = 400
40
+ res.setHeader('Content-Type', 'application/json')
41
+ return res.end(
42
+ JSON.stringify({ error: 'missing cardId', message: '需传入当前卡片 id(切图上传按单卡范围执行)' }),
43
+ )
44
+ }
45
+ try {
46
+ const skillPath = path.join(root, 'skills', 'finmall-card-sync', 'scripts', 'slice_upload.mjs')
47
+ const { uploadSlices } = await import(pathToFileURL(skillPath).href)
48
+ const summary = await uploadSlices({
49
+ cardId: String(payload.cardId),
50
+ force: !!payload.force,
51
+ slicesRoot: path.join(root, 'src', 'slices'),
52
+ cardsRoot: path.join(root, 'src', 'cards'),
53
+ })
54
+ // 上传结果在 dev server 终端同样留痕(与「远端同步」一致的美化框)。
55
+ const replaced = summary.rewritten.reduce((n, r) => n + r.count, 0)
56
+ const lines = [
57
+ `卡片:${summary.cardId}`,
58
+ `目标:sassAppId=${summary.appId} sassWorkspaceId=${summary.workspaceId}`,
59
+ summary.uploaded.length
60
+ ? `已上传 ${summary.uploaded.length} 张:`
61
+ : '已上传 0 张',
62
+ ...summary.uploaded.map(
63
+ (u) => `• ${u.name}(${fmtBytes(u.size)}${u.dimensions ? ` ${u.dimensions}` : ''})→ ${u.url}`,
64
+ ),
65
+ `复用已有地址(内容未变):${summary.skipped.length} 张`,
66
+ `改写引用:${summary.rewritten.length} 个文件 / ${replaced} 处`,
67
+ ...(summary.failed.length
68
+ ? [`失败 ${summary.failed.length} 张:`, ...summary.failed.map((f) => `✗ ${f.name} ${f.error}`)]
69
+ : []),
70
+ ...(summary.dangling.length
71
+ ? [`悬空引用(schema 引用但 src/slices 无此文件):${summary.dangling.join('、')}`]
72
+ : []),
73
+ ]
74
+ log.box({ title: '切图上传完成', level: summary.success ? 'ok' : 'warn', lines })
75
+ res.statusCode = 200
76
+ res.setHeader('Content-Type', 'application/json')
77
+ return res.end(JSON.stringify(summary))
78
+ } catch (e) {
79
+ // 区分「凭据缺失」与「真正的上传错误」:前者让前端弹登录表单,而非裸抛 ENOENT。
80
+ const isNoCred =
81
+ e &&
82
+ (e.code === 'NO_CREDENTIALS' ||
83
+ /credentials\.json|credentials 不存在|NO_CREDENTIALS/.test(String(e && e.message)))
84
+ if (!isNoCred) log.error('切图上传失败', e)
85
+ res.statusCode = isNoCred ? 200 : 500
86
+ res.setHeader('Content-Type', 'application/json')
87
+ return res.end(
88
+ JSON.stringify(
89
+ isNoCred
90
+ ? { error: 'need_credentials', needCredentials: true, message: String(e && e.message) }
91
+ : { error: 'slice upload failed', message: String(e && e.message) },
92
+ ),
93
+ )
94
+ }
95
+ })
96
+ }
97
+ }
@@ -290,8 +290,8 @@ function createEventsEndpoint() {
290
290
  clients.delete(res)
291
291
  })
292
292
  }
293
- const notify = (data) => {
294
- const payload = `event: cards-changed\ndata: ${JSON.stringify(data)}\n\n`
293
+ const notify = (data, event = 'cards-changed') => {
294
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
295
295
  for (const res of clients) res.write(payload)
296
296
  }
297
297
  return { handler, notify }
@@ -397,7 +397,22 @@ export async function startServer(userConfig = {}) {
397
397
 
398
398
  const vite = await createEmbeddedVite(config)
399
399
  // upstream(FINMALL_BASE_URL)透传给资源代理中间件,使其能回源 FinMall 取站内资源
400
- mountCoreMiddlewares(app, { root: config.root, viteServer: vite, upstream: config.upstream })
400
+ mountCoreMiddlewares(app, {
401
+ root: config.root,
402
+ viteServer: vite,
403
+ upstream: config.upstream,
404
+ notify: events.notify,
405
+ // 代码桥单文件监听注入 chokidar 版:原子保存(改名覆盖)由 add 兜住,awaitWriteFinish 防半截读
406
+ watchFile: (file, onChange) => {
407
+ const watcher = chokidar.watch(file, {
408
+ ignoreInitial: true,
409
+ awaitWriteFinish: { stabilityThreshold: 120, pollInterval: 40 },
410
+ })
411
+ watcher.on('change', onChange)
412
+ watcher.on('add', onChange)
413
+ return watcher
414
+ },
415
+ })
401
416
 
402
417
  watchCards(config.root, events.notify)
403
418