cardbuddy 0.19.2 → 0.20.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 +7 -7
- package/dist/cli.js +7 -8
- package/dist/init.js +3 -3
- package/dist/preview-editor/assets/codeBridgeHeartbeat.worker-Zb_A5RUd.js +1 -0
- package/dist/preview-editor/assets/index-08GutEI0.css +1 -0
- package/dist/preview-editor/assets/{index-1exc5zRY.js → index-Dx5hfJ7f.js} +83 -72
- package/dist/preview-editor/index.html +2 -2
- package/dist/skills-upgrade.js +64 -25
- package/dist/vendored/card-editor/components/MultiColumn.vue +19 -10
- package/dist/vendored/server/core/cardHistoryRemote.js +203 -0
- package/dist/vendored/server/core/cardSync.js +425 -95
- package/dist/vendored/server/core/codeBridge.js +4 -2
- package/dist/vendored/server/core/index.js +5 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +358 -11
- package/dist/vendored/skills/finmall-session-navigator/scripts/finmall_login.mjs +37 -5
- package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +25 -1
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-C_uqjKqI.css +0 -1
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16-Cg6Nrosk.png" />
|
|
9
9
|
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32-B_1744sp.png" />
|
|
10
10
|
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon-BQcES9QK.png" />
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-Dx5hfJ7f.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-08GutEI0.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="app"></div>
|
package/dist/skills-upgrade.js
CHANGED
|
@@ -7,18 +7,22 @@
|
|
|
7
7
|
* 就是为这个场景而生,且**只动 skills/**,绝不碰 .env.cb / package.json / README.md
|
|
8
8
|
* (那是 `init --force` 的危险面,可能冲掉端口 / FinMall 配置 / AI 网关占位)。
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* 行为模型(upgrade = 显式要把新版带进来,「有差异 = 应当覆盖」,不做好坏判定):
|
|
11
11
|
* ADDED 源有、目标无 → 复制(安全)
|
|
12
|
-
* SAME
|
|
13
|
-
* MODIFIED 两边都有、内容不同 →
|
|
14
|
-
* ORPHAN 目标有、源无 →
|
|
12
|
+
* SAME 两边一致 → 跳过
|
|
13
|
+
* MODIFIED 两边都有、内容不同 → 默认覆盖(这正是升级的目的,会丢弃本地副本的改动)
|
|
14
|
+
* ORPHAN 目标有、源无 → 默认保留(可能是私有 / 上游已下架)
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* 确认门禁:当存在 MODIFIED(将覆盖并丢弃本地改动)时,默认进入交互确认——展示将覆盖的
|
|
17
|
+
* 文件清单,请用户 [y/N] 确认;确认即覆盖,否认即跳过这些 MODIFIED 并继续其余变更。
|
|
18
|
+
* 交互环境外(非 TTY)无法确认时,必须带 --force 才能升级,否则明确拒绝并提示(不再
|
|
19
|
+
* 静默跳过、假装升级成功)。
|
|
20
|
+
* 标志:--force/-y 跳过确认直接覆盖;--prune 额外删除 ORPHAN;--dry-run 只报告不落盘。
|
|
18
21
|
* 同步后写 skills/.synced-from = 当前装包版本,供下次 drift 检测与启动提示。
|
|
19
22
|
*/
|
|
20
23
|
import fs from 'node:fs'
|
|
21
24
|
import path from 'node:path'
|
|
25
|
+
import readline from 'node:readline/promises'
|
|
22
26
|
import { fileURLToPath } from 'node:url'
|
|
23
27
|
import chalk from 'chalk'
|
|
24
28
|
import { loadConfig } from './config.js'
|
|
@@ -95,14 +99,36 @@ export function diffSkills(src, dest) {
|
|
|
95
99
|
return res
|
|
96
100
|
}
|
|
97
101
|
|
|
102
|
+
/**
|
|
103
|
+
* 确认是否覆盖 MODIFIED 文件。
|
|
104
|
+
* - force/injected confirm:直接给结果(便于非交互 / 测试)
|
|
105
|
+
* - 交互 TTY:readline 逐条列出后请用户 [y/N]
|
|
106
|
+
* - 非 TTY 且无 --force:返回 null(调用方据此明确拒绝,而非静默跳过)
|
|
107
|
+
* @returns {Promise<boolean|null>}
|
|
108
|
+
*/
|
|
109
|
+
async function resolveOverwriteConsent({ modified, force, confirm, log, tag }) {
|
|
110
|
+
if (force) return true
|
|
111
|
+
if (typeof confirm === 'function') return Boolean(await confirm(modified))
|
|
112
|
+
if (!process.stdin.isTTY) return null
|
|
113
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
|
|
114
|
+
try {
|
|
115
|
+
log(tag + chalk.yellow(`将覆盖 ${modified.length} 个本地已修改的技能文件(你的本地改动将被丢弃):`))
|
|
116
|
+
for (const rel of modified) log(chalk.yellow(' ' + rel))
|
|
117
|
+
const ans = await rl.question(chalk.bold('确认用包内版本覆盖以上文件?[y/N] '))
|
|
118
|
+
return /^(y|yes|是|确认|ok)$/i.test(ans.trim())
|
|
119
|
+
} finally {
|
|
120
|
+
rl.close()
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
98
124
|
/**
|
|
99
125
|
* 执行 skills 升级。
|
|
100
126
|
* @param {object} opts
|
|
101
127
|
* @param {string} [opts.targetDir='.'] 目标工程根(skills 落在 <targetDir>/skills)
|
|
102
|
-
* @param {boolean} [opts.force]
|
|
128
|
+
* @param {boolean} [opts.force] 跳过确认直接覆盖本地已修改的 shipped skill(非交互必备)
|
|
103
129
|
* @param {boolean} [opts.dryRun] 只报告不落盘
|
|
104
130
|
* @param {boolean} [opts.prune] 额外删除包内已不存在的技能文件(默认保留)
|
|
105
|
-
* @param {boolean} [opts.
|
|
131
|
+
* @param {(modified:string[])=>boolean|Promise<boolean>} [opts.confirm] 注入式确认(测试用,默认交互)
|
|
106
132
|
* @param {ReturnType<import('./config.js').loadConfig>} [opts.config]
|
|
107
133
|
* @param {(msg: string) => void} [opts.log]
|
|
108
134
|
* @returns {Promise<number>} 进程退出码
|
|
@@ -112,7 +138,7 @@ export async function runSkillsUpgrade({
|
|
|
112
138
|
force = false,
|
|
113
139
|
dryRun = false,
|
|
114
140
|
prune = false,
|
|
115
|
-
|
|
141
|
+
confirm = null,
|
|
116
142
|
config,
|
|
117
143
|
log = (m) => console.log(m),
|
|
118
144
|
} = {}) {
|
|
@@ -141,13 +167,36 @@ export async function runSkillsUpgrade({
|
|
|
141
167
|
const version = ownVersion()
|
|
142
168
|
const diff = diffSkills(src, destSkills)
|
|
143
169
|
|
|
170
|
+
if (!diff.added.length && !diff.modified.length && !diff.orphan.length && fs.existsSync(destSkills)) {
|
|
171
|
+
dim(`skills 已是最新(cardbuddy@${version},无新增 / 修改 / 孤儿)`)
|
|
172
|
+
return 0
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 确认门禁:存在将被覆盖的 MODIFIED 时,先确认
|
|
176
|
+
let overwriteModified = false
|
|
177
|
+
if (diff.modified.length && !dryRun) {
|
|
178
|
+
const consent = await resolveOverwriteConsent({ modified: diff.modified, force, confirm, log, tag })
|
|
179
|
+
if (consent === null) {
|
|
180
|
+
warn(`⚠ 检测到 ${diff.modified.length} 个已修改的技能文件将被覆盖,但当前非交互环境无法确认。`)
|
|
181
|
+
dim(' 加 --force 跳过确认直接覆盖,或在交互终端运行本命令确认。')
|
|
182
|
+
return 1
|
|
183
|
+
}
|
|
184
|
+
overwriteModified = consent
|
|
185
|
+
if (!overwriteModified) {
|
|
186
|
+
warn(`已跳过 ${diff.modified.length} 个 MODIFIED 文件(未确认覆盖,本地副本保留);其余变更仍会进行。`)
|
|
187
|
+
}
|
|
188
|
+
} else if (diff.modified.length && dryRun) {
|
|
189
|
+
warn(`dry-run:${diff.modified.length} 个已修改的技能文件将被覆盖(确认后)。`)
|
|
190
|
+
}
|
|
191
|
+
|
|
144
192
|
let copied = 0
|
|
145
193
|
let bytes = 0
|
|
146
194
|
let overwritten = 0
|
|
195
|
+
let added = 0
|
|
147
196
|
let deleted = 0
|
|
148
197
|
|
|
149
|
-
// ADDED 总是复制;MODIFIED
|
|
150
|
-
const toCopy = [...diff.added, ...(
|
|
198
|
+
// ADDED 总是复制;MODIFIED 仅当用户确认(或 --force)时复制——这正是升级的目的
|
|
199
|
+
const toCopy = [...diff.added, ...(overwriteModified ? diff.modified : [])]
|
|
151
200
|
for (const rel of toCopy) {
|
|
152
201
|
const sp = path.join(src, rel)
|
|
153
202
|
const dp = path.join(destSkills, rel)
|
|
@@ -159,15 +208,7 @@ export async function runSkillsUpgrade({
|
|
|
159
208
|
bytes += fs.statSync(sp).size
|
|
160
209
|
copied++
|
|
161
210
|
if (isModified) overwritten++
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// MODIFIED 未 --force:警告(不复制,防丢改动)
|
|
165
|
-
if (!force && diff.modified.length) {
|
|
166
|
-
warn(`⚠ 检测到 ${diff.modified.length} 个已修改的技能文件(本地副本与包内不一致),默认不覆盖,避免丢失你的改动:`)
|
|
167
|
-
for (const rel of diff.modified) say(chalk.yellow(` ${rel}`))
|
|
168
|
-
dim(' 如需用包内版本覆盖,加 --force;想先看差异,加 --dry-run 后人工对比。')
|
|
169
|
-
} else if (force && diff.modified.length) {
|
|
170
|
-
warn(`--force:已用包内版本覆盖 ${diff.modified.length} 个本地修改的技能文件(你的改动将被丢弃)`)
|
|
211
|
+
else added++
|
|
171
212
|
}
|
|
172
213
|
|
|
173
214
|
// ORPHAN:默认保留;--prune 才删除
|
|
@@ -207,14 +248,12 @@ export async function runSkillsUpgrade({
|
|
|
207
248
|
say(chalk.dim(` 目标:${targetNote}`))
|
|
208
249
|
say(
|
|
209
250
|
chalk.dim(
|
|
210
|
-
` 新增 ${
|
|
251
|
+
` 新增 ${added} · 相同 ${diff.same.length} · 覆盖 ${overwritten}` +
|
|
252
|
+
`${overwriteModified ? '' : ` · 跳过修改 ${diff.modified.length - overwritten}`}` +
|
|
253
|
+
` · 保留私有 ${diff.orphan.length}` +
|
|
211
254
|
`${prune ? ` · 删除孤儿 ${deleted}` : ''} · 体积 ${fmtBytes(bytes)}`
|
|
212
255
|
)
|
|
213
256
|
)
|
|
214
257
|
|
|
215
|
-
if (strict && diff.modified.length && !force) {
|
|
216
|
-
say(chalk.red('(--strict:存在未覆盖的 MODIFIED 文件,返回非 0)'))
|
|
217
|
-
return 2
|
|
218
|
-
}
|
|
219
258
|
return 0
|
|
220
259
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
多列布局容器(官方组件 MultiColumn)。
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
官方 component-meta 里 MultiColumn 没有 column 属性(properties: []),
|
|
5
|
+
真实运行时语义 = **直接子节点数即列数,单行均分**(N 个子节点 → N 等宽列)。
|
|
6
|
+
预览器此前自造 `column=2` 默认值,导致 4 子节点被排成 2×2(overseaTransfer
|
|
7
|
+
步骤条 row_hf 实测踩坑:内容高度翻倍后被 AbsoluteLayout 裁切,圆圈/标签错位)。
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
实现:显式传 column > 0 时按指定列数(保留扩展口子);否则按默认插槽的
|
|
10
|
+
子 vnode 数自动取列数,>1 时切换 CSS Grid 单行均分(flex gap 兼作列间距)。
|
|
10
11
|
-->
|
|
11
12
|
<template>
|
|
12
13
|
<div ref="mc" class="ce_multicolumn" :style="styleObj" @click="$emit('click', $event)">
|
|
@@ -15,6 +16,7 @@
|
|
|
15
16
|
</template>
|
|
16
17
|
|
|
17
18
|
<script>
|
|
19
|
+
import { Comment, Text } from 'vue'
|
|
18
20
|
import StyleParser from '../runtime/StyleParser.js'
|
|
19
21
|
import base from './base.js'
|
|
20
22
|
|
|
@@ -24,16 +26,23 @@ export default {
|
|
|
24
26
|
mixins: [base],
|
|
25
27
|
props: {
|
|
26
28
|
styleProps: { type: Object, default: () => ({}) },
|
|
27
|
-
|
|
29
|
+
/** 列数。0 = 自动(默认):按子节点数单行均分,对齐官方「无 column 属性」语义 */
|
|
30
|
+
column: { type: [Number, String], default: 0 },
|
|
28
31
|
},
|
|
29
32
|
computed: {
|
|
33
|
+
cols() {
|
|
34
|
+
const explicit = parseInt(this.column, 10)
|
|
35
|
+
if (explicit > 0) return explicit
|
|
36
|
+
const vnodes = this.$slots.default ? this.$slots.default() : []
|
|
37
|
+
// 只统计真实子组件 vnode;注释 / 纯文本节点不计入
|
|
38
|
+
return vnodes.filter((v) => v && v.type !== Comment && v.type !== Text).length
|
|
39
|
+
},
|
|
30
40
|
styleObj() {
|
|
31
41
|
const s = new StyleParser(this, this.styleProps).parse()
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
// 多列:用 grid 把子节点按 column 列均分;flex gap 同时充当 grid 行列间距。
|
|
42
|
+
if (this.cols > 1) {
|
|
43
|
+
// 多列:用 grid 把子节点单行均分;flex gap 同时充当 grid 列间距。
|
|
35
44
|
s.display = 'grid'
|
|
36
|
-
s.gridTemplateColumns = `repeat(${cols}, 1fr)`
|
|
45
|
+
s.gridTemplateColumns = `repeat(${this.cols}, 1fr)`
|
|
37
46
|
}
|
|
38
47
|
return s
|
|
39
48
|
},
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url'
|
|
2
|
+
import { findCardDir } from './cardSidecar.js'
|
|
3
|
+
import { readImportMeta, markDirty } from './cardImportMeta.js'
|
|
4
|
+
import { writeCardFiles } from './cardFiles.js'
|
|
5
|
+
import { log } from './logger.js'
|
|
6
|
+
import { resolveSkillPathOrProject } from './skillPaths.js'
|
|
7
|
+
|
|
8
|
+
const SYNC_SCRIPT_REL = 'finmall-card-sync/scripts/api_batch_sync.mjs'
|
|
9
|
+
|
|
10
|
+
/** 读取 JSON 请求体(1MB 上限防失控) */
|
|
11
|
+
function readJsonBody(req) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
let raw = ''
|
|
14
|
+
req.on('data', (chunk) => {
|
|
15
|
+
raw += chunk
|
|
16
|
+
if (raw.length > 1024 * 1024) {
|
|
17
|
+
reject(new Error('请求体过大'))
|
|
18
|
+
req.destroy()
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
req.on('end', () => {
|
|
22
|
+
try {
|
|
23
|
+
resolve(raw ? JSON.parse(raw) : {})
|
|
24
|
+
} catch {
|
|
25
|
+
reject(new Error('请求体不是合法 JSON'))
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
req.on('error', reject)
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 调试面板「卡片历史记录」tab 的远端版本时间线(只读):GET /__card-history/:cardCode
|
|
34
|
+
*
|
|
35
|
+
* 语义:经 .import-meta.json 侧车里的 cardId 调 IDC10013,取该卡历史版本列表(新→旧),
|
|
36
|
+
* 每条含时间 / 预览图(/minio 同源路径)/ 版本记录 id / 相对上一版本的变更标签。
|
|
37
|
+
* 触发时机由前端控制(切到 tab / 点刷新 / 加载更多),不自动轮询——与「卡片来源」tab
|
|
38
|
+
* 的懒加载策略一致,避免每次加载卡片都拉起后台 Chrome。
|
|
39
|
+
*
|
|
40
|
+
* 恢复端点:POST /__card-history/:cardCode/restore(body { versionId })——把指定历史
|
|
41
|
+
* 版本的完整 schema 写入本地 src/cards/<code>/schema.json(原子写 + 派生 data.json),
|
|
42
|
+
* 并标记 dirty(本地偏离导入镜像;推送成功后自会清除)。watcher 推 SSE、编辑器重载后
|
|
43
|
+
* 即可继续编辑,经既有「推送」链路(IDC10010)保存为远端最新版本。
|
|
44
|
+
*
|
|
45
|
+
* 安全闸(与 /__card-version / /__save 同源):仅「已同步导入卡」(有 .import-meta.json
|
|
46
|
+
* 侧车且含 cardId)可操作;本地自建卡 / 未同步卡返回 409 local_card_no_source。
|
|
47
|
+
* 恢复另有防串卡校验:历史 schema.pageId 必须等于侧车 cardId,否则 409 pageId_mismatch。
|
|
48
|
+
*
|
|
49
|
+
* @param {{ root: string }} opts
|
|
50
|
+
* @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
|
|
51
|
+
*/
|
|
52
|
+
export function createCardHistoryMiddleware({ root }) {
|
|
53
|
+
return function cardHistoryMiddleware(req, res, next) {
|
|
54
|
+
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
55
|
+
const mRestore = pathname.match(/^\/__card-history\/([^/]+)\/restore$/)
|
|
56
|
+
const mList = pathname.match(/^\/__card-history\/([^/]+)$/)
|
|
57
|
+
if (!mRestore && !mList) return next()
|
|
58
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
59
|
+
if (mRestore ? method !== 'POST' : method !== 'GET') return next()
|
|
60
|
+
|
|
61
|
+
const id = decodeURIComponent((mRestore || mList)[1])
|
|
62
|
+
const dir = findCardDir(root, { code: id })
|
|
63
|
+
const meta = dir ? readImportMeta(dir) : null
|
|
64
|
+
if (!meta || !meta.cardId) {
|
|
65
|
+
res.statusCode = 409
|
|
66
|
+
res.setHeader('Content-Type', 'application/json')
|
|
67
|
+
return res.end(
|
|
68
|
+
JSON.stringify({
|
|
69
|
+
ok: false,
|
|
70
|
+
error: 'local_card_no_source',
|
|
71
|
+
message: '该卡片为本地自建、无远端同步来源(缺少 .import-meta.json 侧车),无法查看远端历史。',
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (mRestore) return handleRestore(req, res, { root, id, dir, meta })
|
|
77
|
+
return handleList(req, res, { root, id, meta })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** GET 列表:分页参数(query)pageNo / pageSize,缺省 1 / 30,上限 100 防失控 */
|
|
82
|
+
function handleList(req, res, { root, id, meta }) {
|
|
83
|
+
const qs = new URL(req.url || '/', 'http://localhost').searchParams
|
|
84
|
+
const pageNo = Math.max(1, parseInt(qs.get('pageNo') || '1', 10) || 1)
|
|
85
|
+
const pageSize = Math.min(100, Math.max(1, parseInt(qs.get('pageSize') || '30', 10) || 30))
|
|
86
|
+
|
|
87
|
+
;(async () => {
|
|
88
|
+
try {
|
|
89
|
+
const skillPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
|
|
90
|
+
const { fetchCardHistory } = await import(pathToFileURL(skillPath).href)
|
|
91
|
+
const r = await fetchCardHistory(meta.cardId, { pageNo, pageSize })
|
|
92
|
+
log.ok('查询远端卡片历史', { cardCode: id, cardId: String(meta.cardId), count: r.list.length })
|
|
93
|
+
res.statusCode = 200
|
|
94
|
+
res.setHeader('Content-Type', 'application/json')
|
|
95
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
96
|
+
res.end(
|
|
97
|
+
JSON.stringify({
|
|
98
|
+
ok: true,
|
|
99
|
+
cardId: String(meta.cardId),
|
|
100
|
+
cardCode: id,
|
|
101
|
+
list: r.list,
|
|
102
|
+
totalNums: r.totalNums,
|
|
103
|
+
totalPages: r.totalPages,
|
|
104
|
+
}),
|
|
105
|
+
)
|
|
106
|
+
} catch (e) {
|
|
107
|
+
// 区分「凭据缺失」与「真正的查询错误」:前者让前端提示登录,而非裸抛 ENOENT。
|
|
108
|
+
const isNoCred =
|
|
109
|
+
e && /credentials\.json|credentials 不存在|NO_CREDENTIALS/.test(String(e && e.message))
|
|
110
|
+
res.statusCode = isNoCred ? 200 : 500
|
|
111
|
+
res.setHeader('Content-Type', 'application/json')
|
|
112
|
+
res.end(
|
|
113
|
+
JSON.stringify(
|
|
114
|
+
isNoCred
|
|
115
|
+
? { error: 'need_credentials', needCredentials: true, message: String(e && e.message) }
|
|
116
|
+
: { error: 'history failed', message: String(e && e.message) },
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
})()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** POST restore:按 versionId 取历史 schema → 防串卡校验 → 原子写 schema.json + 派生 data.json → 标记 dirty */
|
|
124
|
+
function handleRestore(req, res, { root, id, dir, meta }) {
|
|
125
|
+
;(async () => {
|
|
126
|
+
try {
|
|
127
|
+
const body = await readJsonBody(req)
|
|
128
|
+
const versionId = String((body && body.versionId) || '')
|
|
129
|
+
if (!versionId) {
|
|
130
|
+
res.statusCode = 400
|
|
131
|
+
res.setHeader('Content-Type', 'application/json')
|
|
132
|
+
return res.end(JSON.stringify({ ok: false, error: 'missing_version_id', message: '缺少 versionId(远端版本记录 id)。' }))
|
|
133
|
+
}
|
|
134
|
+
const skillPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
|
|
135
|
+
const { fetchCardVersionSchema } = await import(pathToFileURL(skillPath).href)
|
|
136
|
+
let schema
|
|
137
|
+
let record
|
|
138
|
+
try {
|
|
139
|
+
;({ schema, record } = await fetchCardVersionSchema(meta.cardId, versionId))
|
|
140
|
+
} catch (e) {
|
|
141
|
+
// 缓存未命中(时间线未加载过 / TTL 过期 / 目标在更旧页):先拉一页 100 条预热
|
|
142
|
+
// (cardId, versionId) 缓存再重试——一页 4.2s,通常快于翻页到命中位置,且覆盖最新
|
|
143
|
+
// 100 个版本(用户点击的多为时间线可见项)。
|
|
144
|
+
if (!/找不到版本记录/.test(String(e && e.message))) throw e
|
|
145
|
+
const { fetchCardHistory } = await import(pathToFileURL(skillPath).href)
|
|
146
|
+
await fetchCardHistory(meta.cardId, { pageNo: 1, pageSize: 100 })
|
|
147
|
+
;({ schema, record } = await fetchCardVersionSchema(meta.cardId, versionId))
|
|
148
|
+
}
|
|
149
|
+
// 防串卡:历史 schema 的 pageId 必须等于侧车登记的 cardId(与 /__save 的 pageId 闸门同语义)
|
|
150
|
+
if (String(schema.pageId) !== String(meta.cardId)) {
|
|
151
|
+
res.statusCode = 409
|
|
152
|
+
res.setHeader('Content-Type', 'application/json')
|
|
153
|
+
return res.end(
|
|
154
|
+
JSON.stringify({
|
|
155
|
+
ok: false,
|
|
156
|
+
error: 'pageId_mismatch',
|
|
157
|
+
message: `版本记录 #${versionId} 的 schema.pageId(${schema.pageId})与本地卡片来源(${meta.cardId})不一致,已拒绝加载。`,
|
|
158
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
// 原子写 schema.json + 单向派生 data.json(与编辑器本地保存同一写入口)
|
|
162
|
+
const written = writeCardFiles(dir, { schema })
|
|
163
|
+
// 本地内容已偏离导入镜像:标记 dirty,来源 tab 可见;推送成功后由 recordExport/markDirty 清除
|
|
164
|
+
markDirty(dir, true)
|
|
165
|
+
log.ok('恢复远端历史版本到本地', {
|
|
166
|
+
cardCode: id,
|
|
167
|
+
cardId: String(meta.cardId),
|
|
168
|
+
versionId,
|
|
169
|
+
createTime: record.createTime,
|
|
170
|
+
written,
|
|
171
|
+
})
|
|
172
|
+
res.statusCode = 200
|
|
173
|
+
res.setHeader('Content-Type', 'application/json')
|
|
174
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
175
|
+
res.end(
|
|
176
|
+
JSON.stringify({
|
|
177
|
+
ok: true,
|
|
178
|
+
cardCode: id,
|
|
179
|
+
cardId: String(meta.cardId),
|
|
180
|
+
versionId,
|
|
181
|
+
createTime: record.createTime,
|
|
182
|
+
written,
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
} catch (e) {
|
|
186
|
+
const msg = String(e && e.message)
|
|
187
|
+
const isNoCred = /credentials\.json|credentials 不存在|NO_CREDENTIALS/.test(msg)
|
|
188
|
+
const isNotFound = /找不到版本记录|schema 为空或损坏/.test(msg)
|
|
189
|
+
const status = isNoCred ? 200 : isNotFound ? 404 : 500
|
|
190
|
+
res.statusCode = status
|
|
191
|
+
res.setHeader('Content-Type', 'application/json')
|
|
192
|
+
res.end(
|
|
193
|
+
JSON.stringify(
|
|
194
|
+
isNoCred
|
|
195
|
+
? { ok: false, error: 'need_credentials', needCredentials: true, message: msg }
|
|
196
|
+
: isNotFound
|
|
197
|
+
? { ok: false, error: 'version_not_found', message: msg }
|
|
198
|
+
: { ok: false, error: 'restore failed', message: msg },
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
})()
|
|
203
|
+
}
|