反馈改右上角轻提示;侧栏加撤回重做等配置动作
设置页的横幅换成 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:
+192
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user