文件导入走两步:内容先经新端点 POST /api/provider-files 写进内核工作 目录的 providers/ 下(路径白名单校验,只认两个路径助手生成的形状), 成了再写 file 类型的声明——顺序反了内核重载会去读不存在的文件。 file 类型没有地址和自动更新(内核靠文件监视跟进),详情页照此展示, 编辑按钮禁用。ini 导入的取文件按钮换成同一个拖放模块。 订阅的更新间隔改成开关语义:内核只在 interval > 0 时才起定时拉取 (component/resource/fetcher.go:226-228),关掉开关就是不写这个键。 配置页加「更新所有订阅」,详情的「立刻更新」改叫「更新订阅」。 规则表从零开始写入时自动垫上内网直连——保留地址不直连的话第一条 代理规则一生效,访问局域网的流量就全进了代理;按钮改成「补全内网 直连」,只在有缺口时出现。 所有表单对话框点遮罩关闭;失败横幅右侧加关闭按钮。
327 lines
9.0 KiB
Vue
327 lines
9.0 KiB
Vue
<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()
|
||
}
|
||
},
|
||
)
|
||
|
||
/** 把表单值转成配置里该有的形状。空字段不写,避免生成一堆没意义的键。 */
|
||
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
|
||
function onBackdrop(event: MouseEvent) {
|
||
if (event.target === dialog.value) emit('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"
|
||
@click="onBackdrop"
|
||
@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>
|