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

上半部分收敛成一条规则:草稿和已保存一致时显示内核现状;不一致时显示
按草稿保存后的样子,将移除的画删除线、新增的虚线描边,即将被移除的当前
选中用危险色警示。点击语义只看卡片:现在就存在的成员点了立刻生效,保存
后才加入的点了是预选,保存成功后再作为运行时动作选上。预览的集合运算在
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
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
/**
* 危险动作的二次确认。
*
* 原生 <dialog>,焦点捕获、Esc 关闭、背景遮罩都是浏览器自带的(同 InboundForm)。
* 不用 window.confirm 是因为它的样式游离在页面之外,按钮文案也固定是「确定」,
* 对不上具体要做的动作。打开时焦点落在「取消」上,回车不会误删。
*/
import { ref, watch } from 'vue'
const props = defineProps<{
open: boolean
message: string
/** 确认按钮上的动词,比如「删除」 */
action: string
}>()
const emit = defineEmits<{ close: []; confirm: [] }>()
const dialog = ref<HTMLDialogElement | null>(null)
watch(
() => props.open,
(open) => {
if (open) dialog.value?.showModal()
else dialog.value?.close()
},
)
</script>
<template>
<dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')">
<p class="msg">{{ message }}</p>
<footer class="acts">
<button type="button" class="btn" @click="emit('close')">取消</button>
<button type="button" class="btn go" @click="emit('confirm')">{{ action }}</button>
</footer>
</dialog>
</template>
<style scoped>
.dlg {
width: min(400px, calc(100vw - 2 * var(--s-5)));
padding: var(--s-5);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-lg);
background: var(--c-surface);
color: var(--c-text);
}
.dlg::backdrop {
background: rgb(0 0 0 / 0.45);
}
.msg {
line-height: var(--line);
}
.acts {
display: flex;
justify-content: flex-end;
gap: var(--s-2);
margin-top: var(--s-5);
}
.btn {
padding: var(--s-2) var(--s-4);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
cursor: pointer;
}
/* 确认键用危险色实底:这个对话框只出现在删除这类不可逆动作前 */
.go {
border-color: transparent;
background: var(--c-danger);
color: var(--c-bg);
font-weight: 500;
}
</style>
+27 -8
View File
@@ -6,8 +6,10 @@
*/
import { inboundTypeLabel, isUnsetOutbound, outboundLabel } from '@shared/inbounds.ts'
import { displayName, isGenerated } from '@shared/inbound-groups.ts'
import { resolveChain } from '@shared/outbound-chain.ts'
import type { Inbound } from '@shared/inbounds.ts'
import { runtime } from '../runtime'
import { isChainedLine, runtime } from '../runtime'
import IconLink from '~icons/ri/links-line'
defineProps<{ inbounds: Inbound[] }>()
const selected = defineModel<string | null>('selected', { required: true })
@@ -15,13 +17,19 @@ const selected = defineModel<string | null>('selected', { required: true })
/**
* 这一行右侧显示什么。
*
* 指向 Patchbay 自动创建的代理组时显示那个组当前选中的线路,而不是组名——组名是
* 「patchbay:」加入站名,和左边的名字列完全重复,等于一列没有信息。
* 指向 Patchbay 自动创建的代理组时,顺着「当前选中」解析到最终出口——组名和左边的
* 名字列完全重复,而组选中 AUTO 时「AUTO」也不是答案,用户要的是流量走哪条线。
* 出口经过链式代理入口时带一个标记,和详情里的流量去向对得上。
*/
function outboundOf(inbound: Inbound): string {
function outboundOf(inbound: Inbound): { text: string; chained: boolean } {
const mode = inbound.outbound
if (mode.kind !== 'named' || !isGenerated(mode.name)) return outboundLabel(mode)
return displayName(runtime.value[mode.name]?.now ?? '') || '未选择'
if (mode.kind !== 'named' || !isGenerated(mode.name))
return { text: outboundLabel(mode), chained: false }
const exit = resolveChain(runtime.value, runtime.value[mode.name]?.now ?? null).at(-1)
return {
text: displayName(exit ?? '') || '未选择',
chained: exit !== undefined && isChainedLine(exit),
}
}
</script>
@@ -40,7 +48,10 @@ function outboundOf(inbound: Inbound): string {
<span v-if="inbound.isDefault" class="tag">默认</span>
</span>
<span :class="['out mono', isUnsetOutbound(inbound.outbound) && 'is-muted']">
{{ outboundOf(inbound) }}
<span v-if="outboundOf(inbound).chained" class="chain" title="经过链式代理">
<IconLink />
</span>
{{ outboundOf(inbound).text }}
</span>
</button>
</li>
@@ -114,8 +125,16 @@ function outboundOf(inbound: Inbound): string {
}
.out {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--s-1);
font-size: var(--t-sm);
text-align: right;
}
.chain {
display: flex;
color: var(--c-chain-text);
}
.out.is-muted {
+37 -1
View File
@@ -19,6 +19,10 @@ defineProps<{
disabledReason?: string
/** 这张卡的改动正在提交 */
pending?: boolean
/** 草稿预览里保存后才加入的成员 */
draft?: boolean
/** 草稿预览里保存后将移除的成员 */
removed?: boolean
/** 这条线路正在测延迟 */
testing?: boolean
/** 能不能测延迟。内置出站没得测 */
@@ -48,6 +52,9 @@ function delayClass(ms: number | null): string {
selected && 'is-on',
disabledReason && 'is-off',
pending && 'is-pending',
draft && 'is-draft',
removed && 'is-removed',
!testable && !pending && 'no-foot',
]"
>
<button
@@ -61,7 +68,8 @@ function delayClass(ms: number | null): string {
<span class="sub">{{ sub || '—' }}</span>
</button>
<div class="foot">
<!-- 没内容就不渲染:空的 foot 会盖在卡片下缘,挡住主按钮的点击 -->
<div v-if="testable || pending" class="foot">
<button
v-if="testable"
type="button"
@@ -117,8 +125,30 @@ function delayClass(ms: number | null): string {
border-style: dashed;
}
/* 草稿态:虚线区分于实线的现实。将移除的再加删除线,和「只是禁用」分开 */
.card.is-draft,
.card.is-removed {
border-style: dashed;
}
.card.is-removed .name {
text-decoration: line-through;
}
/* 当前选中的线路即将被移除:危险色警示,这是草稿里最该被看见的一张卡 */
.card.is-removed.is-on {
border-color: var(--c-danger);
background: var(--c-danger-soft);
opacity: 1;
}
.card.is-removed.is-on .name {
color: var(--c-danger);
}
.hit {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--s-1);
min-width: 0;
@@ -129,6 +159,12 @@ function delayClass(ms: number | null): string {
cursor: pointer;
}
/* 没有 foot 时主按钮补回它的全部高度(延迟行 20px + 它的下边距),
草稿卡和真实卡等高,网格不跳动,而且整张卡都是点击区 */
.card.no-foot .hit {
padding-bottom: calc(var(--s-1) + 20px + var(--s-2));
}
.hit:disabled {
cursor: not-allowed;
}
+136 -60
View File
@@ -1,80 +1,145 @@
<script setup lang="ts">
/**
* 挑几个订阅。已选的是一排 tag,末尾一个加号,点开是还没选的那些。
* 订阅多选。已选的是输入框里的一排标签,输入文字过滤下拉列表,点选项在
* 选与不选之间切换,列表保持打开,方便一口气挑好几个。
*
* 不用原生 select:它在已选项旁边显示的是「当前值」,而这里要表达的是「已经选了这几个」,
* 两种语义。之前用 select 拼出来的那版,加号和 tag 的对齐、宽度、选中后的复位都得单独调,
* 全是在跟控件本身的默认行为较劲。
* 交互是多选组合框的通行形态(Ant Design、Element Plus 的多选 Select 都是这一套):
* 标签收在框内、下拉列出全部选项并给已选的打勾、点击不收起。订阅只有一两个时
* 平铺标签更直接,但数量一多平铺就是一面墙,这个形态两头都撑得住。
*/
import { ref } from 'vue'
import IconAdd from '~icons/ri/add-line'
import { computed, ref, watch } from 'vue'
import IconCheck from '~icons/ri/check-line'
import IconRemove from '~icons/ri/close-line'
defineProps<{ options: string[] }>()
const props = defineProps<{ options: string[] }>()
const chosen = defineModel<string[]>({ required: true })
const open = ref(false)
const root = ref<HTMLElement | null>(null)
const input = ref<HTMLInputElement | null>(null)
const open = ref(false)
const query = ref('')
/** 键盘上下键在过滤结果里走到第几项 */
const active = ref(0)
function add(name: string) {
chosen.value = [...chosen.value, name]
open.value = false
const filtered = computed(() =>
props.options.filter((name) => name.toLowerCase().includes(query.value.trim().toLowerCase())),
)
watch(query, () => {
active.value = 0
open.value = true
})
function toggle(name: string) {
chosen.value = chosen.value.includes(name)
? chosen.value.filter((item) => item !== name)
: [...chosen.value, name]
// 点完一项接着挑下一项,不用再点回输入框
input.value?.focus()
}
function remove(name: string) {
chosen.value = chosen.value.filter((item) => item !== name)
}
/** 焦点离开整块时收起。判断新焦点还在不在里面,否则点列表里的按钮会先把自己关掉。 */
function onKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
open.value = true
const count = filtered.value.length
if (count === 0) return
const delta = event.key === 'ArrowDown' ? 1 : -1
active.value = (active.value + delta + count) % count
} else if (event.key === 'Enter') {
event.preventDefault()
const name = filtered.value[active.value]
if (open.value && name) toggle(name)
} else if (event.key === 'Escape') {
open.value = false
} else if (event.key === 'Backspace' && query.value === '') {
const last = chosen.value.at(-1)
if (last) remove(last)
}
}
/** 焦点离开整块时收起。判断新焦点还在不在里面,否则点列表里的选项会先把自己关掉。 */
function onFocusOut(event: FocusEvent) {
const next = event.relatedTarget
if (next instanceof Node && root.value?.contains(next)) return
open.value = false
query.value = ''
}
</script>
<template>
<div ref="root" class="picker" @focusout="onFocusOut">
<span v-for="name in chosen" :key="name" class="tag">
{{ name }}
<button type="button" :title="`移除 ${name}`" @click="remove(name)">
<IconRemove />
</button>
</span>
<div v-if="options.some((o) => !chosen.includes(o))" class="add">
<button type="button" class="plus" title="添加订阅" @click="open = !open">
<IconAdd />
</button>
<ul v-if="open" class="menu">
<template v-for="name in options" :key="name">
<li v-if="!chosen.includes(name)">
<button type="button" @click="add(name)">{{ name }}</button>
</li>
</template>
</ul>
<div class="box" @click="input?.focus()">
<span v-for="name in chosen" :key="name" class="tag">
{{ name }}
<button type="button" :title="`移除 ${name}`" @click.stop="remove(name)">
<IconRemove />
</button>
</span>
<input
ref="input"
v-model="query"
:placeholder="chosen.length === 0 ? '选择订阅' : ''"
:aria-expanded="open"
role="combobox"
autocomplete="off"
@focus="open = true"
@keydown="onKeydown"
/>
</div>
<span v-else-if="chosen.length === 0" class="empty">没有可选的订阅</span>
<ul v-if="open" class="menu" role="listbox" aria-multiselectable="true">
<li v-if="options.length === 0" class="none">没有可选的订阅</li>
<li v-else-if="filtered.length === 0" class="none">没有匹配的订阅</li>
<li v-for="(name, i) in filtered" :key="name">
<button
type="button"
role="option"
:aria-selected="chosen.includes(name)"
:class="['opt', chosen.includes(name) && 'is-on', i === active && 'is-active']"
@mousemove="active = i"
@click="toggle(name)"
>
<span class="mark"><IconCheck v-if="chosen.includes(name)" /></span>
{{ name }}
</button>
</li>
</ul>
</div>
</template>
<style scoped>
.picker {
position: relative;
}
.box {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-2);
gap: var(--s-1);
min-height: 34px;
padding: 3px var(--s-2);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
cursor: text;
}
.box:focus-within {
border-color: var(--c-accent);
}
.tag {
display: inline-flex;
align-items: center;
gap: var(--s-1);
height: 34px;
padding: 0 var(--s-1) 0 var(--s-3);
border: 1px solid var(--c-border-strong);
height: 26px;
padding: 0 var(--s-1) 0 var(--s-2);
border-radius: var(--r-sm);
background: var(--c-surface-sunken);
font-size: var(--t-sm);
@@ -83,8 +148,8 @@ function onFocusOut(event: FocusEvent) {
.tag button {
display: grid;
place-items: center;
width: 22px;
height: 22px;
width: 20px;
height: 20px;
border: none;
border-radius: var(--r-sm);
background: none;
@@ -97,35 +162,31 @@ function onFocusOut(event: FocusEvent) {
color: var(--c-danger);
}
.add {
position: relative;
}
.plus {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 1px dashed var(--c-border-strong);
border-radius: var(--r-sm);
.box input {
flex: 1;
min-width: 6em;
height: 26px;
padding: 0 var(--s-1);
border: none;
background: none;
color: var(--c-text-faint);
cursor: pointer;
font-size: var(--t-sm);
}
.plus:hover {
border-style: solid;
border-color: var(--c-accent);
color: var(--c-accent-text);
/* 焦点态由整个框的描边表达,框里的输入不再自己画一圈 */
.box input:focus,
.box input:focus-visible {
outline: none;
}
/* 浮在上面,不能把下面几行顶下去 */
.menu {
position: absolute;
z-index: 1;
top: calc(34px + var(--s-1));
top: calc(100% + var(--s-1));
left: 0;
min-width: 10em;
min-width: 100%;
max-height: 240px;
overflow-y: auto;
list-style: none;
margin: 0;
padding: var(--s-1);
@@ -134,7 +195,10 @@ function onFocusOut(event: FocusEvent) {
background: var(--c-surface);
}
.menu button {
.opt {
display: flex;
align-items: center;
gap: var(--s-2);
width: 100%;
padding: var(--s-2) var(--s-3);
border: none;
@@ -146,11 +210,23 @@ function onFocusOut(event: FocusEvent) {
cursor: pointer;
}
.menu button:hover {
.opt.is-active {
background: var(--c-surface-sunken);
}
.empty {
.opt.is-on {
color: var(--c-accent-text);
}
/* 勾的位置固定占着,选没选名字都对齐 */
.mark {
display: flex;
flex: none;
width: 1em;
}
.none {
padding: var(--s-2) var(--s-3);
color: var(--c-text-faint);
font-size: var(--t-sm);
}