cardbuddy 0.6.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 (167) hide show
  1. package/README.md +211 -0
  2. package/dist/cli.js +76 -0
  3. package/dist/config.js +63 -0
  4. package/dist/core.js +2 -0
  5. package/dist/preview-editor/assets/apple-touch-icon-BQcES9QK.png +0 -0
  6. package/dist/preview-editor/assets/default-image-wzZYQ86K.png +0 -0
  7. package/dist/preview-editor/assets/favicon-16-Cg6Nrosk.png +0 -0
  8. package/dist/preview-editor/assets/favicon-32-B_1744sp.png +0 -0
  9. package/dist/preview-editor/assets/index-BYB0hngG.js +72 -0
  10. package/dist/preview-editor/assets/index-CpBZEQDy.css +1 -0
  11. package/dist/preview-editor/assets/site-icon-CFMif8z4.png +0 -0
  12. package/dist/preview-editor/assets/svg-symbols-B2exBrAr.svg +118 -0
  13. package/dist/preview-editor/index.html +17 -0
  14. package/dist/server-core/_vendor/finmall-env.mjs +96 -0
  15. package/dist/server-core/assetProxy.js +154 -0
  16. package/dist/server-core/bbox.js +153 -0
  17. package/dist/server-core/browser.js +108 -0
  18. package/dist/server-core/buildDataFromSchema.js +50 -0
  19. package/dist/server-core/cardFiles.js +188 -0
  20. package/dist/server-core/cardHistory.js +156 -0
  21. package/dist/server-core/cardImage.js +85 -0
  22. package/dist/server-core/cardImportMeta.js +139 -0
  23. package/dist/server-core/cardListCache.js +68 -0
  24. package/dist/server-core/cardOpenFolder.js +135 -0
  25. package/dist/server-core/cardSave.js +127 -0
  26. package/dist/server-core/cardSidecar.js +72 -0
  27. package/dist/server-core/cardSync.js +259 -0
  28. package/dist/server-core/cardValidate.js +134 -0
  29. package/dist/server-core/index.js +82 -0
  30. package/dist/server-core/logger.js +241 -0
  31. package/dist/server-core/nodeModels.js +52 -0
  32. package/dist/server-core/renderStatus.js +207 -0
  33. package/dist/server-core/screenshot.js +123 -0
  34. package/dist/server-core/sliceStatic.js +44 -0
  35. package/dist/standalone.js +447 -0
  36. package/dist/vendored/App.vue +2330 -0
  37. package/dist/vendored/api.js +142 -0
  38. package/dist/vendored/assets/apple-touch-icon.png +0 -0
  39. package/dist/vendored/assets/default-image.png +0 -0
  40. package/dist/vendored/assets/favicon-16.png +0 -0
  41. package/dist/vendored/assets/favicon-32.png +0 -0
  42. package/dist/vendored/assets/logo.png +0 -0
  43. package/dist/vendored/assets/site-icon.png +0 -0
  44. package/dist/vendored/assets/svg-symbols.svg +118 -0
  45. package/dist/vendored/assets/svgIcons/icons.svg +54 -0
  46. package/dist/vendored/assets/svgIcons/index.js +39 -0
  47. package/dist/vendored/components/AbsoluteLayout.vue +42 -0
  48. package/dist/vendored/components/AlertCardInstance.vue +58 -0
  49. package/dist/vendored/components/AlertCardLayer.vue +59 -0
  50. package/dist/vendored/components/ApiPayloadTree.vue +168 -0
  51. package/dist/vendored/components/AppToast.vue +37 -0
  52. package/dist/vendored/components/Button.vue +37 -0
  53. package/dist/vendored/components/Canvas.vue +65 -0
  54. package/dist/vendored/components/CardGallery.vue +243 -0
  55. package/dist/vendored/components/CardPreview.vue +72 -0
  56. package/dist/vendored/components/CardSelect.vue +200 -0
  57. package/dist/vendored/components/CheckGroup.vue +131 -0
  58. package/dist/vendored/components/Column.vue +25 -0
  59. package/dist/vendored/components/ComponentPalette.vue +294 -0
  60. package/dist/vendored/components/ConfirmDialog.vue +39 -0
  61. package/dist/vendored/components/CustomPlaceholder.vue +33 -0
  62. package/dist/vendored/components/DatePickerOverlay.vue +345 -0
  63. package/dist/vendored/components/DebugPanel.vue +806 -0
  64. package/dist/vendored/components/DeviceOverlay.vue +70 -0
  65. package/dist/vendored/components/Divider.vue +22 -0
  66. package/dist/vendored/components/Dropdown.vue +191 -0
  67. package/dist/vendored/components/EventEditor.vue +247 -0
  68. package/dist/vendored/components/GridLayout.vue +67 -0
  69. package/dist/vendored/components/HoverGuides.vue +122 -0
  70. package/dist/vendored/components/Icon.vue +34 -0
  71. package/dist/vendored/components/Image.vue +34 -0
  72. package/dist/vendored/components/Input.vue +90 -0
  73. package/dist/vendored/components/JsonTree.vue +516 -0
  74. package/dist/vendored/components/ListLayout.vue +68 -0
  75. package/dist/vendored/components/LogValue.vue +51 -0
  76. package/dist/vendored/components/Map.vue +110 -0
  77. package/dist/vendored/components/MultiColumn.vue +49 -0
  78. package/dist/vendored/components/MultiText.vue +58 -0
  79. package/dist/vendored/components/NodeInspector.vue +295 -0
  80. package/dist/vendored/components/PickerOverlay.vue +131 -0
  81. package/dist/vendored/components/ProgressSlider.vue +167 -0
  82. package/dist/vendored/components/PropControl.vue +241 -0
  83. package/dist/vendored/components/PropTree.vue +128 -0
  84. package/dist/vendored/components/RadioButton.vue +74 -0
  85. package/dist/vendored/components/RemotePreview.vue +171 -0
  86. package/dist/vendored/components/RenderTree.vue +437 -0
  87. package/dist/vendored/components/Root.vue +30 -0
  88. package/dist/vendored/components/Row.vue +25 -0
  89. package/dist/vendored/components/SelectionOverlay.vue +105 -0
  90. package/dist/vendored/components/StructureTree.vue +949 -0
  91. package/dist/vendored/components/SvgCode.vue +19 -0
  92. package/dist/vendored/components/SvgSymbols.vue +30 -0
  93. package/dist/vendored/components/Switch.vue +38 -0
  94. package/dist/vendored/components/Text.vue +32 -0
  95. package/dist/vendored/components/Video.vue +70 -0
  96. package/dist/vendored/components/ViewContainer.vue +52 -0
  97. package/dist/vendored/components/base.js +12 -0
  98. package/dist/vendored/components/icons.js +47 -0
  99. package/dist/vendored/components/index.js +58 -0
  100. package/dist/vendored/components/setters/SetBgPosition.vue +64 -0
  101. package/dist/vendored/components/setters/SetBgSize.vue +90 -0
  102. package/dist/vendored/components/setters/SetCode.vue +90 -0
  103. package/dist/vendored/components/setters/SetColor.vue +244 -0
  104. package/dist/vendored/components/setters/SetCustomSelect.vue +58 -0
  105. package/dist/vendored/components/setters/SetExpr.vue +107 -0
  106. package/dist/vendored/components/setters/SetGradient.vue +125 -0
  107. package/dist/vendored/components/setters/SetGrid9.vue +68 -0
  108. package/dist/vendored/components/setters/SetIconSelect.vue +67 -0
  109. package/dist/vendored/components/setters/SetIfForm.vue +61 -0
  110. package/dist/vendored/components/setters/SetImage.vue +43 -0
  111. package/dist/vendored/components/setters/SetList.vue +139 -0
  112. package/dist/vendored/components/setters/SetNumber.vue +118 -0
  113. package/dist/vendored/components/setters/SetNumberStyle.vue +113 -0
  114. package/dist/vendored/components/setters/SetPadding.vue +77 -0
  115. package/dist/vendored/components/setters/SetSegment.vue +160 -0
  116. package/dist/vendored/components/setters/SetSelect.vue +51 -0
  117. package/dist/vendored/components/setters/SetSwitch.vue +49 -0
  118. package/dist/vendored/components/setters/SetText.vue +52 -0
  119. package/dist/vendored/components/setters/props.js +23 -0
  120. package/dist/vendored/fidelity/baseline-messageOverview.json +87 -0
  121. package/dist/vendored/fidelity/calibration.json +29 -0
  122. package/dist/vendored/fidelity/fidelity-messageOverview-after.html +114 -0
  123. package/dist/vendored/fidelity/fidelity-messageOverview-after.json +272 -0
  124. package/dist/vendored/fidelity/fidelity-messageOverview.html +185 -0
  125. package/dist/vendored/fidelity/fidelity-messageOverview.json +336 -0
  126. package/dist/vendored/fidelity/fidelity-messageOverview_v2.html +262 -0
  127. package/dist/vendored/fidelity/fidelity-messageOverview_v2.json +502 -0
  128. package/dist/vendored/fidelity/fidelity-messageOverview_v3.html +201 -0
  129. package/dist/vendored/fidelity/fidelity-messageOverview_v3.json +417 -0
  130. package/dist/vendored/fidelity/fidelity-messageOverview_visual.html +125 -0
  131. package/dist/vendored/fidelity/fidelity-messageOverview_visual.json +338 -0
  132. package/dist/vendored/index.html +16 -0
  133. package/dist/vendored/main.js +36 -0
  134. package/dist/vendored/meta/action-meta.json +141 -0
  135. package/dist/vendored/meta/component-meta.json +2714 -0
  136. package/dist/vendored/meta/directive-meta.json +40 -0
  137. package/dist/vendored/meta/style-meta.json +1086 -0
  138. package/dist/vendored/runtime/StyleParser.js +179 -0
  139. package/dist/vendored/runtime/adapter.js +166 -0
  140. package/dist/vendored/runtime/alert-card.js +125 -0
  141. package/dist/vendored/runtime/app-toast.js +33 -0
  142. package/dist/vendored/runtime/assets.js +16 -0
  143. package/dist/vendored/runtime/canvas-context.js +141 -0
  144. package/dist/vendored/runtime/card-runtime.js +289 -0
  145. package/dist/vendored/runtime/clipboard-node.js +83 -0
  146. package/dist/vendored/runtime/clipboard.js +29 -0
  147. package/dist/vendored/runtime/evaluate.js +152 -0
  148. package/dist/vendored/runtime/event-model.js +300 -0
  149. package/dist/vendored/runtime/hoverGuide.js +31 -0
  150. package/dist/vendored/runtime/jsapi-mock.js +256 -0
  151. package/dist/vendored/runtime/node-model.js +217 -0
  152. package/dist/vendored/runtime/property.js +364 -0
  153. package/dist/vendored/runtime/selectionOverlay.js +53 -0
  154. package/dist/vendored/runtime/toPreview.js +648 -0
  155. package/dist/vendored/standalone-main.js +36 -0
  156. package/dist/vendored/standalone.html +12 -0
  157. package/dist/vendored/styles/app.css +2565 -0
  158. package/dist/vendored/styles/card-gallery.css +234 -0
  159. package/dist/vendored/styles/card-select.css +281 -0
  160. package/dist/vendored/styles/components.css +383 -0
  161. package/dist/vendored/styles/dropdown.css +337 -0
  162. package/dist/vendored/styles/json-tree.css +308 -0
  163. package/dist/vendored/styles/node-inspector.css +1003 -0
  164. package/dist/vendored/styles/setters.css +962 -0
  165. package/dist/vendored/styles/standalone.css +27 -0
  166. package/dist/vendored/styles/structure-tree.css +320 -0
  167. package/package.json +42 -0
@@ -0,0 +1,188 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { markDirty, recordEdit, readImportMeta } from './cardImportMeta.js'
4
+ import { log } from './logger.js'
5
+ import { buildDataFromSchema } from './buildDataFromSchema.js'
6
+
7
+ /**
8
+ * 单张卡片 / 卡片列表的按需读取 / 写回服务端处理(dev 下实时读磁盘,无缓存)。
9
+ * - GET /__cards :实时扫描 src/cards/* 目录,返回卡片列表 [{id,name,pageId}]。
10
+ * - GET /__card/<id> :实时读 src/cards/<id>/schema.json,data 由 schema 派生返回。
11
+ * - POST /__card/<id> :把预览内编辑后的最新 schema 写回 src/cards/<id>/,data 单向重算。
12
+ *
13
+ * 以下纯函数被 mcp/ 客户端复用(降级模式直接读盘/写盘),保持单一实现、避免逻辑漂移。
14
+ */
15
+
16
+ /** 扫描 src/cards/* 返回 [{id,name,pageId,mtime}](导出供 MCP 复用);
17
+ * mtime 为 schema.json / data.json 中较新的修改时间(毫秒),供前端按修改时间排序 */
18
+ export function listCards(root) {
19
+ const cardsDir = path.join(root, 'src', 'cards')
20
+ const list = []
21
+ if (fs.existsSync(cardsDir)) {
22
+ for (const id of fs.readdirSync(cardsDir)) {
23
+ const dir = path.join(cardsDir, id)
24
+ if (!fs.statSync(dir).isDirectory()) continue
25
+ let name = id
26
+ let pageId = null
27
+ let mtime = 0
28
+ try {
29
+ const schema = JSON.parse(fs.readFileSync(path.join(dir, 'schema.json'), 'utf8'))
30
+ if (schema && schema.name) name = schema.name
31
+ if (schema && schema.pageId != null) pageId = schema.pageId
32
+ } catch {
33
+ /* 读取失败则退用目录名 / pageId=null,不阻断列表 */
34
+ }
35
+ // 卡片修改时间:取 schema.json 的单文件 mtime(毫秒)。data.json 是 schema 的
36
+ // 单向派生产物,不应参与排序,否则它每次被重算都会让卡片「看起来更新」。
37
+ try {
38
+ mtime = fs.statSync(path.join(dir, 'schema.json')).mtimeMs
39
+ } catch {
40
+ /* 取不到修改时间则记为 0,不参与新→旧排序时排在最前 */
41
+ }
42
+ // 优先采用侧车 .import-meta.json 记录的本地编辑时间(editedAt 更准确反映「编辑」而非文件拷贝),
43
+ // 无侧车 / 无 editedAt 时退用上面的文件 mtime。
44
+ try {
45
+ const meta = readImportMeta(dir)
46
+ if (meta && meta.editedAt) {
47
+ const t = Date.parse(meta.editedAt)
48
+ if (!Number.isNaN(t)) mtime = t
49
+ }
50
+ } catch {
51
+ /* 解析失败则保持文件 mtime */
52
+ }
53
+ list.push({ id, name, pageId, mtime })
54
+ }
55
+ }
56
+ list.sort((a, b) => a.id.localeCompare(b.id))
57
+ return list
58
+ }
59
+
60
+ /** 读单卡 {schema,data};不存在返回 null(导出供 MCP 复用) */
61
+ export function readCardFiles(root, id) {
62
+ const dir = path.join(root, 'src', 'cards', id)
63
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return null
64
+ const read = (f) => {
65
+ try {
66
+ return JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))
67
+ } catch {
68
+ return null
69
+ }
70
+ }
71
+ const schema = read('schema.json')
72
+ // schema 读取失败(外部改坏 / 文件缺失)→ 卡片不可用,返回 null 让上层给 404,
73
+ // 而非带着 null schema 进 buildDataFromSchema 再抛 500。
74
+ if (!schema) return null
75
+ // data 由 schema 单向派生,绝不读回 data.json 文件 —— 即便 data.json 缺失/过期,
76
+ // GET /__card 仍返回正确的派生 data(向前兼容旧客户端 / mcp 降级读取)。
77
+ return { schema, data: buildDataFromSchema(schema) }
78
+ }
79
+
80
+ /**
81
+ * 把预览内编辑后的最新 schema 原子写回 src/cards/<id>/(导出供 MCP 复用)。
82
+ *
83
+ * 关键点(「schema 唯一真值」):
84
+ * - 只接受 schema 写盘;写 schema.json 后**始终用 buildDataFromSchema(schema) 重算
85
+ * data.json**,payload 里的 data 字段被忽略 —— data.json 永不成为写入来源。
86
+ * - 对象/数组走 JSON(剥除 _schemaNode 等运行时自引用),其余原样字符串化;
87
+ * - 先写临时文件再 rename,避免写到一半进程退出损坏原文件。
88
+ */
89
+ function writeJsonFile(dir, filename, value) {
90
+ const text =
91
+ value !== null && typeof value === 'object'
92
+ ? JSON.stringify(value, (k, v) => (k === '_schemaNode' ? undefined : v), 2)
93
+ : String(value)
94
+ const target = path.join(dir, filename)
95
+ const tmp = target + '.tmp'
96
+ fs.writeFileSync(tmp, text, 'utf8')
97
+ fs.renameSync(tmp, target)
98
+ }
99
+
100
+ export function writeCardFiles(dir, payload) {
101
+ const written = []
102
+ if ('schema' in payload && payload.schema !== undefined) {
103
+ writeJsonFile(dir, 'schema.json', payload.schema)
104
+ written.push('schema.json')
105
+ // data.json 是 schema 的单向派生产物:写 schema 后强制重算,忽略 payload.data。
106
+ writeJsonFile(dir, 'data.json', buildDataFromSchema(payload.schema))
107
+ written.push('data.json')
108
+ } else if ('data' in payload && payload.data !== undefined) {
109
+ // 防御性分支:理论上不会再发生(预览只发 schema);保留以兼容极旧调用方直接写 data。
110
+ writeJsonFile(dir, 'data.json', payload.data)
111
+ written.push('data.json')
112
+ }
113
+ return written
114
+ }
115
+
116
+ /**
117
+ * @param {{ root: string }} opts
118
+ * @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
119
+ */
120
+ export function createCardFilesMiddleware({ root }) {
121
+ return function cardFilesMiddleware(req, res, next) {
122
+ const pathname = decodeURIComponent((req.url || '').split('?')[0])
123
+ const method = (req.method || 'GET').toUpperCase()
124
+
125
+ // 实时卡片列表:扫描 src/cards/* 目录,逐张读取 schema.name / pageId。
126
+ // dev 下不再依赖任何启动快照,磁盘改了列表立即变(新增 / 改名 / 删除的卡都即时反映)。
127
+ if (pathname === '/__cards' && method === 'GET') {
128
+ const cards = listCards(root)
129
+ log.info('读取卡片列表', '数量: ' + cards.length)
130
+ res.statusCode = 200
131
+ res.setHeader('Content-Type', 'application/json')
132
+ res.setHeader('Cache-Control', 'no-store')
133
+ return res.end(JSON.stringify({ cards }))
134
+ }
135
+
136
+ const m = pathname.match(/^\/__card\/([^/]+)$/)
137
+ if (!m) return next()
138
+ const id = decodeURIComponent(m[1])
139
+ const dir = path.join(root, 'src', 'cards', id)
140
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
141
+ log.warn('卡片未找到', { id })
142
+ res.statusCode = 404
143
+ res.setHeader('Content-Type', 'application/json')
144
+ return res.end(JSON.stringify({ error: 'card not found', id }))
145
+ }
146
+ if (method === 'GET') {
147
+ res.statusCode = 200
148
+ res.setHeader('Content-Type', 'application/json')
149
+ res.setHeader('Cache-Control', 'no-store')
150
+ return res.end(JSON.stringify(readCardFiles(root, id)))
151
+ }
152
+ if (method === 'POST') {
153
+ let body = ''
154
+ req.on('data', (c) => {
155
+ body += c
156
+ if (body.length > 16 * 1024 * 1024) req.destroy()
157
+ })
158
+ req.on('end', () => {
159
+ let payload
160
+ try {
161
+ payload = JSON.parse(body)
162
+ } catch (e) {
163
+ res.statusCode = 400
164
+ res.setHeader('Content-Type', 'application/json')
165
+ return res.end(JSON.stringify({ error: 'invalid json', message: String(e.message) }))
166
+ }
167
+ try {
168
+ const written = writeCardFiles(dir, payload)
169
+ // 本地编辑后偏离导入镜像(Phase E「导入来源可追溯」):标脏,UI「来源」tab 据此提示。
170
+ try { markDirty(dir, true) } catch { /* 无侧车时忽略 */ }
171
+ // 记录本次编辑时间到侧车,供「按修改时间排序」优先采用(无侧车时忽略,退用文件 mtime)。
172
+ try { recordEdit(dir) } catch { /* 无侧车时忽略 */ }
173
+ log.ok('写回卡片', id)
174
+ res.statusCode = 200
175
+ res.setHeader('Content-Type', 'application/json')
176
+ return res.end(JSON.stringify({ ok: true, written }))
177
+ } catch (e) {
178
+ log.error('写回卡片失败', e)
179
+ res.statusCode = 500
180
+ res.setHeader('Content-Type', 'application/json')
181
+ return res.end(JSON.stringify({ error: 'write failed', message: String(e.message) }))
182
+ }
183
+ })
184
+ return
185
+ }
186
+ return next()
187
+ }
188
+ }
@@ -0,0 +1,156 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { buildDataFromSchema } from './buildDataFromSchema.js'
4
+
5
+ /**
6
+ * 编辑历史(撤销/重做)快照的落盘与读取。
7
+ *
8
+ * 每当预览内对 schema 做了一次结构性修改,前端会把「修改前的上一版本 schema」
9
+ * POST 到这里,服务端以 `src/cards/<id>/.history/schema-<ts>.json` 存盘(与卡片同目录、
10
+ * 独立的缓存文件夹,不进版本库)。最多保留 MAX_SNAPSHOTS 个,超出裁剪最旧。
11
+ *
12
+ * GET 返回按时间升序(旧→新)的 snapshots,供前端在页面重载后重建撤销栈,使
13
+ * 撤销能力跨刷新可用。
14
+ */
15
+
16
+ const HISTORY_DIR = '.history'
17
+ const MAX_SNAPSHOTS = 30
18
+
19
+ /** 列出 .history 下全部快照元数据(按 ts 升序) */
20
+ export function listSnapshots(histDir) {
21
+ if (!fs.existsSync(histDir) || !fs.statSync(histDir).isDirectory()) return []
22
+ return fs
23
+ .readdirSync(histDir)
24
+ .filter((f) => /^schema-(\d+)\.json$/.test(f))
25
+ .map((f) => {
26
+ const m = f.match(/^schema-(\d+)\.json$/)
27
+ return { ts: m ? Number(m[1]) : 0, file: f }
28
+ })
29
+ .sort((a, b) => a.ts - b.ts)
30
+ }
31
+
32
+ /** 超出上限时删除最旧文件(就地裁剪传入的数组) */
33
+ function trimSnapshots(histDir, snapshots) {
34
+ while (snapshots.length > MAX_SNAPSHOTS) {
35
+ const oldest = snapshots.shift()
36
+ try {
37
+ fs.unlinkSync(path.join(histDir, oldest.file))
38
+ } catch {
39
+ /* 删除失败不阻断 */
40
+ }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * 把一份 schema 落盘为新快照(原子写 + 唯一递增 ts + 超上限裁剪)。
46
+ * 供中间件 POST 与 MCP card_rollback 离线路径共用同一份实现,避免漂移。
47
+ * @param {string} cardDir src/cards/<id> 目录
48
+ * @param {object} schema 待快照的 schema
49
+ * @returns {{ ts: number, count: number }}
50
+ */
51
+ export function saveSnapshot(cardDir, schema) {
52
+ const histDir = path.join(cardDir, HISTORY_DIR)
53
+ if (!fs.existsSync(histDir)) fs.mkdirSync(histDir, { recursive: true })
54
+ // 生成唯一且单调递增的 ts 文件名:同一毫秒内冲突则自增,保证排序稳定。
55
+ let ts = Date.now()
56
+ let file = `schema-${ts}.json`
57
+ while (fs.existsSync(path.join(histDir, file))) {
58
+ ts += 1
59
+ file = `schema-${ts}.json`
60
+ }
61
+ const content = JSON.stringify(
62
+ { ts, schema },
63
+ (k, v) => (k === '_schemaNode' ? undefined : v),
64
+ 2,
65
+ )
66
+ const tmp = path.join(histDir, file + '.tmp')
67
+ fs.writeFileSync(tmp, content, 'utf8')
68
+ fs.renameSync(tmp, path.join(histDir, file))
69
+ const snaps = listSnapshots(histDir)
70
+ trimSnapshots(histDir, snaps)
71
+ return { ts, count: Math.min(snaps.length, MAX_SNAPSHOTS) }
72
+ }
73
+
74
+ export { HISTORY_DIR }
75
+
76
+ /**
77
+ * @param {{ root: string }} opts
78
+ * @returns {(req, res, next) => void}
79
+ */
80
+ export function createHistoryMiddleware({ root }) {
81
+ return function historyMiddleware(req, res, next) {
82
+ const pathname = decodeURIComponent((req.url || '').split('?')[0])
83
+ const method = (req.method || 'GET').toUpperCase()
84
+ const m = pathname.match(/^\/__history\/([^/]+)$/)
85
+ if (!m) return next()
86
+ const id = decodeURIComponent(m[1])
87
+ const dir = path.join(root, 'src', 'cards', id)
88
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
89
+ res.statusCode = 404
90
+ res.setHeader('Content-Type', 'application/json')
91
+ return res.end(JSON.stringify({ error: 'card not found', id }))
92
+ }
93
+ const histDir = path.join(dir, HISTORY_DIR)
94
+
95
+ // GET:读取全部历史快照(旧→新),返回 schema 内容
96
+ if (method === 'GET') {
97
+ const snaps = listSnapshots(histDir)
98
+ const out = []
99
+ for (const s of snaps) {
100
+ try {
101
+ const data = JSON.parse(fs.readFileSync(path.join(histDir, s.file), 'utf8'))
102
+ const schema = data.schema != null ? data.schema : data
103
+ // 旧快照可能带 data 字段(历史兼容);新快照只存 schema,data 由 schema
104
+ // 单向派生,保证撤销/重做还原时数据绑定与 schema.defaultValue 完全一致。
105
+ const derived = data.data != null ? data.data : buildDataFromSchema(schema)
106
+ out.push({ ts: s.ts, schema, data: derived })
107
+ } catch {
108
+ /* 单文件损坏不阻断整体读取 */
109
+ }
110
+ }
111
+ res.statusCode = 200
112
+ res.setHeader('Content-Type', 'application/json')
113
+ res.setHeader('Cache-Control', 'no-store')
114
+ return res.end(JSON.stringify({ snapshots: out }))
115
+ }
116
+
117
+ // POST:把「上一版本 schema」落盘为一张新快照
118
+ if (method === 'POST') {
119
+ let body = ''
120
+ req.on('data', (c) => {
121
+ body += c
122
+ if (body.length > 32 * 1024 * 1024) req.destroy()
123
+ })
124
+ req.on('end', () => {
125
+ let payload
126
+ try {
127
+ payload = JSON.parse(body)
128
+ } catch (e) {
129
+ res.statusCode = 400
130
+ res.setHeader('Content-Type', 'application/json')
131
+ return res.end(JSON.stringify({ error: 'invalid json', message: String(e.message) }))
132
+ }
133
+ const schema = payload && payload.schema
134
+ if (!schema || typeof schema !== 'object') {
135
+ res.statusCode = 400
136
+ res.setHeader('Content-Type', 'application/json')
137
+ return res.end(JSON.stringify({ error: 'missing schema in body' }))
138
+ }
139
+ try {
140
+ const { ts, count } = saveSnapshot(dir, schema)
141
+ res.statusCode = 200
142
+ res.setHeader('Content-Type', 'application/json')
143
+ return res.end(JSON.stringify({ ok: true, ts, count }))
144
+ } catch (e) {
145
+ console.error('[__history] 写快照失败', e)
146
+ res.statusCode = 500
147
+ res.setHeader('Content-Type', 'application/json')
148
+ return res.end(JSON.stringify({ error: 'write failed', message: String(e.message) }))
149
+ }
150
+ })
151
+ return
152
+ }
153
+
154
+ return next()
155
+ }
156
+ }
@@ -0,0 +1,85 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { takeScreenshot } from './screenshot.js'
4
+
5
+ /**
6
+ * 卡片预览图本地静态服务:把卡片目录下的「同名预览图」映射为
7
+ * GET /__card-image/<id>[?width=375] ,流式返回 src/cards/<id>/<id>.<ext>。
8
+ *
9
+ * 用途:编辑器「卡片网格预览」弹窗用此端点取每格缩略图,复用既有的本地静态服务
10
+ * (与 /slices、/__card 同源,挂在 cardbuddy 上),不依赖 Vite 同源
11
+ * (编辑器经 apiBase 连 8787,<img> 跨域加载由 CORS 中间件放行)。
12
+ *
13
+ * 文件名取「与卡片 id 同名」的位图(png/jpg/jpeg/webp/gif,大小写不敏感);
14
+ * 若卡片目录没有同名预览图,则回退到实时无头截图(takeScreenshot,复用 /__screenshot
15
+ * 同一套渲染内核),保证任何卡片都能出预览。截图失败(如缺 Chrome)则 404,
16
+ * 由调用方回退占位。仅服务 src/cards 下的卡片预览图,禁止目录穿越。
17
+ */
18
+
19
+ const MIME = {
20
+ '.png': 'image/png',
21
+ '.jpg': 'image/jpeg',
22
+ '.jpeg': 'image/jpeg',
23
+ '.webp': 'image/webp',
24
+ '.gif': 'image/gif',
25
+ }
26
+
27
+ // 卡片 id 仅含字母/数字/下划线/连字符(与 src/cards 目录名一致),先收紧再拼路径。
28
+ const ID_RE = /^[A-Za-z0-9_-]+$/
29
+
30
+ /**
31
+ * @param {{ root: string }} opts root = 仓库根(含 src/cards)
32
+ * @returns {(req, res, next) => void}
33
+ */
34
+ export function createCardImageMiddleware({ root }) {
35
+ const cardsRoot = path.join(root, 'src', 'cards')
36
+ return async (req, res, next) => {
37
+ const url = (req.url || '').split('?')[0]
38
+ const m = url.match(/^\/__card-image\/([^/?]+)$/)
39
+ if (!m) return next()
40
+ const id = decodeURIComponent(m[1])
41
+ // 非法 id(含路径分隔/穿越字符)→ 不服务,交下一个中间件
42
+ if (!ID_RE.test(id)) return next()
43
+
44
+ const dir = path.join(cardsRoot, id)
45
+ // 防目录穿越:解析后必须仍在 src/cards/<id> 内
46
+ if (!dir.startsWith(cardsRoot + path.sep) && dir !== cardsRoot) return next()
47
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return next()
48
+
49
+ // 取与 id 同名的位图(大小写不敏感,扩展名见 MIME)
50
+ let file = null
51
+ for (const entry of fs.readdirSync(dir)) {
52
+ const dot = entry.lastIndexOf('.')
53
+ if (dot < 0) continue
54
+ const base = entry.slice(0, dot)
55
+ const ext = entry.slice(dot).toLowerCase()
56
+ if (base.toLowerCase() === id.toLowerCase() && MIME[ext]) {
57
+ file = path.join(dir, entry)
58
+ break
59
+ }
60
+ }
61
+
62
+ // 命中同名预览图:直接静态返回
63
+ if (file) {
64
+ const ext = path.extname(file).toLowerCase()
65
+ res.setHeader('Content-Type', MIME[ext] || 'application/octet-stream')
66
+ res.setHeader('Cache-Control', 'public, max-age=0')
67
+ fs.createReadStream(file).pipe(res)
68
+ return
69
+ }
70
+
71
+ // 无同名预览图:回退实时无头截图(复用 /__screenshot 渲染内核)。
72
+ // width 由查询参数决定(默认 390),与 /__screenshot 一致。
73
+ const q = new URL(req.url, 'http://localhost')
74
+ const width = parseInt(q.searchParams.get('width') || '390', 10) || 390
75
+ const shot = await takeScreenshot(id, width, 'png')
76
+ if (!shot || !shot.buffer) {
77
+ // 截图失败(缺 Chrome / 卡片渲染异常):交下一个中间件(最终 404),由前端回退占位
78
+ return next()
79
+ }
80
+ res.statusCode = 200
81
+ res.setHeader('Content-Type', 'image/png')
82
+ res.setHeader('Cache-Control', 'no-store')
83
+ res.end(shot.buffer)
84
+ }
85
+ }
@@ -0,0 +1,139 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ /**
5
+ * 导入来源元数据(provenance)侧车。
6
+ *
7
+ * 设计纪律(与「绝不污染 DSL 产品数据」一致):
8
+ * - 侧车文件名为 `.import-meta.json`(点前缀),放在 `src/cards/<id>/` 下,**绝不**写进
9
+ * schema.json / data.json。卡片契约(`validate_schema.py`)只校验 schema.json,写回白名单
10
+ * (cardFiles.writeCardFiles)只接受 schema/data,故本文件永远不会进入 DSL 产品数据,也
11
+ * 不会被 `card_write` / 远端保存带出去。
12
+ * - 它只承载「这张卡从哪来 / 何时导入 / 落盘在哪 / 是否偏离导入镜像」这类可追溯信息,
13
+ * 供 UI(DebugPanel「来源」tab)与 AI skill(card_import_meta)读取,落实 Phase E「导入来源可追溯」。
14
+ */
15
+
16
+ export const IMPORT_META_FILE = '.import-meta.json'
17
+ export const META_SCHEMA_VERSION = 1
18
+
19
+ /**
20
+ * 构造一份导入元数据(纯函数,便于单测,不触碰磁盘)。
21
+ * @param {object} o
22
+ * @param {string} o.baseUrl 远端基址(如 https://uat-pkg.finmall.com)
23
+ * @param {string} o.appId 应用标识
24
+ * @param {string} o.workspaceId 工作空间标识
25
+ * @param {string|number} o.cardId 数字型 cardId(IDC10012 主键)
26
+ * @param {string} o.cardCode 英文卡 id(目录名)
27
+ * @param {string} [o.cardName] 卡片标题
28
+ * @param {object} [o.files] { schema, data, cover } 字节数
29
+ */
30
+ export function buildImportMeta({ baseUrl, appId, workspaceId, cardId, cardCode, cardName, files }) {
31
+ return {
32
+ schemaVersion: META_SCHEMA_VERSION,
33
+ source: 'IDC10012',
34
+ interface: 'IDC10012.do',
35
+ baseUrl: baseUrl || '',
36
+ appId: appId || '',
37
+ workspaceId: workspaceId || '',
38
+ cardId: cardId == null ? null : String(cardId),
39
+ cardCode: cardCode || '',
40
+ cardName: cardName || '',
41
+ files: files || null,
42
+ syncedAt: new Date().toISOString(),
43
+ tool: 'finmall-card-sync',
44
+ dirty: false, // 本地编辑后偏离导入镜像为 true(重置为导入镜像后回 false)
45
+ lastExport: null, // 远端保存(/__save)后写入校验结果
46
+ editedAt: null, // 最近一次本地编辑时间(每次写盘都更新),供「按修改时间排序」
47
+ }
48
+ }
49
+
50
+ /** 原子写:先写 tmp 再 rename,避免写到一半进程退出损坏。 */
51
+ export function writeImportMeta(dir, meta) {
52
+ const target = path.join(dir, IMPORT_META_FILE)
53
+ const tmp = target + '.tmp'
54
+ fs.writeFileSync(tmp, JSON.stringify(meta, null, 2), 'utf8')
55
+ fs.renameSync(tmp, target)
56
+ return target
57
+ }
58
+
59
+ /** 读取并解析;文件不存在 / 解析失败返回 null(调用方据此区分「本地新建卡」与「已同步卡」)。 */
60
+ export function readImportMeta(dir) {
61
+ const file = path.join(dir, IMPORT_META_FILE)
62
+ if (!fs.existsSync(file)) return null
63
+ try {
64
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
65
+ } catch {
66
+ return null
67
+ }
68
+ }
69
+
70
+ /** 标记/清除 dirty(本地编辑偏离导入镜像)。无侧车时静默跳过。 */
71
+ export function markDirty(dir, dirty = true) {
72
+ const meta = readImportMeta(dir)
73
+ if (!meta) return null
74
+ const next = { ...meta, dirty: !!dirty }
75
+ writeImportMeta(dir, next)
76
+ return next
77
+ }
78
+
79
+ /**
80
+ * 记录一次本地编辑时间(每次写盘都更新 editedAt),用于「按修改时间排序」。
81
+ * 与 markDirty 正交:dirty 记录是否偏离导入镜像,editedAt 记录最近编辑时刻。
82
+ * 无侧车(本地新建卡)时静默跳过,由文件 mtime 兜底。
83
+ */
84
+ export function recordEdit(dir) {
85
+ const meta = readImportMeta(dir)
86
+ if (!meta) return null
87
+ const next = { ...meta, editedAt: new Date().toISOString() }
88
+ writeImportMeta(dir, next)
89
+ return next
90
+ }
91
+
92
+ /**
93
+ * 记录一次远端保存(/__save)的读回校验结果。
94
+ * @param {string} dir
95
+ * @param {object} verified saveCardToRemote 返回的 { ok, cardIdMatched, fingerprintMatched, diff, readBackError }
96
+ */
97
+ export function recordExport(dir, verified) {
98
+ const meta = readImportMeta(dir)
99
+ if (!meta) return null
100
+ const next = {
101
+ ...meta,
102
+ lastExport: {
103
+ at: new Date().toISOString(),
104
+ ok: !!(verified && verified.ok),
105
+ cardIdMatched: !!(verified && verified.cardIdMatched),
106
+ fingerprintMatched: !!(verified && verified.fingerprintMatched),
107
+ readBackError: (verified && verified.readBackError) || null,
108
+ },
109
+ }
110
+ writeImportMeta(dir, next)
111
+ return next
112
+ }
113
+
114
+ /**
115
+ * dev server 中间件:GET /__import-meta/:id 暴露导入来源元数据(供 UI「来源」tab / AI skill 读取)。
116
+ * 纯只读、no-store;卡片无侧车时 404(区分「本地新建卡」与「已同步卡」)。
117
+ *
118
+ * @param {{ root: string }} opts
119
+ * @returns {(req, res, next) => void}
120
+ */
121
+ export function createImportMetaMiddleware({ root }) {
122
+ return function importMetaMiddleware(req, res, next) {
123
+ const pathname = decodeURIComponent((req.url || '').split('?')[0])
124
+ const m = pathname.match(/^\/__import-meta\/([^/]+)$/)
125
+ if (!m) return next()
126
+ if ((req.method || 'GET').toUpperCase() !== 'GET') return next()
127
+ const id = decodeURIComponent(m[1])
128
+ const dir = path.join(root, 'src', 'cards', id)
129
+ const meta = readImportMeta(dir)
130
+ res.setHeader('Cache-Control', 'no-store')
131
+ res.setHeader('Content-Type', 'application/json')
132
+ if (!meta) {
133
+ res.statusCode = 404
134
+ return res.end(JSON.stringify({ error: 'import-meta not found', id, hint: '该卡尚未经同步远程导入,无导入来源元数据' }))
135
+ }
136
+ res.statusCode = 200
137
+ return res.end(JSON.stringify({ id, meta }))
138
+ }
139
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 远端卡片列表缓存(dev server 进程内单例)。
3
+ *
4
+ * 用途:推送(保存 / 预览)前确认目标卡片确实存在于 FinMall 卡片列表中,
5
+ * 拦截「推到已删除 / pageId 错位的卡片」这类风险。
6
+ *
7
+ * 数据来源:IDC10001 卡片列表(与同步同一份数据)。刷新时机由同步逻辑触发——
8
+ * 全量同步(无 cardCode 过滤)时整体替换;单卡同步时增量合并,避免清空其他卡片。
9
+ * 列表条目字段:cardId(数字型,等于 schema.pageId)、code(英文卡 id)、cardName。
10
+ *
11
+ * 单例性:本模块被 server/* 与 skills/finmall-card-sync 以相同绝对路径 import,
12
+ * 在同一 dev server 进程内共享同一份内存,故同步写入、推送读取天然一致。
13
+ */
14
+
15
+ const cardIndex = new Map(); // cardId(String) -> { cardId, code, cardName }
16
+ let lastRefreshedAt = 0;
17
+
18
+ /**
19
+ * 写入/合并卡片列表。
20
+ * @param {Array<{cardId:string|number, code?:string, cardName?:string}>} cards
21
+ * @param {{ full?: boolean }} [opts] full=true 时整体替换(先清空),否则增量合并。
22
+ */
23
+ export function refreshCardList(cards, { full = false } = {}) {
24
+ if (full) cardIndex.clear();
25
+ for (const c of cards || []) {
26
+ if (c == null || c.cardId == null) continue;
27
+ const id = String(c.cardId);
28
+ cardIndex.set(id, { cardId: id, code: c.code, cardName: c.cardName });
29
+ }
30
+ lastRefreshedAt = Date.now();
31
+ }
32
+
33
+ /** 卡片是否存在于缓存列表(按 cardId / pageId 查)。 */
34
+ export function hasCardId(cardId) {
35
+ return cardIndex.has(String(cardId));
36
+ }
37
+
38
+ /** 取缓存中的卡片元信息,不存在返回 null。 */
39
+ export function getCard(cardId) {
40
+ return cardIndex.get(String(cardId)) || null;
41
+ }
42
+
43
+ /** 缓存是否已非空(至少同步过一次全量列表)。 */
44
+ export function cacheSize() {
45
+ return cardIndex.size;
46
+ }
47
+
48
+ /**
49
+ * 推送前存在性校验:目标卡片是否确实在远端卡片列表中(按 cardId/pageId 查)。
50
+ * - 缓存为空(本次 dev server 尚未全量同步过):放行,返回 allowed=true,避免误拦截首推。
51
+ * - 命中:allowed=true。
52
+ * - 未命中:allowed=false,reason 给出拦截说明(疑似已删除 / pageId 错位)。
53
+ * @param {string|number} pageId
54
+ * @returns {{ allowed: boolean, reason: string }}
55
+ */
56
+ export function checkPushAllowed(pageId) {
57
+ if (cardIndex.size === 0) return { allowed: true, reason: '' };
58
+ if (hasCardId(pageId)) return { allowed: true, reason: '' };
59
+ return {
60
+ allowed: false,
61
+ reason: `卡片 pageId=${pageId} 不在远端卡片列表中,疑似已删除或 pageId 错位,请先「远程同步」刷新列表后再推送`,
62
+ };
63
+ }
64
+
65
+ /** 上次刷新时间戳(ms),未刷新为 0。 */
66
+ export function lastRefreshed() {
67
+ return lastRefreshedAt;
68
+ }