去掉转换入口,加测延迟,改掉刷新时丢状态的毛病

最要紧的是第三条:之前每次改完都会先切到「读取中」,页面被拆掉重建,
选中的那一路入站随之丢失,改个设置还得重新找回原来的位置。现在只有首次加载显示占位,
之后都是原地更新。切换策略组还做成了乐观更新——那一步不写配置文件,
没必要让点击等一个往返,真失败了随后的刷新会纠正回来。

测延迟按钮让内核并发测整个策略组。测不通是关于线路的结论而不是 Patchbay 出了问题,
所以全部超时用中性提示,不弹红框。
This commit is contained in:
im0o
2026-08-10 12:53:56 +08:00
parent 0062a261f7
commit 5cdc183190
5 changed files with 114 additions and 77 deletions
+38 -37
View File
@@ -6,7 +6,7 @@ import type { ProxyInfo } from '../api'
import IconOffline from '~icons/ri/cloud-off-line'
import IconEdit from '~icons/ri/edit-line'
import IconDelete from '~icons/ri/delete-bin-line'
import IconConvert from '~icons/ri/swap-line'
import IconFlash from '~icons/ri/flashlight-line'
const props = defineProps<{
inbound: Inbound
@@ -19,14 +19,16 @@ const props = defineProps<{
runtime: Record<string, ProxyInfo>
kernelOnline: boolean
saving: boolean
/** 正在测延迟 */
testing: boolean
}>()
const emit = defineEmits<{
pick: [mode: OutboundMode]
edit: []
remove: []
convert: []
select: [group: string, member: string]
test: [group: string]
}>()
/**
@@ -175,7 +177,18 @@ function delayClass(ms: number | null): string {
</p>
<template v-if="switchable">
<h4>{{ switchable.group }} 当前选中</h4>
<div class="sub-head">
<h4>{{ switchable.group }} 当前选中</h4>
<button
type="button"
class="flash"
:disabled="testing"
:title="`测试 ${switchable.group} 里所有线路的延迟`"
@click="emit('test', switchable.group)"
>
<IconFlash />
</button>
</div>
<div class="grid">
<button
v-for="m in switchable.members"
@@ -198,15 +211,9 @@ function delayClass(ms: number | null): string {
<section class="pick">
<h3>出站</h3>
<div v-if="locked" class="convert">
<p>
由 <code>{{ inbound.legacyKey }}</code> 声明,这种写法没有 proxy 字段,出站固定是
<span class="mono">RULE</span>,下面这些都不能选。
</p>
<button type="button" :disabled="saving" @click="emit('convert')">
<IconConvert /> 转成命名入站
</button>
</div>
<p v-if="locked" class="aside-note">
由 <code>{{ inbound.legacyKey }}</code> 声明,这种写法没有 proxy 字段,出站固定是 RULE
</p>
<div v-if="!locked" class="grid">
<button
@@ -373,43 +380,37 @@ function delayClass(ms: number | null): string {
cursor: not-allowed;
}
.convert {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s-3);
padding: var(--s-3);
border: 1px solid var(--c-border);
border-radius: var(--r-sm);
background: var(--c-surface-sunken);
margin-bottom: var(--s-4);
.is-dim {
opacity: 0.6;
}
.convert p {
color: var(--c-text-soft);
font-size: var(--t-sm);
line-height: 1.6;
}
.convert button {
.sub-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-2);
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
}
.flash {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border: 1px solid transparent;
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.convert button:hover:not(:disabled) {
border-color: var(--c-accent);
.flash:hover:not(:disabled) {
background: var(--c-accent-soft);
color: var(--c-accent-text);
}
.is-dim {
opacity: 0.6;
.flash:disabled {
cursor: progress;
opacity: 0.5;
}
.pick {
+27 -35
View File
@@ -23,7 +23,7 @@ const props = defineProps<{
runtime: Record<string, ProxyInfo>
}>()
const emit = defineEmits<{ changed: [] }>()
const emit = defineEmits<{ changed: []; optimisticSelect: [group: string, member: string] }>()
const inbounds = computed(() => collectInbounds(props.data))
const outbounds = computed(() => collectOutboundNames(props.data))
@@ -35,8 +35,7 @@ const done = ref('')
const fields = ref<ListenerFields | null>(null)
const formOpen = ref(false)
const formEditing = ref<Inbound | null>(null)
/** 正在把哪个老式端口字段转成命名入站。null 表示不是在做转换 */
const convertingKey = ref<string | null>(null)
const testing = ref(false)
onMounted(async () => {
try {
@@ -116,30 +115,13 @@ async function pickOutbound(mode: OutboundMode) {
function openAdd() {
formEditing.value = null
convertingKey.value = null
formOpen.value = true
}
/**
* 把一个老式顶层端口转成命名入站。
*
* 转完它才有 proxy 字段,才能单独指定出站。表单预填端口、类型和它当前实际绑定的地址
* (老式端口的地址是内核从 allow-lan 和 bind-address 推出来的,转换后要显式写进 listen,
* 否则改 allow-lan 会连带影响这个端口)。名字留空,让用户自己起一个——沿用 mixed-port
* 当名字很难看。
*/
function openConvert() {
const target = selected.value
if (!target?.legacyKey) return
formEditing.value = { ...target, name: '' }
convertingKey.value = target.legacyKey
formOpen.value = true
}
function openEdit() {
if (!selected.value || selected.value.isDefault) return
formEditing.value = selected.value
convertingKey.value = null
formOpen.value = true
}
@@ -147,41 +129,50 @@ async function submitForm(entry: ConfigObject) {
const next = clone()
const listeners = Array.isArray(next.listeners) ? (next.listeners as ConfigObject[]) : []
const editingIndex = formEditing.value?.listenerIndex ?? null
const converting = convertingKey.value
if (editingIndex === null) listeners.push(entry)
else listeners[editingIndex] = entry
next.listeners = listeners
// 转换时把老式端口字段去掉,否则同一个端口会被声明两次,内核起不来
if (converting) delete next[converting]
formOpen.value = false
convertingKey.value = null
const what = converting
? `${converting} 转成了命名入站 ${String(entry.name)}`
: editingIndex === null
? `新增了 ${String(entry.name)}`
: '改动已保存'
await commit(next, what)
await commit(next, editingIndex === null ? `新增了 ${String(entry.name)}` : '改动已保存')
if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
}
/** 切换策略组当前选中谁。运行时动作,不写配置文件,所以不走那条落盘链路。 */
async function selectMember(group: string, member: string) {
if (saving.value) return
saving.value = true
// 先本地改掉,别让点击等一个往返。失败了随后的刷新会纠正回来。
emit('optimisticSelect', group, member)
failure.value = null
done.value = ''
try {
await api.selectProxy(group, member)
done.value = `${group} 切到了 ${member}`
emit('changed')
} catch (error) {
const message = error instanceof ApiError ? String(error.payload.error ?? error.message) : String(error)
failure.value = { message, hint: '这一步只改内核的运行状态,配置文件没有被动过。' }
}
}
/** 让内核并发测一个策略组里所有成员的延迟,测完刷新一次拿到新数字。 */
async function testGroup(group: string) {
if (testing.value) return
testing.value = true
failure.value = null
done.value = ''
try {
const result = await api.testGroup(group)
const ok = Object.values(result).filter((ms) => ms > 0).length
done.value = `${group} 测完了,${ok} / ${Object.keys(result).length} 条通`
emit('changed')
} catch (error) {
// 测不通是关于线路的结论,不是 Patchbay 出了问题,所以用中性提示而不是错误框。
// 内核在全部超时时回 504,单条测失败回 503,两种都属于「测出来了,结果不好」。
const raw = error instanceof ApiError ? String(error.payload.message ?? error.message) : String(error)
done.value = raw.includes('timeout') ? `${group} 里的线路全部超时` : `测试没能完成:${raw}`
} finally {
saving.value = false
testing.value = false
}
}
@@ -265,8 +256,9 @@ async function removeSelected() {
@pick="pickOutbound"
@edit="openEdit"
@remove="removeSelected"
@convert="openConvert"
@select="selectMember"
@test="testGroup"
:testing="testing"
/>
</div>