Files
mihomo-patchbay/web/App.vue
T
im0o 8d4579f987 弹窗加快速淡出退场;重启确认的文案按配置说话
退场用 opacity 过渡加 display/overlay 的 allow-discrete——过渡结束前
元素留在 top layer,不支持的浏览器退化为立即消失。所有 dialog 一并
生效。

重启确认原来把 profile.store-selected 的配置项名直接甩给用户。现在读
配置判断(内核默认记住选中,config.go:576,显式 false 才会丢),按
实际情况说「选中的线路重启后不变」或「会回到默认成员」。
2026-08-12 03:32:53 +08:00

752 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ApiError, api, clearToken, getToken, setToken } from './api'
import type { ConfigSnapshot } from './api'
import { NAV } from './nav'
import { density, toggleDensity } from './prefs'
import { kernel, refreshKernel, reloadRuntime } from './runtime'
import { startTelemetry } from './telemetry'
import { startLogStream } from './log-store'
import ConfigPage from './views/ConfigPage.vue'
import ConnectionsPage from './views/ConnectionsPage.vue'
import DashboardPage from './views/DashboardPage.vue'
import RequestsPage from './views/RequestsPage.vue'
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())
const loadError = ref('')
const booted = ref(false)
const refreshing = ref(false)
const active = ref('dashboard')
/**
* 仪表盘的路由行点过去时要选中的那一路。放外壳是因为跳转跨页面:
* 仪表盘发起、路由页消费,消费完清掉,下次再点同一行还能再跳。
*/
const routerFocus = ref<string | null>(null)
function openInbound(id: string) {
routerFocus.value = id
active.value = 'router'
}
/**
* 重新读一遍状态。
*
* 关键在于**不能把页面拆掉重建**:之前每次刷新都会先切到「读取中」,于是路由页被卸载、
* 选中的那一路入站丢失,改完一个设置还要重新找回原来的位置。所以只有首次加载显示占位,
* 之后都是原地更新。
*/
async function load() {
refreshing.value = true
loadError.value = ''
try {
// 先刷运行时再换配置数据。反过来的话,保存后界面已经按新配置渲染,
// 而新建的组在运行时里还查不到,成员卡片会先闪空一下再出现。
await reloadRuntime()
snapshot.value = await api.readConfig()
needsToken.value = false
// 撤回/重做按钮的可用性跟着每次重读走
void refreshHistory()
} catch (error) {
if (error instanceof ApiError && error.unauthorized) {
clearToken()
needsToken.value = true
} else {
loadError.value = error instanceof Error ? error.message : String(error)
}
} finally {
refreshing.value = false
booted.value = true
}
}
function submitToken(value: string) {
setToken(value)
void load()
}
function logout() {
clearToken()
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)
/**
* 重启后各组选中的线路能不能找回来:内核把选中记在缓存文件里,由
* profile.store-selected 控制,默认开(config.go:576),显式写 false 才会丢。
* 按用户的配置说话,不把配置项名字甩给他。
*/
const keepsSelection = computed(() => {
const profile = snapshot.value?.data.profile
if (typeof profile !== 'object' || profile === null || Array.isArray(profile)) return true
return (profile as Record<string, unknown>)['store-selected'] !== 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()
// 流量、每路吞吐和日志的历史跨页面留存,所以收集挂在外壳上而不是各页里。
// 单例自己跟着 kernel.online 起落,这里只负责点火
startTelemetry()
startLogStream()
})
/**
* 内核状态的心跳。kernel.online 原本只在 load() 里更新,用户停在任何页面期间它都是
* 静态快照:内核重启一次,请求页和仪表盘会锁死在「内核未连接」,各页等它回来的 watch
* 永远不会触发。10 秒探一次(/api/kernel 是个 5 秒超时的轻量探测),恢复在线的那一次
* 顺带把运行时数据拉新,路由页的组和线路也跟着回来。
*/
let kernelTimer = 0
onMounted(() => {
kernelTimer = window.setInterval(() => {
if (needsToken.value || !snapshot.value) return
const wasOnline = kernel.value.online
void refreshKernel().then(() => {
if (!wasOnline && kernel.value.online) void reloadRuntime()
})
}, 10_000)
})
onBeforeUnmount(() => window.clearInterval(kernelTimer))
const problems = computed(
() => snapshot.value?.diagnostics.filter((d) => d.severity === 'error') ?? [],
)
const current = computed(() => NAV.find((n) => n.key === active.value))
</script>
<template>
<TokenGate v-if="needsToken" @submit="submitToken" />
<div v-else :class="['shell', density === 'compact' && 'is-compact']">
<nav class="side">
<div class="brand">
<span class="mark" aria-hidden="true"></span>
<span class="brand-name">Patchbay</span>
</div>
<ul class="nav">
<li v-for="item in NAV" :key="item.key">
<button
type="button"
:class="['nav-item', active === item.key && 'is-active']"
:title="item.label"
@click="active = item.key"
>
<component :is="item.icon" class="nav-icon" />
<span class="nav-label">{{ item.label }}</span>
</button>
</li>
</ul>
<button type="button" class="nav-item off" title="退出登录" @click="logout">
<IconLogout class="nav-icon" />
<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>
</div>
<div class="side-foot">
<!-- 平时是内核状态,悬停变成重启入口。内核不在线时只当状态看 -->
<button
type="button"
class="kernel-btn"
:title="kernel.online ? '重启内核' : '内核未连接'"
:disabled="!kernel.online"
@click="askRestart = true"
>
<span class="k-idle">
<span :class="['dot', kernel.online ? 'dot-ok' : 'dot-off']" aria-hidden="true"></span>
<span class="kernel-text mono">{{ kernel.online ? kernel.version : 'offline' }}</span>
</span>
<span class="k-hot" aria-hidden="true"><IconRestart class="k-ico" /> 重启内核</span>
</button>
<button
type="button"
class="densify"
:title="density === 'full' ? '只显示图标' : '显示图标和文字'"
@click="toggleDensity"
>
<IconFold v-if="density === 'full'" />
<IconUnfold v-else />
</button>
</div>
</nav>
<main class="main">
<p v-if="!booted && refreshing" class="note">载入中</p>
<div v-else-if="loadError" class="alert">
<h2>配置读取失败</h2>
<p>{{ loadError }}</p>
</div>
<template v-else-if="snapshot">
<div v-if="problems.length" class="alert">
<h2>配置存在 {{ problems.length }} 处错误</h2>
<ul>
<li v-for="(p, i) in problems" :key="i">
<code>{{ p.path.join('.') }}</code> {{ p.message }}
</li>
</ul>
</div>
<DashboardPage
v-if="active === 'dashboard'"
:data="snapshot.data"
@open-inbound="openInbound"
@open-router="active = 'router'"
/>
<RouterPage
v-else-if="active === 'router'"
:data="snapshot.data"
:revision="snapshot.revision"
:focus="routerFocus"
@focus-done="routerFocus = null"
@saved="load"
/>
<ConfigPage
v-else-if="active === 'config'"
:data="snapshot.data"
:revision="snapshot.revision"
@saved="load"
/>
<RulesPage
v-else-if="active === 'rules'"
:data="snapshot.data"
:revision="snapshot.revision"
@saved="load"
/>
<SettingsPage
v-else-if="active === 'settings'"
:data="snapshot.data"
:revision="snapshot.revision"
@saved="load"
/>
<div
v-else-if="active !== 'requests' && active !== 'connections'"
class="note"
>
<component :is="current?.icon" class="note-icon" />
<p>{{ current?.label }} 尚未实现</p>
</div>
<!-- 请求页和连接页保活:切走不销毁,筛选词、排序、滚动位置都留住。
KeepAlive 必须常驻、让子组件自己 v-if 进出——把 v-if 放在 KeepAlive
上的话它随条件卸载,缓存跟着没了。轮询的起停走 activated 钩子 -->
<KeepAlive>
<RequestsPage v-if="active === 'requests'" />
</KeepAlive>
<KeepAlive>
<ConnectionsPage v-if="active === 'connections'" />
</KeepAlive>
</template>
</main>
<HistoryDialog :open="historyOpen" @close="historyOpen = false" @restored="load" />
<ConfirmDialog
:open="askRestart"
:message="
keepsSelection
? '重启内核进程?所有连接会断开,几秒后自动恢复;各组选中的线路由内核记住,重启后不变。'
: '重启内核进程?所有连接会断开,几秒后自动恢复;这份配置关闭了记住选中(store-selected: false),各组会回到默认成员。'
"
action="重启"
@close="askRestart = false"
@confirm="restartConfirmed"
/>
</div>
<ToastHost />
</template>
<style scoped>
/*
* 侧栏折叠的做法:只过渡外层宽度,内层内容保持展开时的固定宽度、被 overflow 裁切,
* 文字用透明度淡出。不用 display: none 藏文字——那会让内容在动画中途重排、图标跳位。
* 60px 这个折叠宽度是算出来的:图标中心在 12+1+8+9=30px,正好是 60 的一半,
* 收拢后图标视觉居中,全程不需要挪动任何东西。
*/
.shell {
--side-w: 184px;
display: grid;
grid-template-columns: var(--side-w) 1fr;
min-height: 100%;
transition: grid-template-columns 0.3s ease;
}
.shell.is-compact {
--side-w: 60px;
}
/* ── 侧栏 ───────────────────────────────────── */
/*
* 钉在视口里。不加 sticky 的话它会被网格行撑到和主区一样高,页面一长,
* 导航就跟着滚出屏幕外,而它是全局导航,任何时候都该够得着。
*/
.side {
position: sticky;
top: 0;
align-self: start;
height: 100vh;
display: flex;
flex-direction: column;
gap: var(--s-5);
padding: var(--s-4) var(--s-3);
border-right: 1px solid var(--c-border);
background: var(--c-surface);
overflow: hidden;
}
/* 内容按展开宽度定死(184 - 24 内边距 - 1 边框),收拢时被裁掉而不是挤压换行 */
.brand,
.nav,
.side-foot {
min-width: 159px;
}
.brand {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-2) var(--s-2);
}
.mark {
flex: none;
width: 22px;
height: 10px;
border: 1.5px solid var(--c-accent);
border-radius: 999px;
position: relative;
}
.mark::before,
.mark::after {
content: '';
position: absolute;
top: 50%;
width: 4px;
height: 4px;
margin-top: -2px;
border-radius: 50%;
background: var(--c-accent);
}
.mark::before {
left: 2px;
}
.mark::after {
right: 2px;
}
.brand-name {
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
}
/* 收拢时淡出的文字。比外层宽度的 0.3s 快,字先消失、再收边,不露半截字 */
.brand-name,
.nav-label,
.kernel-btn {
transition: opacity 0.2s ease;
}
.is-compact .brand-name,
.is-compact .nav-label,
.is-compact .kernel-btn {
opacity: 0;
}
.nav {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--s-1);
flex: 1;
}
.nav-item {
/* 写死而不是 100%:百分比到像素之间没法插值,宽度动画会直接跳变 */
width: 159px;
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-2) var(--s-2);
border: 1px solid transparent;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-soft);
cursor: pointer;
text-align: left;
white-space: nowrap;
overflow: hidden;
/* width 跟侧栏同节奏,颜色沿用全局的 0.15s;transition 不叠加,这里要写全 */
transition:
width 0.3s ease,
color 0.15s ease,
background-color 0.15s ease,
border-color 0.15s ease;
}
/* 收拢后按钮缩成正方形贴着图标,高亮块不再被裁切:8 + 18 + 8 加两侧边框 = 36 */
.is-compact .nav-item {
width: 36px;
}
.nav-item:hover {
background: var(--c-surface-sunken);
color: var(--c-text);
}
.nav-item.is-active {
background: var(--c-accent-soft);
border-color: color-mix(in srgb, var(--c-accent) 25%, transparent);
color: var(--c-accent-text);
}
.nav-icon {
flex: none;
width: 18px;
height: 18px;
}
/* 退出登录:形态同导航项,但不参与「当前页」的高亮,收拢时同样缩成方块 */
.off {
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;
gap: var(--s-2);
/* 右边给绝对定位的折叠按钮留位置 */
padding: 0 44px 0 var(--s-2);
}
/* 内核状态兼重启按钮:两层叠在同一格里,悬停换层 */
.kernel-btn {
position: relative;
display: flex;
align-items: center;
flex: 1;
min-width: 0;
height: 26px;
padding: 0 var(--s-2);
border: none;
border-radius: var(--r-sm);
background: none;
cursor: pointer;
text-align: left;
}
.kernel-btn:disabled {
cursor: default;
}
.kernel-btn:hover:not(:disabled) {
background: var(--c-surface-sunken);
}
.k-idle {
display: flex;
align-items: center;
gap: var(--s-2);
min-width: 0;
color: var(--c-text-faint);
font-size: var(--t-xs);
/* 等宽字体的行框上下不对称,压成 1 让点和文字真正居中 */
line-height: 1;
transition: opacity 0.15s ease;
}
.k-hot {
position: absolute;
inset: 0;
display: flex;
align-items: center;
gap: var(--s-2);
padding: 0 var(--s-2);
color: var(--c-text-soft);
font-size: var(--t-xs);
line-height: 1;
white-space: nowrap;
opacity: 0;
transition: opacity 0.15s ease;
pointer-events: none;
}
.kernel-btn:hover:not(:disabled) .k-idle,
.kernel-btn:focus-visible:not(:disabled) .k-idle {
opacity: 0;
}
.kernel-btn:hover:not(:disabled) .k-hot,
.kernel-btn:focus-visible:not(:disabled) .k-hot {
opacity: 1;
}
.k-ico {
flex: none;
width: 14px;
height: 14px;
}
.dot {
flex: none;
width: 7px;
height: 7px;
border-radius: 50%;
}
.dot-ok {
background: var(--c-ok);
}
.dot-off {
background: var(--c-text-faint);
}
.kernel-text {
overflow: hidden;
text-overflow: ellipsis;
}
/* 钉在侧栏右下角:宽度过渡时自动跟着边缘走。right 取 17 是为了收拢到 60px 时正好居中 */
.densify {
position: absolute;
right: 17px;
bottom: var(--s-4);
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;
}
.densify:hover {
background: var(--c-surface-sunken);
color: var(--c-text);
}
/* ── 主区 ───────────────────────────────────── */
.main {
padding: var(--s-6) var(--s-6) var(--s-7);
max-width: 1600px;
}
.note {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s-3);
color: var(--c-text-faint);
}
.note-icon {
width: 24px;
height: 24px;
}
.alert {
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-4) var(--s-5);
margin-bottom: var(--s-5);
}
.alert h2 {
font-size: var(--t-base);
font-weight: 600;
margin-bottom: var(--s-2);
}
.alert ul {
margin: 0;
padding-left: 1.2em;
color: var(--c-text-soft);
font-size: var(--t-sm);
}
</style>