Files
mihomo-patchbay/web/views/InboundForm.vue
T
im0o de3a4c9bcd 路由页动效与细节一批
基调是简单快速:hover 颜色 0.15s 过渡(全局规则,只过渡颜色不碰布局)、
对话框入场淡入加 6px 上移、tab 下划线改成共享的一条并在切换时滑动、tab
内容 out-in 淡切、配置折叠箭头居右随展开旋转 90 度、订阅下拉 0.12s 出入场。
不引 animate.css:这些效果各自十几行 CSS,引库只多不少。

配置展开和拨开链式代理时,若详情底部长出窗口则平滑滚进视野;tab 切换
不触发,来回切时页面跟着跳只会晕。二次确认对话框点遮罩可关闭,内边距
挪进内层,否则点在 dialog 自己的 padding 上也会被当成遮罩。新增入站的
表单不做遮罩关闭:误点会丢掉填了一半的内容。对话框标题改为「新增路由」
「编辑路由」。select 换自绘箭头,原生箭头贴边且位置调不了。
2026-08-11 16:23:03 +08:00

330 lines
9.4 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: 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,
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);
}
/* 自绘下拉箭头:原生箭头贴着右边缘,位置调不了。灰色取两套主题中间值 */
select {
appearance: none;
padding-right: var(--s-6);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='16' height='16'%3E%3Cpath fill='%23898f98' d='M12 15.6 5.7 9.3l1.4-1.4 4.9 4.9 4.9-4.9 1.4 1.4z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--s-3) center;
}
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>