路由页做完:新增、编辑、删除入站
弹窗用原生 <dialog>,焦点捕获和 Esc 关闭是浏览器自带的,自己实现只会更差。 表单字段由那张从内核源码提取的字段表驱动:公共字段加当前类型的必填项。 20 种类型合计 398 个字段,全铺出来没人填得完,冷门的留给配置文件手写。 默认入站不再显示出站选择块——那一整块对它都是不可选的,画出来只是噪音, 换成一行说明。另外去掉了行里那根装饰线,导航把仪表盘挪到路由前面。
This commit is contained in:
+101
-39
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user