diff --git a/server/config-store.test.ts b/server/config-store.test.ts index ba567e3..f19f752 100644 --- a/server/config-store.test.ts +++ b/server/config-store.test.ts @@ -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') diff --git a/server/config-store.ts b/server/config-store.ts index 3cf0603..d617634 100644 --- a/server/config-store.ts +++ b/server/config-store.ts @@ -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 { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { const dir = await backupDir(env) diff --git a/server/routes.ts b/server/routes.ts index 44c9d2e..3bec538 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -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) diff --git a/web/App.vue b/web/App.vue index 4bdd093..83bcb45 100644 --- a/web/App.vue +++ b/web/App.vue @@ -15,9 +15,18 @@ import RouterPage from './views/RouterPage.vue' import RulesPage from './views/RulesPage.vue' import SettingsPage from './views/SettingsPage.vue' import TokenGate from './views/TokenGate.vue' +import ToastHost from './components/ToastHost.vue' +import HistoryDialog from './components/HistoryDialog.vue' +import ConfirmDialog from './components/ConfirmDialog.vue' +import { toast, toastError } from './toast' import IconFold from '~icons/ri/sidebar-fold-line' import IconUnfold from '~icons/ri/sidebar-unfold-line' import IconLogout from '~icons/ri/logout-box-r-line' +import IconUndo from '~icons/ri/arrow-go-back-line' +import IconRedo from '~icons/ri/arrow-go-forward-line' +import IconHistory from '~icons/ri/history-line' +import IconReload from '~icons/ri/refresh-line' +import IconRestart from '~icons/ri/restart-line' const snapshot = ref(null) const needsToken = ref(!getToken()) @@ -53,6 +62,8 @@ async function load() { await reloadRuntime() snapshot.value = await api.readConfig() needsToken.value = false + // 撤回/重做按钮的可用性跟着每次重读走 + void refreshHistory() } catch (error) { if (error instanceof ApiError && error.unauthorized) { clearToken() @@ -76,6 +87,91 @@ function logout() { location.reload() } +/* ── 侧栏左下角的配置动作 ─────────────────────── */ + +/** 撤回栈(备份链)和重做栈的深度,决定按钮亮不亮。每次 load() 时刷新。 */ +const history = ref<{ backups: string[]; redo: number }>({ backups: [], redo: 0 }) + +async function refreshHistory() { + history.value = await api.listBackups().catch(() => ({ backups: [], redo: 0 })) +} + +const busyAct = ref(false) +const historyOpen = ref(false) +const askRestart = ref(false) + +async function undo() { + if (busyAct.value) return + busyAct.value = true + try { + const result = await api.undoConfig() + toast(result.reloaded ? '已撤回上一次改动' : `已撤回上一次改动(${result.reloadMessage})`) + await load() + } catch (error) { + toastError(error) + } finally { + busyAct.value = false + } +} + +async function redo() { + if (busyAct.value) return + busyAct.value = true + try { + const result = await api.redoConfig() + toast(result.reloaded ? '已重做' : `已重做(${result.reloadMessage})`) + await load() + } catch (error) { + toastError(error) + } finally { + busyAct.value = false + } +} + +/** 让内核重读磁盘上的配置文件,界面跟着重读。给 SSH 手改过配置的场景用。 */ +async function reloadFromDisk() { + if (busyAct.value) return + busyAct.value = true + try { + await api.reloadKernelConfig() + toast('内核已重新加载配置文件') + await load() + } catch (error) { + toastError(error) + } finally { + busyAct.value = false + } +} + +/** 重启后每 2 秒探一次、最多 30 秒;心跳兜底是 10 秒一次,这里只是快一点。 */ +let restartTimer = 0 +onBeforeUnmount(() => window.clearInterval(restartTimer)) + +async function restartConfirmed() { + askRestart.value = false + try { + await api.restartKernel() + kernel.value = { online: false } + toast('已发出重启指令,等待内核回来') + const started = Date.now() + window.clearInterval(restartTimer) + restartTimer = window.setInterval(() => { + void refreshKernel().then(() => { + if (kernel.value.online) { + window.clearInterval(restartTimer) + void reloadRuntime() + toast('内核已恢复在线') + } else if (Date.now() - started > 30_000) { + window.clearInterval(restartTimer) + toast('内核 30 秒内没有回来', { kind: 'error', hint: '去日志页看看它在说什么。' }) + } + }) + }, 2_000) + } catch (error) { + toastError(error) + } +} + onMounted(() => { if (!needsToken.value) void load() // 流量、每路吞吐和日志的历史跨页面留存,所以收集挂在外壳上而不是各页里。 @@ -137,6 +233,55 @@ const current = computed(() => NAV.find((n) => n.key === active.value)) 退出登录 + +
+ + + + + +
+
@@ -219,7 +364,19 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
+ + + +
+ + diff --git a/web/components/ToastHost.vue b/web/components/ToastHost.vue new file mode 100644 index 0000000..7929284 --- /dev/null +++ b/web/components/ToastHost.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/web/toast.ts b/web/toast.ts new file mode 100644 index 0000000..701b37e --- /dev/null +++ b/web/toast.ts @@ -0,0 +1,43 @@ +/** + * 右上角的轻提示(成就弹窗式):从右滑入一行字,几秒后上移淡出。 + * + * 模块级单例,谁都能发;渲染在外壳的 ToastHost 里。适合「动作已完成/失败」 + * 这类看一眼就行的反馈;需要用户处理的内容(表单校验、带重试的错误) + * 仍然留在页面里,不进这里。 + */ +import { ref } from 'vue' +import { ApiError } from './api' + +export interface Toast { + id: number + kind: 'ok' | 'error' + message: string + hint: string +} + +export const toasts = ref([]) + +let nextId = 1 + +export function toast(message: string, opts?: { kind?: 'ok' | 'error'; hint?: string }): void { + const item: Toast = { + id: nextId++, + kind: opts?.kind ?? 'ok', + message, + hint: opts?.hint ?? '', + } + toasts.value = [...toasts.value, item] + // 错误多留一会儿,看清 hint 里的下一步 + const ttl = item.kind === 'error' ? 8000 : 4000 + setTimeout(() => dismiss(item.id), ttl) +} + +export function dismiss(id: number): void { + toasts.value = toasts.value.filter((t) => t.id !== id) +} + +/** 接口报错的统一出口:message 说发生了什么,hint 说下一步。 */ +export function toastError(error: unknown): void { + if (error instanceof ApiError) toast(error.message, { kind: 'error', hint: error.hint }) + else toast(String(error), { kind: 'error' }) +} diff --git a/web/views/SettingsPage.vue b/web/views/SettingsPage.vue index 20f2f45..1682a81 100644 --- a/web/views/SettingsPage.vue +++ b/web/views/SettingsPage.vue @@ -15,7 +15,8 @@ import { hasValidMarker } from '@shared/config-marker.ts' import { formatAgo, formatBytes } from '@shared/format.ts' import type { GeoFileStatus } from '@shared/geo.ts' import type { ConfigObject } from '../../vendor/config-editor/index.ts' -import { ApiError, api } from '../api' +import { api } from '../api' +import { toast, toastError } from '../toast' import { DEFAULT_TEST_URL, testUrl, theme } from '../prefs' import type { Theme } from '../prefs' import { kernel } from '../runtime' @@ -23,7 +24,6 @@ import { DISCLAIMER, DISCLAIMER_TITLE } from '../legal' import ConfirmDialog from '../components/ConfirmDialog.vue' import InlineEdit from '../components/InlineEdit.vue' import SelectBox from '../components/SelectBox.vue' -import IconCloseTip from '~icons/ri/close-line' import IconRight from '~icons/ri/arrow-right-s-line' import IconBack from '~icons/ri/arrow-left-s-line' import IconDownload from '~icons/ri/download-2-line' @@ -56,31 +56,21 @@ function saveTestUrl(value: string) { } /* ── 状态与反馈 ───────────────────────────────── */ +/* 动作结果一律走右上角的轻提示(web/toast.ts),页面里不再放常驻的横幅 */ const saving = ref(false) -const failure = ref<{ message: string; hint: string } | null>(null) -const done = ref('') - -function fail(error: unknown) { - failure.value = - error instanceof ApiError - ? { message: error.message, hint: error.hint } - : { message: String(error), hint: '' } -} /** 所有改配置文件的动作都从这里走。 */ async function commitConfig(next: ConfigObject, success: string) { if (saving.value) return saving.value = true - failure.value = null - done.value = '' try { const result = await api.applyConfig(next, props.revision) // 内核不在线时只写了文件没重载,如实说,不能只报成功 - done.value = result.reloaded ? success : `${success}(${result.reloadMessage})` + toast(result.reloaded ? success : `${success}(${result.reloadMessage})`) emit('saved') } catch (error) { - fail(error) + toastError(error) } finally { saving.value = false } @@ -137,14 +127,12 @@ function geoMeta(g: GeoFileStatus): string { async function updateGeo() { if (geoBusy.value || !kernel.value.online) return geoBusy.value = true - failure.value = null - done.value = '' try { await api.updateGeo() - done.value = 'GEO 数据库已更新' + toast('GEO 数据库已更新') await loadGeo() } catch (error) { - fail(error) + toastError(error) } finally { geoBusy.value = false } @@ -170,6 +158,16 @@ async function openGeoEdit(g: GeoFileStatus) { geoInput.value?.select() } +/** + * 编辑按钮在输入框打开期间还留在行里。直接点它会先触发输入框的 blur(关闭) + * 再触发 click(重新打开),肉眼看就是闪一下。按钮上 mousedown.prevent 保住 + * 焦点不掉,click 再显式决定开还是关。 + */ +function toggleGeoEdit(g: GeoFileStatus) { + if (geoEditing.value === g.configKey) finishGeoEdit(g, true) + else void openGeoEdit(g) +} + /** lenient 是失焦路径:无效就放弃改动。空值 = 回到内核默认(配置里删掉这个键)。 */ function finishGeoEdit(g: GeoFileStatus, lenient: boolean) { if (geoEditing.value !== g.configKey) return @@ -193,7 +191,6 @@ function finishGeoEdit(g: GeoFileStatus, lenient: boolean) { /* ── 配置文件 ─────────────────────────────────── */ async function downloadConfig() { - failure.value = null try { const text = await api.configRaw() const url = URL.createObjectURL(new Blob([text], { type: 'text/yaml' })) @@ -203,7 +200,7 @@ async function downloadConfig() { link.click() URL.revokeObjectURL(url) } catch (error) { - fail(error) + toastError(error) } } @@ -217,6 +214,8 @@ const STRATEGY_OPTIONS = [ /** 窗口里正拖着文件。为真时两个上传行整行变成投放区。 */ const dragging = ref(false) +/** 悬在哪个投放区上,给高亮用 */ +const overZone = ref(null) let dragDepth = 0 function hasFiles(event: DragEvent): boolean { @@ -232,7 +231,10 @@ function onWindowEnter(event: DragEvent) { function onWindowLeave(event: DragEvent) { if (!hasFiles(event)) return dragDepth = Math.max(0, dragDepth - 1) - if (dragDepth === 0) dragging.value = false + if (dragDepth === 0) { + dragging.value = false + overZone.value = null + } } // dragover 不阻止默认行为浏览器不允许 drop;窗口级兜底 drop 防止误放导致页面跳走 @@ -244,6 +246,7 @@ function onWindowDrop(event: DragEvent) { event.preventDefault() dragDepth = 0 dragging.value = false + overZone.value = null } onMounted(() => { @@ -279,26 +282,24 @@ function onDrop(kind: UploadKind, event: DragEvent) { const pendingUpload = ref<{ content: string; kind: UploadKind } | null>(null) async function readUpload(kind: UploadKind, file: File) { - failure.value = null - done.value = '' const content = await file.text() let parsed: unknown try { parsed = parse(content) } catch (error) { - failure.value = { message: `这份文件不是合法的 YAML:${String(error)}`, hint: '' } + toast(`这份文件不是合法的 YAML:${String(error)}`, { kind: 'error' }) return } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - failure.value = { message: '这份文件不是一份 mihomo 配置。', hint: '' } + toast('这份文件不是一份 mihomo 配置。', { kind: 'error' }) return } // 恢复入口只收 Patchbay 自己下载的文件,认标记。别家的配置走导入 if (kind === 'restore' && !hasValidMarker(parsed as ConfigObject)) { - failure.value = { - message: '这份文件没有 Patchbay 标记,不像是从 Patchbay 下载的配置。', + toast('这份文件没有 Patchbay 标记,不像是从 Patchbay 下载的配置。', { + kind: 'error', hint: '如果确实要用它,走「导入配置文件」。', - } + }) return } pendingUpload.value = { content, kind } @@ -319,15 +320,13 @@ async function uploadConfirmed() { pendingUpload.value = null if (!pending || saving.value) return saving.value = true - failure.value = null - done.value = '' try { const result = await api.importConfig(pending.content, strategy.value) const success = pending.kind === 'restore' ? '已从文件恢复配置' : '已导入配置文件' - done.value = result.reloaded ? success : `${success}(${result.reloadMessage})` + toast(result.reloaded ? success : `${success}(${result.reloadMessage})`) emit('saved') } catch (error) { - fail(error) + toastError(error) } finally { saving.value = false } @@ -370,17 +369,6 @@ const backend = location.origin
-
-
- {{ failure.message }} - {{ failure.hint }} -
- -
-

{{ done }}

-

外观

@@ -459,7 +447,8 @@ const backend = location.origin class="ico" title="编辑下载地址" :disabled="saving" - @click="openGeoEdit(g)" + @mousedown.prevent + @click="toggleGeoEdit(g)" > @@ -503,7 +492,9 @@ const backend = location.origin
@@ -512,17 +503,7 @@ const backend = location.origin
通过文件恢复配置 -
- -
- 拖拽到此处,导入配置文件 -
-
- 导入配置文件 -
@@ -531,6 +512,23 @@ const backend = location.origin
+
+ 拖拽到此处,导入配置文件 +
+
+ 导入配置文件 + +
+