路由页做完:新增、编辑、删除入站

弹窗用原生 <dialog>,焦点捕获和 Esc 关闭是浏览器自带的,自己实现只会更差。
表单字段由那张从内核源码提取的字段表驱动:公共字段加当前类型的必填项。
20 种类型合计 398 个字段,全铺出来没人填得完,冷门的留给配置文件手写。

默认入站不再显示出站选择块——那一整块对它都是不可选的,画出来只是噪音,
换成一行说明。另外去掉了行里那根装饰线,导航把仪表盘挪到路由前面。
This commit is contained in:
im0o
2026-08-10 12:27:39 +08:00
parent e678e7716b
commit cf24755375
5 changed files with 491 additions and 50 deletions
+101 -39
View File
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { collectInbounds, collectOutboundNames, outboundToProxyField } from '@shared/inbounds.ts'
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import { ApiError, api } from '../api'
import type { ProxyInfo } from '../api'
import type { ListenerFields, ProxyInfo } from '../api'
import { density } from '../prefs'
import InboundDetail from './InboundDetail.vue'
import InboundForm from './InboundForm.vue'
import IconAdd from '~icons/ri/add-line'
const props = defineProps<{
@@ -25,6 +26,18 @@ const outbounds = computed(() => collectOutboundNames(props.data))
const selectedId = ref<string | null>(null)
const saving = ref(false)
const failure = ref<{ message: string; hint: string } | null>(null)
const done = ref('')
const fields = ref<ListenerFields | null>(null)
const formOpen = ref(false)
const formEditing = ref<Inbound | null>(null)
onMounted(async () => {
try {
fields.value = await api.listenerFields()
} catch {
fields.value = null
}
})
// 默认选中第一行。空面板没有信息量,不如直接展示一路入站。
watch(
@@ -36,6 +49,7 @@ watch(
)
const selected = computed(() => inbounds.value.find((i) => i.id === selectedId.value) ?? null)
const takenNames = computed(() => inbounds.value.map((i) => i.name))
const TYPE_LABEL: Record<string, string> = {
mixed: 'HTTP + SOCKS5',
@@ -56,31 +70,21 @@ function outboundLabel(mode: OutboundMode): { text: string; muted: boolean } {
return { text: mode.name, muted: false }
}
/** 改一路命名入站的出站,然后走完整的落盘链路。 */
async function pickOutbound(mode: OutboundMode) {
const target = selected.value
if (!target || target.listenerIndex === null || saving.value) return
const next = JSON.parse(JSON.stringify(props.data)) as ConfigObject
const listeners = next.listeners as Array<Record<string, unknown>>
const entry = listeners[target.listenerIndex]!
const proxy = outboundToProxyField(mode)
// RULE 是「没有这个字段」而不是「字段为空」,所以是删掉而不是置空。
if (proxy === undefined) delete entry.proxy
else entry.proxy = proxy
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
/** 所有改动都走这一条路:落盘失败时按停在哪一步说不同的话。 */
async function commit(next: ConfigObject, success: string) {
saving.value = true
failure.value = null
done.value = ''
try {
await api.applyConfig(next, props.revision)
done.value = success
emit('changed')
} catch (error) {
if (error instanceof ApiError) {
const payload = error.payload as { message?: string; hint?: string }
failure.value = {
message: payload.message ?? error.message,
hint: payload.hint ?? '',
}
failure.value = { message: payload.message ?? error.message, hint: payload.hint ?? '' }
} else {
failure.value = { message: String(error), hint: '' }
}
@@ -88,6 +92,60 @@ async function pickOutbound(mode: OutboundMode) {
saving.value = false
}
}
async function pickOutbound(mode: OutboundMode) {
const target = selected.value
if (!target || target.listenerIndex === null || saving.value) return
const next = clone()
const entry = (next.listeners as ConfigObject[])[target.listenerIndex]!
const proxy = outboundToProxyField(mode)
// RULE 是「没有这个字段」而不是「字段为空」,所以是删掉而不是置空。
if (proxy === undefined) delete entry.proxy
else entry.proxy = proxy
await commit(next, `${target.name} 的出站改成了 ${outboundLabel(mode).text}`)
}
function openAdd() {
formEditing.value = null
formOpen.value = true
}
function openEdit() {
if (!selected.value || selected.value.isDefault) return
formEditing.value = selected.value
formOpen.value = true
}
async function submitForm(entry: ConfigObject) {
const next = clone()
const listeners = Array.isArray(next.listeners) ? (next.listeners as ConfigObject[]) : []
const editingIndex = formEditing.value?.listenerIndex ?? null
if (editingIndex === null) listeners.push(entry)
else listeners[editingIndex] = entry
next.listeners = listeners
formOpen.value = false
await commit(next, editingIndex === null ? `新增了 ${String(entry.name)}` : '改动已保存')
if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
}
async function removeSelected() {
const target = selected.value
if (!target || target.listenerIndex === null || saving.value) return
if (!confirm(`删除入站「${target.name}」?端口 ${target.port} 会立刻停止监听。`)) return
const next = clone()
const listeners = (next.listeners as ConfigObject[]).filter((_, i) => i !== target.listenerIndex)
// 删到一个不剩时把整个键去掉,别在文件里留一个空数组
if (listeners.length === 0) delete next.listeners
else next.listeners = listeners
selectedId.value = null
await commit(next, `删除了 ${target.name}`)
}
</script>
<template>
@@ -108,7 +166,7 @@ async function pickOutbound(mode: OutboundMode) {
</div>
</div>
<button class="add" type="button" disabled>
<button class="add" type="button" :disabled="saving" @click="openAdd">
<IconAdd />
<span v-if="density === 'full'">新增</span>
</button>
@@ -118,6 +176,7 @@ async function pickOutbound(mode: OutboundMode) {
<strong>{{ failure.message }}</strong>
<span v-if="failure.hint">{{ failure.hint }}</span>
</div>
<p v-else-if="done" class="done">{{ done }}</p>
<div class="split">
<ol class="rows">
@@ -133,7 +192,6 @@ async function pickOutbound(mode: OutboundMode) {
{{ inbound.name }}
<span v-if="inbound.isDefault" class="tag">默认</span>
</span>
<span class="wire" aria-hidden="true"></span>
<span :class="['out', outboundLabel(inbound.outbound).muted && 'is-muted']">
{{ outboundLabel(inbound.outbound).text }}
</span>
@@ -151,10 +209,22 @@ async function pickOutbound(mode: OutboundMode) {
:kernel-online="kernelOnline"
:saving="saving"
@pick="pickOutbound"
@edit="openEdit"
@remove="removeSelected"
/>
</div>
<p v-if="inbounds.length === 0" class="empty">没有入站。mihomo 不会监听任何端口。</p>
<InboundForm
:open="formOpen"
:editing="formEditing"
:fields="fields"
:taken-names="takenNames"
:saving="saving"
@close="formOpen = false"
@submit="submitForm"
/>
</div>
</template>
@@ -200,6 +270,10 @@ async function pickOutbound(mode: OutboundMode) {
background: var(--c-surface);
cursor: pointer;
}
.add:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.add:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
@@ -217,6 +291,12 @@ async function pickOutbound(mode: OutboundMode) {
font-size: var(--t-sm);
}
.done {
color: var(--c-ok);
font-size: var(--t-sm);
margin-bottom: var(--s-4);
}
/* 左列表右详情。窄屏时改成上下,详情不再吸顶 */
.split {
display: grid;
@@ -245,11 +325,10 @@ async function pickOutbound(mode: OutboundMode) {
border-top: 1px solid var(--c-border);
}
/* 线占据弹性列,名字按内容宽——这样那根线是真的把两头连起来,而不是一小截装饰 */
.line {
width: 100%;
display: grid;
grid-template-columns: 4.5rem 6.5rem auto minmax(var(--s-5), 1fr) minmax(5rem, auto);
grid-template-columns: 4.5rem 6.5rem 1fr minmax(5rem, auto);
align-items: center;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
@@ -297,23 +376,6 @@ async function pickOutbound(mode: OutboundMode) {
line-height: 1.6;
}
/*
* 一段接线的意象,纯装饰,不承载任何数据。
* 它原本占满整个弹性列,结果被误认成折线图——一个看起来像信息的东西如果什么都不表示,
* 那就是缺陷。所以限制宽度,让它明确读作「一段线」而不是「一条曲线」。
*/
.wire {
height: 1px;
width: 100%;
max-width: 72px;
margin: 0 auto;
background: linear-gradient(
to right,
var(--c-border-strong),
color-mix(in srgb, var(--c-accent) 60%, transparent)
);
}
.out {
font-family: var(--f-mono);
font-size: var(--t-sm);