Files
mihomo-patchbay/web/views/InboundForm.vue
T
im0o cf24755375 路由页做完:新增、编辑、删除入站
弹窗用原生 <dialog>,焦点捕获和 Esc 关闭是浏览器自带的,自己实现只会更差。
表单字段由那张从内核源码提取的字段表驱动:公共字段加当前类型的必填项。
20 种类型合计 398 个字段,全铺出来没人填得完,冷门的留给配置文件手写。

默认入站不再显示出站选择块——那一整块对它都是不可选的,画出来只是噪音,
换成一行说明。另外去掉了行里那根装饰线,导航把仪表盘挪到路由前面。
2026-08-10 12:27:39 +08:00

320 lines
8.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 新增或编辑一路命名入站。
*
* 用原生 <dialog>:焦点捕获、Esc 关闭、背景遮罩都是浏览器自带的行为,
* 自己实现一遍只会做得更差。
*
* 表单字段来自从内核源码提取的字段表。只呈现公共字段加该类型的必填项——
* 20 种类型合计 398 个字段,全铺出来没人填得完,冷门字段留给配置文件手写。
*/
import { computed, ref, watch } from 'vue'
import type { Inbound } from '@shared/inbounds.ts'
import type { FieldSpec, ListenerFields } from '../api'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import IconClose from '~icons/ri/close-line'
const props = defineProps<{
open: boolean
/** 传了就是编辑,没传就是新增 */
editing: Inbound | null
fields: ListenerFields | null
/** 已被占用的名字,用来挡重名 */
takenNames: string[]
saving: boolean
}>()
const emit = defineEmits<{
close: []
submit: [entry: ConfigObject]
}>()
/** 常见的客户端入站排在前面——给容器开端口用的就是这几种。 */
const COMMON_TYPES = ['mixed', 'http', 'socks', 'redir', 'tproxy', 'tunnel', 'tun']
const dialog = ref<HTMLDialogElement | null>(null)
const name = ref('')
const type = ref('mixed')
const port = ref('')
const listen = ref('')
const extras = ref<Record<string, string>>({})
const allTypes = computed(() => Object.keys(props.fields?.types ?? {}))
const serverTypes = computed(() => allTypes.value.filter((t) => !COMMON_TYPES.includes(t)))
/** 当前类型的必填字段,公共字段之外的那些。 */
const requiredExtras = computed<FieldSpec[]>(
() => props.fields?.types[type.value]?.fields.filter((f) => f.required && !f.key.includes('.')) ?? [],
)
const typeNote = computed(() => props.fields?.types[type.value]?.note ?? '')
const nameTaken = computed(
() =>
name.value.trim() !== '' &&
props.takenNames.includes(name.value.trim()) &&
name.value.trim() !== props.editing?.name,
)
const portInvalid = computed(() => {
const raw = port.value.trim()
if (raw === '') return false
// 端口支持 "7890"、"200,302"、"401-429" 这几种写法
return !/^\d+([,-]\d+)*$/.test(raw)
})
const canSubmit = computed(
() =>
name.value.trim() !== '' &&
port.value.trim() !== '' &&
!nameTaken.value &&
!portInvalid.value &&
requiredExtras.value.every((f) => (extras.value[f.key] ?? '').trim() !== '') &&
!props.saving,
)
watch(
() => props.open,
(open) => {
if (open) {
const e = props.editing
name.value = e?.name ?? ''
type.value = e?.type ?? 'mixed'
port.value = e?.port ?? ''
listen.value = e?.listen ?? ''
extras.value = {}
dialog.value?.showModal()
} else {
dialog.value?.close()
}
},
)
/** 把表单值转成配置里该有的形状。空字段不写,避免生成一堆没意义的键。 */
function submit() {
if (!canSubmit.value) return
const entry: ConfigObject = {
name: name.value.trim(),
type: type.value,
port: /^\d+$/.test(port.value.trim()) ? Number(port.value.trim()) : port.value.trim(),
}
if (listen.value.trim()) entry.listen = listen.value.trim()
for (const field of requiredExtras.value) {
const raw = (extras.value[field.key] ?? '').trim()
if (!raw) continue
if (field.type === 'number') entry[field.key] = Number(raw)
else if (field.type === 'string[]') entry[field.key] = raw.split(/[,\s]+/).filter(Boolean)
else if (field.type === 'boolean') entry[field.key] = raw === 'true'
else entry[field.key] = raw
}
// 编辑时保留原来的出站和附加规则——它们在右侧面板改,不归这个表单管
if (props.editing?.outbound.kind === 'named') entry.proxy = props.editing.outbound.name
else if (props.editing?.outbound.kind === 'direct') entry.proxy = 'DIRECT'
else if (props.editing?.outbound.kind === 'global') entry.proxy = 'GLOBAL'
if (props.editing?.ruleSet) entry.rule = props.editing.ruleSet
emit('submit', entry)
}
</script>
<template>
<dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')">
<form method="dialog" @submit.prevent="submit">
<header class="dlg-head">
<h2>{{ editing ? '编辑入站' : '新增入站' }}</h2>
<button type="button" class="x" @click="emit('close')"><IconClose /></button>
</header>
<div class="body">
<label class="row">
<span class="k">名字</span>
<span class="v">
<input v-model="name" type="text" placeholder="容器A" autocomplete="off" />
<span v-if="nameTaken" class="bad">这个名字已经被占用了</span>
</span>
</label>
<label class="row">
<span class="k">类型</span>
<span class="v">
<select v-model="type">
<optgroup label="客户端入站">
<option v-for="t in COMMON_TYPES" :key="t" :value="t">{{ t }}</option>
</optgroup>
<optgroup label="服务端入站">
<option v-for="t in serverTypes" :key="t" :value="t">{{ t }}</option>
</optgroup>
</select>
<span v-if="typeNote" class="note">{{ typeNote }}</span>
</span>
</label>
<label class="row">
<span class="k">端口</span>
<span class="v">
<input v-model="port" class="mono" type="text" placeholder="7778" autocomplete="off" />
<span v-if="portInvalid" class="bad">支持 7778、200,302、401-429 这几种写法</span>
</span>
</label>
<label class="row">
<span class="k">监听</span>
<span class="v">
<input v-model="listen" class="mono" type="text" placeholder="0.0.0.0" autocomplete="off" />
<span class="note">留空等于 0.0.0.0,本机之外也能连</span>
</span>
</label>
<label v-for="f in requiredExtras" :key="f.key" class="row">
<span class="k">{{ f.key }}</span>
<span class="v">
<select v-if="f.enum" v-model="extras[f.key]">
<option value="" disabled>选一个</option>
<option v-for="opt in f.enum" :key="String(opt)" :value="String(opt)">{{ opt }}</option>
</select>
<input v-else v-model="extras[f.key]" class="mono" type="text" autocomplete="off" />
<span v-if="f.note" class="note">{{ f.note }}</span>
</span>
</label>
</div>
<footer class="dlg-foot">
<button type="button" class="btn" @click="emit('close')">取消</button>
<button type="submit" class="btn btn-go" :disabled="!canSubmit">
{{ saving ? '保存中' : '保存' }}
</button>
</footer>
</form>
</dialog>
</template>
<style scoped>
.dlg {
width: min(520px, calc(100vw - 2 * var(--s-5)));
padding: 0;
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);
}
.dlg-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--s-4) var(--s-5);
border-bottom: 1px solid var(--c-border);
}
.dlg-head h2 {
font-size: var(--t-lg);
font-weight: 600;
}
.x {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.x:hover {
background: var(--c-surface-sunken);
color: var(--c-text);
}
.body {
display: flex;
flex-direction: column;
gap: var(--s-4);
padding: var(--s-5);
max-height: min(60vh, 520px);
overflow-y: auto;
}
.row {
display: grid;
grid-template-columns: 5rem 1fr;
gap: var(--s-4);
align-items: baseline;
}
.k {
color: var(--c-text-soft);
font-size: var(--t-sm);
}
.v {
display: flex;
flex-direction: column;
gap: var(--s-1);
min-width: 0;
}
input,
select {
width: 100%;
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-bg);
}
input:focus,
select:focus {
border-color: var(--c-accent);
outline: none;
}
.note {
font-size: var(--t-xs);
color: var(--c-text-faint);
line-height: 1.5;
}
.bad {
font-size: var(--t-xs);
color: var(--c-danger);
}
.dlg-foot {
display: flex;
justify-content: flex-end;
gap: var(--s-2);
padding: var(--s-4) var(--s-5);
border-top: 1px solid var(--c-border);
}
.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;
}
.btn-go {
background: var(--c-accent);
border-color: transparent;
color: #fff;
font-weight: 500;
}
.btn-go:disabled {
background: var(--c-surface-sunken);
border-color: var(--c-border);
color: var(--c-text-faint);
cursor: not-allowed;
}
</style>