路由页一轮交互细化:草稿预览、预选、若干修正
上半部分收敛成一条规则:草稿和已保存一致时显示内核现状;不一致时显示 按草稿保存后的样子,将移除的画删除线、新增的虚线描边,即将被移除的当前 选中用危险色警示。点击语义只看卡片:现在就存在的成员点了立刻生效,保存 后才加入的点了是预选,保存成功后再作为运行时动作选上。预览的集合运算在 web/member-preview.ts,组件里只留接线。 其余:订阅选择器重写成可搜索多选(原平铺标签订阅一多就是一面墙);删除 入站换成页面内确认对话框;列表右列解析到最终出口并标注链式;「高级配置」 更名「配置」;表单重置改按内容比较(按引用的话任何一次运行时刷新都会冲掉 未保存的草稿,watch 传返回数组的 getter 同样触发这个问题);保存后先刷 运行时再换配置数据,避免卡片区闪空。
This commit is contained in:
+3
-1
@@ -28,9 +28,11 @@ async function load() {
|
||||
refreshing.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
// 先刷运行时再换配置数据。反过来的话,保存后界面已经按新配置渲染,
|
||||
// 而新建的组在运行时里还查不到,成员卡片会先闪空一下再出现。
|
||||
await reloadRuntime()
|
||||
snapshot.value = await api.readConfig()
|
||||
needsToken.value = false
|
||||
await reloadRuntime()
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.unauthorized) {
|
||||
clearToken()
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+131
-55
@@ -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">
|
||||
<div class="box" @click="input?.focus()">
|
||||
<span v-for="name in chosen" :key="name" class="tag">
|
||||
{{ name }}
|
||||
<button type="button" :title="`移除 ${name}`" @click="remove(name)">
|
||||
<button type="button" :title="`移除 ${name}`" @click.stop="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>
|
||||
<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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { mergeMemberPreview } from './member-preview.ts'
|
||||
import type { ProxyInfo } from './api.ts'
|
||||
|
||||
const info = (name: string) => ({ name, type: 'Vmess' }) as ProxyInfo
|
||||
const noLookup = () => undefined
|
||||
|
||||
describe('成员预览', () => {
|
||||
it('草稿和现状一致时谁都不标', () => {
|
||||
const cards = mergeMemberPreview([{ name: 'A', info: info('A') }], ['A'], noLookup)
|
||||
expect(cards).toEqual([{ name: 'A', info: info('A'), removed: false, added: false }])
|
||||
})
|
||||
|
||||
it('不在保存后名单里的现有成员标为将移除', () => {
|
||||
const cards = mergeMemberPreview(
|
||||
[
|
||||
{ name: 'A', info: info('A') },
|
||||
{ name: 'B', info: info('B') },
|
||||
],
|
||||
['B'],
|
||||
noLookup,
|
||||
)
|
||||
expect(cards.map((c) => [c.name, c.removed])).toEqual([
|
||||
['A', true],
|
||||
['B', false],
|
||||
])
|
||||
})
|
||||
|
||||
it('新增的追加在末尾,线路信息从 lookup 取', () => {
|
||||
const cards = mergeMemberPreview([{ name: 'A', info: info('A') }], ['A', 'C'], info)
|
||||
expect(cards[1]).toEqual({ name: 'C', info: info('C'), removed: false, added: true })
|
||||
})
|
||||
|
||||
it('现有成员保持原序在前,新增按保存后名单的顺序在后', () => {
|
||||
// 顺序稳定才不会出现「改一个筛选,卡片全部跳位」
|
||||
const cards = mergeMemberPreview(
|
||||
[
|
||||
{ name: 'B', info: undefined },
|
||||
{ name: 'A', info: undefined },
|
||||
],
|
||||
['C', 'A', 'D'],
|
||||
noLookup,
|
||||
)
|
||||
expect(cards.map((c) => c.name)).toEqual(['B', 'A', 'C', 'D'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 成员卡片的「保存后会怎样」预览。
|
||||
*
|
||||
* 配置草稿和已保存内容不一致时,卡片区不再显示内核现状,而是显示按草稿保存后组里
|
||||
* 会有谁:留下的照常、将移除的标出来、新增的追加在后面。放在组件外面是为了让
|
||||
* bun test 够得着——这是纯集合运算,写进 computed 就没人测了。
|
||||
*/
|
||||
import type { ProxyInfo } from './api'
|
||||
|
||||
export interface MemberPreview {
|
||||
name: string
|
||||
info: ProxyInfo | undefined
|
||||
/** 现在是成员,保存后不再是 */
|
||||
removed: boolean
|
||||
/** 现在不是成员,保存后才加入 */
|
||||
added: boolean
|
||||
}
|
||||
|
||||
export function mergeMemberPreview(
|
||||
current: Array<{ name: string; info: ProxyInfo | undefined }>,
|
||||
future: string[],
|
||||
lookup: (name: string) => ProxyInfo | undefined,
|
||||
): MemberPreview[] {
|
||||
const futureSet = new Set(future)
|
||||
const currentNames = new Set(current.map((item) => item.name))
|
||||
return [
|
||||
...current.map((item) => ({
|
||||
name: item.name,
|
||||
info: item.info,
|
||||
removed: !futureSet.has(item.name),
|
||||
added: false,
|
||||
})),
|
||||
...future
|
||||
.filter((name) => !currentNames.has(name))
|
||||
.map((name) => ({ name, info: lookup(name), removed: false, added: true })),
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -118,7 +118,7 @@ export function isChainedLine(name: string): boolean {
|
||||
/**
|
||||
* 这几个订阅里一共有哪些线路。
|
||||
*
|
||||
* 只给高级配置里那个「匹配到几条」的预览用:那时用户选的来源还没保存,内核也还不知道
|
||||
* 只给配置表单里那个「匹配到几条」的预览用:那时用户选的来源还没保存,内核也还不知道
|
||||
* 有这么个组,只能自己按来源算一遍。已经存在的组要用下面的 `membersOf`。
|
||||
*/
|
||||
export function linesFrom(uses: string[]): string[] {
|
||||
|
||||
+242
-53
@@ -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) {
|
||||
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
chainGroupName,
|
||||
groupUses,
|
||||
panelGroups,
|
||||
proxyGroupName,
|
||||
pruneGenerated,
|
||||
sharedPeers,
|
||||
syncGenerated,
|
||||
readSetup,
|
||||
userProviders,
|
||||
@@ -27,6 +29,7 @@ import type { GroupView } from '../runtime'
|
||||
import { optimisticSelect, providerLines, reloadRuntime, runtime } from '../runtime'
|
||||
import { useDelayTest } from '../delay-test'
|
||||
import { density } from '../prefs'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import InboundList from '../components/InboundList.vue'
|
||||
import InboundDetail from './InboundDetail.vue'
|
||||
import InboundForm from './InboundForm.vue'
|
||||
@@ -91,7 +94,12 @@ const panel = computed(() =>
|
||||
: { groups: [], shared: false },
|
||||
)
|
||||
|
||||
/** 共用的组是别人的,改不了它的来源,所以高级配置也不给。 */
|
||||
/** 选中这一路落在共用组上时,还有哪些入站也走同一个组。决定「共用」标记显不显示。 */
|
||||
const sharedWith = computed(() =>
|
||||
selected.value ? sharedPeers(props.data, runtime.value, inbounds.value, selected.value) : [],
|
||||
)
|
||||
|
||||
/** 共用的组是别人的,改不了它的来源,所以配置表单也不给。 */
|
||||
const setup = computed<OutboundSetup | null>(() =>
|
||||
selected.value && !panel.value.shared ? readSetup(props.data, selected.value.name) : null,
|
||||
)
|
||||
@@ -115,8 +123,9 @@ async function commit(raw: ConfigObject, success: string) {
|
||||
done.value = ''
|
||||
notice.value = ''
|
||||
try {
|
||||
await api.applyConfig(next, props.revision)
|
||||
done.value = success
|
||||
const result = await api.applyConfig(next, props.revision)
|
||||
// 没重载有两种情况(内容没有变化、内核不在线只写了文件),都要如实说,不能只报成功
|
||||
done.value = result.reloaded ? success : `${success}(${result.reloadMessage})`
|
||||
emit('saved')
|
||||
} catch (error) {
|
||||
failure.value =
|
||||
@@ -128,11 +137,25 @@ async function commit(raw: ConfigObject, success: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存高级配置:改的是这一路的组从哪些订阅取成员、筛掉哪些。 */
|
||||
async function saveSetup(next: OutboundSetup) {
|
||||
/** 保存这一路的配置:组从哪些订阅取成员、筛掉哪些、开不开链式代理。 */
|
||||
async function saveSetup(next: OutboundSetup, picks: { proxy: string | null; chain: string | null }) {
|
||||
const target = selected.value
|
||||
if (!target || saving.value) return
|
||||
await commit(applySetup(clone(), target.name, next), `${target.name} 的代理配置已保存`)
|
||||
if (failure.value) return
|
||||
// 草稿态里预选的线路在保存成功、组按新配置就位之后逐个选上。这是运行时动作,
|
||||
// 失败不影响已保存的配置:组停在内核的默认选中上,刷新后显示的就是真实状态。
|
||||
const picked: Array<[string, string | null]> = [
|
||||
[proxyGroupName(target.name), picks.proxy],
|
||||
[chainGroupName(target.name), picks.chain],
|
||||
]
|
||||
let selectedAny = false
|
||||
for (const [group, member] of picked) {
|
||||
if (!member) continue
|
||||
await api.selectProxy(group, member).catch(() => {})
|
||||
selectedAny = true
|
||||
}
|
||||
if (selectedAny) await reloadRuntime()
|
||||
}
|
||||
|
||||
/** 把一路还没接管的入站改成专属代理组模式。 */
|
||||
@@ -192,10 +215,19 @@ async function selectMember(group: string, member: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSelected() {
|
||||
/** 待确认删除的那一路。非 null 时确认对话框打开着。 */
|
||||
const removing = ref<Inbound | null>(null)
|
||||
|
||||
function askRemove() {
|
||||
const target = selected.value
|
||||
if (!target || target.listenerIndex === null || saving.value) return
|
||||
if (!confirm(`删除入站「${target.name}」?端口 ${target.port} 将停止监听。`)) return
|
||||
removing.value = target
|
||||
}
|
||||
|
||||
async function removeConfirmed() {
|
||||
const target = removing.value
|
||||
removing.value = null
|
||||
if (!target || target.listenerIndex === null || saving.value) return
|
||||
|
||||
const next = clone()
|
||||
const listeners = (next.listeners as ConfigObject[]).filter((_, i) => i !== target.listenerIndex)
|
||||
@@ -246,13 +278,14 @@ async function removeSelected() {
|
||||
v-if="selected"
|
||||
:inbound="selected"
|
||||
:panel="panel"
|
||||
:shared-with="sharedWith"
|
||||
:chain="chainView"
|
||||
:subscriptions="subscriptions"
|
||||
:setup="setup"
|
||||
:testing="testing"
|
||||
:saving="saving"
|
||||
@edit="openEdit"
|
||||
@remove="removeSelected"
|
||||
@remove="askRemove"
|
||||
@select="selectMember"
|
||||
@test="test"
|
||||
@save="saveSetup"
|
||||
@@ -271,6 +304,14 @@ async function removeSelected() {
|
||||
@close="formOpen = false"
|
||||
@submit="submitForm"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="removing !== null"
|
||||
:message="removing ? `删除入站「${removing.name}」?端口 ${removing.port} 将停止监听。` : ''"
|
||||
action="删除"
|
||||
@close="removing = null"
|
||||
@confirm="removeConfirmed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user