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.
- package/README.md +211 -0
- package/dist/cli.js +76 -0
- package/dist/config.js +63 -0
- package/dist/core.js +2 -0
- package/dist/preview-editor/assets/apple-touch-icon-BQcES9QK.png +0 -0
- package/dist/preview-editor/assets/default-image-wzZYQ86K.png +0 -0
- package/dist/preview-editor/assets/favicon-16-Cg6Nrosk.png +0 -0
- package/dist/preview-editor/assets/favicon-32-B_1744sp.png +0 -0
- package/dist/preview-editor/assets/index-BYB0hngG.js +72 -0
- package/dist/preview-editor/assets/index-CpBZEQDy.css +1 -0
- package/dist/preview-editor/assets/site-icon-CFMif8z4.png +0 -0
- package/dist/preview-editor/assets/svg-symbols-B2exBrAr.svg +118 -0
- package/dist/preview-editor/index.html +17 -0
- package/dist/server-core/_vendor/finmall-env.mjs +96 -0
- package/dist/server-core/assetProxy.js +154 -0
- package/dist/server-core/bbox.js +153 -0
- package/dist/server-core/browser.js +108 -0
- package/dist/server-core/buildDataFromSchema.js +50 -0
- package/dist/server-core/cardFiles.js +188 -0
- package/dist/server-core/cardHistory.js +156 -0
- package/dist/server-core/cardImage.js +85 -0
- package/dist/server-core/cardImportMeta.js +139 -0
- package/dist/server-core/cardListCache.js +68 -0
- package/dist/server-core/cardOpenFolder.js +135 -0
- package/dist/server-core/cardSave.js +127 -0
- package/dist/server-core/cardSidecar.js +72 -0
- package/dist/server-core/cardSync.js +259 -0
- package/dist/server-core/cardValidate.js +134 -0
- package/dist/server-core/index.js +82 -0
- package/dist/server-core/logger.js +241 -0
- package/dist/server-core/nodeModels.js +52 -0
- package/dist/server-core/renderStatus.js +207 -0
- package/dist/server-core/screenshot.js +123 -0
- package/dist/server-core/sliceStatic.js +44 -0
- package/dist/standalone.js +447 -0
- package/dist/vendored/App.vue +2330 -0
- package/dist/vendored/api.js +142 -0
- package/dist/vendored/assets/apple-touch-icon.png +0 -0
- package/dist/vendored/assets/default-image.png +0 -0
- package/dist/vendored/assets/favicon-16.png +0 -0
- package/dist/vendored/assets/favicon-32.png +0 -0
- package/dist/vendored/assets/logo.png +0 -0
- package/dist/vendored/assets/site-icon.png +0 -0
- package/dist/vendored/assets/svg-symbols.svg +118 -0
- package/dist/vendored/assets/svgIcons/icons.svg +54 -0
- package/dist/vendored/assets/svgIcons/index.js +39 -0
- package/dist/vendored/components/AbsoluteLayout.vue +42 -0
- package/dist/vendored/components/AlertCardInstance.vue +58 -0
- package/dist/vendored/components/AlertCardLayer.vue +59 -0
- package/dist/vendored/components/ApiPayloadTree.vue +168 -0
- package/dist/vendored/components/AppToast.vue +37 -0
- package/dist/vendored/components/Button.vue +37 -0
- package/dist/vendored/components/Canvas.vue +65 -0
- package/dist/vendored/components/CardGallery.vue +243 -0
- package/dist/vendored/components/CardPreview.vue +72 -0
- package/dist/vendored/components/CardSelect.vue +200 -0
- package/dist/vendored/components/CheckGroup.vue +131 -0
- package/dist/vendored/components/Column.vue +25 -0
- package/dist/vendored/components/ComponentPalette.vue +294 -0
- package/dist/vendored/components/ConfirmDialog.vue +39 -0
- package/dist/vendored/components/CustomPlaceholder.vue +33 -0
- package/dist/vendored/components/DatePickerOverlay.vue +345 -0
- package/dist/vendored/components/DebugPanel.vue +806 -0
- package/dist/vendored/components/DeviceOverlay.vue +70 -0
- package/dist/vendored/components/Divider.vue +22 -0
- package/dist/vendored/components/Dropdown.vue +191 -0
- package/dist/vendored/components/EventEditor.vue +247 -0
- package/dist/vendored/components/GridLayout.vue +67 -0
- package/dist/vendored/components/HoverGuides.vue +122 -0
- package/dist/vendored/components/Icon.vue +34 -0
- package/dist/vendored/components/Image.vue +34 -0
- package/dist/vendored/components/Input.vue +90 -0
- package/dist/vendored/components/JsonTree.vue +516 -0
- package/dist/vendored/components/ListLayout.vue +68 -0
- package/dist/vendored/components/LogValue.vue +51 -0
- package/dist/vendored/components/Map.vue +110 -0
- package/dist/vendored/components/MultiColumn.vue +49 -0
- package/dist/vendored/components/MultiText.vue +58 -0
- package/dist/vendored/components/NodeInspector.vue +295 -0
- package/dist/vendored/components/PickerOverlay.vue +131 -0
- package/dist/vendored/components/ProgressSlider.vue +167 -0
- package/dist/vendored/components/PropControl.vue +241 -0
- package/dist/vendored/components/PropTree.vue +128 -0
- package/dist/vendored/components/RadioButton.vue +74 -0
- package/dist/vendored/components/RemotePreview.vue +171 -0
- package/dist/vendored/components/RenderTree.vue +437 -0
- package/dist/vendored/components/Root.vue +30 -0
- package/dist/vendored/components/Row.vue +25 -0
- package/dist/vendored/components/SelectionOverlay.vue +105 -0
- package/dist/vendored/components/StructureTree.vue +949 -0
- package/dist/vendored/components/SvgCode.vue +19 -0
- package/dist/vendored/components/SvgSymbols.vue +30 -0
- package/dist/vendored/components/Switch.vue +38 -0
- package/dist/vendored/components/Text.vue +32 -0
- package/dist/vendored/components/Video.vue +70 -0
- package/dist/vendored/components/ViewContainer.vue +52 -0
- package/dist/vendored/components/base.js +12 -0
- package/dist/vendored/components/icons.js +47 -0
- package/dist/vendored/components/index.js +58 -0
- package/dist/vendored/components/setters/SetBgPosition.vue +64 -0
- package/dist/vendored/components/setters/SetBgSize.vue +90 -0
- package/dist/vendored/components/setters/SetCode.vue +90 -0
- package/dist/vendored/components/setters/SetColor.vue +244 -0
- package/dist/vendored/components/setters/SetCustomSelect.vue +58 -0
- package/dist/vendored/components/setters/SetExpr.vue +107 -0
- package/dist/vendored/components/setters/SetGradient.vue +125 -0
- package/dist/vendored/components/setters/SetGrid9.vue +68 -0
- package/dist/vendored/components/setters/SetIconSelect.vue +67 -0
- package/dist/vendored/components/setters/SetIfForm.vue +61 -0
- package/dist/vendored/components/setters/SetImage.vue +43 -0
- package/dist/vendored/components/setters/SetList.vue +139 -0
- package/dist/vendored/components/setters/SetNumber.vue +118 -0
- package/dist/vendored/components/setters/SetNumberStyle.vue +113 -0
- package/dist/vendored/components/setters/SetPadding.vue +77 -0
- package/dist/vendored/components/setters/SetSegment.vue +160 -0
- package/dist/vendored/components/setters/SetSelect.vue +51 -0
- package/dist/vendored/components/setters/SetSwitch.vue +49 -0
- package/dist/vendored/components/setters/SetText.vue +52 -0
- package/dist/vendored/components/setters/props.js +23 -0
- package/dist/vendored/fidelity/baseline-messageOverview.json +87 -0
- package/dist/vendored/fidelity/calibration.json +29 -0
- package/dist/vendored/fidelity/fidelity-messageOverview-after.html +114 -0
- package/dist/vendored/fidelity/fidelity-messageOverview-after.json +272 -0
- package/dist/vendored/fidelity/fidelity-messageOverview.html +185 -0
- package/dist/vendored/fidelity/fidelity-messageOverview.json +336 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_v2.html +262 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_v2.json +502 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_v3.html +201 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_v3.json +417 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_visual.html +125 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_visual.json +338 -0
- package/dist/vendored/index.html +16 -0
- package/dist/vendored/main.js +36 -0
- package/dist/vendored/meta/action-meta.json +141 -0
- package/dist/vendored/meta/component-meta.json +2714 -0
- package/dist/vendored/meta/directive-meta.json +40 -0
- package/dist/vendored/meta/style-meta.json +1086 -0
- package/dist/vendored/runtime/StyleParser.js +179 -0
- package/dist/vendored/runtime/adapter.js +166 -0
- package/dist/vendored/runtime/alert-card.js +125 -0
- package/dist/vendored/runtime/app-toast.js +33 -0
- package/dist/vendored/runtime/assets.js +16 -0
- package/dist/vendored/runtime/canvas-context.js +141 -0
- package/dist/vendored/runtime/card-runtime.js +289 -0
- package/dist/vendored/runtime/clipboard-node.js +83 -0
- package/dist/vendored/runtime/clipboard.js +29 -0
- package/dist/vendored/runtime/evaluate.js +152 -0
- package/dist/vendored/runtime/event-model.js +300 -0
- package/dist/vendored/runtime/hoverGuide.js +31 -0
- package/dist/vendored/runtime/jsapi-mock.js +256 -0
- package/dist/vendored/runtime/node-model.js +217 -0
- package/dist/vendored/runtime/property.js +364 -0
- package/dist/vendored/runtime/selectionOverlay.js +53 -0
- package/dist/vendored/runtime/toPreview.js +648 -0
- package/dist/vendored/standalone-main.js +36 -0
- package/dist/vendored/standalone.html +12 -0
- package/dist/vendored/styles/app.css +2565 -0
- package/dist/vendored/styles/card-gallery.css +234 -0
- package/dist/vendored/styles/card-select.css +281 -0
- package/dist/vendored/styles/components.css +383 -0
- package/dist/vendored/styles/dropdown.css +337 -0
- package/dist/vendored/styles/json-tree.css +308 -0
- package/dist/vendored/styles/node-inspector.css +1003 -0
- package/dist/vendored/styles/setters.css +962 -0
- package/dist/vendored/styles/standalone.css +27 -0
- package/dist/vendored/styles/structure-tree.css +320 -0
- package/package.json +42 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { log } from './logger.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 在系统文件管理器中打开「当前卡片对应的本地文件夹」。
|
|
8
|
+
*
|
|
9
|
+
* 设计(前后端分离方案 Phase 2):
|
|
10
|
+
* - 前端(编辑器)只知道卡片 id,不知道绝对路径;由后端解析 `src/cards/<id>`
|
|
11
|
+
* 并调用系统命令打开,跨平台(mac / win / linux)全在后端完成。
|
|
12
|
+
* - 端点:POST /__open-folder,body `{ id }`。
|
|
13
|
+
*
|
|
14
|
+
* 安全(不可省略):
|
|
15
|
+
* - id 强制白名单 `^[A-Za-z0-9_-]+$`,杜绝 `..`、`/` 等路径穿越字符进入 path.join。
|
|
16
|
+
* - 解析后对 dir 做「同目录归属」校验:path.resolve 后必须落在
|
|
17
|
+
* `path.resolve(root, 'src', 'cards')` 之下,且真实存在且为目录。
|
|
18
|
+
* - 打开用 `spawn(命令, [dir], { shell: false })`,参数走 argv 而非 shell,
|
|
19
|
+
* 既避免 shell 注入,又让带空格的路径被当作单个参数正确传递。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** 卡片 id 白名单:只允许仓库卡片目录名使用的字符 */
|
|
23
|
+
const ID_RE = /^[A-Za-z0-9_-]+$/
|
|
24
|
+
|
|
25
|
+
/** 校验并归一化卡片 id;非法返回 null(含空值 / 含穿越字符)。 */
|
|
26
|
+
export function normalizeCardId(id) {
|
|
27
|
+
if (typeof id !== 'string' || !id) return null
|
|
28
|
+
// 先行白名单,连 `.` 都不允许,彻底排除 ../.. 等穿越形态
|
|
29
|
+
if (!ID_RE.test(id)) return null
|
|
30
|
+
return id
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 解析卡片目录绝对路径(不做存在性判断)。
|
|
35
|
+
* 仅当 id 合法且解析结果确属 `root/src/cards` 之下才返回路径,否则 null。
|
|
36
|
+
*/
|
|
37
|
+
export function resolveCardDir(root, id) {
|
|
38
|
+
const safeId = normalizeCardId(id)
|
|
39
|
+
if (!safeId) return null
|
|
40
|
+
const base = path.resolve(root, 'src', 'cards')
|
|
41
|
+
const dir = path.resolve(base, safeId)
|
|
42
|
+
// 归属校验:dir 必须是 base 自身或其一阶子目录(base + sep 前缀,杜绝穿越)
|
|
43
|
+
if (dir !== base && !dir.startsWith(base + path.sep)) return null
|
|
44
|
+
return dir
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 按平台返回「打开文件夹」的命令与参数(不含 shell)。 */
|
|
48
|
+
function openCommand(dir) {
|
|
49
|
+
switch (process.platform) {
|
|
50
|
+
case 'darwin':
|
|
51
|
+
return { cmd: 'open', args: [dir] }
|
|
52
|
+
case 'win32':
|
|
53
|
+
return { cmd: 'explorer', args: [dir] }
|
|
54
|
+
case 'linux':
|
|
55
|
+
return { cmd: 'xdg-open', args: [dir] }
|
|
56
|
+
default:
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 在系统文件管理器中打开目录。
|
|
63
|
+
* @returns {boolean} 是否成功触发命令(仅表示进程拉起,不代表用户已看到窗口)
|
|
64
|
+
*/
|
|
65
|
+
export function openDirInOS(dir) {
|
|
66
|
+
const spec = openCommand(dir)
|
|
67
|
+
if (!spec) {
|
|
68
|
+
log.warn('打开文件夹不支持当前平台', { platform: process.platform })
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
// shell:false —— 参数经 argv 传递,杜绝命令注入;带空格路径被当作单个参数。
|
|
73
|
+
const child = spawn(spec.cmd, spec.args, { shell: false, stdio: 'ignore' })
|
|
74
|
+
child.on('error', (e) => {
|
|
75
|
+
log.error('打开文件夹命令失败', { cmd: spec.cmd, message: String(e.message) })
|
|
76
|
+
})
|
|
77
|
+
return true
|
|
78
|
+
} catch (e) {
|
|
79
|
+
log.error('打开文件夹异常', e)
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {{ root: string }} opts
|
|
86
|
+
* @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
|
|
87
|
+
*/
|
|
88
|
+
export function createOpenFolderMiddleware({ root }) {
|
|
89
|
+
return function openFolderMiddleware(req, res, next) {
|
|
90
|
+
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
91
|
+
if (pathname !== '/__open-folder' || (req.method || 'GET').toUpperCase() !== 'POST') {
|
|
92
|
+
return next()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let body = ''
|
|
96
|
+
req.on('data', (c) => {
|
|
97
|
+
body += c
|
|
98
|
+
if (body.length > 1 * 1024 * 1024) req.destroy()
|
|
99
|
+
})
|
|
100
|
+
req.on('end', () => {
|
|
101
|
+
let payload
|
|
102
|
+
try {
|
|
103
|
+
payload = body ? JSON.parse(body) : {}
|
|
104
|
+
} catch (e) {
|
|
105
|
+
res.statusCode = 400
|
|
106
|
+
res.setHeader('Content-Type', 'application/json')
|
|
107
|
+
return res.end(JSON.stringify({ ok: false, error: 'invalid json' }))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const dir = resolveCardDir(root, payload && payload.id)
|
|
111
|
+
if (!dir) {
|
|
112
|
+
res.statusCode = 400
|
|
113
|
+
res.setHeader('Content-Type', 'application/json')
|
|
114
|
+
return res.end(JSON.stringify({ ok: false, error: 'invalid card id' }))
|
|
115
|
+
}
|
|
116
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
|
117
|
+
res.statusCode = 404
|
|
118
|
+
res.setHeader('Content-Type', 'application/json')
|
|
119
|
+
return res.end(JSON.stringify({ ok: false, error: 'card folder not found', id: payload.id }))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const opened = openDirInOS(dir)
|
|
123
|
+
if (!opened) {
|
|
124
|
+
res.statusCode = 501
|
|
125
|
+
res.setHeader('Content-Type', 'application/json')
|
|
126
|
+
return res.end(JSON.stringify({ ok: false, error: 'unsupported platform' }))
|
|
127
|
+
}
|
|
128
|
+
log.ok('打开卡片文件夹', { id: payload.id, dir })
|
|
129
|
+
res.statusCode = 200
|
|
130
|
+
res.setHeader('Content-Type', 'application/json')
|
|
131
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
132
|
+
return res.end(JSON.stringify({ ok: true, dir }))
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
|
|
5
|
+
const skillDir = path.resolve(process.cwd(), 'skills', 'finmall-card-sync')
|
|
6
|
+
const skillPath = path.join(skillDir, 'scripts', 'api_batch_sync.mjs')
|
|
7
|
+
import { recordExport } from './cardImportMeta.js'
|
|
8
|
+
import { checkPushAllowed } from './cardListCache.js'
|
|
9
|
+
import { assertRemoteSource, findCardDir } from './cardSidecar.js'
|
|
10
|
+
import { log } from './logger.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 远端保存(写回 FinMall)服务端处理。
|
|
14
|
+
* 前端「保存」按钮把当前预览的 schema(含实时数据绑定)POST 过来,
|
|
15
|
+
* 本中间件校验 pageId 后,经已登录浏览器中转调用 IDC10010.do 写回远端,
|
|
16
|
+
* 与「同步」共用 finmall-card-sync 的 saveCardToRemote(带会话自愈)。
|
|
17
|
+
*/
|
|
18
|
+
export function createSaveMiddleware({ root }) {
|
|
19
|
+
return async function saveMiddleware(req, res, next) {
|
|
20
|
+
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
21
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
22
|
+
if (pathname !== '/__save' || method !== 'POST') return next()
|
|
23
|
+
let body = ''
|
|
24
|
+
req.on('data', (c) => {
|
|
25
|
+
body += c
|
|
26
|
+
if (body.length > 32 * 1024 * 1024) req.destroy()
|
|
27
|
+
})
|
|
28
|
+
req.on('end', async () => {
|
|
29
|
+
let payload
|
|
30
|
+
try {
|
|
31
|
+
payload = JSON.parse(body)
|
|
32
|
+
} catch (e) {
|
|
33
|
+
res.statusCode = 400
|
|
34
|
+
res.setHeader('Content-Type', 'application/json')
|
|
35
|
+
return res.end(JSON.stringify({ ok: false, error: 'invalid json', message: String(e.message) }))
|
|
36
|
+
}
|
|
37
|
+
const schema = payload && payload.schema
|
|
38
|
+
const code = payload && payload.code
|
|
39
|
+
if (!schema || typeof schema !== 'object') {
|
|
40
|
+
res.statusCode = 400
|
|
41
|
+
res.setHeader('Content-Type', 'application/json')
|
|
42
|
+
return res.end(JSON.stringify({ ok: false, error: 'missing schema in body' }))
|
|
43
|
+
}
|
|
44
|
+
if (!schema.pageId) {
|
|
45
|
+
res.statusCode = 400
|
|
46
|
+
res.setHeader('Content-Type', 'application/json')
|
|
47
|
+
return res.end(JSON.stringify({ ok: false, error: 'schema.pageId 缺失,无法确定要保存的卡片' }))
|
|
48
|
+
}
|
|
49
|
+
// 远端来源硬闸:本地自建、无 .import-meta.json 侧车的卡一律禁止推送,
|
|
50
|
+
// 避免把测试数据误提交到 FinMall 服务器(比「是否在远端列表」更根本)。
|
|
51
|
+
{
|
|
52
|
+
const src = assertRemoteSource(root, { code, pageId: schema.pageId })
|
|
53
|
+
if (!src.ok) {
|
|
54
|
+
res.statusCode = 409
|
|
55
|
+
res.setHeader('Content-Type', 'application/json')
|
|
56
|
+
return res.end(JSON.stringify({ ok: false, error: 'local_card_no_source', message: src.reason }))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// 推送前 pageId 错位闸门:基于实测不变量(IDC10001 列表 cardId ≡ schema pageId),
|
|
60
|
+
// 本地 schema.pageId 必须等于该 code 同步来源侧车记录的 cardId;不等则拒绝推送,
|
|
61
|
+
// 以防按错 pageId 打错卡。仅当请求带 code 且侧车存在时才校验(缺则跳过,不阻断)。
|
|
62
|
+
if (code) {
|
|
63
|
+
try {
|
|
64
|
+
const metaPath = path.join(root, 'src', 'cards', code, '.import-meta.json')
|
|
65
|
+
if (fs.existsSync(metaPath)) {
|
|
66
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'))
|
|
67
|
+
const expected = meta.cardId != null ? String(meta.cardId) : null
|
|
68
|
+
if (expected && String(schema.pageId) !== expected) {
|
|
69
|
+
res.statusCode = 409
|
|
70
|
+
res.setHeader('Content-Type', 'application/json')
|
|
71
|
+
return res.end(JSON.stringify({
|
|
72
|
+
ok: false,
|
|
73
|
+
error: 'pageId_mismatch',
|
|
74
|
+
message: `推送中止:schema.pageId=${schema.pageId} 与该卡同步来源 cardId=${expected} 不符,疑似 pageId 错位,拒绝推送以防打错卡`,
|
|
75
|
+
}))
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
console.warn(`[__save] 读取导入元数据侧车失败,跳过 pageId 错位校验:${e && e.message}`)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// 存在性闸门:推送前确认目标卡片在远端卡片列表缓存中(全量同步时刷新)。
|
|
83
|
+
// 缓存非空才校验(为空说明尚未同步,放行);不存在则拒绝推送,防打错/已删卡片。
|
|
84
|
+
{
|
|
85
|
+
const allow = checkPushAllowed(schema.pageId)
|
|
86
|
+
if (!allow.allowed) {
|
|
87
|
+
res.statusCode = 409
|
|
88
|
+
res.setHeader('Content-Type', 'application/json')
|
|
89
|
+
return res.end(JSON.stringify({ ok: false, error: 'card_not_in_list', message: allow.reason }))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// 骨架屏:线上编辑器始终传 SVG 缩略图;本地无生成器,传空串(仅影响编辑器列表缩略图)。
|
|
93
|
+
const skeletonScreen = typeof payload.skeletonScreen === 'string' ? payload.skeletonScreen : ''
|
|
94
|
+
try {
|
|
95
|
+
const mod = await import(pathToFileURL(skillPath).href)
|
|
96
|
+
const { response, verified } = await mod.saveCardToRemote(schema, { skeletonScreen })
|
|
97
|
+
// 导出可追溯(Phase E):把远端保存的读回校验结果写进导入元数据侧车。
|
|
98
|
+
// 注意:目录按 cardCode 命名,必须按 pageId 反查到正确的 src/cards/<cardCode>/。
|
|
99
|
+
if (root) {
|
|
100
|
+
try {
|
|
101
|
+
const dir = findCardDir(root, { pageId: schema.pageId })
|
|
102
|
+
if (dir) await recordExport(dir, verified)
|
|
103
|
+
else console.warn(`[__save] 未找到 pageId=${schema.pageId} 对应的本地卡片目录,跳过导出元数据`)
|
|
104
|
+
} catch (e) {
|
|
105
|
+
log.warn('写导出元数据失败(不影响保存结果)', { error: e && e.message })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
log.ok('远端保存成功', {
|
|
109
|
+
cardId: String(schema.pageId),
|
|
110
|
+
cardIdMatched: verified.cardIdMatched,
|
|
111
|
+
fingerprintMatched: verified.fingerprintMatched,
|
|
112
|
+
})
|
|
113
|
+
if (!verified.fingerprintMatched && verified.diff && verified.diff.length) {
|
|
114
|
+
log.warn('指纹不匹配(落库内容与本地不一致)', { diff: verified.diff.slice(0, 3) })
|
|
115
|
+
}
|
|
116
|
+
res.statusCode = 200
|
|
117
|
+
res.setHeader('Content-Type', 'application/json')
|
|
118
|
+
return res.end(JSON.stringify({ ok: true, code, cardId: String(schema.pageId), response, verified }))
|
|
119
|
+
} catch (e) {
|
|
120
|
+
log.error('远端保存失败', e)
|
|
121
|
+
res.statusCode = 502
|
|
122
|
+
res.setHeader('Content-Type', 'application/json')
|
|
123
|
+
return res.end(JSON.stringify({ ok: false, error: String(e && e.message) }))
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 卡片「远端来源侧车」判定。
|
|
3
|
+
*
|
|
4
|
+
* 本地卡片目录 src/cards/<code>/ 下的 .import-meta.json 由「远程同步」写入,
|
|
5
|
+
* 记录该卡来自 FinMall 远端的 cardId 等来源信息。只有存在侧车的卡才被视为
|
|
6
|
+
* 「有合法远端来源」,允许推送(保存 / 预览)到服务器;本地自建、从未同步过的
|
|
7
|
+
* 测试卡没有侧车,一律禁止推送,避免把测试数据误提交到 FinMall。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
|
|
13
|
+
const SIDECAR = '.import-meta.json'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 定位卡片本地目录:优先用 code(目录名),否则用 pageId 反查 schema.json。
|
|
17
|
+
* @param {string} root 项目根
|
|
18
|
+
* @param {{ code?: string, pageId?: string|number }} [opts]
|
|
19
|
+
* @returns {string|null}
|
|
20
|
+
*/
|
|
21
|
+
export function findCardDir(root, { code, pageId } = {}) {
|
|
22
|
+
const cardsDir = path.join(root, 'src', 'cards')
|
|
23
|
+
if (!fs.existsSync(cardsDir)) return null
|
|
24
|
+
if (code) {
|
|
25
|
+
const dir = path.join(cardsDir, String(code))
|
|
26
|
+
if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) return dir
|
|
27
|
+
}
|
|
28
|
+
if (pageId != null) {
|
|
29
|
+
for (const id of fs.readdirSync(cardsDir)) {
|
|
30
|
+
const dir = path.join(cardsDir, id)
|
|
31
|
+
if (!fs.statSync(dir).isDirectory()) continue
|
|
32
|
+
try {
|
|
33
|
+
const sch = JSON.parse(fs.readFileSync(path.join(dir, 'schema.json'), 'utf8'))
|
|
34
|
+
if (String(sch.pageId) === String(pageId)) return dir
|
|
35
|
+
} catch {
|
|
36
|
+
/* 单个卡读取失败不阻断整体匹配 */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 读取侧车;不存在 / 损坏返回 null。 */
|
|
44
|
+
export function readSidecar(root, { code, pageId } = {}) {
|
|
45
|
+
const dir = findCardDir(root, { code, pageId })
|
|
46
|
+
if (!dir) return null
|
|
47
|
+
const p = path.join(dir, SIDECAR)
|
|
48
|
+
if (!fs.existsSync(p)) return null
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 推送前「远端来源」硬闸:无侧车 = 本地自建 = 禁止推送。
|
|
58
|
+
* @param {string} root
|
|
59
|
+
* @param {{ code?: string, pageId?: string|number }} [opts]
|
|
60
|
+
* @returns {{ ok: boolean, reason: string }}
|
|
61
|
+
*/
|
|
62
|
+
export function assertRemoteSource(root, { code, pageId } = {}) {
|
|
63
|
+
const sidecar = readSidecar(root, { code, pageId })
|
|
64
|
+
if (sidecar) return { ok: true, reason: '' }
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
reason:
|
|
68
|
+
'该卡片为本地自建、无远端同步来源(缺少 .import-meta.json 侧车)。' +
|
|
69
|
+
'为防止将测试数据误提交到 FinMall 服务器,已禁止保存 / 推送。' +
|
|
70
|
+
'请先通过「远程同步」把卡片来源刷新到本地后再操作。',
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { pathToFileURL } from 'node:url'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { writeFile, mkdir } from 'node:fs/promises'
|
|
5
|
+
import { checkPushAllowed } from './cardListCache.js'
|
|
6
|
+
import { assertRemoteSource } from './cardSidecar.js'
|
|
7
|
+
import { log, fmtBytes } from './logger.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 预览「同步远程数据」按钮的服务端处理:经已认证的 FinMall 浏览器会话拉取最新卡片写回 src/cards。
|
|
11
|
+
* 复用 finmall-card-sync 的 syncCards(connectOverCDP 连用户已开的 Chrome,disconnect 不杀进程)。
|
|
12
|
+
*
|
|
13
|
+
* @param {{ root: string }} opts
|
|
14
|
+
* @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
|
|
15
|
+
*/
|
|
16
|
+
export function createSyncMiddleware({ root }) {
|
|
17
|
+
return function syncMiddleware(req, res, next) {
|
|
18
|
+
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
19
|
+
if (pathname !== '/__sync') return next()
|
|
20
|
+
if ((req.method || 'GET').toUpperCase() !== 'POST') return next()
|
|
21
|
+
|
|
22
|
+
let body = ''
|
|
23
|
+
req.on('data', (c) => {
|
|
24
|
+
body += c
|
|
25
|
+
if (body.length > 1 * 1024 * 1024) req.destroy()
|
|
26
|
+
})
|
|
27
|
+
req.on('end', async () => {
|
|
28
|
+
let payload
|
|
29
|
+
try {
|
|
30
|
+
payload = JSON.parse(body || '{}')
|
|
31
|
+
} catch (e) {
|
|
32
|
+
res.statusCode = 400
|
|
33
|
+
res.setHeader('Content-Type', 'application/json')
|
|
34
|
+
return res.end(JSON.stringify({ error: 'invalid json', message: String(e.message) }))
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const skillPath = path.join(
|
|
38
|
+
root,
|
|
39
|
+
'skills',
|
|
40
|
+
'finmall-card-sync',
|
|
41
|
+
'scripts',
|
|
42
|
+
'api_batch_sync.mjs'
|
|
43
|
+
)
|
|
44
|
+
const { syncCards } = await import(pathToFileURL(skillPath).href)
|
|
45
|
+
const summary = await syncCards({
|
|
46
|
+
cardCode: payload.cardCode || undefined,
|
|
47
|
+
outRoot: path.join(root, 'src', 'cards'),
|
|
48
|
+
})
|
|
49
|
+
// 同步成功后在 dev server 终端打印同步结果(统一美化框)。
|
|
50
|
+
const lines = []
|
|
51
|
+
lines.push(`请求卡片:${payload.cardCode || '(全部)'}`)
|
|
52
|
+
if (summary.synced && summary.synced.length) {
|
|
53
|
+
lines.push(`已同步 ${summary.synced.length} 张:`)
|
|
54
|
+
for (const s of summary.synced) {
|
|
55
|
+
const f = s.files || {}
|
|
56
|
+
// 空白卡(远端无 schemaValue):标注落盘策略,避免误读成「抓到了完整 schema」。
|
|
57
|
+
const tag = s.blank
|
|
58
|
+
? s.keptLocal
|
|
59
|
+
? ' [空白卡·保留本地]'
|
|
60
|
+
: ' [空白卡·新建空白 schema]'
|
|
61
|
+
: ''
|
|
62
|
+
lines.push(
|
|
63
|
+
`• ${s.id}${s.title ? `(${s.title})` : ''}${tag} schema=${fmtBytes(f.schema)} data=${fmtBytes(f.data)} cover=${fmtBytes(f.cover)}`
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
lines.push('已同步 0 张')
|
|
68
|
+
}
|
|
69
|
+
if (summary.failed && summary.failed.length) {
|
|
70
|
+
lines.push(`失败 ${summary.failed.length} 张:`)
|
|
71
|
+
for (const f of summary.failed) {
|
|
72
|
+
lines.push(`✗ ${f.id || ''} ${f.error || ''}`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (summary.duplicates && summary.duplicates.length) {
|
|
76
|
+
lines.push(`重复 schema ${summary.duplicates.length} 项:${summary.duplicates.map((d) => d.id).join('、')}`)
|
|
77
|
+
}
|
|
78
|
+
log.box({ title: '远端同步完成', level: summary.success ? 'ok' : 'warn', lines })
|
|
79
|
+
res.statusCode = 200
|
|
80
|
+
res.setHeader('Content-Type', 'application/json')
|
|
81
|
+
return res.end(JSON.stringify(summary))
|
|
82
|
+
} catch (e) {
|
|
83
|
+
// 区分「凭据缺失」与「真正的同步错误」:前者让前端弹登录表单,而非裸抛 ENOENT。
|
|
84
|
+
const isNoCred =
|
|
85
|
+
e &&
|
|
86
|
+
(e.code === 'NO_CREDENTIALS' ||
|
|
87
|
+
/credentials\.json|credentials 不存在|NO_CREDENTIALS/.test(
|
|
88
|
+
String(e && e.message),
|
|
89
|
+
))
|
|
90
|
+
res.statusCode = isNoCred ? 200 : 500
|
|
91
|
+
res.setHeader('Content-Type', 'application/json')
|
|
92
|
+
return res.end(
|
|
93
|
+
JSON.stringify(
|
|
94
|
+
isNoCred
|
|
95
|
+
? {
|
|
96
|
+
error: 'need_credentials',
|
|
97
|
+
needCredentials: true,
|
|
98
|
+
message: String(e && e.message),
|
|
99
|
+
}
|
|
100
|
+
: { error: 'sync failed', message: String(e && e.message) },
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 预览「远端预览」按钮的服务端处理:复刻 ubomp 的 PreviewPopover 全链路——
|
|
110
|
+
* 先把当前卡片保存到 FinMall(IDC10010),再取卡片 code(IDC10015)、
|
|
111
|
+
* 发布预览测试版(IDC10020)、通知真机(IDC10021),最终返回 cardCode 供前端生成二维码。
|
|
112
|
+
* 复用 finmall-card-sync 的 previewCardRemote(与「同步」「保存远端」共用同一后台 FinMall 会话)。
|
|
113
|
+
*
|
|
114
|
+
* @param {{ root: string }} opts
|
|
115
|
+
* @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
|
|
116
|
+
*/
|
|
117
|
+
export function createPreviewMiddleware({ root }) {
|
|
118
|
+
return function previewMiddleware(req, res, next) {
|
|
119
|
+
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
120
|
+
if (pathname !== '/__preview') return next()
|
|
121
|
+
if ((req.method || 'GET').toUpperCase() !== 'POST') return next()
|
|
122
|
+
|
|
123
|
+
let body = ''
|
|
124
|
+
req.on('data', (c) => {
|
|
125
|
+
body += c
|
|
126
|
+
if (body.length > 4 * 1024 * 1024) req.destroy()
|
|
127
|
+
})
|
|
128
|
+
req.on('end', async () => {
|
|
129
|
+
let payload
|
|
130
|
+
try {
|
|
131
|
+
payload = JSON.parse(body || '{}')
|
|
132
|
+
} catch (e) {
|
|
133
|
+
res.statusCode = 400
|
|
134
|
+
res.setHeader('Content-Type', 'application/json')
|
|
135
|
+
return res.end(JSON.stringify({ error: 'invalid json', message: String(e.message) }))
|
|
136
|
+
}
|
|
137
|
+
if (!payload.schema || !payload.schema.pageId) {
|
|
138
|
+
res.statusCode = 400
|
|
139
|
+
res.setHeader('Content-Type', 'application/json')
|
|
140
|
+
return res.end(JSON.stringify({ error: 'missing schema.pageId', message: '需传入当前卡片 schema(含 pageId)' }))
|
|
141
|
+
}
|
|
142
|
+
// 远端来源硬闸:本地自建、无 .import-meta.json 侧车的卡一律禁止推送,
|
|
143
|
+
// 避免把测试数据误提交到 FinMall 服务器(比「是否在远端列表」更根本)。
|
|
144
|
+
{
|
|
145
|
+
const src = assertRemoteSource(root, { pageId: payload.schema.pageId })
|
|
146
|
+
if (!src.ok) {
|
|
147
|
+
res.statusCode = 409
|
|
148
|
+
res.setHeader('Content-Type', 'application/json')
|
|
149
|
+
return res.end(JSON.stringify({ ok: false, error: 'local_card_no_source', message: src.reason }))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// 存在性闸门:推送前确认目标卡片在远端卡片列表缓存中(全量同步时刷新)。
|
|
153
|
+
// 缓存非空才校验(为空说明尚未同步,放行);不存在则拒绝推送,防打错/已删卡片。
|
|
154
|
+
{
|
|
155
|
+
const allow = checkPushAllowed(payload.schema.pageId)
|
|
156
|
+
if (!allow.allowed) {
|
|
157
|
+
res.statusCode = 409
|
|
158
|
+
res.setHeader('Content-Type', 'application/json')
|
|
159
|
+
return res.end(JSON.stringify({ ok: false, error: 'card_not_in_list', message: allow.reason }))
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const skillPath = path.join(
|
|
164
|
+
root,
|
|
165
|
+
'skills',
|
|
166
|
+
'finmall-card-sync',
|
|
167
|
+
'scripts',
|
|
168
|
+
'api_batch_sync.mjs'
|
|
169
|
+
)
|
|
170
|
+
const { previewCardRemote } = await import(pathToFileURL(skillPath).href)
|
|
171
|
+
const result = await previewCardRemote(payload.schema)
|
|
172
|
+
log.ok('远端预览完成', {
|
|
173
|
+
cardCode: result.cardCode,
|
|
174
|
+
saved: result.steps.saved,
|
|
175
|
+
published: result.steps.published,
|
|
176
|
+
notified: result.steps.notified,
|
|
177
|
+
})
|
|
178
|
+
res.statusCode = 200
|
|
179
|
+
res.setHeader('Content-Type', 'application/json')
|
|
180
|
+
return res.end(JSON.stringify(result))
|
|
181
|
+
} catch (e) {
|
|
182
|
+
log.error('远端预览失败', e)
|
|
183
|
+
res.statusCode = 500
|
|
184
|
+
res.setHeader('Content-Type', 'application/json')
|
|
185
|
+
return res.end(JSON.stringify({ error: 'preview failed', message: String(e && e.message) }))
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 预览界面「远程同步」缺凭据时,由前端弹登录表单提交账号+明文密码,
|
|
193
|
+
* 服务端做 MD5 后写入 skills/finmall-session-navigator/.local/credentials.json。
|
|
194
|
+
* 路径与 finmall_login.mjs 的 DEFAULT_CREDENTIALS_PATH 保持一致。
|
|
195
|
+
*
|
|
196
|
+
* @param {{ root: string }} opts
|
|
197
|
+
* @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
|
|
198
|
+
*/
|
|
199
|
+
export function createLoginCredentialsMiddleware({ root }) {
|
|
200
|
+
return function loginCredentialsMiddleware(req, res, next) {
|
|
201
|
+
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
202
|
+
if (pathname !== '/__login-credentials') return next()
|
|
203
|
+
if ((req.method || 'GET').toUpperCase() !== 'POST') return next()
|
|
204
|
+
|
|
205
|
+
let body = ''
|
|
206
|
+
req.on('data', (c) => {
|
|
207
|
+
body += c
|
|
208
|
+
if (body.length > 64 * 1024) req.destroy()
|
|
209
|
+
})
|
|
210
|
+
req.on('end', async () => {
|
|
211
|
+
let payload
|
|
212
|
+
try {
|
|
213
|
+
payload = JSON.parse(body || '{}')
|
|
214
|
+
} catch (e) {
|
|
215
|
+
res.statusCode = 400
|
|
216
|
+
res.setHeader('Content-Type', 'application/json')
|
|
217
|
+
return res.end(JSON.stringify({ error: 'invalid json', message: String(e.message) }))
|
|
218
|
+
}
|
|
219
|
+
const loginName =
|
|
220
|
+
typeof payload.loginName === 'string' ? payload.loginName.trim() : ''
|
|
221
|
+
const rawPassword =
|
|
222
|
+
typeof payload.loginPassword === 'string' ? payload.loginPassword : ''
|
|
223
|
+
if (!loginName || !rawPassword) {
|
|
224
|
+
res.statusCode = 400
|
|
225
|
+
res.setHeader('Content-Type', 'application/json')
|
|
226
|
+
return res.end(
|
|
227
|
+
JSON.stringify({
|
|
228
|
+
error: 'missing_fields',
|
|
229
|
+
message: 'loginName 与 loginPassword 均不能为空',
|
|
230
|
+
}),
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const credentialsPath = path.join(
|
|
235
|
+
root,
|
|
236
|
+
'skills',
|
|
237
|
+
'finmall-session-navigator',
|
|
238
|
+
'.local',
|
|
239
|
+
'credentials.json',
|
|
240
|
+
)
|
|
241
|
+
const credentials = {
|
|
242
|
+
loginName,
|
|
243
|
+
loginPassword: createHash('md5').update(rawPassword, 'utf8').digest('hex'),
|
|
244
|
+
}
|
|
245
|
+
await mkdir(path.dirname(credentialsPath), { recursive: true })
|
|
246
|
+
await writeFile(credentialsPath, JSON.stringify(credentials, null, 2), 'utf8')
|
|
247
|
+
log.ok('已保存登录凭据', { loginName })
|
|
248
|
+
res.statusCode = 200
|
|
249
|
+
res.setHeader('Content-Type', 'application/json')
|
|
250
|
+
return res.end(JSON.stringify({ ok: true }))
|
|
251
|
+
} catch (e) {
|
|
252
|
+
log.error('保存登录凭据失败', e)
|
|
253
|
+
res.statusCode = 500
|
|
254
|
+
res.setHeader('Content-Type', 'application/json')
|
|
255
|
+
return res.end(JSON.stringify({ error: 'save failed', message: String(e && e.message) }))
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
}
|