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

设置页的横幅换成 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)
+192
View File
@@ -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<ConfigSnapshot | null>(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))
<span class="nav-label">退出登录</span>
</button>
<!-- 配置动作一排。收拢时容器变窄,图标自动折行成一列 -->
<div class="acts">
<button
type="button"
class="act"
title="撤回"
:disabled="busyAct || !snapshot || history.backups.length === 0"
@click="undo"
>
<IconUndo />
</button>
<button
type="button"
class="act"
title="重做"
:disabled="busyAct || !snapshot || history.redo === 0"
@click="redo"
>
<IconRedo />
</button>
<button
type="button"
class="act"
title="历史记录"
:disabled="!snapshot"
@click="historyOpen = true"
>
<IconHistory />
</button>
<button
type="button"
class="act"
:title="kernel.online ? '更新配置(让内核重读配置文件)' : '内核未连接'"
:disabled="busyAct || !snapshot || !kernel.online"
@click="reloadFromDisk"
>
<IconReload />
</button>
<button
type="button"
class="act"
:title="kernel.online ? '重启内核' : '内核未连接'"
:disabled="!kernel.online"
@click="askRestart = true"
>
<IconRestart />
</button>
</div>
<div class="side-foot">
<div class="kernel" :title="kernel.online ? kernel.version : '内核未连接'">
<span :class="['dot', kernel.online ? 'dot-ok' : 'dot-off']" aria-hidden="true"></span>
@@ -219,7 +364,19 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
</div>
</template>
</main>
<HistoryDialog :open="historyOpen" @close="historyOpen = false" @restored="load" />
<ConfirmDialog
:open="askRestart"
message="重启内核进程?所有连接会断开,几秒后自动恢复;策略组的选中依赖 profile.store-selected 找回。"
action="重启"
@close="askRestart = false"
@confirm="restartConfirmed"
/>
</div>
<ToastHost />
</template>
<style scoped>
@@ -379,6 +536,41 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
color: var(--c-text-faint);
}
/* 配置动作排:展开时一行五个,收拢时容器随 --side-w 变窄、flex 自动折成一列 */
.acts {
display: flex;
flex-wrap: wrap;
gap: var(--s-1);
padding: 0 var(--s-2);
width: calc(var(--side-w) - 2 * var(--s-3));
transition: width 0.3s ease;
}
.act {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 1px solid transparent;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.act:hover:not(:disabled) {
background: var(--c-surface-sunken);
color: var(--c-text);
}
.act:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.act svg {
width: 15px;
height: 15px;
}
.side-foot {
display: flex;
align-items: center;
+11 -1
View File
@@ -141,7 +141,14 @@ export const api = {
applyConfig: (data: ConfigObject, baseRevision: string) =>
request<ApplySuccess>('PUT', '/api/config', { data, baseRevision }),
listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'),
/** 备份链(撤回栈)和重做栈深度。侧栏用它决定撤回/重做按钮亮不亮。 */
listBackups: () => request<{ backups: string[]; redo: number }>('GET', '/api/config/backups'),
/** 撤回最近一次改动:应用最新备份并消费掉它,撤回前的状态进重做栈。 */
undoConfig: () => request<ApplySuccess>('POST', '/api/config/undo'),
/** 重做最近一次被撤回的改动。新的正常保存会清空重做栈。 */
redoConfig: () => request<ApplySuccess>('POST', '/api/config/redo'),
/** 配置文件的原文,给「下载当前配置」用。 */
configRaw: async (): Promise<string> => {
@@ -189,6 +196,9 @@ export const api = {
*/
restartKernel: () => request<{ ok: true }>('POST', '/api/kernel/restart'),
/** 让内核重新加载磁盘上的配置文件。给 SSH 手改过配置的场景用。 */
reloadKernelConfig: () => request<{ ok: true }>('POST', '/api/kernel/reload'),
/**
* 让内核更新 GEO 数据库。同步接口,下载完才回话,可能要等上几十秒。
* 内核只有全量更新这一个口子,没变化的文件靠内容哈希跳过重写。
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
/**
* 备份链(历史记录)的弹窗:列出每次改动前留下的备份,点还原直接跳回去。
* 还原走服务端同一条保护链路,当前状态会先自动备份,所以不需要再套一层确认。
*/
import { ref, watch } from 'vue'
import { backupTime } from '@shared/backups.ts'
import { api } from '../api'
import { toast, toastError } from '../toast'
import IconClose from '~icons/ri/close-line'
const props = defineProps<{ open: boolean }>()
/** restored:还原成功,外壳该重读配置了。 */
const emit = defineEmits<{ close: []; restored: [] }>()
const dialog = ref<HTMLDialogElement | null>(null)
const backups = ref<string[] | null>(null)
const busy = ref(false)
watch(
() => props.open,
(open) => {
if (open) {
dialog.value?.showModal()
backups.value = null
void api
.listBackups()
.then((r) => (backups.value = r.backups))
.catch(() => (backups.value = []))
} else {
dialog.value?.close()
}
},
)
async function restore(name: string) {
if (busy.value) return
busy.value = true
try {
const result = await api.restoreBackup(name)
const time = backupTime(name) ?? name
toast(result.reloaded ? `已还原到 ${time}` : `已还原到 ${time}(${result.reloadMessage})`)
emit('restored')
emit('close')
} catch (error) {
toastError(error)
} finally {
busy.value = false
}
}
/** 点在遮罩上就关闭。内容区的点击 target 是里面的元素,到不了 dialog 本身。 */
function onBackdrop(event: MouseEvent) {
if (event.target === dialog.value) emit('close')
}
</script>
<template>
<dialog
ref="dialog"
class="dlg"
@click="onBackdrop"
@close="emit('close')"
@cancel.prevent="emit('close')"
>
<div class="body">
<header class="head">
<h2>历史记录</h2>
<button type="button" class="x" title="关闭" @click="emit('close')"><IconClose /></button>
</header>
<p v-if="backups === null" class="faint">载入中</p>
<p v-else-if="backups.length === 0" class="faint">
没有备份。每次保存都会把改动前的状态留在这里,最多 10 份。
</p>
<ul v-else class="list">
<li v-for="name in backups" :key="name" class="item" :title="name">
<span class="mono time">{{ backupTime(name) ?? name }}</span>
<button type="button" class="btn" :disabled="busy" @click="restore(name)">还原</button>
</li>
</ul>
</div>
</dialog>
</template>
<style scoped>
.dlg {
width: min(420px, calc(100vw - 2 * var(--s-5)));
padding: 0;
border: 1px solid var(--c-border-strong);
border-radius: var(--r-lg);
background: var(--c-surface);
color: var(--c-text);
}
.dlg::backdrop {
background: rgb(0 0 0 / 0.45);
}
.body {
padding: var(--s-5);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--s-3);
}
.head h2 {
font-size: var(--t-lg);
font-weight: 600;
}
.x {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.x:hover {
background: var(--c-surface-sunken);
color: var(--c-text);
}
.faint {
color: var(--c-text-faint);
font-size: var(--t-sm);
}
.list {
list-style: none;
margin: 0;
padding: 0;
max-height: 50vh;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--c-border-strong) transparent;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-4);
padding: var(--s-2) 0;
}
.item + .item {
border-top: 1px solid var(--c-border);
}
.time {
font-size: var(--t-sm);
}
.btn {
padding: var(--s-1) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-sm);
cursor: pointer;
}
.btn:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.btn:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
</style>
+121
View File
@@ -0,0 +1,121 @@
<script setup lang="ts">
/**
* 轻提示的渲染宿主,挂在外壳根部。进场从右滑入,退场上移淡出;
* 点一下立刻消掉。内容来自 web/toast.ts 的单例队列。
*/
import { dismiss, toasts } from '../toast'
import IconOk from '~icons/ri/checkbox-circle-line'
import IconErr from '~icons/ri/error-warning-line'
</script>
<template>
<div class="host" aria-live="polite">
<TransitionGroup name="tst">
<div
v-for="t in toasts"
:key="t.id"
:class="['tst', t.kind === 'error' && 'is-error']"
@click="dismiss(t.id)"
>
<IconErr v-if="t.kind === 'error'" class="tico" />
<IconOk v-else class="tico" />
<div class="tbody">
<strong class="tmsg">{{ t.message }}</strong>
<span v-if="t.hint" class="thint">{{ t.hint }}</span>
</div>
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.host {
position: fixed;
top: var(--s-4);
right: var(--s-4);
z-index: 300;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--s-2);
/* 宿主自己不挡点击,字条才挡 */
pointer-events: none;
}
.tst {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: var(--s-3);
min-width: 240px;
max-width: 380px;
padding: var(--s-3) var(--s-4);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-md);
background: var(--c-surface);
box-shadow: 0 6px 24px rgb(0 0 0 / 0.16);
cursor: pointer;
user-select: none;
}
.tst.is-error {
border-color: color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
}
.tico {
flex: none;
width: 18px;
height: 18px;
margin-top: 1px;
color: var(--c-ok);
}
.is-error .tico {
color: var(--c-danger);
}
.tbody {
display: flex;
flex-direction: column;
gap: var(--s-1);
min-width: 0;
}
.tmsg {
font-size: var(--t-sm);
font-weight: 500;
overflow-wrap: anywhere;
}
.thint {
font-size: var(--t-xs);
color: var(--c-text-soft);
overflow-wrap: anywhere;
}
/* 进场:从右滑入;退场:上移淡出;队列压缩时其余字条平滑归位 */
.tst-enter-active {
transition:
transform 0.28s cubic-bezier(0.21, 0.8, 0.35, 1),
opacity 0.28s ease;
}
.tst-enter-from {
transform: translateX(120%);
opacity: 0;
}
.tst-leave-active {
transition:
transform 0.4s ease,
opacity 0.4s ease;
/* 退场中脱离布局,后面的字条立刻开始归位 */
position: absolute;
right: 0;
}
.tst-leave-to {
transform: translateY(-14px);
opacity: 0;
}
.tst-move {
transition: transform 0.3s ease;
}
</style>
+43
View File
@@ -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<Toast[]>([])
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' })
}
+64 -100
View File
@@ -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<UploadKind | null>(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
</div>
<div v-else class="page">
<div v-if="failure" class="failure">
<div class="failure-text">
<strong>{{ failure.message }}</strong>
<span v-if="failure.hint">{{ failure.hint }}</span>
</div>
<button type="button" class="failure-x" title="关闭提示" @click="failure = null">
<IconCloseTip />
</button>
</div>
<p v-else-if="done" class="done">{{ done }}</p>
<section class="group">
<h2 class="title">外观</h2>
<div class="row">
@@ -459,7 +447,8 @@ const backend = location.origin
class="ico"
title="编辑下载地址"
:disabled="saving"
@click="openGeoEdit(g)"
@mousedown.prevent
@click="toggleGeoEdit(g)"
>
<IconEdit />
</button>
@@ -503,7 +492,9 @@ const backend = location.origin
<!-- 拖着文件进窗口时,两个上传行整行变成投放区 -->
<div
v-if="dragging"
class="drop"
:class="['drop', overZone === 'restore' && 'is-over']"
@dragenter="overZone = 'restore'"
@dragleave="overZone = null"
@dragover.prevent
@drop.prevent="onDrop('restore', $event)"
>
@@ -512,17 +503,7 @@ const backend = location.origin
<div v-else class="row">
<span class="key">通过文件恢复配置</span>
<button type="button" class="btn" :disabled="saving" @click="restoreInput?.click()">
选择文件
</button>
</div>
<div v-if="dragging" class="drop" @dragover.prevent @drop.prevent="onDrop('import', $event)">
拖拽到此处,导入配置文件
</div>
<div v-else class="row">
<span class="key">导入配置文件</span>
<button type="button" class="btn" :disabled="saving" @click="importInput?.click()">
选择文件
选择或拖放文件
</button>
</div>
@@ -531,6 +512,23 @@ const backend = location.origin
<SelectBox v-model="strategy" :options="STRATEGY_OPTIONS" class="strategy" aria-label="恢复策略" />
</div>
<div
v-if="dragging"
:class="['drop', overZone === 'import' && 'is-over']"
@dragenter="overZone = 'import'"
@dragleave="overZone = null"
@dragover.prevent
@drop.prevent="onDrop('import', $event)"
>
拖拽到此处,导入配置文件
</div>
<div v-else class="row">
<span class="key">导入配置文件</span>
<button type="button" class="btn" :disabled="saving" @click="importInput?.click()">
选择或拖放文件
</button>
</div>
<input
ref="restoreInput"
type="file"
@@ -747,6 +745,15 @@ const backend = location.origin
border-radius: var(--r-md);
color: var(--c-text-soft);
font-size: var(--t-sm);
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}
.drop.is-over {
border-color: var(--c-accent);
background: var(--c-accent-soft);
color: var(--c-accent-text);
}
.strategy {
@@ -810,47 +817,4 @@ const backend = location.origin
margin-top: var(--s-3);
}
/* ── 反馈 ────────────────────────────────────── */
.failure {
display: flex;
align-items: flex-start;
gap: var(--s-3);
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
border-radius: var(--r-md);
background: var(--c-danger-soft);
padding: var(--s-3) var(--s-4);
margin-bottom: var(--s-3);
font-size: var(--t-sm);
}
.failure-text {
display: flex;
flex-direction: column;
gap: var(--s-1);
flex: 1;
min-width: 0;
}
.failure-x {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-danger);
cursor: pointer;
}
.failure-x:hover {
background: color-mix(in srgb, var(--c-danger) 12%, transparent);
}
.done {
color: var(--c-ok);
font-size: var(--t-sm);
margin-bottom: var(--s-3);
}
</style>