Files
mihomo-patchbay/web/views/RouterPage.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

393 lines
12 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">
/**
* 路由页:一张「端口 → 出站」的表,加上选中那一路的详情。
*
* 这里只做编排——把配置数据、运行时状态和三个子组件接起来,并守住一条:
* 所有改配置文件的动作都从 commit() 走,失败时按停在哪一步说不同的话。
* 运行时动作(切换选中、测延迟)不经过它,因为它们不写文件也不需要校验。
*/
import { computed, onMounted, ref, watch } from 'vue'
import { collectInbounds } from '@shared/inbounds.ts'
import type { Inbound } from '@shared/inbounds.ts'
import {
applySetup,
chainGroupName,
groupUses,
panelGroups,
proxyGroupName,
pruneGenerated,
sharedPeers,
syncGenerated,
readSetup,
userProviders,
} from '@shared/inbound-groups.ts'
import type { OutboundSetup } from '@shared/inbound-groups.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import { ApiError, api } from '../api'
import { toast, toastError } from '../toast'
import type { ListenerFields } from '../api'
import type { GroupView } from '../runtime'
import { optimisticSelect, providerLines, reloadRuntime, runtime } from '../runtime'
import { useDelayTest } from '../delay-test'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import InboundList from '../components/InboundList.vue'
import InboundDetail from './InboundDetail.vue'
import InboundForm from './InboundForm.vue'
import IconAdd from '~icons/ri/add-line'
const props = defineProps<{
data: ConfigObject
revision: string
/** 外部(仪表盘的路由行)指名要选中的一路。消费后通过 focus-done 请外壳清掉 */
focus?: string | null
}>()
/** 配置文件变了,需要重读整份配置。运行时的变化不走这里。 */
const emit = defineEmits<{ saved: []; 'focus-done': [] }>()
const inbounds = computed(() => collectInbounds(props.data))
const subscriptions = computed(() => userProviders(props.data))
/** 刚接管一路入站时给它什么:订阅全给上,不筛。三个组由 applySetup 一并建出来。 */
const defaultSetup = (): OutboundSetup => ({
proxy: { sources: [...subscriptions.value], filter: null },
chain: null,
})
const selectedId = ref<string | null>(null)
const saving = ref(false)
const fields = ref<ListenerFields | null>(null)
const formOpen = ref(false)
const formEditing = ref<Inbound | null>(null)
const { testing, test } = useDelayTest(
() => void reloadRuntime(),
// 「某条线路测不通」是关于线路的结论,不是操作失败,走中性的轻提示
(message) => toast(message, { kind: 'info' }),
)
onMounted(async () => {
try {
fields.value = await api.listenerFields()
} catch {
fields.value = null
}
})
// 默认选中第一行。空面板没有信息量,不如直接展示一路入站。
watch(
inbounds,
(list) => {
if (!list.some((i) => i.id === selectedId.value)) selectedId.value = list[0]?.id ?? null
},
{ immediate: true },
)
// 带着指名跳进来时选中那一路。immediate 是因为组件随页签切换重建,
// watch 挂上时 focus 早就是最终值了,不 immediate 就永远等不到变化
watch(
() => props.focus,
(id) => {
if (!id) return
if (inbounds.value.some((i) => i.id === id)) selectedId.value = id
emit('focus-done')
},
{ immediate: true },
)
const selected = computed(() => inbounds.value.find((i) => i.id === selectedId.value) ?? null)
const takenNames = computed(() => inbounds.value.map((i) => i.name))
/** 面板上能操作哪些策略组、各自能不能点。这个问题只在 panelGroups 里回答一次。 */
const panel = computed(() =>
selected.value
? panelGroups(props.data, runtime.value, selected.value)
: { groups: [], shared: false },
)
/** 选中这一路落在共用组上时,还有哪些入站也走同一个组。决定「共用」标记显不显示。 */
const sharedWith = computed(() =>
selected.value ? sharedPeers(props.data, runtime.value, inbounds.value, selected.value) : [],
)
/** 共用的组是别人的,改不了它的来源,所以配置表单也不给。 */
const setup = computed<OutboundSetup | null>(() =>
selected.value && !panel.value.shared ? readSetup(props.data, selected.value.name) : null,
)
const chainView = computed<GroupView | null>(() => {
const target = selected.value
if (!target || !setup.value?.chain) return null
const name = chainGroupName(target.name)
return { name, uses: groupUses(props.data, name) ?? [] }
})
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
/** 所有改配置文件的动作都从这里走。反馈走右上角轻提示,返回值告知成败。 */
async function commit(raw: ConfigObject, success: string): Promise<boolean> {
// 先把生成的东西按当前规则重建一遍,再清掉没人再引用的。不清理的话,改几次文件里就
// 攒下一堆用户从没主动建过的东西,而没人用的订阅副本还会按 interval 继续联网。
const next = pruneGenerated(syncGenerated(raw, collectInbounds(raw)))
saving.value = true
try {
const result = await api.applyConfig(next, props.revision)
// 没重载有两种情况(内容没有变化、内核不在线只写了文件),都要如实说,不能只报成功
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
emit('saved')
return true
} catch (error) {
toastError(error)
return false
} finally {
saving.value = false
}
}
/** 保存这一路的配置:组从哪些订阅取成员、筛掉哪些、开不开链式代理。 */
async function saveSetup(next: OutboundSetup, picks: { proxy: string | null; chain: string | null }) {
const target = selected.value
if (!target || saving.value) return
const ok = await commit(applySetup(clone(), target.name, next), `${target.name} 的代理配置已保存`)
if (!ok) return
// 草稿态里预选的线路在保存成功、组按新配置就位之后逐个选上。这是运行时动作,
// 失败不影响已保存的配置:组停在内核的默认选中上,刷新后显示的就是真实状态。
const picked: Array<[string, string | null]> = [
[proxyGroupName(target.name), picks.proxy],
[chainGroupName(target.name), picks.chain],
]
let selectedAny = false
for (const [group, member] of picked) {
if (!member) continue
await api.selectProxy(group, member).catch(() => {})
selectedAny = true
}
if (selectedAny) await reloadRuntime()
}
/** 把一路还没接管的入站改成专属代理组模式。 */
async function adopt() {
const target = selected.value
if (!target || saving.value) return
await commit(applySetup(clone(), target.name, defaultSetup()), `${target.name} 已改为逐条选线路`)
}
function openAdd() {
formEditing.value = null
formOpen.value = true
}
function openEdit() {
if (!selected.value || selected.value.isDefault) return
formEditing.value = selected.value
formOpen.value = true
}
async function submitForm(entry: ConfigObject) {
const next = clone()
const listeners = Array.isArray(next.listeners) ? (next.listeners as ConfigObject[]) : []
const editingIndex = formEditing.value?.listenerIndex ?? null
if (editingIndex === null) listeners.push(entry)
else listeners[editingIndex] = entry
next.listeners = listeners
// 新增的入站直接进专属组模式。否则新建完看到的是一块「这一路不由 Patchbay 管理」
// 的提示,还得再点一次才能用。
const name = String(entry.name)
const withGroup = editingIndex === null ? applySetup(next, name, defaultSetup()) : next
const ok = await commit(withGroup, editingIndex === null ? `已新增 ${name}` : '已保存')
// 失败时不能关对话框——关了再打开就是空表单,用户填的名称、端口、类型全没了
if (!ok) return
formOpen.value = false
if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
}
/** 切换策略组当前选中谁。运行时动作,不写配置文件,所以不走 commit()。 */
async function selectMember(group: string, member: string) {
if (saving.value) return
optimisticSelect(group, member)
try {
await api.selectProxy(group, member)
await reloadRuntime()
} catch (error) {
toast(error instanceof ApiError ? error.message : String(error), {
kind: 'error',
hint: '该操作仅修改内核运行状态,配置文件未被修改。',
})
}
}
/** 待确认删除的那一路。非 null 时确认对话框打开着。 */
const removing = ref<Inbound | null>(null)
function askRemove() {
const target = selected.value
if (!target || target.listenerIndex === null || saving.value) return
removing.value = target
}
async function removeConfirmed() {
const target = removing.value
removing.value = null
if (!target || target.listenerIndex === null || saving.value) return
const next = clone()
const listeners = (next.listeners as ConfigObject[]).filter((_, i) => i !== target.listenerIndex)
// 删到一个不剩时把整个键去掉,别在文件里留一个空数组
if (listeners.length === 0) delete next.listeners
else next.listeners = listeners
selectedId.value = null
await commit(next, `已删除 ${target.name}`)
}
</script>
<template>
<div class="page">
<header class="head">
<div class="sums">
<div class="sum">
<span class="sum-n">{{ inbounds.length }}</span>
<span class="sum-k">入站</span>
</div>
<div class="sum">
<span class="sum-n">{{ subscriptions.length }}</span>
<span class="sum-k">订阅</span>
</div>
<div class="sum">
<span class="sum-n">{{ providerLines.length }}</span>
<span class="sum-k">订阅线路</span>
</div>
</div>
<!-- 侧栏收拢是侧栏自己的事,页面内容不跟着变形 -->
<button class="add" type="button" :disabled="saving" @click="openAdd">
<IconAdd />
<span>新增</span>
</button>
</header>
<div class="split">
<InboundList v-model:selected="selectedId" :inbounds="inbounds" />
<InboundDetail
v-if="selected"
:inbound="selected"
:panel="panel"
:shared-with="sharedWith"
:chain="chainView"
:subscriptions="subscriptions"
:setup="setup"
:testing="testing"
:saving="saving"
@edit="openEdit"
@remove="askRemove"
@select="selectMember"
@test="test"
@save="saveSetup"
@adopt="adopt"
/>
</div>
<p v-if="inbounds.length === 0" class="empty">没有入站,内核不会监听任何端口。</p>
<InboundForm
:open="formOpen"
:editing="formEditing"
:fields="fields"
:taken-names="takenNames"
:saving="saving"
@close="formOpen = false"
@submit="submitForm"
/>
<ConfirmDialog
:open="removing !== null"
:message="removing ? `删除入站「${removing.name}」?端口 ${removing.port} 将停止监听。` : ''"
action="删除"
@close="removing = null"
@confirm="removeConfirmed"
/>
</div>
</template>
<style scoped>
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-4);
margin-bottom: var(--s-5);
}
.sums {
display: flex;
gap: var(--s-6);
}
.sum {
display: flex;
align-items: baseline;
gap: var(--s-2);
}
.sum-n {
font-size: var(--t-2xl);
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.sum-k {
color: var(--c-text-soft);
font-size: var(--t-sm);
}
.add {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
cursor: pointer;
}
.add:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.add:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
/* 左列表右详情。窄时改成上下,详情不再吸顶 */
.split {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 1fr);
gap: var(--s-5);
align-items: start;
}
/* 按内容区宽度切换而不是按视口:侧栏能收拢,同一个视口宽度下内容区可宽可窄。
900px 时右列还有约 380px,再窄详情就挤得难用了 */
.page {
container-type: inline-size;
}
@container (max-width: 900px) {
.split {
grid-template-columns: 1fr;
}
}
.empty {
color: var(--c-text-faint);
font-size: var(--t-sm);
margin-top: var(--s-4);
}
</style>