Files
mihomo-patchbay/web/views/RouterPage.vue
T
im0o d028e63a74 路由页双列转单列改按内容区宽度判断,阈值等效提高
原来按视口 900px 切换,侧栏收拢与否会让同一视口下的内容区差出一百多
像素,且用户实测切换时右列已经太窄。改成容器查询,内容区 900px 以下
转单列,等效视口阈值提高到约 1100-1160。
2026-08-11 17:52:00 +08:00

419 lines
13 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 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 }>()
/** 配置文件变了,需要重读整份配置。运行时的变化不走这里。 */
const emit = defineEmits<{ saved: [] }>()
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 failure = ref<{ message: string; hint: string } | null>(null)
/** 成功的结果,绿色 */
const done = ref('')
/** 中性的告知,比如某条线路测不通——那是关于线路的结论,不是操作失败 */
const notice = ref('')
const fields = ref<ListenerFields | null>(null)
const formOpen = ref(false)
const formEditing = ref<Inbound | null>(null)
const { testing, test } = useDelayTest(
() => void reloadRuntime(),
(message) => {
notice.value = message
},
)
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 },
)
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) {
// 先把生成的东西按当前规则重建一遍,再清掉没人再引用的。不清理的话,改几次文件里就
// 攒下一堆用户从没主动建过的东西,而没人用的订阅副本还会按 interval 继续联网。
const next = pruneGenerated(syncGenerated(raw, collectInbounds(raw)))
saving.value = true
failure.value = null
done.value = ''
notice.value = ''
try {
const result = await api.applyConfig(next, props.revision)
// 没重载有两种情况(内容没有变化、内核不在线只写了文件),都要如实说,不能只报成功
done.value = result.reloaded ? success : `${success}(${result.reloadMessage})`
emit('saved')
} catch (error) {
failure.value =
error instanceof ApiError
? { message: error.message, hint: error.hint }
: { message: String(error), hint: '' }
} 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
await commit(applySetup(clone(), target.name, next), `${target.name} 的代理配置已保存`)
if (failure.value) 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
await commit(withGroup, editingIndex === null ? `已新增 ${name}` : '已保存')
// 失败时不能关对话框——关了再打开就是空表单,用户填的名称、端口、类型全没了
if (failure.value) 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)
failure.value = null
done.value = ''
notice.value = ''
try {
await api.selectProxy(group, member)
await reloadRuntime()
} catch (error) {
failure.value = {
message: error instanceof ApiError ? error.message : String(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 v-if="failure" class="failure">
<strong>{{ failure.message }}</strong>
<span v-if="failure.hint">{{ failure.hint }}</span>
</div>
<p v-else-if="done" class="done">{{ done }}</p>
<p v-else-if="notice" class="notice">{{ notice }}</p>
<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;
}
.failure {
display: flex;
flex-direction: column;
gap: var(--s-1);
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-3) var(--s-4);
margin-bottom: var(--s-4);
font-size: var(--t-sm);
}
.done {
color: var(--c-ok);
font-size: var(--t-sm);
margin-bottom: var(--s-4);
}
.notice {
color: var(--c-text-soft);
font-size: var(--t-sm);
margin-bottom: var(--s-4);
}
/* 左列表右详情。窄时改成上下,详情不再吸顶 */
.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>