From fa8910b3d67ed2784db23336b710294e540241d3 Mon Sep 17 00:00:00 2001 From: whyour Date: Tue, 6 Oct 2026 22:00:11 +0800 Subject: [PATCH] feat: add script version history (#3085) * feat: add script version history * fix: validate canonical save-as paths before backup * fix: stack script history footer on small screens --- back/api/script.ts | 161 ++++- back/services/scriptHistory.ts | 12 + back/shared/scriptHistory.ts | 467 +++++++++++++++ back/shared/utils.ts | 2 +- cli/skills/qinglong-cli/references/openapi.md | 10 +- cli/src/remote/api/openOperations.ts | 12 + cli/test/remote/openapi.test.cjs | 25 + docs/script-history.md | 53 ++ scripts/benchmark-script-history.cjs | 39 ++ src/locales/en-US.json | 30 +- src/locales/zh-CN.json | 30 +- src/pages/script/historyDrawer.module.less | 48 ++ src/pages/script/historyDrawer.tsx | 333 +++++++++++ src/pages/script/index.tsx | 141 +++-- src/pages/script/saveModal.tsx | 28 +- src/pages/script/saveWithHistory.ts | 74 +++ test/back/draft-path-security.test.cjs | 1 + test/back/http-security.test.cjs | 25 +- test/back/script-file-access.test.cjs | 21 +- test/back/script-history-api.test.cjs | 551 ++++++++++++++++++ test/back/script-history.test.cjs | 383 ++++++++++++ test/front/script-history-save.test.cjs | 121 ++++ 22 files changed, 2487 insertions(+), 80 deletions(-) create mode 100644 back/services/scriptHistory.ts create mode 100644 back/shared/scriptHistory.ts create mode 100644 docs/script-history.md create mode 100644 scripts/benchmark-script-history.cjs create mode 100644 src/pages/script/historyDrawer.module.less create mode 100644 src/pages/script/historyDrawer.tsx create mode 100644 src/pages/script/saveWithHistory.ts create mode 100644 test/back/script-history-api.test.cjs create mode 100644 test/back/script-history.test.cjs create mode 100644 test/front/script-history-save.test.cjs diff --git a/back/api/script.ts b/back/api/script.ts index 84f1a48e..e1e7da89 100644 --- a/back/api/script.ts +++ b/back/api/script.ts @@ -9,9 +9,14 @@ import * as fs from 'fs/promises'; import { celebrate, Joi } from 'celebrate'; import path, { dirname, join, parse, resolve, sep } from 'path'; import ScriptService from '../services/script'; +import ScriptHistoryService from '../services/scriptHistory'; import { t } from '../shared/i18n'; import multer from 'multer'; import { writeFileWithLock } from '../shared/utils'; +import { + ScriptHistoryError, + HistoryUnavailableError, +} from '../shared/scriptHistory'; const route = Router(); function isPathAllowed(targetPath: string): boolean { @@ -30,6 +35,34 @@ function isPathAllowed(targetPath: string): boolean { ); } +async function existingWritableRealPath(targetPath: string): Promise { + if (!isPathAllowed(targetPath)) { + throw new ScriptHistoryError(403, '暂无权限'); + } + const resolved = resolve(targetPath); + for (const writableRoot of config.writePathList) { + const root = resolve(writableRoot); + const rootPrefix = root.endsWith(sep) ? root : root + sep; + // Keep an explicit normalized boundary check at the filesystem operation. + if (!resolved.startsWith(rootPrefix)) continue; + const realPath = await fs.realpath(resolved); + const realRoot = await fs.realpath(root); + const realRootPrefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep; + if ( + !realPath.startsWith(realRootPrefix) || + !resolveFileAccess( + realRoot, + [realPath], + root === resolve(config.configPath) ? config.blackFileList : [], + ) + ) { + throw new ScriptHistoryError(403, '暂无权限'); + } + return realPath; + } + throw new ScriptHistoryError(403, '暂无权限'); +} + const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, config.tmpPath); @@ -120,6 +153,82 @@ export default (app: Router) => { }, ); + const historyError = (error: unknown, res: Response, next: NextFunction) => { + if (error instanceof ScriptHistoryError) { + return res.status(error.status).send({ + code: error.status, + message: t(error.message), + ...(error instanceof HistoryUnavailableError + ? { historyUnavailable: true, currentHash: error.currentHash } + : {}), + }); + } + return next(error); + }; + const historyQuery = { + filename: Joi.string().required(), + path: Joi.string().optional().allow(''), + }; + + route.get( + '/history', + celebrate({ query: Joi.object(historyQuery).unknown(true) }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const service = Container.get(ScriptHistoryService); + const data = await service.list( + (req.query.path as string) || '', + req.query.filename as string, + ); + res.send({ code: 200, data }); + } catch (error) { + historyError(error, res, next); + } + }, + ); + route.get( + '/history/detail', + celebrate({ + query: Joi.object({ + ...historyQuery, + id: Joi.string().uuid().required(), + }).unknown(true), + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const service = Container.get(ScriptHistoryService); + const data = await service.detail( + (req.query.path as string) || '', + req.query.filename as string, + req.query.id as string, + ); + res.send({ code: 200, data }); + } catch (error) { + historyError(error, res, next); + } + }, + ); + route.put( + '/history/restore', + celebrate({ + body: Joi.object({ + ...historyQuery, + id: Joi.string().uuid().required(), + expectedHash: Joi.string().hex().length(64).required(), + }), + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const { path = '', filename, id, expectedHash } = req.body; + const service = Container.get(ScriptHistoryService); + const data = await service.restore(path, filename, id, expectedHash); + res.send({ code: 200, data }); + } catch (error) { + historyError(error, res, next); + } + }, + ); + route.get('/:file', (req: Request, res: Response) => { return res.send({ code: 410, @@ -141,6 +250,11 @@ export default (app: Router) => { filename: Joi.string().required(), path: Joi.string().optional().allow(''), content: Joi.string().optional().allow(''), + skipHistory: Joi.boolean().optional(), + expectedHash: Joi.string() + .hex() + .length(64) + .when('skipHistory', { is: true, then: Joi.required() }), originFilename: Joi.string().optional().allow(''), directory: Joi.string().optional().allow(''), file: Joi.string().optional().allow(''), @@ -220,6 +334,36 @@ export default (app: Router) => { return res.send({ code: 403, message: t('暂无权限') }); } const fileExists = await fileExist(filePath); + if (fileExists && resolveFileAccess(config.scriptPath, [filePath])) { + let removeSource = filename !== originFilename; + let backupSource = ''; + if (removeSource) { + const [originRealPath, targetRealPath] = await Promise.all([ + existingWritableRealPath(originFilePath), + existingWritableRealPath(filePath), + ]); + // Aliases of one script are an in-place save. Removing the source + // would also remove the destination behind a target symlink. + removeSource = originRealPath !== targetRealPath; + backupSource = originRealPath; + } + // Save-as removes the source after committing the destination. Keep + // its original content too: destination history only protects the + // file being overwritten, not the source being deleted. + if (removeSource) { + await fs.copyFile( + backupSource, + join(config.bakPath, originFilename.replace(/\//g, '')), + ); + } + const service = Container.get(ScriptHistoryService); + const data = await service.save(path, filename, content, { + skipHistory: req.body.skipHistory, + expectedHash: req.body.expectedHash, + }); + if (removeSource) await rmPath(originFilePath); + return res.send({ code: 200, data }); + } if (fileExists) { await fs.copyFile( originFilePath, @@ -232,7 +376,7 @@ export default (app: Router) => { await writeFileWithLock(filePath, content); return res.send({ code: 200 }); } catch (e) { - return next(e); + return historyError(e, res, next); } }, ); @@ -244,6 +388,11 @@ export default (app: Router) => { filename: Joi.string().required(), path: Joi.string().optional().allow(''), content: Joi.string().required().allow(''), + skipHistory: Joi.boolean().optional(), + expectedHash: Joi.string() + .hex() + .length(64) + .when('skipHistory', { is: true, then: Joi.required() }), }), }), async (req: Request, res: Response, next: NextFunction) => { @@ -261,10 +410,14 @@ export default (app: Router) => { message: t('暂无权限'), }); } - await writeFileWithLock(filePath, content); - return res.send({ code: 200 }); + const service = Container.get(ScriptHistoryService); + const data = await service.save(path || '', filename, content, { + skipHistory: req.body.skipHistory, + expectedHash: req.body.expectedHash, + }); + return res.send({ code: 200, data }); } catch (e) { - return next(e); + return historyError(e, res, next); } }, ); diff --git a/back/services/scriptHistory.ts b/back/services/scriptHistory.ts new file mode 100644 index 00000000..936ffffb --- /dev/null +++ b/back/services/scriptHistory.ts @@ -0,0 +1,12 @@ +import path from 'path'; +import { Service } from 'typedi'; +import config from '../config'; +import { ScriptHistory } from '../shared/scriptHistory'; + +/** Panel configuration and lifecycle; the storage core stays independently testable. */ +@Service() +export default class ScriptHistoryService extends ScriptHistory { + constructor() { + super(config.scriptPath, path.join(config.dataPath, 'script-history')); + } +} diff --git a/back/shared/scriptHistory.ts b/back/shared/scriptHistory.ts new file mode 100644 index 00000000..c74e7ace --- /dev/null +++ b/back/shared/scriptHistory.ts @@ -0,0 +1,467 @@ +import * as fs from 'fs/promises'; +import { createReadStream } from 'fs'; +import path from 'path'; +import { createHash, randomUUID } from 'crypto'; +import { promisify } from 'util'; +import { brotliCompress, brotliDecompress, constants } from 'zlib'; +import { lock } from 'proper-lockfile'; +import { resolveFileAccess } from './fileAccess'; +import { getUniqueLockPath } from './utils'; + +const compress = promisify(brotliCompress); +const decompress = promisify(brotliDecompress); +const hash = (content: string) => + createHash('sha256').update(content).digest('hex'); + +export const SCRIPT_HISTORY_LIMITS = { + versions: 20, + fileBytes: 1024 * 1024, + unpackedBytes: 8 * 1024 * 1024, + archiveBytes: 2 * 1024 * 1024, + totalBytes: 32 * 1024 * 1024, + files: 1000, +}; + +type Source = 'baseline' | 'save' | 'restore' | 'external'; +interface Version { + id: string; + createdAt: string; + source: Source; + hash: string; + content: string; +} +interface Archive { + format: 1; + path: string; + versions: Version[]; + head?: { hash: string; createdAt: string; source: Source }; +} + +export class ScriptHistoryError extends Error { + constructor(public status: number, message: string) { + super(message); + } +} + +export class HistoryUnavailableError extends ScriptHistoryError { + constructor(public currentHash: string) { + super(413, '历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本'); + } +} +interface SaveOptions { + skipHistory?: boolean; + expectedHash?: string; +} + +/** Lazy, bounded storage: no watcher, database, daemon, or in-memory cache. + * Full snapshots share a Brotli window, so similar revisions compress together + * without a patch chain. All mutations use the existing script lock and a + * cross-process archive lock. History is durable before overwriting the script. */ +export class ScriptHistory { + constructor( + private scriptRoot: string, + private historyRoot: string, + private blacklist: string[] = [], + private limits = SCRIPT_HISTORY_LIMITS, + ) {} + + private async target(directory: string, filename: string) { + const candidate = resolveFileAccess( + this.scriptRoot, + [directory, filename], + this.blacklist, + ); + if (!candidate) throw new ScriptHistoryError(403, '暂无权限'); + try { + const file = await fs.realpath(candidate); + const root = await fs.realpath(this.scriptRoot); + const relative = path.relative(root, file); + const key = hash(relative); + return { + file, + relative, + archive: path.join(this.historyRoot, key + '.br'), + }; + } catch (error: any) { + if (error.code === 'ENOENT') + throw new ScriptHistoryError(404, '脚本不存在'); + throw error; + } + } + + private async readText(file: string) { + const handle = await fs.open(file, 'r'); + try { + const stat = await handle.stat(); + if (!stat.isFile()) throw new ScriptHistoryError(400, '请选择脚本文件'); + if (stat.size > this.limits.fileBytes) + throw new ScriptHistoryError( + 413, + '历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本', + ); + // Bounded read even if an external process grows the file after stat. + const bytes = Buffer.alloc(this.limits.fileBytes + 1); + let size = 0; + while (size < bytes.length) { + const { bytesRead } = await handle.read( + bytes, + size, + bytes.length - size, + null, + ); + if (!bytesRead) break; + size += bytesRead; + } + const buffer = bytes.subarray(0, size); + const content = buffer.toString('utf8'); + if ( + size > this.limits.fileBytes || + content.includes('\0') || + !Buffer.from(content).equals(buffer) + ) { + throw new ScriptHistoryError( + 413, + '历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本', + ); + } + return content; + } finally { + await handle.close(); + } + } + + private async readArchive(file: string, relative: string): Promise { + try { + const stat = await fs.lstat(file); + if (!stat.isFile() || stat.size > this.limits.archiveBytes) + throw new Error('Invalid archive'); + const bytes = await decompress(await fs.readFile(file), { + maxOutputLength: this.limits.unpackedBytes, + }); + const archive: Archive = JSON.parse(bytes.toString('utf8')); + if ( + archive.format !== 1 || + archive.path !== relative || + !Array.isArray(archive.versions) || + archive.versions.length > this.limits.versions || + archive.versions.some( + (v) => + typeof v.content !== 'string' || + typeof v.id !== 'string' || + typeof v.createdAt !== 'string' || + !['baseline', 'save', 'restore', 'external'].includes(v.source) || + Buffer.byteLength(v.content) > this.limits.fileBytes || + hash(v.content) !== v.hash, + ) + ) { + throw new Error('Invalid archive'); + } + return archive; + } catch (error: any) { + if (error.code === 'ENOENT') + return { format: 1, path: relative, versions: [] }; + throw new ScriptHistoryError(500, '历史版本读取失败,请检查历史文件'); + } + } + + async list(directory: string, filename: string) { + const target = await this.target(directory, filename); + const [current, archive] = await Promise.all([ + this.readText(target.file), + this.readArchive(target.archive, target.relative), + ]); + const currentHash = hash(current); + const versions = archive.versions + .slice() + .reverse() + .map(({ content, ...v }) => ({ + ...v, + size: Buffer.byteLength(content), + identical: v.hash === currentHash, + })); + return { versions, currentHash, limit: this.limits.versions }; + } + + async detail(directory: string, filename: string, id: string) { + const target = await this.target(directory, filename); + const [current, archive] = await Promise.all([ + this.readText(target.file), + this.readArchive(target.archive, target.relative), + ]); + const version = archive.versions.find((v) => v.id === id); + if (!version) throw new ScriptHistoryError(404, '历史版本不存在或已清理'); + return { version, current, currentHash: hash(current) }; + } + + async save( + directory: string, + filename: string, + content: string, + options: SaveOptions = {}, + ) { + return this.mutate(directory, filename, { content, ...options }); + } + + async restore( + directory: string, + filename: string, + id: string, + expectedHash: string, + ) { + return this.mutate(directory, filename, { id, expectedHash }); + } + + private async mutate( + directory: string, + filename: string, + change: + | ({ content: string } & SaveOptions) + | { id: string; expectedHash: string }, + ) { + const target = await this.target(directory, filename); + const releaseFile = await lock(target.file, { + lockfilePath: getUniqueLockPath(target.file), + retries: { retries: 20, minTimeout: 50, maxTimeout: 250 }, + }); + try { + let current: string; + try { + current = await this.readText(target.file); + if ( + 'content' in change && + (Buffer.byteLength(change.content) > this.limits.fileBytes || + change.content.includes('\0')) + ) { + throw new ScriptHistoryError( + 413, + '历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本', + ); + } + } catch (error) { + if ( + !('content' in change) || + !(error instanceof ScriptHistoryError) || + error.status !== 413 + ) + throw error; + // Hash oversized or non-UTF-8 files without reading them all into memory. + const digest = createHash('sha256'); + for await (const chunk of createReadStream(target.file)) + digest.update(chunk); + const currentHash = digest.digest('hex'); + if (!change.skipHistory) throw new HistoryUnavailableError(currentHash); + if (change.expectedHash !== currentHash) + throw new ScriptHistoryError(409, '文件已变化,请刷新历史版本后重试'); + await this.preparedWrite(target.file, change.content, (commit) => + commit(), + ); + return { + content: change.content, + changed: true, + historyRecorded: false, + }; + } + if ( + ('id' in change || change.skipHistory) && + hash(current) !== change.expectedHash + ) { + throw new ScriptHistoryError(409, '文件已变化,请刷新历史版本后重试'); + } + if ('content' in change && change.content === current) + return { content: current, changed: false }; + await fs.mkdir(this.historyRoot, { recursive: true, mode: 0o700 }); + const releaseHistory = await lock(this.historyRoot, { + retries: { retries: 20, minTimeout: 50, maxTimeout: 250 }, + }); + try { + const archive = await this.readArchive(target.archive, target.relative); + const restored = + 'id' in change + ? archive.versions.find((v) => v.id === change.id) + : undefined; + if ('id' in change && !restored) + throw new ScriptHistoryError(404, '历史版本不存在或已清理'); + const content = + 'content' in change ? change.content : restored!.content; + if (content === current) return { content, changed: false }; + if ( + Buffer.byteLength(content) > this.limits.fileBytes || + content.includes('\0') + ) { + throw new ScriptHistoryError( + 413, + '历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本', + ); + } + const currentHash = hash(current); + if ( + archive.versions[archive.versions.length - 1]?.hash !== currentHash + ) { + const head = + archive.head?.hash === currentHash ? archive.head : undefined; + archive.versions.push({ + id: randomUUID(), + createdAt: head?.createdAt || new Date().toISOString(), + source: + head?.source || + (archive.versions.length ? 'external' : 'baseline'), + hash: currentHash, + content: current, + }); + } + // Only prior contents are stored. Head metadata labels the next snapshot, + // but is used only if its hash matches the file. A failed file write can + // never introduce a phantom content version or lose the old snapshot. + archive.head = { + hash: hash(content), + createdAt: new Date().toISOString(), + source: 'id' in change ? 'restore' : 'save', + }; + const packed = await this.pack(archive); + const cleanupPending = await this.preparedWrite( + target.file, + content, + (commit) => this.store(target.archive, packed, commit), + ); + return { + content, + changed: true, + historyRecorded: true, + cleanupPending, + }; + } finally { + await releaseHistory(); + } + } finally { + await releaseFile(); + } + } + + private async pack(archive: Archive) { + archive.versions = archive.versions.slice(-this.limits.versions); + while (true) { + const serialized = Buffer.from(JSON.stringify(archive)); + if (serialized.length <= this.limits.unpackedBytes) { + const compressed = await compress(serialized, { + params: { + [constants.BROTLI_PARAM_QUALITY]: 4, + [constants.BROTLI_PARAM_LGWIN]: 22, + }, + }); + if (compressed.length <= this.limits.archiveBytes) return compressed; + } + if (archive.versions.length <= 1) + throw new ScriptHistoryError(413, '历史版本容量不足,无法安全保存'); + archive.versions.shift(); + } + } + + /** Stage the script before publishing history. A failed write/fsync never + * truncates the original. Preserve the existing file's ownership and mode. */ + private async preparedWrite( + file: string, + content: string, + run: (commit: () => Promise) => Promise, + ): Promise { + const stat = await fs.stat(file); + const temporary = path.join( + path.dirname(file), + `.ql-save-${randomUUID()}.tmp`, + ); + try { + await fs.writeFile(temporary, content, { + encoding: 'utf8', + mode: 0o600, + flag: 'wx', + }); + if ( + typeof process.getuid === 'function' && + typeof process.getgid === 'function' && + (stat.uid !== process.getuid() || stat.gid !== process.getgid()) + ) { + await fs.chown(temporary, stat.uid, stat.gid); + } + await fs.chmod(temporary, stat.mode & 0o7777); + const handle = await fs.open(temporary, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } + return await run(() => fs.rename(temporary, file)); + } finally { + await fs.unlink(temporary).catch(() => undefined); + } + } + + private async store( + file: string, + content: Buffer, + commit: () => Promise, + ) { + const entries = []; + for (const name of await fs.readdir(this.historyRoot)) { + if (!/^[a-f0-9]{64}\.br$/.test(name)) continue; + const fullPath = path.join(this.historyRoot, name); + if (fullPath === file) continue; + const stat = await fs.lstat(fullPath); + entries.push({ file: fullPath, size: stat.size, time: stat.mtimeMs }); + } + entries.sort((a, b) => a.time - b.time); + let total = entries.reduce((sum, v) => sum + v.size, content.length); + const victims: string[] = []; + while ( + entries.length && + (total > this.limits.totalBytes || entries.length >= this.limits.files) + ) { + const oldest = entries.shift()!; + victims.push(oldest.file); + total -= oldest.size; + } + if (total > this.limits.totalBytes) + throw new ScriptHistoryError(413, '历史版本容量不足,无法安全保存'); + const previous = await fs.readFile(file).catch((error) => { + if (error.code === 'ENOENT') return undefined; + throw error; + }); + const temporary = path.join(this.historyRoot, '.pending.tmp'); + const publish = async (bytes: Buffer) => { + await fs.unlink(temporary).catch((error) => { + if (error.code !== 'ENOENT') throw error; + }); + await fs.writeFile(temporary, bytes, { mode: 0o600, flag: 'wx' }); + const handle = await fs.open(temporary, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } + await fs.rename(temporary, file); + }; + try { + await publish(content); + try { + await commit(); + } catch (error) { + // The original script is still intact. Restore its exact history before + // returning an error; no unrelated archive has been removed yet. + if (previous) await publish(previous); + else await fs.unlink(file); + throw error; + } + // Quota eviction is post-commit maintenance. Report cleanup failures as a + // warning, not a failed save (which would encourage a misleading retry). + let cleanupPending = false; + for (const victim of victims) { + try { + await fs.unlink(victim); + } catch (error: any) { + if (error.code !== 'ENOENT') cleanupPending = true; + } + } + return cleanupPending; + } finally { + await fs.unlink(temporary).catch(() => undefined); + } + } +} diff --git a/back/shared/utils.ts b/back/shared/utils.ts index fb344111..6760f4f9 100644 --- a/back/shared/utils.ts +++ b/back/shared/utils.ts @@ -4,7 +4,7 @@ import path from 'path'; import { writeFile, open, chmod } from 'fs/promises'; import { fileExist } from '../config/util'; -function getUniqueLockPath(filePath: string) { +export function getUniqueLockPath(filePath: string) { const sanitizedPath = filePath .replace(/[<>:"/\\|?*]/g, '_') .replace(/^_/, ''); diff --git a/cli/skills/qinglong-cli/references/openapi.md b/cli/skills/qinglong-cli/references/openapi.md index 29a1aa91..e6c25c80 100644 --- a/cli/skills/qinglong-cli/references/openapi.md +++ b/cli/skills/qinglong-cli/references/openapi.md @@ -1,8 +1,8 @@ # OpenAPI 全量参考 / Complete OpenAPI reference -CLI 对应当前 develop 的 143 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。 +CLI 对应本版本后端的 146 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。 -The catalogue covers 143 active routes in the current develop backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes. +The catalogue covers 146 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes. ## 认证与权限 / Authentication and permissions @@ -46,6 +46,9 @@ ql env create --data @envs.json --json ql env update 8 --data '{"name":"EXAMPLE","value":"value"}' --json ql script create --file ./demo.js --data '{"filename":"demo.js","path":""}' --json ql script get --query '{"file":"demo.js","path":""}' --json +ql script history-list --query '{"filename":"demo.js","path":""}' --json +ql script history-detail --query '{"filename":"demo.js","path":"","id":"VERSION_UUID"}' --json +ql script history-restore --data '{"filename":"demo.js","path":"","id":"VERSION_UUID","expectedHash":"CURRENT_SHA256"}' --json ql config save --data '{"name":"example.sh","content":"# example"}' --json ql config get --query '{"path":"example.sh"}' --json ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --json @@ -133,6 +136,9 @@ System/user operations below execute remotely through the panel API. They do not | `ql config save ` | POST | `configs/save` | --data, --query | | `ql script list ` | GET | `scripts` | --query | | `ql script get ` | GET | `scripts/detail` | --query | +| `ql script history-list ` | GET | `scripts/history` | --query | +| `ql script history-detail ` | GET | `scripts/history/detail` | --query | +| `ql script history-restore ` | PUT | `scripts/history/restore` | --data, --query | | `ql script create ` | POST | `scripts` | --data, --file (file), --query | | `ql script update ` | PUT | `scripts` | --data, --file (file), --query | | `ql script delete ` | DELETE | `scripts` | --data, --query | diff --git a/cli/src/remote/api/openOperations.ts b/cli/src/remote/api/openOperations.ts index e8d968a2..56f5fad9 100644 --- a/cli/src/remote/api/openOperations.ts +++ b/cli/src/remote/api/openOperations.ts @@ -275,6 +275,18 @@ export const openOperations: readonly OpenOperation[] = [ { name: 'config save', method: 'POST', path: 'configs/save', body: 'json' }, { name: 'script list', method: 'GET', path: 'scripts' }, { name: 'script get', method: 'GET', path: 'scripts/detail' }, + { name: 'script history-list', method: 'GET', path: 'scripts/history' }, + { + name: 'script history-detail', + method: 'GET', + path: 'scripts/history/detail', + }, + { + name: 'script history-restore', + method: 'PUT', + path: 'scripts/history/restore', + body: 'json', + }, { name: 'script create', method: 'POST', diff --git a/cli/test/remote/openapi.test.cjs b/cli/test/remote/openapi.test.cjs index e36b893c..8c872cbc 100644 --- a/cli/test/remote/openapi.test.cjs +++ b/cli/test/remote/openapi.test.cjs @@ -58,6 +58,31 @@ test('dashboard record sends execution statistics and rejects a missing body bef assert.equal(f.requests.length, 1); }); +test('script history commands send exact query parameters and restore payloads', async t => { + const f = await fixture(t); + const identity = { filename: '中文 script.js', path: 'nested folder' }; + const version = { ...identity, id: '32ec8b22-2c48-4bcf-85d4-b394198417ba' }; + for (const [command, endpoint, query] of [ + ['history-list', 'scripts/history', identity], + ['history-detail', 'scripts/history/detail', version], + ]) { + success(await f.run(['script', command, '--query', JSON.stringify(query)])); + const req = f.requests.at(-1), url = new URL(req.url, 'http://localhost'); + assert.equal(req.method, 'GET'); + assert.equal(url.pathname, '/panel/open/' + endpoint); + for (const [key, value] of Object.entries(query)) assert.equal(url.searchParams.get(key), value); + assert.equal(req.body, ''); + } + const payload = { ...version, expectedHash: 'a'.repeat(64) }; + success(await f.run(['script', 'history-restore', '--data', '-'], JSON.stringify(payload))); + assert.equal(f.requests.at(-1).method, 'PUT'); + assert.equal(f.requests.at(-1).url, '/panel/open/scripts/history/restore'); + assert.deepEqual(JSON.parse(f.requests.at(-1).body), payload); + const count = f.requests.length; + assert.equal((await f.run(['script', 'history-restore'])).code, 2); + assert.equal(f.requests.length, count, 'missing restore payload must not issue a request'); +}); + test('OpenAPI catalogue covers every active registered backend route; retired 410 routes are explicit', async () => { const root = path.resolve(__dirname, '../../../back/api'); const expected = []; diff --git a/docs/script-history.md b/docs/script-history.md new file mode 100644 index 00000000..5c058ac0 --- /dev/null +++ b/docs/script-history.md @@ -0,0 +1,53 @@ +# 脚本编辑历史 + +脚本管理工具栏的“历史版本”打开右侧抽屉。选择版本后,对比历史内容与服务器上当前已保存文件;桌面使用双栏对比,手机使用行内对比。恢复需要确认,有未保存修改时必须先保存或取消。恢复前保留当前版本,因此恢复操作也可以撤销。普通保存不再弹出不可恢复确认。 + +## 记录范围 + +- 编辑器 `PUT /api/scripts` 和调试窗口 `POST /api/scripts` 覆盖已有脚本时,在内容变化时留档,首次修改前的内容也保留;相同内容不写历史。 +- 历史只包含保存前真实存在的内容;不把失败写入当成一个内容版本。 +- 上传、新增、重命名、删除、订阅更新和外部编辑不主动留档。若下次面板保存时检测到外部变更,会保留那一刻的内容,标记为“面板外修改后”。这不代表外部修改之前的内容受到保护。 +- 按脚本规范化后的相对路径隔离历史;不同目录同名文件不会混淆,指向同一脚本的安全符号链接共用历史。重命名后不会自动迁移历史。调试器另存为覆盖已有脚本时,目标旧内容进入历史,删除源文件前将其旧内容保留到现有 bak 目录;备份失败则不覆盖目标、不删除源文件。源和目标实际指向同一脚本时(如安全符号链接或不同路径写法),按原地保存处理,保留两个路径,不删除源文件。历史不能用于回收已删除的文件。 +- 历史记录支持至多 1 MiB 的 UTF-8 文本文件(含空文件)。当前文件或新内容超出范围时,界面要求明确确认“不记录历史直接保存”;取消则保持文件和草稿不变。确认后以 `skipHistory: true` 和服务端返回的当前哈希重试,锁内检查哈希以防确认期间覆盖他人修改。成功后明确提示本次未留档。大文件可以通过此流程缩小,后续恢复普通留档。未确认的 API 请求返回 413,并包含 `historyUnavailable` 和 `currentHash`;历史读取损坏等其他错误不允许用此选项绕过。 + +## 存储与资源边界 + +位置:`/script-history/.br`。目录位于脚本目录之外,避免进入脚本树、订阅和任务扫描。目录权限 `0700`,历史文件权限 `0600`。目录随整个数据目录备份。 + +每个脚本仅一个 Brotli 压缩 JSON 包,存储完整的旧内容快照以及时间、来源、内容哈希。新文件内容不再重复存储,只保存头版本元数据,供下一次留档标注来源。面板服务通过 TypeDI 注册为单例,路由使用 `Container.get(ScriptHistoryService)` 获取;构造实例只初始化配置,首次发生保存时才创建历史目录。包内重复文本共享压缩窗口,不依赖补丁链,也不需要额外数据库、文件监听、后台任务或常驻缓存。使用 Node 内置 zlib 异步压缩,Brotli quality=4、window=4 MiB,不新增依赖。 + +| 限制 | 默认值 | +| --- | --- | +| 每脚本历史版本 | 最多 20 个 | +| 单个脚本内容 | 1 MiB | +| 单包解压后的 JSON | 8 MiB | +| 单个压缩历史包 | 2 MiB | +| 所有压缩历史包合计 | 32 MiB | +| 有历史的脚本数 | 最多 1000 个 | +| 历史写入暂存文件 | 共享一个,最多 2 MiB | +| 脚本写入暂存文件 | 每次保存一个,与本次保存内容等大,结束后清理 | + +达到单文件限制时删除最旧版本,至少保留本次覆盖前的内容;无法保留则拒绝覆盖。达到全局限制时,在脚本成功提交后优先清理最久未更新的脚本历史包(该文件的整份历史),不删除脚本本身。历史暂存、提交或脚本提交失败时不清理任何其他历史;脚本提交失败会回滚本文件历史。如果提交成功但清理失败,返回 `cleanupPending`,界面提示检查存储权限和空间,后续保存重新尝试清理。配额是正常清理成功后的目标,事务期间和清理失败时会暂时超出。32 MiB 是文件内容字节数,不包含文件系统块分配、目录和锁的开销。清理只在发生保存时执行,通常最多扫描 1000 个包的元数据(清理失败期间可能更多),不解压其他脚本的历史。读取仅解压当前脚本,限制解压输出长度。 + +文件保存复用现有锁路径,同时以独立跨进程目录锁串行化历史写入和配额清理;`finally` 释放锁。先准备并同步脚本暂存文件,保留文件权限和所有者;随后写并同步历史暂存文件、原子提交历史,再原子替换脚本。任一步准备失败不会截断原脚本;最后替换脚本失败时还原此前历史;成功后才清理配额。超限确认保存不创建新历史包,使用同样的脚本暂存和替换方式,并流式计算旧文件哈希。外部进程不遵守该锁时,不承诺与其并发写入的事务一致性。 + +恢复请求携带预览时的当前内容哈希,锁内检查后才写入;文件变化则返回 409。历史读写与脚本读取采用相同的路径和符号链接边界检查。面板配置目录中的敏感文件仍受黑名单保护;脚本目录中的 auth.json、token.json 等普通脚本名称可以正常保存和留档。 + +## 压缩样本 + +运行 `node scripts/benchmark-script-history.cjs` 可复现比较。模拟同一脚本连续 20 次小修改,比较独立 gzip、合并 gzip 与合并 Brotli。首次本机测量: + +| 样本 | 独立 gzip | 合并 gzip | 合并 Brotli(quality 4) | +| --- | ---: | ---: | ---: | +| sample/notify.js | 242,430 B | 221,832 B | 13,930 B | +| shell/task.sh | 30,159 B | 2,317 B | 1,737 B | + +这是文本重复度较高的模拟样本,不是所有工作负载的压缩保证。Brotli 合包特别适合大小超过 gzip 32 KiB 窗口、但每次只改少量内容的脚本。增量补丁可能在部分样本更小,但增加补丁生成、链式恢复、裁剪和损坏恢复的复杂度;本实现选择压缩合包与硬上限,在磁盘、CPU 和可靠性之间取平衡。 + +## API + +- `GET /api/scripts/history?filename=...&path=...`:版本元数据、当前哈希、保留数量。 +- `GET /api/scripts/history/detail?filename=...&path=...&id=...`:历史内容、当前内容及当前哈希。 +- `PUT /api/scripts/history/restore`:`{filename, path, id, expectedHash}`。 + +沿用现有 scripts 路由的认证与权限。历史 ID 使用 UUID;不存在或已清理返回 404,文件已变更返回 409,文本或容量限制返回 413。 diff --git a/scripts/benchmark-script-history.cjs b/scripts/benchmark-script-history.cjs new file mode 100644 index 00000000..4288952f --- /dev/null +++ b/scripts/benchmark-script-history.cjs @@ -0,0 +1,39 @@ +// Reproducible storage comparison, without touching actual script history. +const fs = require('node:fs'); +const path = require('node:path'); +const zlib = require('node:zlib'); +for (const file of ['sample/notify.js', 'shell/task.sh']) { + const source = fs.readFileSync(path.join(__dirname, '..', file), 'utf8'); + const versions = Array.from({ length: 20 }, (_, i) => ({ + content: source + '\n// edit ' + i, + })); + const raw = Buffer.from(JSON.stringify(versions)); + const strategies = { + gzipSeparate: () => + versions.reduce( + (sum, version) => sum + zlib.gzipSync(version.content).length, + 0, + ), + gzipBundle: () => zlib.gzipSync(raw).length, + brotliBundle: () => + zlib.brotliCompressSync(raw, { + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: 4, + [zlib.constants.BROTLI_PARAM_LGWIN]: 22, + }, + }).length, + }; + for (const [strategy, compress] of Object.entries(strategies)) { + const start = performance.now(); + const bytes = compress(); + console.log( + JSON.stringify({ + file, + strategy, + rawBytes: raw.length, + bytes, + ms: +(performance.now() - start).toFixed(2), + }), + ); + } +} diff --git a/src/locales/en-US.json b/src/locales/en-US.json index e19986f7..95d90d7a 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -665,5 +665,33 @@ "重试": "Retry", "WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs": "WPUSH API Key from https://wpush.cn/settings, see https://wpush.cn/docs", "推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat": "Channel: wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot, default wechat", - "可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "Optional Topic code for broadcast; see https://wpush.cn/docs" + "可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "Optional Topic code for broadcast; see https://wpush.cn/docs", + "历史版本": "Version history", + "首次修改前": "Before the first edit", + "手动保存": "Manual save", + "从历史版本恢复": "Restored version", + "面板外修改后": "After an external edit", + "恢复此版本": "Restore this version", + "当前已保存的内容会保留在历史版本中。": "The currently saved content will be kept in version history.", + "请先保存或取消当前修改,再恢复历史版本": "Save or discard your unsaved changes before restoring a version", + "已恢复,恢复前的内容已保留": "Restored. The previous content is kept in version history", + "最多保留 {count} 个历史版本,容量不足时自动清理旧历史。": "Keep up to {count} previous versions. Older history is removed when storage is full.", + "仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。": "Only panel saves are recorded. Subscription updates may still overwrite restored files.", + "暂无历史版本,下次保存修改时会自动保留当前内容": "No versions yet. Your current content will be kept when you next save changes.", + "选择历史版本": "Choose a version", + "历史版本加载失败,请刷新重试": "Unable to load this version. Refresh and try again.", + "当前已保存文件": "Currently saved file", + "与当前文件一致": "Identical to the current file", + "历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本": "Version history supports UTF-8 text scripts up to 1 MiB", + "历史版本读取失败,请检查历史文件": "Unable to read version history. Check the history file.", + "历史版本不存在或已清理": "This version does not exist or has been removed", + "文件已变化,请刷新历史版本后重试": "The file has changed. Refresh version history and try again.", + "历史版本容量不足,无法安全保存": "Not enough version history capacity to save safely", + "脚本不存在": "The script does not exist", + "不记录历史直接保存?": "Save without version history?", + "当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。": "The current file or new content is outside the history limits. The content being replaced will not be kept.", + "仍然保存": "Save anyway", + "已保存,本次未记录历史版本": "Saved without a history snapshot", + "已保存,但旧历史清理失败,请检查存储权限和空间": "Saved, but old history could not be removed. Check storage permissions and free space.", + "保存失败": "Unable to save" } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 0296e618..daafeb5b 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -665,5 +665,33 @@ "重试": "重试", "WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs": "WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs", "推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat": "推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat", - "可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs" + "可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs", + "历史版本": "历史版本", + "首次修改前": "首次修改前", + "手动保存": "手动保存", + "从历史版本恢复": "从历史版本恢复", + "面板外修改后": "面板外修改后", + "恢复此版本": "恢复此版本", + "当前已保存的内容会保留在历史版本中。": "当前已保存的内容会保留在历史版本中。", + "请先保存或取消当前修改,再恢复历史版本": "请先保存或取消当前修改,再恢复历史版本", + "已恢复,恢复前的内容已保留": "已恢复,恢复前的内容已保留", + "最多保留 {count} 个历史版本,容量不足时自动清理旧历史。": "最多保留 {count} 个历史版本,容量不足时自动清理旧历史。", + "仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。": "仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。", + "暂无历史版本,下次保存修改时会自动保留当前内容": "暂无历史版本,下次保存修改时会自动保留当前内容", + "选择历史版本": "选择历史版本", + "历史版本加载失败,请刷新重试": "历史版本加载失败,请刷新重试", + "当前已保存文件": "当前已保存文件", + "与当前文件一致": "与当前文件一致", + "历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本": "历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本", + "历史版本读取失败,请检查历史文件": "历史版本读取失败,请检查历史文件", + "历史版本不存在或已清理": "历史版本不存在或已清理", + "文件已变化,请刷新历史版本后重试": "文件已变化,请刷新历史版本后重试", + "历史版本容量不足,无法安全保存": "历史版本容量不足,无法安全保存", + "脚本不存在": "脚本不存在", + "不记录历史直接保存?": "不记录历史直接保存?", + "当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。": "当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。", + "仍然保存": "仍然保存", + "已保存,本次未记录历史版本": "已保存,本次未记录历史版本", + "已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间", + "保存失败": "保存失败" } diff --git a/src/pages/script/historyDrawer.module.less b/src/pages/script/historyDrawer.module.less new file mode 100644 index 00000000..68231440 --- /dev/null +++ b/src/pages/script/historyDrawer.module.less @@ -0,0 +1,48 @@ +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + :global(.ant-typography) { + min-width: 0; + overflow-wrap: anywhere; + } + :global(.ant-btn) { + flex-shrink: 0; + } +} +.labels { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + font-weight: 500; +} +.diff { + flex: 1; + min-height: 260px; + overflow: auto; + border: 1px solid var(--ant-border-color-split, #d9d9d9); +} +@media (max-width: 600px) { + .footer { + flex-direction: column; + align-items: stretch; + gap: 8px; + :global(.ant-btn) { + width: 100%; + height: auto; + min-height: 32px; + white-space: normal; + overflow-wrap: anywhere; + } + } + .labels { + gap: 8px; + font-size: 12px; + } + .diff { + flex: none; + min-height: 200px; + border: 0; + } +} diff --git a/src/pages/script/historyDrawer.tsx b/src/pages/script/historyDrawer.tsx new file mode 100644 index 00000000..fdfc8cf3 --- /dev/null +++ b/src/pages/script/historyDrawer.tsx @@ -0,0 +1,333 @@ +import { useEffect, useRef, useState } from 'react'; +import { + Alert, + Button, + Drawer, + Empty, + Modal, + Select, + Space, + Spin, + Typography, + message, +} from 'antd'; +import { DiffEditor } from '@monaco-editor/react'; +import ReactDiffViewer from 'react-diff-viewer'; +import intl from 'react-intl-universal'; +import config from '@/utils/config'; +import { request } from '@/utils/http'; +import styles from './historyDrawer.module.less'; + +interface Version { + id: string; + createdAt: string; + source: 'baseline' | 'save' | 'restore' | 'external'; + identical: boolean; +} +interface Detail { + version: Version & { content: string }; + current: string; + currentHash: string; +} +interface Props { + filename: string; + directory: string; + isPhone: boolean; + theme: string; + language: string; + hasUnsavedChanges: () => boolean; + onClose: () => void; + onRestored: (content: string) => void; +} + +const sourceLabel = (source: Version['source']) => + intl.get( + { + baseline: '首次修改前', + save: '手动保存', + restore: '从历史版本恢复', + external: '面板外修改后', + }[source], + ); +const versionLabel = (v: Version) => + `${new Date(v.createdAt).toLocaleString()} · ${sourceLabel(v.source)}`; + +export default function HistoryDrawer(props: Props) { + const { + filename, + directory, + isPhone, + theme, + language, + onClose, + onRestored, + hasUnsavedChanges, + } = props; + const [versions, setVersions] = useState([]); + const [selected, setSelected] = useState(); + const [detail, setDetail] = useState(); + const [loading, setLoading] = useState(true); + const [detailLoading, setDetailLoading] = useState(false); + const [restoring, setRestoring] = useState(false); + const [error, setError] = useState(''); + const [detailError, setDetailError] = useState(false); + const [limit, setLimit] = useState(20); + const active = useRef(true); + const detailRequest = useRef(0); + const params = { filename, path: directory }; + + const load = async () => { + setLoading(true); + setError(''); + setDetail(undefined); + setSelected(undefined); + detailRequest.current++; + try { + const result = await request.get(`${config.apiPrefix}scripts/history`, { + params, + }); + if (result.code !== 200) throw new Error(result.message); + if (!active.current) return; + setVersions(result.data.versions); + setLimit(result.data.limit); + setSelected(result.data.versions[0]?.id); + } catch (e: any) { + if (active.current) + setError(e?.response?.data?.message || intl.get('加载失败')); + } finally { + if (active.current) setLoading(false); + } + }; + + const loadDetail = async (id: string) => { + const sequence = ++detailRequest.current; + setDetail(undefined); + setDetailLoading(true); + setDetailError(false); + try { + const result = await request.get( + `${config.apiPrefix}scripts/history/detail`, + { params: { ...params, id } }, + ); + if (result.code !== 200) throw new Error(result.message); + if (active.current && sequence === detailRequest.current) + setDetail(result.data); + } catch { + if (active.current && sequence === detailRequest.current) + setDetailError(true); + } finally { + if (active.current && sequence === detailRequest.current) + setDetailLoading(false); + } + }; + + useEffect(() => { + active.current = true; + load(); + return () => { + active.current = false; + detailRequest.current++; + }; + }, []); + useEffect(() => { + if (selected) loadDetail(selected); + }, [selected]); + + const restore = () => { + if (!detail) return; + if (hasUnsavedChanges()) { + message.warning(intl.get('请先保存或取消当前修改,再恢复历史版本')); + return; + } + Modal.confirm({ + title: intl.get('恢复此版本'), + content: ( + <> +

{versionLabel(detail.version)}

+

{intl.get('当前已保存的内容会保留在历史版本中。')}

+ + ), + okText: intl.get('恢复此版本'), + onOk: async () => { + if (hasUnsavedChanges()) { + message.warning(intl.get('请先保存或取消当前修改,再恢复历史版本')); + throw new Error('Unsaved changes'); + } + setRestoring(true); + try { + const result = await request.put( + `${config.apiPrefix}scripts/history/restore`, + { + ...params, + id: detail.version.id, + expectedHash: detail.currentHash, + }, + ); + if (result.code !== 200) throw new Error(result.message); + if (!active.current) return; + onRestored(result.data.content); + if (result.data.cleanupPending) { + message.warning( + intl.get('已保存,但旧历史清理失败,请检查存储权限和空间'), + ); + } else { + message.success(intl.get('已恢复,恢复前的内容已保留')); + } + await load(); + } catch (e: any) { + if (e?.response?.status === 409) { + setDetail(undefined); + setDetailError(true); + } + throw e; + } finally { + if (active.current) setRestoring(false); + } + }, + }); + }; + const identical = detail?.version.content === detail?.current; + + return ( + + {intl.get('历史版本')} + + {directory ? `${directory}/` : ''} + {filename} + + + } + open + width={isPhone ? '100%' : 'min(1100px, 90vw)'} + onClose={restoring ? undefined : onClose} + closable={!restoring} + maskClosable={!restoring} + keyboard={!restoring} + bodyStyle={{ + display: 'flex', + flexDirection: 'column', + gap: 16, + padding: isPhone ? 16 : 24, + }} + footer={ +
+ + {intl.get( + '最多保留 {count} 个历史版本,容量不足时自动清理旧历史。', + { count: limit }, + )} + + +
+ } + > + + {loading ? ( + + ) : error ? ( + {intl.get('重试')}} + /> + ) : versions.length === 0 ? ( + + ) : ( + <> +