Files
mihomo-patchbay/web/views/InboundForm.vue
T
im0o f927f9fff7 日志改常驻收集,加清空按钮;原生 select 统一换成 SelectBox
日志收集从请求页组件搬进模块级单例(web/log-store.ts,模式同 telemetry),
切走页面不再清空;离线时保留已收的日志,只提示不再更新。清空只清界面。

五处原生 <select> 在三个文件里各自重复同一套自绘箭头样式,而且原生控件
没有「展开中」状态可读,箭头做不了开合动画——提成 SelectBox 组件统一替换:
箭头随开合旋转 180 度,弹层带出入场动画,下方视口不够时向上弹。背景色
留 --select-bg 口子,对话框里沉一档、页面上浮一档,一个值满足不了两边。
2026-08-11 23:47:28 +08:00

316 lines
8.8 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 SelectBox from '../components/SelectBox.vue'
import type { SelectItem } from '../components/SelectBox.vue'
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 typeOptions = computed<SelectItem[]>(() => [
...COMMON_TYPES.map((t) => ({ value: t, group: '客户端入站' })),
...serverTypes.value.map((t) => ({ value: t, group: '服务端入站' })),
])
/** 当前类型的必填字段,公共字段之外的那些。 */
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">
<SelectBox v-model="type" :options="typeOptions" />
<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">
<SelectBox
v-if="f.enum"
v-model="extras[f.key]"
:options="f.enum.map((opt) => ({ value: String(opt) }))"
placeholder="请选择"
/>
<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: flex;
align-items: center;
justify-content: 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 {
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);
}
.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: var(--c-bg);
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>