Files
mihomo-patchbay/web/views/InboundForm.vue
T
im0o 71adcd60ef 界面文案改成中性措辞;延迟测试报出真实原因
原来那句「X 测不通」把两种要查不同地方的情况混在了一起。内核的语义是精确的
(hub/route/proxies.go:122-145):504 表示在给定超时内没有完成,503 表示连接建立失败
或测得延迟为 0。现在分开报,超时会写明是几秒,503 且内核没给具体错误时说明可能是
线路失效或本机到该服务器网络不通。

其余文案统一过一遍:「本机之外也能连」「这个名字已经被占用了」「还没做」这类口语改成
「允许来自其它主机的连接」「名称已存在」「尚未实现」。
2026-08-10 13:38:02 +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">支持单个端口、逗号分隔的多个端口、连字符表示的区间</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>