Files
mihomo-patchbay/web/App.vue
T
im0o b276c4a8fe 轻提示铺开到全部页面;内核状态悬停变重启;GEO 支持单文件下载
侧栏动作排撤掉重启图标,内核状态(圆点加版本号)本身变成按钮:悬停
换层显示「重启内核」,不在线时只当状态看。行高压成 1 修掉圆点和等宽
字体不居中的问题。

四个页面的一次性反馈(保存、删除、更新订阅、断开连接、测延迟结论)
全部改走 toast,commit() 改返回成败给「失败不关对话框」用;持续状态
(连接页轮询失败、请求页日志流失败)留在页内,会自动消失的提示装不下
需要一直可见的信息。toast 加 info 类别给中性结论用。

GEO 每行的更新改成单独下载那一个文件:内核只有全量更新的口子,单文件
只能服务端自己下(POST /api/geo/sync),代价是只有形状校验(MaxMind
末尾标记、geodata 首字节,shared/geo.ts 带测试)且内核重启后才换用新
文件;「更新全部」文字按钮保留内核的全量口子,有完整校验和热加载。
上传两行的按钮改「选择或拖放文件」,投放区拖入高亮,恢复策略挪到
恢复行下方。
2026-08-12 02:20:46 +08:00

728 lines
19 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)
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"
/>
<RequestsPage v-else-if="active === 'requests'" />
<ConnectionsPage v-else-if="active === 'connections'" />
<SettingsPage
v-else-if="active === 'settings'"
:data="snapshot.data"
:revision="snapshot.revision"
@saved="load"
/>
<div v-else class="note">
<component :is="current?.icon" class="note-icon" />
<p>{{ current?.label }} 尚未实现</p>
</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>
/*
* 侧栏折叠的做法:只过渡外层宽度,内层内容保持展开时的固定宽度、被 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>