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,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FinMall 环境配置统一加载器(优先级:真实环境变量 > .env.finmall.local > .env.finmall)。
|
|
3
|
+
* 不依赖 Vite loadEnv(serve 期 / node 脚本 / cardbuddy 三处取值一致)。
|
|
4
|
+
* root 默认 process.cwd(),可传参覆盖;发布态不用 __dirname 反推仓库根。
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
function parseEnvFile(file) {
|
|
10
|
+
const out = {}
|
|
11
|
+
if (!fs.existsSync(file)) return out
|
|
12
|
+
for (const raw of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
13
|
+
const line = raw.trim()
|
|
14
|
+
if (!line || line.startsWith('#')) continue
|
|
15
|
+
const i = line.indexOf('=')
|
|
16
|
+
if (i < 0) continue
|
|
17
|
+
const key = line.slice(0, i).trim()
|
|
18
|
+
let val = line.slice(i + 1).trim()
|
|
19
|
+
if (
|
|
20
|
+
(val.startsWith('"') && val.endsWith('"')) ||
|
|
21
|
+
(val.startsWith("'") && val.endsWith("'"))
|
|
22
|
+
) {
|
|
23
|
+
val = val.slice(1, -1)
|
|
24
|
+
}
|
|
25
|
+
out[key] = val
|
|
26
|
+
}
|
|
27
|
+
return out
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 按指定 root 加载 FinMall 环境配置。
|
|
32
|
+
* @param {string} [root] 项目根(含 .env.finmall);默认 process.cwd()
|
|
33
|
+
* @returns {{
|
|
34
|
+
* FINMALL_BASE_URL:string, FINMALL_APP_ID:string, FINMALL_WORKSPACE_ID:string,
|
|
35
|
+
* FINMALL_CDP_PORT:number, FINMALL_DEV_PORT:number, FINMALL_EDITOR_PATH:string
|
|
36
|
+
* }}
|
|
37
|
+
*/
|
|
38
|
+
export function loadFinmallEnv(root = process.cwd()) {
|
|
39
|
+
const fromFile = parseEnvFile(path.join(root, '.env.finmall'))
|
|
40
|
+
const fromLocal = parseEnvFile(path.join(root, '.env.finmall.local'))
|
|
41
|
+
// 真实环境变量优先级最高;但只关心 FINMALL_ 前缀与已存在的覆盖变量
|
|
42
|
+
const env = { ...fromFile, ...fromLocal, ...process.env }
|
|
43
|
+
return {
|
|
44
|
+
/** 远端站点基址(如 https://uat-pkg.finmall.com) */
|
|
45
|
+
FINMALL_BASE_URL: env.FINMALL_BASE_URL || 'https://uat-pkg.finmall.com',
|
|
46
|
+
/** 应用标识 */
|
|
47
|
+
FINMALL_APP_ID: env.FINMALL_APP_ID || '100042',
|
|
48
|
+
/** 工作空间标识 */
|
|
49
|
+
FINMALL_WORKSPACE_ID: env.FINMALL_WORKSPACE_ID || 'dev',
|
|
50
|
+
/** Chrome 远程调试端口(connectOverCDP 用) */
|
|
51
|
+
FINMALL_CDP_PORT: Number(env.FINMALL_CDP_PORT || 9222),
|
|
52
|
+
/** 本地 dev server 监听端口(vite / 截图 / MCP / 各类工具默认连此端口) */
|
|
53
|
+
FINMALL_DEV_PORT: Number(env.FINMALL_DEV_PORT || 5180),
|
|
54
|
+
/** 远端编辑器 Web 访问别名(站点下挂载路径,部署到别的目录时改这里) */
|
|
55
|
+
FINMALL_EDITOR_PATH: (env.FINMALL_EDITOR_PATH || '/ubomp-h5-sub').replace(/\/+$/, ''),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 向后兼容:直接命名导出(默认取 cwd 为项目根,与「默认项目根 = cwd」约定一致)。
|
|
60
|
+
// 注意:这些常量在模块加载时按 cwd 计算一次;若需按其他 root 取值,请改用 loadFinmallEnv(root)。
|
|
61
|
+
const d = loadFinmallEnv(process.cwd())
|
|
62
|
+
export const FINMALL_BASE_URL = d.FINMALL_BASE_URL
|
|
63
|
+
export const FINMALL_APP_ID = d.FINMALL_APP_ID
|
|
64
|
+
export const FINMALL_WORKSPACE_ID = d.FINMALL_WORKSPACE_ID
|
|
65
|
+
export const FINMALL_CDP_PORT = d.FINMALL_CDP_PORT
|
|
66
|
+
export const FINMALL_DEV_PORT = d.FINMALL_DEV_PORT
|
|
67
|
+
export const FINMALL_EDITOR_PATH = d.FINMALL_EDITOR_PATH
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 本地 dev server 的 HTTP 基址。host 默认 127.0.0.1;
|
|
71
|
+
* 仍可用各自的覆盖环境变量(CARD_SCREENSHOT_BASE / CARD_API_BASE / CARD_CHECK_BASE)兜底。
|
|
72
|
+
* @param {string} [host]
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
export function devApiBase(host = '127.0.0.1') {
|
|
76
|
+
return `http://${host}:${FINMALL_DEV_PORT}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 构造远端编辑器访问 URL(线上属性面板比对 / 真机预览用)。
|
|
81
|
+
* @param {string|number} pageId 数字型 cardId(pageId)
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
export function remoteEditorUrl(pageId) {
|
|
85
|
+
return (
|
|
86
|
+
`${FINMALL_BASE_URL}${FINMALL_EDITOR_PATH}/` +
|
|
87
|
+
`?platform=flame&appId=${FINMALL_APP_ID}&workspaceId=${FINMALL_WORKSPACE_ID}` +
|
|
88
|
+
`#/inner_card/editor/design?id=${pageId}&showType=view`
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default {
|
|
93
|
+
...d,
|
|
94
|
+
devApiBase,
|
|
95
|
+
remoteEditorUrl,
|
|
96
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import crypto from 'node:crypto'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 远端资源代理:把预览里引用的站内资源路径(@filestore / /minio / /fastdfs …)
|
|
7
|
+
* 回源到 FinMall 上游站点并落盘缓存。所有跨网络回源逻辑集中在本模块维护。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* adapter.doReplaceUrl 产出的是站内绝对路径(例如 `@filestore/x` -> `/minio/x`)。
|
|
12
|
+
* 这里把站内路径还原成上游真实地址;同一前缀可能有多个候选,按序回退。
|
|
13
|
+
*/
|
|
14
|
+
const PREFIX_UPSTREAM = {
|
|
15
|
+
'/minio/': ['/filestore/', '/minio/'],
|
|
16
|
+
'/filestore/': ['/filestore/'],
|
|
17
|
+
'/fastdfs/': ['/fastdfs/', '/filestore/'],
|
|
18
|
+
'/floorstatic/': ['/floorstatic/', '/filestore/'],
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const PROXY_PREFIXES = Object.keys(PREFIX_UPSTREAM)
|
|
22
|
+
|
|
23
|
+
const MIME = {
|
|
24
|
+
'.png': 'image/png',
|
|
25
|
+
'.jpg': 'image/jpeg',
|
|
26
|
+
'.jpeg': 'image/jpeg',
|
|
27
|
+
'.gif': 'image/gif',
|
|
28
|
+
'.webp': 'image/webp',
|
|
29
|
+
'.svg': 'image/svg+xml',
|
|
30
|
+
'.ico': 'image/x-icon',
|
|
31
|
+
'.json': 'application/json',
|
|
32
|
+
'.css': 'text/css',
|
|
33
|
+
'.js': 'text/javascript',
|
|
34
|
+
'.woff': 'font/woff',
|
|
35
|
+
'.woff2': 'font/woff2',
|
|
36
|
+
'.ttf': 'font/ttf',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 上游拿不到时的占位图,避免整卡渲染被一张图阻塞 */
|
|
40
|
+
const PLACEHOLDER = Buffer.from(
|
|
41
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120">
|
|
42
|
+
<rect width="120" height="120" fill="#f0f1f5"/>
|
|
43
|
+
<path d="M30 78l22-26 16 19 12-13 10 20z" fill="#c8ccd8"/>
|
|
44
|
+
<circle cx="44" cy="42" r="8" fill="#c8ccd8"/>
|
|
45
|
+
<text x="60" y="106" font-size="10" fill="#9aa0b0" text-anchor="middle" font-family="sans-serif">asset missing</text>
|
|
46
|
+
</svg>`,
|
|
47
|
+
'utf8'
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
/** URL 里的中文/空格等需要转义后才能发请求,但缓存 key 用原始 pathname */
|
|
51
|
+
function encodePath(p) {
|
|
52
|
+
return p
|
|
53
|
+
.split('/')
|
|
54
|
+
.map((seg) => (/%[0-9a-fA-F]{2}/.test(seg) ? seg : encodeURIComponent(seg)))
|
|
55
|
+
.join('/')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cachePathFor(pathname, cacheDir) {
|
|
59
|
+
const hash = crypto.createHash('md5').update(pathname).digest('hex').slice(0, 16)
|
|
60
|
+
const ext = path.extname(pathname.split('?')[0]) || '.bin'
|
|
61
|
+
return path.join(cacheDir, `${hash}${ext}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function fetchUpstream(pathname, upstream) {
|
|
65
|
+
const prefix = PROXY_PREFIXES.find((p) => pathname.startsWith(p))
|
|
66
|
+
if (!prefix) return null
|
|
67
|
+
const rest = pathname.slice(prefix.length)
|
|
68
|
+
for (const upPrefix of PREFIX_UPSTREAM[prefix]) {
|
|
69
|
+
const url = upstream + encodePath(upPrefix + rest)
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(url, {
|
|
72
|
+
headers: { 'User-Agent': 'Mozilla/5.0', Referer: upstream + '/' },
|
|
73
|
+
signal: AbortSignal.timeout(12000),
|
|
74
|
+
})
|
|
75
|
+
if (!res.ok) continue
|
|
76
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
77
|
+
if (!buf.length) continue
|
|
78
|
+
// 上游 404 页可能返回 200 + html,做一次内容嗅探
|
|
79
|
+
const ct = res.headers.get('content-type') || ''
|
|
80
|
+
if (ct.includes('text/html')) continue
|
|
81
|
+
return buf
|
|
82
|
+
} catch {
|
|
83
|
+
/* 换下一个候选 */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 资源代理 + 落盘缓存(回源地址从传入的 upstream 取)。
|
|
91
|
+
* @param {{ root: string, upstream: string }} opts
|
|
92
|
+
*/
|
|
93
|
+
/**
|
|
94
|
+
* 资源代理 + 落盘缓存中间件(与 Express / Vite middleware 容器解耦)。
|
|
95
|
+
* 既可由 Vite 插件 configureServer 挂载,也可由 server/core 的 mountCoreMiddlewares
|
|
96
|
+
* 直接挂载到 cardbuddy(8787),实现「Vite 零后端插件、8787 是唯一后端」。
|
|
97
|
+
* @param {{ root: string, upstream: string }} opts
|
|
98
|
+
* @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
|
|
99
|
+
*/
|
|
100
|
+
export function createAssetProxyMiddleware({ root, upstream }) {
|
|
101
|
+
const CACHE_DIR = path.join(root, '.cache', 'assets')
|
|
102
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true })
|
|
103
|
+
return async (req, res, next) => {
|
|
104
|
+
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
105
|
+
if (!PROXY_PREFIXES.some((p) => pathname.startsWith(p))) return next()
|
|
106
|
+
|
|
107
|
+
const ext = path.extname(pathname) || '.bin'
|
|
108
|
+
const mime = MIME[ext.toLowerCase()] || 'application/octet-stream'
|
|
109
|
+
const cacheFile = cachePathFor(pathname, CACHE_DIR)
|
|
110
|
+
|
|
111
|
+
// 1) 命中缓存
|
|
112
|
+
if (fs.existsSync(cacheFile)) {
|
|
113
|
+
res.setHeader('Content-Type', mime)
|
|
114
|
+
res.setHeader('X-Asset-Cache', 'HIT')
|
|
115
|
+
res.setHeader('Cache-Control', 'public, max-age=31536000')
|
|
116
|
+
return res.end(fs.readFileSync(cacheFile))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 2) 回源
|
|
120
|
+
const buf = await fetchUpstream(pathname, upstream)
|
|
121
|
+
if (buf) {
|
|
122
|
+
try {
|
|
123
|
+
fs.writeFileSync(cacheFile, buf)
|
|
124
|
+
} catch {
|
|
125
|
+
/* 缓存写失败不影响响应 */
|
|
126
|
+
}
|
|
127
|
+
res.setHeader('Content-Type', mime)
|
|
128
|
+
res.setHeader('X-Asset-Cache', 'MISS')
|
|
129
|
+
res.setHeader('Cache-Control', 'public, max-age=31536000')
|
|
130
|
+
return res.end(buf)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 3) 占位图兜底
|
|
134
|
+
res.statusCode = 200
|
|
135
|
+
res.setHeader('Content-Type', 'image/svg+xml')
|
|
136
|
+
res.setHeader('X-Asset-Cache', 'FALLBACK')
|
|
137
|
+
return res.end(PLACEHOLDER)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Vite 插件适配层(保留以兼容纯 vite dev 场景):configureServer 阶段把
|
|
143
|
+
* createAssetProxyMiddleware 挂到 server.middlewares。前后分离架构下已由 server/core
|
|
144
|
+
* 的 mountCoreMiddlewares 挂载到 cardbuddy(8787),Vite 不再需要此插件。
|
|
145
|
+
* @param {{ root: string, upstream: string }} opts
|
|
146
|
+
*/
|
|
147
|
+
export function createAssetProxyPlugin({ root, upstream }) {
|
|
148
|
+
return {
|
|
149
|
+
name: 'finmall-asset-proxy',
|
|
150
|
+
configureServer(server) {
|
|
151
|
+
server.middlewares.use(createAssetProxyMiddleware({ root, upstream }))
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import { getBrowser, serialize, openCardScreen, MAX_QUEUED } from './browser.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 渲染几何度量端点(UI 还原度对拍闭环 P0):GET /__bbox/<id>?width=390
|
|
7
|
+
*
|
|
8
|
+
* 与 /__screenshot 互补:截图只给「整卡像素」,本端点输出「逐元素 bbox」,
|
|
9
|
+
* 供 fidelity_report.mjs(或任何对拍器)与设计稿 spec 的 rect 逐元素比对,
|
|
10
|
+
* 回答"差在哪里、差多少、疑似改哪个属性"。
|
|
11
|
+
*
|
|
12
|
+
* 输出结构(相对 .phone__screen 左上角的卡内坐标,CSS 像素):
|
|
13
|
+
* { id, viewportWidth, elements: [{ cid, className, text, rect: {x,y,width,height}, visible }] }
|
|
14
|
+
*
|
|
15
|
+
* 实现要点:
|
|
16
|
+
* - 复用 ./browser.js 的单例浏览器 + 串行队列 + [data-card-ready] 等待(与截图同源,
|
|
17
|
+
* 保证"度量到的东西 = 截图里画出来的东西")。
|
|
18
|
+
* - 遍历 .phone__screen 内所有 [data-cid] 元素(RenderTree 在每个组件根挂
|
|
19
|
+
* data-cid=node.componentId||node.id、data-class=node.className),叶子与容器都输出,
|
|
20
|
+
* 由对拍器按 className/层级过滤——这里只做纯度量,不做业务判断。
|
|
21
|
+
* - text 为 textContent 归一化快照(截 80 字符),供对拍器做"文本匹配"(spec 无稳定 id)。
|
|
22
|
+
* - visible 标记:宽高为 0 或 display/visibility 隐藏的元素不参与对拍。
|
|
23
|
+
* - 失败一律结构化响应(429/500/503/504),不抛未捕获异常。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** 保留 1 位小数(deviceScaleFactor=2 下 CSS 像素可能为小数) */
|
|
27
|
+
function r1(n) {
|
|
28
|
+
return Math.round(n * 10) / 10
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 对单卡度量全部 [data-cid] 元素 bbox。纯函数、无 HTTP。
|
|
33
|
+
* @param {string} id 卡片 id
|
|
34
|
+
* @param {number} width 视口宽度(卡片框固定 375px,这里给外层留白)
|
|
35
|
+
*/
|
|
36
|
+
async function takeBboxes(id, width) {
|
|
37
|
+
let browser
|
|
38
|
+
try {
|
|
39
|
+
browser = await getBrowser()
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return {
|
|
42
|
+
error: 'browser-launch-failed',
|
|
43
|
+
code: 503,
|
|
44
|
+
message: String((e && e.message) || e),
|
|
45
|
+
fix: '系统 Chrome 未找到或未启动。请安装 Google Chrome,并确保 playwright 以 channel:"chrome" 启动(本端点固定使用该通道)。',
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let context
|
|
50
|
+
try {
|
|
51
|
+
context = await browser.newContext({
|
|
52
|
+
viewport: { width: width + 80, height: 800 },
|
|
53
|
+
deviceScaleFactor: 2,
|
|
54
|
+
})
|
|
55
|
+
const { page, screen } = await openCardScreen(context, id)
|
|
56
|
+
try {
|
|
57
|
+
if (!screen) {
|
|
58
|
+
return { error: 'capture-target-not-found', code: 500, message: '.phone__screen 元素未找到' }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const elements = await page.evaluate(() => {
|
|
62
|
+
// 注意:evaluate 回调在浏览器上下文执行,不能引用 Node 侧函数(如 r1),需内联。
|
|
63
|
+
const r1 = (n) => Math.round(n * 10) / 10
|
|
64
|
+
const s = document.querySelector('.phone__screen')
|
|
65
|
+
if (!s) return []
|
|
66
|
+
const sr = s.getBoundingClientRect()
|
|
67
|
+
const out = []
|
|
68
|
+
s.querySelectorAll('[data-cid]').forEach((el) => {
|
|
69
|
+
const r = el.getBoundingClientRect()
|
|
70
|
+
const cs = getComputedStyle(el)
|
|
71
|
+
const text = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120)
|
|
72
|
+
out.push({
|
|
73
|
+
cid: el.getAttribute('data-cid'),
|
|
74
|
+
className: el.getAttribute('data-class'),
|
|
75
|
+
text,
|
|
76
|
+
rect: {
|
|
77
|
+
x: r1(r.x - sr.x),
|
|
78
|
+
y: r1(r.y - sr.y),
|
|
79
|
+
width: r1(r.width),
|
|
80
|
+
height: r1(r.height),
|
|
81
|
+
},
|
|
82
|
+
visible: !!(r.width > 0 && r.height > 0) && cs.visibility !== 'hidden' && cs.display !== 'none',
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
return out
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// screen 尺寸供对拍器判断「spec 整屏稿 vs 渲染卡主体」的 chrome 裁剪
|
|
89
|
+
// (变量名避开第 55 行解构出的 screen ElementHandle)
|
|
90
|
+
const screenInfo = await page.evaluate(() => {
|
|
91
|
+
const s = document.querySelector('.phone__screen')
|
|
92
|
+
if (!s) return null
|
|
93
|
+
const sr = s.getBoundingClientRect()
|
|
94
|
+
return { width: Math.round(sr.width), height: Math.round(sr.height) }
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
return { json: { id, viewportWidth: width, screen: screenInfo, elements } }
|
|
98
|
+
} finally {
|
|
99
|
+
await page.close().catch(() => {})
|
|
100
|
+
}
|
|
101
|
+
} catch (e) {
|
|
102
|
+
const isTimeout = /Timeout\s*\d+ms exceeded/.test(String((e && e.message) || e))
|
|
103
|
+
return {
|
|
104
|
+
error: isTimeout ? 'render-wait-timeout' : 'bbox-failed',
|
|
105
|
+
code: isTimeout ? 504 : 500,
|
|
106
|
+
message: String((e && e.message) || e),
|
|
107
|
+
fix: isTimeout
|
|
108
|
+
? '卡片首帧未在限定时间内挂载(data-card-ready 未出现)。检查该卡是否在 chrome=off 模式下渲染报错,或卡片依赖真实运行时数据(customAction/RPC)而无法在纯渲染模式落定。'
|
|
109
|
+
: undefined,
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
if (context) await context.close().catch(() => {})
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createBboxMiddleware({ root }) {
|
|
117
|
+
return async function bboxMiddleware(req, res, next) {
|
|
118
|
+
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
119
|
+
const m = pathname.match(/^\/__bbox\/([^/]+)$/)
|
|
120
|
+
if (!m) return next()
|
|
121
|
+
const id = decodeURIComponent(m[1])
|
|
122
|
+
|
|
123
|
+
const dir = path.join(root, 'src', 'cards', id)
|
|
124
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
|
125
|
+
res.statusCode = 404
|
|
126
|
+
res.setHeader('Content-Type', 'application/json')
|
|
127
|
+
return res.end(JSON.stringify({ error: 'card not found', id }))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const url = new URL(req.url, 'http://localhost')
|
|
131
|
+
const width = parseInt(url.searchParams.get('width') || '390', 10) || 390
|
|
132
|
+
|
|
133
|
+
const result = await serialize(() => takeBboxes(id, width))
|
|
134
|
+
if (result.tooBusy) {
|
|
135
|
+
res.statusCode = 429
|
|
136
|
+
res.setHeader('Content-Type', 'application/json')
|
|
137
|
+
return res.end(
|
|
138
|
+
JSON.stringify({ error: 'too-busy', message: '度量队列已满(并发上限 ' + MAX_QUEUED + '),请稍后重试' }),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
if (result.error) {
|
|
142
|
+
const code = result.code || 503
|
|
143
|
+
res.statusCode = code
|
|
144
|
+
res.setHeader('Content-Type', 'application/json')
|
|
145
|
+
return res.end(JSON.stringify({ error: result.error, message: result.message, fix: result.fix }))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
res.statusCode = 200
|
|
149
|
+
res.setHeader('Content-Type', 'application/json')
|
|
150
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
151
|
+
return res.end(JSON.stringify(result.json))
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { devApiBase } from './_vendor/finmall-env.mjs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 共享浏览器单例 + 卡片页面加载(screenshot.js / bbox.js 共用)。
|
|
5
|
+
*
|
|
6
|
+
* 设计要点(与 screenshot.js 历史实现保持一致,避免回归):
|
|
7
|
+
* - playwright 不在此处静态 import,统一在 getBrowser() 内动态 import:
|
|
8
|
+
* 否则它会被卷进 vite.config 的加载图,导致「未装 devDependencies 的机器上
|
|
9
|
+
* npm run build 因解析不到 playwright 而失败」。缺失时端点运行时返回 503 + 修复指引。
|
|
10
|
+
* - 单例浏览器(channel:'chrome' 系统 Chrome)+ 串行队列(>MAX_QUEUED 在途返回 tooBusy)
|
|
11
|
+
* + 惰性启动 + 空闲 5 分钟自动关闭。
|
|
12
|
+
* - openCardScreen():打开 /?card=<id>&chrome=off,等 [data-card-ready] 出现(而非
|
|
13
|
+
* networkidle),再稳定一拍 SETTLE_MS,返回 { page, screen }(screen 为 .phone__screen
|
|
14
|
+
* 的 ElementHandle,不存在时为 null)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const BASE_URL = process.env.CARD_SCREENSHOT_BASE || devApiBase()
|
|
18
|
+
const IDLE_CLOSE_MS = 5 * 60 * 1000
|
|
19
|
+
const MAX_QUEUED = 10
|
|
20
|
+
const NAV_TIMEOUT_MS = 30000
|
|
21
|
+
const SETTLE_MS = 400
|
|
22
|
+
|
|
23
|
+
let browserPromise = null
|
|
24
|
+
let idleTimer = null
|
|
25
|
+
let chromiumMod = null
|
|
26
|
+
|
|
27
|
+
/** 取(惰性启动的)单例浏览器;启动失败会把 browserPromise 复位并抛出 */
|
|
28
|
+
async function getBrowser() {
|
|
29
|
+
// 动态 import playwright:避免其进入 vite.config 加载图(见文件头注释)
|
|
30
|
+
if (!chromiumMod) chromiumMod = await import('playwright')
|
|
31
|
+
if (!browserPromise) {
|
|
32
|
+
browserPromise = chromiumMod.chromium
|
|
33
|
+
.launch({ channel: 'chrome', headless: true })
|
|
34
|
+
.catch((e) => {
|
|
35
|
+
browserPromise = null
|
|
36
|
+
throw e
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
resetIdleTimer()
|
|
40
|
+
return browserPromise
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resetIdleTimer() {
|
|
44
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
45
|
+
idleTimer = setTimeout(async () => {
|
|
46
|
+
idleTimer = null
|
|
47
|
+
const p = browserPromise
|
|
48
|
+
browserPromise = null
|
|
49
|
+
// 注意:browserPromise 是 launch 的 Promise,不是浏览器实例本身;
|
|
50
|
+
// 必须先 await 拿到实例再 close,否则 b.close is not a function 会抛未捕获异常拖垮进程。
|
|
51
|
+
if (p) {
|
|
52
|
+
try {
|
|
53
|
+
const b = await p
|
|
54
|
+
if (b && typeof b.close === 'function') await b.close()
|
|
55
|
+
} catch (_) { /* 关闭失败忽略 */ }
|
|
56
|
+
}
|
|
57
|
+
}, IDLE_CLOSE_MS)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 串行化所有浏览器任务,超过 MAX_QUEUED 在途则返回 { tooBusy: true } */
|
|
61
|
+
let chain = Promise.resolve()
|
|
62
|
+
let inflight = 0
|
|
63
|
+
function serialize(task) {
|
|
64
|
+
if (inflight >= MAX_QUEUED) return Promise.resolve({ tooBusy: true })
|
|
65
|
+
inflight += 1
|
|
66
|
+
const p = chain.then(() => task()).finally(() => {
|
|
67
|
+
inflight -= 1
|
|
68
|
+
})
|
|
69
|
+
// 保持链存活(吞掉错误,避免后续任务被牵连拒绝)
|
|
70
|
+
chain = p.catch(() => {})
|
|
71
|
+
return p
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 打开卡片渲染页(chrome=off 纯净模式)并等待首帧稳定。
|
|
76
|
+
* @param {import('playwright').BrowserContext} context
|
|
77
|
+
* @param {string} id 卡片 id
|
|
78
|
+
* @returns {Promise<{ page: import('playwright').Page, screen: import('playwright').ElementHandle | null }>}
|
|
79
|
+
*/
|
|
80
|
+
async function openCardScreen(context, id) {
|
|
81
|
+
const page = await context.newPage()
|
|
82
|
+
const target = `${BASE_URL}/?card=${encodeURIComponent(id)}&chrome=off`
|
|
83
|
+
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS })
|
|
84
|
+
// 等卡片首帧挂载(App.vue 置 data-card-ready=1)。用 waitForFunction 精确判断属性出现,
|
|
85
|
+
// 而非等待「元素可见」(避免卡片有但属性未置位时误判)。
|
|
86
|
+
await page.waitForFunction(
|
|
87
|
+
() => {
|
|
88
|
+
const el = document.querySelector('.phone__screen')
|
|
89
|
+
return !!el && el.hasAttribute('data-card-ready')
|
|
90
|
+
},
|
|
91
|
+
{ timeout: NAV_TIMEOUT_MS },
|
|
92
|
+
)
|
|
93
|
+
// 再稳定一拍:让 created/mounted 里可能触发的重渲染(如补数据)落定
|
|
94
|
+
await page.waitForTimeout(SETTLE_MS)
|
|
95
|
+
const screen = await page.$('.phone__screen')
|
|
96
|
+
return { page, screen }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export {
|
|
100
|
+
BASE_URL,
|
|
101
|
+
IDLE_CLOSE_MS,
|
|
102
|
+
MAX_QUEUED,
|
|
103
|
+
NAV_TIMEOUT_MS,
|
|
104
|
+
SETTLE_MS,
|
|
105
|
+
getBrowser,
|
|
106
|
+
serialize,
|
|
107
|
+
openCardScreen,
|
|
108
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 由 schema 派生 data 的「单一真相」实现 —— 服务端写盘 / 同步落盘 / 预览渲染注入 /
|
|
3
|
+
* 历史快照还原 三处共用,保证任意环节看到的 data 都严格等于 buildDataFromSchema(schema)。
|
|
4
|
+
*
|
|
5
|
+
* 架构前提(「schema 唯一真值、data 单向派生」):
|
|
6
|
+
* data.json 文件是 schema.json 的纯投影,绝不被读回作为数据源。每次写盘或远端同步
|
|
7
|
+
* 后都由本函数依据 schema.dictionary.inputParams[].defaultValue 重算 data.json。
|
|
8
|
+
* 仅当 defaultValue 为空 / 空对象 / 未定义时,回退到 param.desc 里的 JSON 兜底
|
|
9
|
+
* (desc 以 '{' 开头且解析后含该 key 时生效)。
|
|
10
|
+
*
|
|
11
|
+
* 本文件是纯 ESM(无任何 node / 浏览器专有依赖),因此既能被服务端(Node)import,
|
|
12
|
+
* 也能被预览前端(Vite 打包)直接 import,避免逻辑漂移。
|
|
13
|
+
*/
|
|
14
|
+
export function buildDataFromSchema(schema) {
|
|
15
|
+
// 稳健性:schema 缺失 / 非对象(如外部改坏 schema.json)时返回空对象,
|
|
16
|
+
// 不抛错,避免 GET /__card 在损坏卡片上返回 500 堆栈。
|
|
17
|
+
if (!schema || typeof schema !== 'object') return {}
|
|
18
|
+
const data = {}
|
|
19
|
+
for (const param of schema.dictionary?.inputParams || []) {
|
|
20
|
+
const fallback = parseDescFallback(param)
|
|
21
|
+
const defaultValue = param.defaultValue
|
|
22
|
+
const emptyObject =
|
|
23
|
+
defaultValue &&
|
|
24
|
+
typeof defaultValue === 'object' &&
|
|
25
|
+
!Array.isArray(defaultValue) &&
|
|
26
|
+
Object.keys(defaultValue).length === 0
|
|
27
|
+
data[param.key] = clone(
|
|
28
|
+
fallback !== undefined && (defaultValue === undefined || defaultValue === '' || emptyObject)
|
|
29
|
+
? fallback
|
|
30
|
+
: defaultValue,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
return data
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseDescFallback(param) {
|
|
37
|
+
if (!param.desc || typeof param.desc !== 'string') return undefined
|
|
38
|
+
const text = param.desc.trim()
|
|
39
|
+
if (!text.startsWith('{')) return undefined
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(text)
|
|
42
|
+
return Object.prototype.hasOwnProperty.call(parsed, param.key) ? parsed[param.key] : undefined
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function clone(value) {
|
|
49
|
+
return value == null ? value : JSON.parse(JSON.stringify(value))
|
|
50
|
+
}
|