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

设置页的横幅换成 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
+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;