路由页一轮交互细化:草稿预览、预选、若干修正

上半部分收敛成一条规则:草稿和已保存一致时显示内核现状;不一致时显示
按草稿保存后的样子,将移除的画删除线、新增的虚线描边,即将被移除的当前
选中用危险色警示。点击语义只看卡片:现在就存在的成员点了立刻生效,保存
后才加入的点了是预选,保存成功后再作为运行时动作选上。预览的集合运算在
web/member-preview.ts,组件里只留接线。

其余:订阅选择器重写成可搜索多选(原平铺标签订阅一多就是一面墙);删除
入站换成页面内确认对话框;列表右列解析到最终出口并标注链式;「高级配置」
更名「配置」;表单重置改按内容比较(按引用的话任何一次运行时刷新都会冲掉
未保存的草稿,watch 传返回数组的 getter 同样触发这个问题);保存后先刷
运行时再换配置数据,避免卡片区闪空。
This commit is contained in:
2026-08-11 14:39:47 +08:00
parent 6bedce215c
commit a4d08892f7
10 changed files with 658 additions and 132 deletions
+242 -53
View File
@@ -2,20 +2,30 @@
/**
* 选中那一路入站的详情。
*
* 上下两半,界限就是高级配置上面那条分割线:
* 上半 点卡片立刻生效。换的是策略组当前选中谁,不写配置文件(决策记录 0007)
* 下半 高级配置。改的是组从哪些订阅取成员,写配置文件,要校验和重载
* 上下两半,界限就是「配置」那一段上面的分割线:
* 上半 现实与操作。草稿和已保存一致时显示内核现状,点卡片立刻生效(决策记录 0007);
* 不一致时切换成「按草稿保存后的样子」,标「未保存」。现有成员点了照旧立刻
* 生效,只有保存后才加入的成员点了是预选
* 下半 配置草稿。改的是组从哪些订阅取成员,写配置文件,要校验和重载
*
* 两半的反馈方式必须不一样。它们长得一样的时候,用户分不清哪个动作要等、哪个不用。
*/
import { computed, ref, watch } from 'vue'
import type { Inbound } from '@shared/inbounds.ts'
import { displayName, linesMatching } from '@shared/inbound-groups.ts'
import type { OutboundSetup, PanelGroup } from '@shared/inbound-groups.ts'
import {
autoGroupName,
displayName,
fallbackGroupName,
linesMatching,
proxyGroupName,
} from '@shared/inbound-groups.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, runtime } 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'
@@ -28,15 +38,17 @@ const props = defineProps<{
inbound: Inbound
/** 能操作哪些策略组、各自能不能点。groups 为空表示这一路没有能点的组 */
panel: { groups: PanelGroup[]; shared: boolean }
/** 还有哪些入站也走这个共用组。空数组表示只有自己在用,不需要提醒 */
sharedWith: string[]
/** 链式代理的入口组。null 表示没开 */
chain: GroupView | null
/** 配置里定义过的订阅,高级配置里从这里挑来源 */
/** 配置文件里定义过的订阅,配置表单里从这里挑来源 */
subscriptions: string[]
/** 当前的来源和筛选,高级配置的表单初值 */
/** 当前的来源和筛选,配置表单的初值 */
setup: OutboundSetup | null
/** 正在测延迟的线路 */
testing: string[]
/** 高级配置正在保存 */
/** 配置正在保存 */
saving: boolean
}>()
@@ -46,8 +58,8 @@ const emit = defineEmits<{
/** 切换某个策略组当前选中谁 */
select: [group: string, member: string]
test: [name: string, provider: string]
/** 保存高级配置 */
save: [setup: OutboundSetup]
/** 保存配置。picks 是预选:保存成功后由父组件对相应组发起真正的选中 */
save: [setup: OutboundSetup, picks: { proxy: string | null; chain: string | null }]
/** 把这一路改成专属代理组模式 */
adopt: []
}>()
@@ -68,15 +80,6 @@ const activeGroup = computed(
() => props.panel.groups.find((g) => g.name === activeTab.value) ?? null,
)
/**
* 表单里开了链式代理但还没保存。
*
* 这时内核里还没有入口组,卡片点不了,但候选线路是知道的,先按只读画出来。开关拨完
* 一片空白、要按了保存才看得见东西,会让人以为开关没生效。
*/
const chainPreview = computed(() =>
props.chain === null && form.value.chain ? linesFrom(form.value.chain.sources) : [],
)
const proxyLines = computed(() => (activeGroup.value ? membersOf(activeGroup.value) : []))
const chainLines = computed(() => (props.chain ? membersOf(props.chain) : []))
@@ -95,28 +98,47 @@ const exitChain = computed(() =>
)
/**
* 这一路当前这条出口是不是真的经过入口。
* 这一路的出口(预选时按预选算)是不是真的经过入口。
*
* 开了链式代理不等于每条出口都走中转:`DIRECT` 是点名加进代理组的成员,不属于任何订阅,
* 身上没有 `dialer-proxy`,选中它就是直连。这时画一跳入口是在骗人。
*/
const throughChain = computed(() => {
const exit = exitChain.value.at(-1)
const exit = flowExit.value.at(-1)
return exit !== undefined && props.chain !== null && isChainedLine(exit)
})
// ── 高级配置 ────────────────────────────────────────────────
// ── 配置 ────────────────────────────────────────────────
const open = ref(false)
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
}
// 切到别的入站时表单要跟着换,否则会把上一路的设置保存到这一路上
watch(() => [props.inbound.id, props.setup], reset, { immediate: true, deep: true })
// 切到别的入站、或保存后配置内容变了,表单要跟着换,否则会把上一路的设置保存到这一路上。
// 两个坑都踩过,都会让随手换一次线就冲掉没保存的草稿:
// 按引用比较不行——setup 是 computed,任何一次运行时刷新都返回内容相同的新对象;
// 包成一个返回数组的 getter 也不行——Vue 对这种源只做整体引用比较,数组每次都是新的。
// 必须是「源数组」,Vue 才逐项比较,字符串相等就不触发。
watch([() => props.inbound.id, () => JSON.stringify(props.setup)], reset, { immediate: true })
const chainOn = computed({
get: () => form.value.chain !== null,
@@ -125,6 +147,108 @@ const chainOn = computed({
},
})
// ── 草稿预览 ────────────────────────────────────────────
// 上半部分的显示规则只有一条:草稿和已保存一致时显示现实;不一致时显示「按草稿保存后
// 的样子」。点击的语义看卡片本身:现在就存在的成员任何时候点了都立刻生效,只有保存后
// 才加入的成员点了是预选。删除预览、新增预览、链式代理开关的预览都是推论,不是特例。
// 这个块必须放在 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)
@@ -132,6 +256,33 @@ function preview(spec: { sources: string[]; filter: string | 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,
)
/**
* 至少要选一个订阅。AUTO 和 FALLBACK 只有 `use:` 没有点名成员,一个订阅都不选就是空组,
* 内核会直接拒绝整份配置(内核源码 adapter/outboundgroup/parser.go:117-119)。
@@ -167,19 +318,20 @@ const complete = computed(
<dd class="mono">{{ inbound.ruleSet }}</dd>
</template>
<template v-if="exitChain.length">
<template v-if="flowExit.length">
<dt>流量去向</dt>
<dd class="flow mono">
<template v-if="throughChain">
<span class="hop is-chain">{{ displayName(nowOf(chain)) || '未选择' }}</span>
<template v-if="flowEntry !== null">
<span class="hop is-chain">{{ displayName(flowEntry) || '未选择' }}</span>
<span class="arrow">→</span>
</template>
<template v-for="(hop, i) in exitChain" :key="hop">
<template v-for="(hop, i) in flowExit" :key="hop">
<span v-if="i" class="arrow">→</span>
<span :class="['hop', i === exitChain.length - 1 && 'is-proxy']">{{
<span :class="['hop', i === flowExit.length - 1 && 'is-proxy']">{{
displayName(hop)
}}</span>
</template>
<span v-if="flowPreview" class="unsaved">(未保存)</span>
</dd>
</template>
</dl>
@@ -198,11 +350,12 @@ const complete = computed(
<h3>
代理
<span
v-if="panel.shared"
v-if="panel.shared && sharedWith.length > 0"
class="tag"
:title="`换的是 ${panel.groups[0]?.name},所有走分流规则的入站共用它`"
: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">
@@ -217,8 +370,10 @@ const complete = computed(
</button>
</div>
<p v-if="!kernel.online" class="hint"><IconOffline /> 内核未连接</p>
<p v-else-if="proxyLines.length === 0" class="hint">这个策略组现在没有成员</p>
<div v-else class="grid">
<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"
@@ -234,28 +389,55 @@ const complete = computed(
@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>
</section>
<section v-if="chain || chainPreview.length" class="block">
<!-- 跟着表单里的开关走:拨开立刻出现,拨掉(哪怕还没保存)立刻消失 -->
<section v-if="form.chain" class="block">
<h3>
链式代理
<span v-if="!chain" class="tag">未保存</span>
<span v-if="chainDraft" class="tag">未保存</span>
</h3>
<div v-if="!chain" class="grid">
<!-- 草稿态:显示保存后的成员。现有成员点了立刻生效,保存后才加入的点了是预选 -->
<div v-if="chainDraft" class="grid">
<OutboundCard
v-for="name in chainPreview"
:key="name"
:name="name"
sub=""
:delay="null"
:selected="false"
disabled-reason="保存之后才能选"
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 class="grid">
<div v-else-if="chain" class="grid">
<OutboundCard
v-for="line in chainLines"
:key="line.name"
@@ -279,9 +461,9 @@ const complete = computed(
</button>
</section>
<section v-if="!panel.shared" class="block advanced">
<section v-if="!panel.shared" class="block setup">
<button type="button" class="toggle" @click="open = !open">
<span>高级配置</span>
<span>配置</span>
<component :is="open ? IconUp : IconDown" />
</button>
@@ -306,8 +488,10 @@ const complete = computed(
</div>
</div>
<h4>链式代理</h4>
<div class="row">
<span class="label">链式代理</span>
<span class="label">启用</span>
<div class="field">
<button
type="button"
@@ -322,8 +506,6 @@ const complete = computed(
</div>
<template v-if="form.chain">
<h4>链式代理</h4>
<div class="row">
<span class="label">订阅</span>
<SourcePicker v-model="form.chain!.sources" :options="subscriptions" />
@@ -350,7 +532,7 @@ const complete = computed(
type="button"
class="primary"
:disabled="!dirty || !complete || saving"
@click="emit('save', form)"
@click="emit('save', form, { proxy: pendingPick, chain: pendingEntry })"
>
{{ saving ? '保存中' : '保存' }}
</button>
@@ -468,6 +650,11 @@ const complete = computed(
font-weight: 500;
}
.unsaved {
color: var(--c-warn);
font-size: var(--t-xs);
}
.block {
padding-top: var(--s-4);
}
@@ -558,7 +745,7 @@ h3 {
cursor: not-allowed;
}
/* ── 高级配置 ───────────────────────────────── */
/* ── 配置 ───────────────────────────────── */
.toggle {
display: flex;
align-items: center;
@@ -585,10 +772,12 @@ h3 {
}
.form h4 {
font-size: var(--t-xs);
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) {