Files
mihomo-patchbay/web/views/InboundDetail.vue
T
im0o 56546b20c5 新增入站不再预填订阅
来源一个都不选时 AUTO 和 FALLBACK 就是空组,内核会拒绝整份配置
(adapter/outboundgroup/parser.go:117-119),所以 applySetup 这时只建
SELECT、成员只有 DIRECT;用户在配置里选好订阅再保存,那两个组建回来。
链式代理打开时同样不预填入口订阅。

「改成逐条选线路」保持全给上:那一路本来就在把流量送到某个出站,
接管不该顺手把它改成直连。
2026-08-14 14:04:47 +08:00

1314 lines
41 KiB
Vue
Raw Permalink 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">
/**
* 选中那一路入站的详情。
*
* 上下两半,界限就是「配置」那一段上面的分割线:
* 上半 现实与操作。草稿和已保存一致时显示内核现状,点卡片立刻生效(决策记录 0007);
* 不一致时切换成「按草稿保存后的样子」,标「未保存」。现有成员点了照旧立刻
* 生效,只有保存后才加入的成员点了是预选
* 下半 配置草稿。改的是组从哪些订阅取成员,写配置文件,要校验和重载
*
* 两半的反馈方式必须不一样。它们长得一样的时候,用户分不清哪个动作要等、哪个不用。
*/
import { computed, nextTick, ref, watch } from 'vue'
import type { Inbound } from '@shared/inbounds.ts'
import {
autoGroupName,
displayName,
fallbackGroupName,
linesMatching,
proxyGroupName,
subRuleName,
} from '@shared/inbound-groups.ts'
import { firstMatchIndex, missingLanRules } from '@shared/rules.ts'
import type { GroupSpec, OutboundSetup, PanelGroup } from '@shared/inbound-groups.ts'
import { resolveChain } from '@shared/outbound-chain.ts'
import { latestDelay } from '../api'
import type { GroupView } from '../runtime'
import { isChainedLine, kernel, linesFrom, membersOf, providers, runtime } from '../runtime'
import { mergeMemberPreview } from '../member-preview'
import type { MemberPreview } from '../member-preview'
import OutboundCard from '../components/OutboundCard.vue'
import SourcePicker from '../components/SourcePicker.vue'
import IconEdit from '~icons/ri/edit-line'
import IconDelete from '~icons/ri/delete-bin-line'
import IconUpgrade from '~icons/ri/arrow-up-circle-line'
import IconRight from '~icons/ri/arrow-right-s-line'
import IconOffline from '~icons/ri/cloud-off-line'
import IconAdd from '~icons/ri/add-line'
import IconUp from '~icons/ri/arrow-up-line'
import IconDown from '~icons/ri/arrow-down-line'
const props = defineProps<{
inbound: Inbound
/** 能操作哪些策略组、各自能不能点。groups 为空表示这一路没有能点的组 */
panel: { groups: PanelGroup[]; shared: boolean }
/** 还有哪些入站也走这个共用组。空数组表示只有自己在用,不需要提醒 */
sharedWith: string[]
/** 链式代理的入口组。null 表示没开 */
chain: GroupView | null
/** 配置文件里定义过的订阅,配置表单里从这里挑来源 */
subscriptions: string[]
/** 当前的来源和筛选,配置表单的初值 */
setup: OutboundSetup | null
/** 这一路的附加规则(不含表底的兜底 MATCH,那条由 Patchbay 自动维护) */
subRules: string[]
/** 正在测延迟的线路 */
testing: string[]
/** 配置正在保存 */
saving: boolean
}>()
const emit = defineEmits<{
edit: []
remove: []
/** 把 legacy 入站升级成命名入站。写配置文件,由父组件先弹确认 */
upgrade: []
/** 切换某个策略组当前选中谁 */
select: [group: string, member: string]
test: [name: string, provider: string]
/** 保存配置。picks 是预选:保存成功后由父组件对相应组发起真正的选中 */
save: [setup: OutboundSetup, picks: { proxy: string | null; chain: string | null }]
/** 把这一路改成专属代理组模式 */
adopt: []
/** 附加规则的增删改和排序。规则文本的编辑走父组件的 RuleForm */
'sub-add': []
'sub-edit': [index: number]
'sub-remove': [index: number]
'sub-move': [index: number, delta: -1 | 1]
'sub-lan': []
}>()
/** 手写的 rule 字段(不是 Patchbay 的附加规则表)才在事实行里原样展示 */
const foreignRuleSet = computed(
() => props.inbound.ruleSet !== null && props.inbound.ruleSet !== subRuleName(props.inbound.name),
)
/** 内网直连还缺几条。全齐了按钮就不出现 */
const lanMissing = computed(() => missingLanRules(props.subRules).length)
/**
* 用户自己写的第一条 MATCH 的下标,没有时是 -1。它之后的行——包括表底的
* 自动兜底——永远轮不到,标红;它自己标黄,hover 说明原因。
*/
const subMatchAt = computed(() => firstMatchIndex(props.subRules))
/** legacy 入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删,只能升级。 */
const locked = computed(() => props.inbound.isDefault)
const activeTab = ref('')
watch(
() => props.panel.groups,
(list) => {
if (!list.some((t) => t.name === activeTab.value)) activeTab.value = list[0]?.name ?? ''
},
{ immediate: true },
)
const activeGroup = computed(
() => props.panel.groups.find((g) => g.name === activeTab.value) ?? null,
)
// ── tab 下划线 ──────────────────────────────────────────
// 一条绝对定位的线,位置按活动 tab 的实际尺寸算,切换时靠 transition 滑过去。
// 每个 tab 各画一条做不出「移动」:线是各自的边框,只能一条消失一条出现。
const tabEls = new Map<string, HTMLElement>()
function setTabEl(name: string, el: unknown) {
if (el instanceof HTMLElement) tabEls.set(name, el)
else tabEls.delete(name)
}
const ink = ref({ left: '0px', width: '0px', opacity: '0' })
function moveInk() {
const el = tabEls.get(activeTab.value)
if (!el) {
ink.value = { ...ink.value, opacity: '0' }
return
}
ink.value = { left: `${String(el.offsetLeft)}px`, width: `${String(el.offsetWidth)}px`, opacity: '1' }
}
watch([activeTab, () => props.panel.groups], () => void nextTick(moveInk), { immediate: true })
const proxyLines = computed(() => (activeGroup.value ? membersOf(activeGroup.value) : []))
const chainLines = computed(() => (props.chain ? membersOf(props.chain) : []))
const nowOf = (group: { name: string } | null) =>
group ? (runtime.value[group.name]?.now ?? '') : ''
const isTesting = (name: string) => props.testing.includes(name)
/**
* 流量最终走到哪。
*
* 从代理组当前选中的那个开始一路跟着「当前选中」查下去。选中的是一条线路就到此为止,
* 选中的是 `AUTO` 这种自动测速组就还要再查一层,否则只显示一个组名等于没回答。
*/
const exitChain = computed(() =>
resolveChain(runtime.value, nowOf(props.panel.groups[0] ?? null) || null),
)
/**
* 这一路的出口(预选时按预选算)是不是真的经过入口。
*
* 开了链式代理不等于每条出口都走中转:`DIRECT` 是点名加进代理组的成员,不属于任何订阅,
* 身上没有 `dialer-proxy`,选中它就是直连。这时画一跳入口是在骗人。
*/
const throughChain = computed(() => {
const exit = flowExit.value.at(-1)
return exit !== undefined && props.chain !== null && isChainedLine(exit)
})
// ── 配置 ────────────────────────────────────────────────
const open = ref(false)
const rootEl = ref<HTMLElement | null>(null)
/**
* 详情底部长出窗口外时,把新内容滚进视野。必须在 foldEnter 把高度压到 0 之前调:
* 那一刻元素刚插入、还是自然高度,量到的 bottom 就是折叠结束后的最终位置,
* 滚动因此能和折叠同时开跑、停在正确的地方;动画中途量到的只有半截。
* 只由配置展开和链式代理拨开触发(见 foldEnter):tab 切换也变高度,但不滚,
* 来回切 tab 时页面跟着跳只会晕。
*/
function revealBottom() {
const el = rootEl.value
if (!el) return
const gap = el.getBoundingClientRect().bottom + 16 - window.innerHeight
if (gap > 0) window.scrollBy({ top: gap, behavior: 'smooth' })
}
/**
* 内容 out-in 切换(换入站、切 tab 共用)时,容器高度从旧值滑到新值。
* 高度过渡没法纯 CSS 做:auto 不可插值。所以离开前把当前高度钉住,
* 新内容进场时量出目标高度、过渡过去,结束后清掉内联样式让内容重新做主。
*/
function lockHeight() {
const el = rootEl.value
if (!el) return
el.style.height = `${String(el.offsetHeight)}px`
el.style.overflow = 'hidden'
}
/**
* v-if 内容的展开收起动画:高度和上外边距一起在 0 和实际值之间过渡,结束后清掉
* 内联样式。给配置表单和链式代理段用。auto 高度不可插值,只能量出来做。
* 外边距必须一起动:链式代理段顶着 .block + .block 给的 24px margin,它在高度
* 动画之外,不带上它出现和消失的瞬间就各抖一下。
*/
function foldEnter(element: Element, done: () => void) {
const el = element as HTMLElement
// 先量再压:此刻元素是自然高度,revealBottom 量到的就是折叠结束后的位置
revealBottom()
const margin = getComputedStyle(el).marginTop
el.style.height = '0'
el.style.marginTop = '0'
el.style.overflow = 'hidden'
void el.offsetHeight
el.style.transition = 'height 0.15s ease, margin-top 0.15s ease'
el.style.height = `${String(el.scrollHeight)}px`
el.style.marginTop = margin
el.addEventListener(
'transitionend',
() => {
el.style.transition = ''
el.style.height = ''
el.style.marginTop = ''
el.style.overflow = ''
done()
},
{ once: true },
)
}
function foldLeave(element: Element, done: () => void) {
const el = element as HTMLElement
el.style.height = `${String(el.offsetHeight)}px`
el.style.overflow = 'hidden'
void el.offsetHeight
el.style.transition = 'height 0.15s ease, margin-top 0.15s ease'
el.style.height = '0'
el.style.marginTop = '0'
el.addEventListener('transitionend', done, { once: true })
}
function settleHeight() {
const el = rootEl.value
if (!el || !el.style.height) return
const from = el.style.height
el.style.height = 'auto'
const to = `${String(el.offsetHeight)}px`
if (to === from) {
el.style.height = ''
el.style.overflow = ''
return
}
el.style.height = from
void el.offsetHeight
el.style.transition = 'height 0.15s ease'
el.style.height = to
el.addEventListener('transitionend', function done(event) {
if (event.propertyName !== 'height') return
el.removeEventListener('transitionend', done)
el.style.transition = ''
el.style.height = ''
el.style.overflow = ''
})
}
const form = ref<OutboundSetup>({ proxy: { sources: [], filter: null }, chain: null })
/**
* 草稿态里预先点中的线路:entry 是链式代理的入口组,pick 是代理组。
*
* 它们不是表单的一部分——不写进配置文件,语义是「保存之后帮我选上它」。保存成功、
* 组按新配置就位之后,父组件把它们作为运行时动作选上。不预选也没关系,选中如果
* 在保存后仍是成员就保持不变,否则落到内核的默认上。
*
* 必须声明在 reset 之前:下面那个 watch 带 immediate,setup 阶段就会执行 reset,
* 这时引用还没初始化的 const 会直接抛错。
*/
const pendingEntry = ref<string | null>(null)
const pendingPick = ref<string | null>(null)
function reset() {
form.value = props.setup
? (JSON.parse(JSON.stringify(props.setup)) as OutboundSetup)
: { proxy: { sources: [...props.subscriptions], filter: null }, chain: null }
pendingEntry.value = null
pendingPick.value = null
}
// 切到别的入站、或保存后配置内容变了,表单要跟着换,否则会把上一路的设置保存到这一路上。
// 两个坑都踩过,都会让随手换一次线就冲掉没保存的草稿:
// 按引用比较不行——setup 是 computed,任何一次运行时刷新都返回内容相同的新对象;
// 包成一个返回数组的 getter 也不行——Vue 对这种源只做整体引用比较,数组每次都是新的。
// 必须是「源数组」,Vue 才逐项比较,字符串相等就不触发。
watch([() => props.inbound.id, () => JSON.stringify(props.setup)], reset, { immediate: true })
const chainOn = computed({
get: () => form.value.chain !== null,
set: (on: boolean) => {
// 入口订阅不预设:入口是「先落到哪一跳」,用户心里有具体的那一条,
// 把订阅全给上只是让他保存前先去取消勾选
form.value.chain = on ? { sources: [], filter: null } : null
},
})
// ── 草稿预览 ────────────────────────────────────────────
// 上半部分的显示规则只有一条:草稿和已保存一致时显示现实;不一致时显示「按草稿保存后
// 的样子」。点击的语义看卡片本身:现在就存在的成员任何时候点了都立刻生效,只有保存后
// 才加入的成员点了是预选。删除预览、新增预览、链式代理开关的预览都是推论,不是特例。
// 这个块必须放在 form 声明之后:watch 会在 setup 阶段立刻求值一次数据源,放在前面
// 就是对暂时性死区里的 form 取值,整个组件直接抛错。
const specDirty = (a: GroupSpec | null | undefined, b: GroupSpec | null | undefined) =>
JSON.stringify(a ?? null) !== JSON.stringify(b ?? null)
const proxyDraft = computed(
() => props.setup !== null && specDirty(form.value.proxy, props.setup.proxy),
)
const chainDraft = computed(
() =>
form.value.chain !== null && (props.chain === null || specDirty(form.value.chain, props.setup?.chain)),
)
/** 按草稿保存后这个来源规格会给组留下哪些线路。筛选写错时按全要画,反正保存被数字预览拦着。 */
function futureLines(spec: GroupSpec): string[] {
const lines = linesFrom(spec.sources)
return linesMatching(spec.filter, lines) ?? lines
}
/** 给预览里新增的名字找线路信息:先按来源订阅找,找不到再查全局名字表。 */
const lookupIn = (sources: string[]) => (name: string) => {
for (const source of sources) {
const line = (providers.value[source] ?? []).find((item) => item.name === name)
if (line) return line
}
return runtime.value[name]
}
const chainFuture = computed(() => (form.value.chain ? futureLines(form.value.chain) : []))
/** 代理组保存后的完整成员:三个点名成员加订阅线路,顺序和 applySetup 生成的一致。
AUTO 和 FALLBACK 两个 tab 只有线路。 */
const proxyFutureAll = computed(() => [
autoGroupName(props.inbound.name),
fallbackGroupName(props.inbound.name),
'DIRECT',
...futureLines(form.value.proxy),
])
const proxyFuture = computed(() =>
activeTab.value === proxyGroupName(props.inbound.name)
? proxyFutureAll.value
: futureLines(form.value.proxy),
)
// 草稿一变,预选可能已经不在保存后的名单里,留着会在保存后选一条不存在的线
watch(chainFuture, (lines) => {
if (pendingEntry.value !== null && !lines.includes(pendingEntry.value)) pendingEntry.value = null
})
watch(proxyFutureAll, (names) => {
if (pendingPick.value !== null && !names.includes(pendingPick.value)) pendingPick.value = null
})
// 预选只在草稿态里有意义。退出草稿的路一共四条:取消、切换入站、保存成功(这三条走
// reset),以及把表单改回和已保存一致(走这里)。漏掉这条的话,预选和「未保存」标记
// 会留在一个没有东西可保存的界面上。
watch(proxyDraft, (on) => {
if (!on) pendingPick.value = null
})
watch(chainDraft, (on) => {
if (!on) pendingEntry.value = null
})
const proxyCards = computed(() =>
mergeMemberPreview(proxyLines.value, proxyFuture.value, lookupIn(form.value.proxy.sources)),
)
const chainCards = computed(() =>
mergeMemberPreview(
props.chain ? chainLines.value : [],
chainFuture.value,
lookupIn(form.value.chain?.sources ?? []),
),
)
/**
* 草稿态里点一张卡是什么动作,取决于这张卡现在存不存在:
* 现在就是成员的 和平时一样立刻换线。真实切换让预选失去意义,一并清掉
* 保存后才加入的 预选,保存成功后由父组件真正选上
* 只有后者才需要「等保存」,把两者都做成预选的话,撤销草稿会让用户以为切换被回退了。
*/
function pickProxyCard(card: MemberPreview) {
if (card.added) {
pendingPick.value = card.name
} else {
pendingPick.value = null
emit('select', activeTab.value, card.name)
}
}
function pickChainCard(card: MemberPreview) {
if (card.added || props.chain === null) {
pendingEntry.value = card.name
} else {
pendingEntry.value = null
emit('select', props.chain.name, card.name)
}
}
/** 当前筛选条件会留下几条线路。写错了 kept 是 null,界面据此提示而不是显示错的数字。 */
function preview(spec: { sources: string[]; filter: string | null }) {
const names = linesFrom(spec.sources)
return { total: names.length, kept: linesMatching(spec.filter, names)?.length ?? null }
}
const dirty = computed(() => JSON.stringify(form.value) !== JSON.stringify(props.setup))
/**
* 流量去向里入口那一跳画不画、画谁。null 表示不画。
*
* 以表单里的开关为准而不是已保存的状态:拨动开关立刻在链条上看到差别,「保存后会变成
* 什么样」在按保存前就有答案。差别还没生效这件事由末尾的「未保存」标记交代。
*/
const flowEntry = computed<string | null>(() => {
if (form.value.chain === null) return null
// 预选优先;入口组还不存在且没预选时画一个空位
if (pendingEntry.value) return pendingEntry.value
if (props.chain === null) return ''
return throughChain.value ? nowOf(props.chain) : null
})
/** 出口链。预选了新的出口时按预选解析,这也是「保存后的样子」。 */
const flowExit = computed(() =>
pendingPick.value ? resolveChain(runtime.value, pendingPick.value) : exitChain.value,
)
/** 流量去向画的是保存后的样子而不是现状:开关没保存,或者有预选。 */
const flowPreview = computed(
() =>
(form.value.chain !== null) !== (props.chain !== null) ||
pendingPick.value !== null ||
pendingEntry.value !== null,
)
/**
* 两边都至少要选一个订阅。
*
* 入口组只有 `use:` 没有点名成员,一个订阅都不选就是空组,内核会直接拒绝整份配置
* (内核源码 adapter/outboundgroup/parser.go:117-119)。在这里拦住,比让用户按下
* 保存再读一段内核报错好。
*
* 代理这边空来源是能落盘的(applySetup 只建 SELECT、成员只有 DIRECT,新增入站就停在
* 这个状态),但那是「还没配」的起点,不该是取消勾选保存出来的结果——一路已经在走代理
* 的入站不能这么悄悄变成直连。
*/
const filled = (spec: { sources: string[] }) => spec.sources.length > 0
const complete = computed(
() => filled(form.value.proxy) && (form.value.chain === null || filled(form.value.chain)),
)
</script>
<template>
<aside ref="rootEl" class="detail">
<!-- 换一路入站:淡出旧内容、淡入新内容,高度的过渡由上面两个钩子做 -->
<Transition
name="swap"
mode="out-in"
@before-leave="lockHeight"
@enter="settleHeight"
@after-enter="moveInk"
>
<div :key="inbound.id">
<header class="top">
<div class="title">
<span class="port mono">{{ inbound.port }}</span>
<span class="name">{{ inbound.name }}</span>
<div class="acts">
<template v-if="!locked">
<button type="button" @click="emit('edit')"><IconEdit /> 编辑</button>
<button type="button" class="danger" @click="emit('remove')">
<IconDelete /> 删除
</button>
</template>
<button v-else type="button" :disabled="saving" @click="emit('upgrade')">
<IconUpgrade /> 改为命名入站
</button>
</div>
</div>
<dl class="facts">
<dt>类型</dt>
<dd class="mono">{{ inbound.type }}</dd>
<dt>监听</dt>
<dd class="mono">{{ inbound.listen ?? '0.0.0.0' }}</dd>
<!-- Patchbay 自己的附加规则表有专门的编辑区块,这里只展示手写的 -->
<template v-if="foreignRuleSet">
<dt>附加规则</dt>
<dd class="mono">{{ inbound.ruleSet }}</dd>
</template>
<template v-if="flowExit.length">
<dt>流量去向</dt>
<dd class="flow mono">
<template v-if="flowEntry !== null">
<span class="hop is-chain">{{ displayName(flowEntry) || '未选择' }}</span>
<span class="arrow">→</span>
</template>
<template v-for="(hop, i) in flowExit" :key="hop">
<span v-if="i" class="arrow">→</span>
<span :class="['hop', i === flowExit.length - 1 && 'is-proxy']">{{
displayName(hop)
}}</span>
</template>
<span v-if="flowPreview" class="unsaved">(未保存)</span>
</dd>
</template>
</dl>
</header>
<!-- 链条上一个能点的组都没有:只能说明现状 -->
<section v-if="panel.groups.length === 0" class="block">
<p class="hint">没有可切换的策略组。</p>
<button v-if="!locked" type="button" class="adopt" :disabled="saving" @click="emit('adopt')">
改成逐条选线路
</button>
</section>
<template v-else>
<section class="block">
<h3>
代理
<span
v-if="panel.shared && sharedWith.length > 0"
class="tag"
:title="`换的是 ${panel.groups[0]?.name},${sharedWith.join('、')} 也走它`"
>共用</span
>
<span v-if="proxyDraft" class="tag">未保存</span>
</h3>
<div v-if="panel.groups.length > 1" class="tabs">
<button
v-for="t in panel.groups"
:key="t.name"
:ref="(el) => setTabEl(t.name, el)"
type="button"
:class="['tab', activeTab === t.name && 'is-on']"
@click="activeTab = t.name"
>
{{ t.label }}
</button>
<span class="ink" :style="ink"></span>
</div>
<!-- 复用入站切换的高度钩子:三个 tab 的成员数不同,高度直接跳很突兀 -->
<Transition name="pane" mode="out-in" @before-leave="lockHeight" @enter="settleHeight">
<div :key="activeTab">
<p v-if="!kernel.online" class="hint"><IconOffline /> 内核未连接</p>
<p v-else-if="!proxyDraft && proxyLines.length === 0" class="hint">
这个策略组现在没有成员
</p>
<div v-else-if="!proxyDraft" class="grid">
<OutboundCard
v-for="line in proxyLines"
:key="line.name"
:name="displayName(line.name)"
:sub="line.info?.type ?? ''"
:delay="latestDelay(line.info)"
:selected="line.name === nowOf(activeGroup)"
:disabled-reason="activeGroup?.pickable ? undefined : '这个组自己决定用哪条'"
:testing="isTesting(line.name)"
testable
tone="proxy"
@pick="emit('select', activeTab, line.name)"
@test="emit('test', line.name, line.provider)"
/>
</div>
<!-- 草稿态:显示保存后的成员。现有成员点了立刻生效,保存后才加入的点了是预选 -->
<div v-else class="grid">
<OutboundCard
v-for="card in proxyCards"
:key="card.name"
:name="displayName(card.name)"
:sub="card.info?.type ?? ''"
:delay="latestDelay(card.info)"
:selected="card.name === (pendingPick ?? nowOf(activeGroup))"
:disabled-reason="
card.removed
? '保存后将从组里移除'
: activeGroup?.pickable
? undefined
: '这个组自己决定用哪条'
"
:draft="card.added"
:removed="card.removed"
tone="proxy"
@pick="pickProxyCard(card)"
/>
</div>
</div>
</Transition>
</section>
<!-- 跟着表单里的开关走:拨开立刻出现,拨掉(哪怕还没保存)立刻消失 -->
<Transition name="fold" @enter="foldEnter" @leave="foldLeave">
<section v-if="form.chain" class="block">
<h3>
链式代理
<span v-if="chainDraft" class="tag">未保存</span>
</h3>
<!-- 草稿态:显示保存后的成员。现有成员点了立刻生效,保存后才加入的点了是预选 -->
<p v-if="chainDraft && form.chain.sources.length === 0" class="hint">还没有选订阅</p>
<div v-else-if="chainDraft" class="grid">
<OutboundCard
v-for="card in chainCards"
:key="card.name"
:name="card.name"
:sub="card.info?.type ?? ''"
:delay="latestDelay(card.info)"
:selected="card.name === (pendingEntry ?? nowOf(chain))"
:disabled-reason="card.removed ? '保存后将从组里移除' : undefined"
:draft="card.added"
:removed="card.removed"
tone="chain"
@pick="pickChainCard(card)"
/>
</div>
<div v-else-if="chain" class="grid">
<OutboundCard
v-for="line in chainLines"
:key="line.name"
:name="line.name"
:sub="line.info?.type ?? ''"
:delay="latestDelay(line.info)"
:selected="line.name === nowOf(chain)"
:testing="isTesting(line.name)"
testable
tone="chain"
@pick="emit('select', chain.name, line.name)"
@test="emit('test', line.name, line.provider)"
/>
</div>
</section>
</Transition>
<!-- 共用的组只能换选中,改不了它的成员来源:那是别人的组 -->
<section v-if="panel.shared && !locked" class="block">
<button type="button" class="adopt" :disabled="saving" @click="emit('adopt')">
改成逐条选线路
</button>
</section>
<section v-if="!panel.shared" class="block setup">
<button type="button" class="toggle" @click="open = !open">
<span>配置</span>
<IconRight :class="['chev', open && 'is-open']" />
</button>
<Transition name="fold" @enter="foldEnter" @leave="foldLeave">
<div v-if="open" class="form">
<h4>代理</h4>
<div class="row">
<span class="label">订阅</span>
<SourcePicker v-model="form.proxy.sources" :options="subscriptions" />
</div>
<div class="row">
<span class="label">筛选</span>
<div class="field">
<input v-model="form.proxy.filter" placeholder="正则,留空表示全部保留" />
<p class="count">
<template v-if="preview(form.proxy).kept === null">筛选条件写法有误</template>
<template v-else>
{{ preview(form.proxy).total }} 条线路里匹配到 {{ preview(form.proxy).kept }} 条
</template>
</p>
</div>
</div>
<h4>链式代理</h4>
<div class="row">
<span class="label">启用</span>
<div class="field">
<button
type="button"
role="switch"
:aria-checked="chainOn"
:class="['switch', chainOn && 'is-on']"
@click="chainOn = !chainOn"
>
<span class="knob"></span>
</button>
</div>
</div>
<template v-if="form.chain">
<div class="row">
<span class="label">订阅</span>
<SourcePicker v-model="form.chain!.sources" :options="subscriptions" />
</div>
<div class="row">
<span class="label">筛选</span>
<div class="field">
<input v-model="form.chain.filter" placeholder="正则,建议筛到作为中转的那一条" />
<p class="count">
<template v-if="preview(form.chain).kept === null">筛选条件写法有误</template>
<template v-else>
{{ preview(form.chain).total }} 条线路里匹配到
{{ preview(form.chain).kept }} 条
</template>
</p>
</div>
</div>
</template>
<div class="form-acts">
<button type="button" :disabled="!dirty || saving" @click="reset">取消</button>
<button
type="button"
class="primary"
:disabled="!dirty || !complete || saving"
@click="emit('save', form, { proxy: pendingPick, chain: pendingEntry })"
>
{{ saving ? '保存中' : '保存' }}
</button>
</div>
</div>
</Transition>
</section>
<!-- 附加规则:只对从这一路进来的流量生效的分流。表底的兜底 MATCH 是生成物,
把没被分走的流量送回这一路自己的出站,单独画、不参与编辑 -->
<section v-if="setup !== null" class="block">
<h3>
附加规则
<span class="h3-acts">
<button
v-if="lanMissing > 0"
type="button"
class="mini"
:disabled="saving"
:title="`加入 ${lanMissing} 条内网直连规则:回环、私网和链路本地地址不进代理`"
@click="emit('sub-lan')"
>
内网直连
</button>
<button type="button" class="mini" :disabled="saving" @click="emit('sub-add')">
<IconAdd /> 添加
</button>
</span>
</h3>
<ul class="srules">
<li
v-for="(rule, i) in subRules"
:key="i"
:class="['srule', subMatchAt >= 0 && i > subMatchAt && 'is-dead', i === subMatchAt && 'is-culprit']"
>
<span
class="mono s-text"
:title="
i === subMatchAt
? 'MATCH 命中所有流量,它之后的规则永远不会被检查'
: subMatchAt >= 0 && i > subMatchAt
? '排在 MATCH 之后,永远不会被检查'
: rule
"
>{{ rule }}</span
>
<span class="s-acts">
<button
type="button"
title="上移"
:disabled="saving || i === 0"
@click="emit('sub-move', i, -1)"
>
<IconUp />
</button>
<button
type="button"
title="下移"
:disabled="saving || i === subRules.length - 1"
@click="emit('sub-move', i, 1)"
>
<IconDown />
</button>
<button type="button" title="编辑" :disabled="saving" @click="emit('sub-edit', i)">
<IconEdit />
</button>
<button
type="button"
class="s-danger"
title="删除"
:disabled="saving"
@click="emit('sub-remove', i)"
>
<IconDelete />
</button>
</span>
</li>
<li
:class="['srule is-final', subMatchAt >= 0 && 'is-dead']"
:title="
subMatchAt >= 0
? '上面的 MATCH 已拦下所有流量,这条自动兜底不会再被使用'
: '兜底:没被上面的规则分走的流量走这一路自己的出站。这一条由 Patchbay 自动维护'
"
>
<span class="mono s-text">MATCH,{{ proxyGroupName(inbound.name) }}</span>
</li>
</ul>
</section>
</template>
</div>
</Transition>
</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-left: auto;
}
.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:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.acts button.danger:hover:not(:disabled) {
border-color: var(--c-danger);
color: var(--c-danger);
}
.acts button:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
.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;
min-width: 0;
}
.flow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-2);
}
.arrow {
color: var(--c-text-faint);
}
.hop {
color: var(--c-text-soft);
}
/* 两跳用两种颜色,和下面卡片的选中态对得上 */
.hop.is-proxy {
color: var(--c-accent-text);
font-weight: 500;
}
.hop.is-chain {
color: var(--c-chain-text);
font-weight: 500;
}
.unsaved {
color: var(--c-warn);
font-size: var(--t-xs);
}
.block {
padding-top: var(--s-4);
}
.block + .block {
margin-top: var(--s-5);
border-top: 1px solid var(--c-border);
}
/* ── 附加规则 ────────────────────────────────── */
.h3-acts {
margin-left: auto;
display: inline-flex;
gap: var(--s-2);
}
.mini {
display: inline-flex;
align-items: center;
gap: var(--s-1);
padding: 2px var(--s-2);
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-soft);
font-size: var(--t-xs);
font-weight: 400;
cursor: pointer;
}
.mini:hover:not(:disabled) {
background: var(--c-surface-sunken);
color: var(--c-text);
}
.mini:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
.mini svg {
width: 13px;
height: 13px;
}
.srules {
list-style: none;
margin: 0;
padding: 0;
}
.srule {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-1) var(--s-2);
border-radius: var(--r-sm);
}
.srule:hover {
background: var(--c-surface-sunken);
}
.s-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--t-xs);
}
/* 兜底那行是生成物:淡色示意,不给操作 */
.is-final .s-text {
color: var(--c-text-faint);
}
/* 不可达:在用户自己的 MATCH 之后(含自动兜底),永远轮不到 */
.srule.is-dead .s-text {
color: var(--c-danger);
}
/* 拦路的那条 MATCH:它后面的行全是死行 */
.srule.is-culprit .s-text {
color: var(--c-warn);
}
/* 操作按钮悬停到行上才现身,规则文本是主角 */
.s-acts {
flex: none;
display: inline-flex;
gap: 1px;
opacity: 0;
transition: opacity 0.15s ease;
}
.srule:hover .s-acts,
.s-acts:focus-within {
opacity: 1;
}
.s-acts button {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.s-acts button:hover:not(:disabled) {
background: var(--c-surface);
color: var(--c-text);
}
.s-acts button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.s-acts .s-danger:hover:not(:disabled) {
color: var(--c-danger);
}
.s-acts svg {
width: 13px;
height: 13px;
}
h3 {
display: flex;
align-items: center;
gap: var(--s-2);
font-size: var(--t-sm);
font-weight: 600;
color: var(--c-text);
letter-spacing: 0.02em;
margin-bottom: var(--s-4);
}
.tag {
padding: 0 var(--s-2);
border: 1px solid var(--c-border-strong);
border-radius: 999px;
font-size: var(--t-xs);
font-weight: 400;
color: var(--c-text-faint);
cursor: help;
}
.tabs {
position: relative;
display: flex;
gap: var(--s-4);
margin-bottom: var(--s-4);
}
/* 选中态是一条离文字有距离的下划线。线是共享的 .ink,切换时滑到活动 tab 底下 */
.tab {
padding: 0 0 var(--s-2);
border: none;
background: none;
color: var(--c-text-faint);
font-size: var(--t-sm);
cursor: pointer;
}
.tab:hover {
color: var(--c-text-soft);
}
.tab.is-on {
color: var(--c-text);
}
.ink {
position: absolute;
bottom: 0;
height: 2px;
background: var(--c-accent);
transition:
left 0.15s ease,
width 0.15s ease,
opacity 0.15s ease;
}
.pane-enter-active,
.pane-leave-active {
transition: opacity 0.12s ease;
}
.pane-enter-from,
.pane-leave-to {
opacity: 0;
}
/* 切换入站的内容淡切。高度的过渡不在这里,由 lockHeight/settleHeight 用内联样式做 */
.swap-enter-active,
.swap-leave-active {
transition: opacity 0.12s ease;
}
.swap-enter-from,
.swap-leave-to {
opacity: 0;
}
.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;
}
.adopt {
padding: var(--s-1) var(--s-4);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-sm);
cursor: pointer;
}
.adopt:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.adopt:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
/* ── 配置 ───────────────────────────────── */
.toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0;
border: none;
background: none;
color: var(--c-text-soft);
font-size: var(--t-sm);
font-weight: 600;
cursor: pointer;
}
.toggle:hover {
color: var(--c-text);
}
/* 收起时朝右,展开顺时针转成朝下,收起再逆时针转回去 */
.chev {
transition: transform 0.15s ease;
}
.chev.is-open {
transform: rotate(90deg);
}
/* 表单不套盒子,靠小标题和一条分割线分段(设计基调第 2 条:不要卡片套卡片)。
上方留白用 padding 而不是 margin:margin 在高度折叠动画之外,会瞬跳 */
.form {
display: flex;
flex-direction: column;
gap: var(--s-3);
padding-top: var(--s-4);
}
.form h4 {
font-size: var(--t-sm);
font-weight: 600;
letter-spacing: 0.04em;
color: var(--c-text-faint);
/* .form 的 flex gap 是统一的 12px,负 margin 单独收窄标题和它第一行之间的距离 */
margin-bottom: calc(-1 * var(--s-1));
}
.form h4:not(:first-child) {
margin-top: var(--s-4);
}
.row {
display: grid;
grid-template-columns: 4.5em minmax(0, 1fr);
align-items: start;
gap: var(--s-3);
}
/* 标签和第一行控件对齐。控件本身高 34px,所以标签也按 34px 居中 */
.label {
display: flex;
align-items: center;
min-height: 34px;
color: var(--c-text-faint);
font-size: var(--t-sm);
}
.field {
display: flex;
min-height: 34px;
flex-direction: column;
justify-content: center;
}
select,
input {
height: 34px;
padding: 0 var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-base);
}
input {
width: 100%;
font-family: var(--f-mono);
}
.count {
margin-top: var(--s-1);
color: var(--c-text-faint);
font-size: var(--t-xs);
}
.switch {
flex: none;
align-self: flex-start;
width: 38px;
height: 22px;
padding: 2px;
border: 1px solid var(--c-border-strong);
border-radius: 999px;
background: var(--c-surface-sunken);
cursor: pointer;
}
.switch .knob {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--c-text-faint);
transition:
transform 0.15s ease,
background 0.15s ease;
}
.switch.is-on {
border-color: var(--c-chain);
background: var(--c-chain-soft);
}
.switch.is-on .knob {
background: var(--c-chain);
transform: translateX(16px);
}
.form-acts {
display: flex;
justify-content: flex-end;
gap: var(--s-2);
}
.form-acts button {
padding: var(--s-1) var(--s-4);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-sm);
cursor: pointer;
}
.form-acts button:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
.form-acts .primary:not(:disabled) {
border-color: var(--c-accent);
background: var(--c-accent-soft);
color: var(--c-accent-text);
}
</style>