Files
mihomo-patchbay/web/views/RouterPage.vue
T
im0o e9e2eb555d 每路入站配一组专属策略组,换线不再改配置文件
原本换一条线要改 config.yaml、跑校验、等内核重载,几秒起步;而换线恰恰是这个工具
最高频的动作,把它放在最重的路径上方向就错了。现在每一路配一个 select 组加 AUTO、
FALLBACK 两个,入站的 proxy 永久指向 select,换线只是调一次内核接口。这样做的前提是
选中不会丢:内核把它写进 cache 文件,重启后由 patchSelectGroup 恢复,而
profile.store-selected 默认为真。

链式代理靠给来源订阅复制一份声明、挂上 override.dialer-proxy 实现,因为这个字段写在
策略组上内核只会打条日志然后忽略。副本是 type: file 读原订阅已经落盘的缓存,而不是
自己再拉一次:自己拉的话订阅地址一不可达副本就是空的,那一路入站会一条线都不剩,
表现是端口还在监听却没有任何出口。

顺带把 shared/outbound-chain.ts 里「只有 Selector 接受手动选择」那条断言删了,它是
错的(内核检查的是 SelectAble 接口,URLTest 和 Fallback 也实现了)。界面仍然只让
Selector 可点,但理由换成了另外两种的手动指定会被它们自己撤销。

完整取舍见工作区决策记录 0007。
2026-08-11 03:10:38 +08:00

372 lines
11 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,
pruneGenerated,
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 { density } from '../prefs'
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 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 {
await api.applyConfig(next, props.revision)
done.value = success
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) {
const target = selected.value
if (!target || saving.value) return
await commit(applySetup(clone(), target.name, next), `${target.name} 的代理配置已保存`)
}
/** 把一路还没接管的入站改成专属代理组模式。 */
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: '该操作仅修改内核运行状态,配置文件未被修改。',
}
}
}
async function removeSelected() {
const target = selected.value
if (!target || target.listenerIndex === null || saving.value) return
if (!confirm(`删除入站「${target.name}」?端口 ${target.port} 将停止监听。`)) 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 v-if="density === 'full'">新增</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"
:chain="chainView"
:subscriptions="subscriptions"
:setup="setup"
:testing="testing"
:saving="saving"
@edit="openEdit"
@remove="removeSelected"
@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"
/>
</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;
}
@media (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>