Files
mihomo-patchbay/web/views/InboundDetail.vue
T
im0o c5ac59ecf9 按审查结论重构:先补测试网,再搬代码
顺序是刻意的。前端一行测试都没有,直接挪代码挪坏了 bun test 不会红,所以先把纯逻辑
抽到能被测试的位置并补上用例,最后才动组件。测试从 37 条增加到 77 条。

抽出来的四块:
- shared/outbound-chain.ts  「这个端口现在走哪条线」的链路解析,带防环,9 条测试
- web/delay-test.ts          延迟测试失败时说什么,6 个分支原本一条测试都没有
- web/runtime.ts             运行时状态的模块级单例,照 prefs.ts 已有的模式
- web/components/InboundList.vue  入站列表

出站的文本原本散成四份(读一份、显示一份、写回一份、表单又抄一份),加一种出站模式
要同时改四处,漏一个就是显示与写回不一致。合并成 OUTBOUND_PRESETS 一张表。

给认证和透传白名单补了 20 条测试。两者都是安全边界、都是纯函数、都不需要内核在线,
之前却只靠需要活内核的冒烟脚本间接覆盖。其中「形似但不是健康检查的路径」几条确认了
正则没写松。

另外:配置快照不再把整份文件原文下发(前端从不读,白白让响应体积翻倍);
yaml-cosmetics 的测试改走生产路径,之前它自己拼了一遍三步流程,落盘链路漏掉其中一步
测试照样会绿。
2026-08-10 18:21:23 +08:00

351 lines
9.2 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 } from 'vue'
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
import { lastSwitchableGroup, resolveChain } from '@shared/outbound-chain.ts'
import { latestDelay } from '../api'
import { kernel, providerLines, runtime } from '../runtime'
import OutboundCard from '../components/OutboundCard.vue'
import IconOffline from '~icons/ri/cloud-off-line'
import IconEdit from '~icons/ri/edit-line'
import IconDelete from '~icons/ri/delete-bin-line'
const props = defineProps<{
inbound: Inbound
groups: string[]
ownProxies: string[]
/** 流量最终交给哪个出站。走规则的入站要查到规则表末尾的 MATCH */
destination: string | null
/** 正在提交的那个出站的名字。只有它显示忙碌,其余照常能点 */
pendingOutbound: string | null
/** 正在测延迟的线路 */
testing: string[]
}>()
const emit = defineEmits<{
pick: [mode: OutboundMode]
edit: []
remove: []
select: [group: string, member: string]
test: [name: string]
}>()
/** 默认入站没有 proxy 字段可写,「出站」那一块对它是只读的。 */
const locked = computed(() => props.inbound.isDefault)
function describe(name: string): string {
const info = runtime.value[name]
if (!info) return ''
return info.now ? `${info.type}(${info.now})` : info.type
}
const delayOf = (name: string) => latestDelay(runtime.value[name])
const isTesting = (name: string) => props.testing.includes(name)
const chain = computed(() => resolveChain(runtime.value, props.destination))
const switchable = computed(() => lastSwitchableGroup(runtime.value, chain.value))
function isCurrentOutbound(mode: OutboundMode): boolean {
const now = props.inbound.outbound
if (now.kind !== mode.kind) return false
return now.kind !== 'named' || now.name === (mode as { name: string }).name
}
const BUILTINS: Array<{ mode: OutboundMode; label: string; sub: string }> = [
{ mode: { kind: 'rules' }, label: 'RULE', sub: '由规则决定' },
{ mode: { kind: 'direct' }, label: 'DIRECT', sub: '直接连接' },
{ mode: { kind: 'global' }, label: 'GLOBAL', sub: '跟随 GLOBAL 组' },
]
const LINE_LOCKED = '订阅线路无法被入站直接引用,需要通过筛选组间接引入。该功能尚未实现。'
const NO_PROXY_FIELD = '该入站没有 proxy 字段'
</script>
<template>
<aside class="detail">
<header class="top">
<div class="title">
<span class="port mono">{{ inbound.port }}</span>
<span class="name">{{ inbound.name }}</span>
</div>
<div v-if="!locked" class="acts">
<button type="button" @click="emit('edit')"><IconEdit /> 编辑</button>
<button type="button" class="danger" @click="emit('remove')"><IconDelete /> 删除</button>
</div>
<dl class="facts">
<dt>类型</dt>
<dd class="mono">{{ inbound.type }}</dd>
<dt>监听</dt>
<dd class="mono">{{ inbound.listen ?? '0.0.0.0' }}</dd>
<dt>附加规则</dt>
<dd class="mono">{{ inbound.ruleSet ?? '—' }}</dd>
</dl>
</header>
<!-- 运行时:换线路。不写文件,点了立刻变 -->
<section v-if="chain.length" class="block">
<h3>流量去向</h3>
<p class="chain mono">
<template v-for="(hop, i) in chain" :key="hop">
<span v-if="i" class="arrow">→</span>
<span :class="i === chain.length - 1 && 'tail'">{{ hop }}</span>
</template>
</p>
<template v-if="switchable">
<h4>{{ switchable.group }} 的当前选中</h4>
<div class="grid">
<OutboundCard
v-for="m in switchable.members"
:key="m"
:name="m"
:sub="describe(m)"
:delay="delayOf(m)"
:selected="m === switchable.now"
:testing="isTesting(m)"
testable
@pick="emit('select', switchable.group, m)"
@test="emit('test', m)"
/>
</div>
</template>
</section>
<!-- 配置:换这路入站指向谁。要写文件、要校验 -->
<section class="block">
<h3>出站</h3>
<p v-if="locked" class="hint">
由 <code>{{ inbound.legacyKey }}</code> 声明。该写法没有 proxy 字段,出站固定为 RULE。
</p>
<div v-if="!locked" class="grid">
<OutboundCard
v-for="b in BUILTINS"
:key="b.label"
:name="b.label"
:sub="b.sub"
:delay="null"
:selected="isCurrentOutbound(b.mode)"
:pending="pendingOutbound === b.label"
@pick="emit('pick', b.mode)"
/>
</div>
<template v-if="groups.length">
<h4 :class="locked && 'is-dim'">策略组</h4>
<div class="grid">
<OutboundCard
v-for="g in groups"
:key="g"
:name="g"
:sub="describe(g)"
:delay="delayOf(g)"
:selected="isCurrentOutbound({ kind: 'named', name: g })"
:disabled-reason="locked ? NO_PROXY_FIELD : undefined"
:pending="pendingOutbound === g"
:testing="isTesting(g)"
testable
@pick="emit('pick', { kind: 'named', name: g })"
@test="emit('test', g)"
/>
</div>
</template>
<template v-if="ownProxies.length">
<h4 :class="locked && 'is-dim'">自有线路</h4>
<div class="grid">
<OutboundCard
v-for="p in ownProxies"
:key="p"
:name="p"
:sub="describe(p)"
:delay="delayOf(p)"
:selected="isCurrentOutbound({ kind: 'named', name: p })"
:disabled-reason="locked ? NO_PROXY_FIELD : undefined"
:pending="pendingOutbound === p"
:testing="isTesting(p)"
testable
@pick="emit('pick', { kind: 'named', name: p })"
@test="emit('test', p)"
/>
</div>
</template>
<h4 :class="locked && 'is-dim'">订阅线路</h4>
<p v-if="!kernel.online" class="hint">
<IconOffline />
内核未连接,无法获取订阅线路
</p>
<p v-else-if="providerLines.length === 0" class="hint">订阅中没有线路</p>
<div v-else class="grid">
<OutboundCard
v-for="line in providerLines"
:key="line"
:name="line"
:sub="describe(line)"
:delay="delayOf(line)"
:selected="false"
:disabled-reason="LINE_LOCKED"
:testing="isTesting(line)"
testable
@test="emit('test', line)"
/>
</div>
</section>
</aside>
</template>
<style scoped>
.detail {
border: 1px solid var(--c-border);
border-radius: var(--r-md);
background: var(--c-surface);
padding: var(--s-4) var(--s-5) var(--s-5);
align-self: start;
position: sticky;
top: var(--s-6);
}
.top {
padding-bottom: var(--s-4);
border-bottom: 1px solid var(--c-border);
}
.title {
display: flex;
align-items: baseline;
gap: var(--s-3);
margin-bottom: var(--s-3);
}
.port {
font-size: var(--t-xl);
font-variant-numeric: tabular-nums;
}
.name {
color: var(--c-text-soft);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acts {
display: flex;
gap: var(--s-2);
margin-bottom: var(--s-3);
}
.acts button {
display: flex;
align-items: center;
gap: var(--s-1);
padding: var(--s-1) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-sm);
cursor: pointer;
}
.acts button:hover {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.acts button.danger:hover {
border-color: var(--c-danger);
color: var(--c-danger);
}
.facts {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--s-1) var(--s-4);
margin: 0;
font-size: var(--t-sm);
}
.facts dt {
color: var(--c-text-faint);
}
.facts dd {
margin: 0;
}
.block {
padding-top: var(--s-4);
}
.block + .block {
margin-top: var(--s-5);
border-top: 1px solid var(--c-border);
}
h3 {
font-size: var(--t-sm);
font-weight: 600;
color: var(--c-text-soft);
margin-bottom: var(--s-3);
}
h4 {
font-size: var(--t-xs);
font-weight: 500;
color: var(--c-text-faint);
margin: var(--s-5) 0 var(--s-2);
}
.chain {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-2);
font-size: var(--t-sm);
color: var(--c-text-soft);
margin-bottom: var(--s-2);
}
.chain .arrow {
color: var(--c-text-faint);
}
.chain .tail {
color: var(--c-accent-text);
font-weight: 500;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: var(--s-2);
}
.hint {
display: flex;
align-items: center;
gap: var(--s-2);
color: var(--c-text-faint);
font-size: var(--t-sm);
line-height: 1.6;
}
.hint code {
color: var(--c-text-soft);
}
.is-dim {
opacity: 0.6;
}
</style>