反馈改右上角轻提示;侧栏加撤回重做等配置动作

设置页的横幅换成 toast(右滑入、几秒后上移淡出,web/toast.ts 单例):
动作结果看一眼就行,不值得在页面里占一块常驻位置。表单校验类的错误
仍留在各自表单里。

撤回栈就是备份链:撤回把最新备份应用回去并消费掉它,撤回前的状态进
重做栈(.patchbay-redo,同样留 10 份);重做反向;新的正常保存清空
重做栈,不留分叉历史。这两条路复用 applyConfig 的保护链路,用
restoreFrom 参数跳过自建备份——否则备份链会被撤回本身污染,撤两次
等于原地打转。侧栏左下角一排图标:撤回、重做、历史记录(备份列表
弹窗)、更新配置(POST /api/kernel/reload,给 SSH 手改后用)、重启
内核;收拢时容器变窄自动折成一列。

设置页顺手修三处:GEO 行的编辑按钮在输入框开着时再点会闪(blur 关了
click 又开),mousedown.prevent 保焦点、click 显式开关;上传按钮改
「选择或拖放文件」,投放区拖入高亮;恢复策略挪到恢复行下面。
This commit is contained in:
2026-08-12 01:59:22 +08:00
parent 2580c03bfa
commit f761d13579
9 changed files with 798 additions and 106 deletions
+52 -1
View File
@@ -2,7 +2,16 @@ import { afterAll, describe, expect, it } from 'bun:test'
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { applyConfig, importConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
import {
applyConfig,
importConfig,
listBackups,
listRedo,
readConfig,
redoConfig,
restoreBackup,
undoConfig,
} from './config-store.ts'
import type { Env } from './env.ts'
/**
@@ -184,6 +193,48 @@ describe('落盘保护链路(内核在线)', () => {
if (!result.ok) expect(result.message).toContain('没有这个备份')
})
it('撤回回到改动前,重做拿回改动,备份链不被撤回污染', async () => {
const env = makeEnv()
const first = await readConfig(env)
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
const undone = await undoConfig(env)
expect(undone.ok).toBe(true)
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
// 备份被消费、当前状态进了重做栈
expect(await listBackups(env)).toHaveLength(0)
expect(await listRedo(env)).toHaveLength(1)
const redone = await redoConfig(env)
expect(redone.ok).toBe(true)
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7891')
expect(await listRedo(env)).toHaveLength(0)
expect(await listBackups(env)).toHaveLength(1)
})
it('栈空时撤回和重做被拦住,文件不动', async () => {
const env = makeEnv()
const undone = await undoConfig(env)
expect(undone.ok).toBe(false)
if (!undone.ok) expect(undone.message).toContain('没有可以撤回')
const redone = await redoConfig(env)
expect(redone.ok).toBe(false)
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
})
it('新的保存清空重做栈', async () => {
const env = makeEnv()
const first = await readConfig(env)
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
await undoConfig(env)
expect(await listRedo(env)).toHaveLength(1)
const { data, revision } = await readConfig(env)
await applyConfig(env, { ...data, 'mixed-port': 7892 }, revision)
expect(await listRedo(env)).toHaveLength(0)
})
it('备份最多留 10 份,旧的被清掉', async () => {
const env = makeEnv()
const dir = join(env.configPath, '..', '.patchbay-backups')
+102 -2
View File
@@ -106,11 +106,16 @@ export function rewriteYaml(currentYaml: string, nextData: ConfigObject): string
*
* `baseRevision` 是客户端读到这份配置时拿到的指纹。传 undefined 表示
* 「不管别人改没改,以我为准」,仅用于还原备份这类明确要覆盖的场景。
*
* `opts.restoreFrom`:撤回/重做专用。给了它就不再新建备份(否则备份链会被
* 撤回本身污染,撤两次等于原地打转),重载失败时用它当还原源;同时不清空
* 重做栈——只有「正常的新改动」才让已撤回的未来失效。
*/
export async function applyConfig(
env: Env,
nextData: ConfigObject,
baseRevision?: string,
opts?: { restoreFrom?: string },
): Promise<ApplyOutcome> {
const current = await readDocument(env)
@@ -185,8 +190,8 @@ export async function applyConfig(
}
// 校验过了才动真文件。备份放在写入之前。
const backupPath = await createBackup(env)
const backupTiming = lap('备份')
const backupPath = opts?.restoreFrom ?? (await createBackup(env))
const backupTiming = opts?.restoreFrom ? '' : lap('备份')
try {
await writeFile(env.configPath, nextYaml, 'utf8')
} catch (error) {
@@ -204,6 +209,7 @@ export async function applyConfig(
if (!kernel.online) {
// mihomo -t 已经把过关,文件留给内核下次启动时读。
await pruneBackups(env)
if (!opts?.restoreFrom) await clearRedo(env)
console.log(
`[apply] 共 ${String(Date.now() - clock.start)}ms(${checkTiming},${backupTiming},${writeTiming},未重载)`,
)
@@ -240,6 +246,7 @@ export async function applyConfig(
}
await pruneBackups(env)
if (!opts?.restoreFrom) await clearRedo(env)
const timings = [checkTiming, backupTiming, writeTiming, reloadTiming].filter(Boolean).join(',')
console.log(`[apply] 共 ${String(Date.now() - clock.start)}ms(${timings})`)
return {
@@ -343,6 +350,99 @@ export async function importConfig(
return applyConfig(env, merged)
}
/* ── 撤回 / 重做 ──────────────────────────────────
*
* 撤回栈就是备份链:每次成功落盘都留了「改动前」的备份。撤回把最新备份应用
* 回去并**消费掉它**(从链里删除),同时把撤回前的状态推进重做栈;重做反向。
* 任何正常的新改动清空重做栈——和编辑器撤回的通行语义一致,不留分叉历史。
*/
async function redoDir(env: Env): Promise<string> {
const dir = join(dirname(env.configPath), '.patchbay-redo')
await mkdir(dir, { recursive: true })
return dir
}
/** 重做条目按名字排序就是按时间排序。带毫秒:连续快速撤回不能互相覆盖。 */
function redoName(configPath: string, at: Date): string {
const p = (n: number, w = 2) => String(n).padStart(w, '0')
const stamp =
`${String(at.getFullYear())}${p(at.getMonth() + 1)}${p(at.getDate())}` +
`-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}${p(at.getMilliseconds(), 3)}`
return `${basename(configPath)}.redo-${stamp}`
}
/** 新的在前,同 listBackups。 */
export async function listRedo(env: Env): Promise<string[]> {
const dir = await redoDir(env)
const prefix = `${basename(env.configPath)}.redo-`
const entries = await readdir(dir)
return entries.filter((name) => name.startsWith(prefix)).sort().reverse()
}
async function clearRedo(env: Env): Promise<void> {
const dir = await redoDir(env)
const entries = await listRedo(env)
await Promise.all(entries.map((name) => unlink(join(dir, name)).catch(() => {})))
}
/** 当前文件进重做栈,返回落点路径。 */
async function pushRedo(env: Env): Promise<string> {
const dir = await redoDir(env)
const path = join(dir, redoName(env.configPath, new Date()))
await copyFile(env.configPath, path)
// 和备份同一个上限。裁掉的是最「未来」的状态,同备份链裁掉最远的过去
const stale = (await listRedo(env)).slice(KEEP_BACKUPS)
await Promise.all(stale.map((name) => unlink(join(dir, name)).catch(() => {})))
return path
}
/** 撤回最近一次改动:应用最新备份并消费掉它,撤回前的状态进重做栈。 */
export async function undoConfig(env: Env): Promise<ApplyOutcome> {
const [target] = await listBackups(env)
if (!target) {
return {
ok: false,
stage: 'self-check',
message: '没有可以撤回的改动',
hint: '每次保存都会把改动前的状态留进撤回栈,现在栈是空的。',
restored: false,
}
}
const dir = await backupDir(env)
const targetYaml = await readFile(join(dir, target), 'utf8')
const redoPath = await pushRedo(env)
const result = await applyConfig(env, openDocument(targetYaml).data, undefined, {
restoreFrom: redoPath,
})
if (result.ok) await unlink(join(dir, target)).catch(() => {})
else await unlink(redoPath).catch(() => {})
return result
}
/** 重做最近一次被撤回的改动。当前状态回到撤回栈,重做条目被消费。 */
export async function redoConfig(env: Env): Promise<ApplyOutcome> {
const [target] = await listRedo(env)
if (!target) {
return {
ok: false,
stage: 'self-check',
message: '没有可以重做的改动',
hint: '只有撤回过的改动才能重做,新的保存会清空重做栈。',
restored: false,
}
}
const dir = await redoDir(env)
const targetYaml = await readFile(join(dir, target), 'utf8')
const backupPath = await createBackup(env)
const result = await applyConfig(env, openDocument(targetYaml).data, undefined, {
restoreFrom: backupPath,
})
if (result.ok) await unlink(join(dir, target)).catch(() => {})
else await unlink(backupPath).catch(() => {})
return result
}
/** 把某个备份恢复成当前配置。走的是和普通改动同一条保护链路。 */
export async function restoreBackup(env: Env, name: string): Promise<ApplyOutcome> {
const dir = await backupDir(env)
+32 -2
View File
@@ -12,13 +12,23 @@
import { resolve, sep } from 'node:path'
import listenerFields from '../shared/listener-fields.json' with { type: 'json' }
import { isProviderFilePath } from '../shared/provider-files.ts'
import { applyConfig, importConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
import {
applyConfig,
importConfig,
listBackups,
listRedo,
readConfig,
redoConfig,
restoreBackup,
undoConfig,
} from './config-store.ts'
import { geoStatus } from './geo.ts'
import { hostInfo, probeExit } from './host.ts'
import {
kernelVersion,
proxyGet,
proxyWrite,
reloadConfig,
restartKernel,
selectProxy,
updateGeoDatabases,
@@ -181,7 +191,19 @@ function makeRoute(env: Env, token: string) {
}
if (path === '/api/config/backups' && req.method === 'GET') {
return json({ backups: await listBackups(env) })
// redo 是重做栈深度,侧栏用它决定重做按钮亮不亮
return json({ backups: await listBackups(env), redo: (await listRedo(env)).length })
}
// 撤回/重做。走 config-store 的保护链路,备份链的进出由它们自己管
if (path === '/api/config/undo' && req.method === 'POST') {
const result = await undoConfig(env)
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
}
if (path === '/api/config/redo' && req.method === 'POST') {
const result = await redoConfig(env)
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
}
// 配置文件原文,给「下载当前配置」用。带 attachment 让浏览器按下载处理
@@ -269,6 +291,14 @@ function makeRoute(env: Env, token: string) {
: json({ message: upstream.message || '内核拒绝了这次切换' }, 422)
}
// 让内核重新加载磁盘上的配置文件。给 SSH 手改过配置的场景用;运行时动作
if (path === '/api/kernel/reload' && req.method === 'POST') {
const upstream = await reloadConfig(env, env.configPath)
return upstream.ok
? json({ ok: true })
: json({ message: upstream.message || '内核重载失败' }, 502)
}
// GEO 数据库文件的本地状态(大小、更新时间)和生效中的下载地址
if (path === '/api/geo' && req.method === 'GET') {
const snapshot = await readConfig(env)