路由页做完:新增、编辑、删除入站
弹窗用原生 <dialog>,焦点捕获和 Esc 关闭是浏览器自带的,自己实现只会更差。 表单字段由那张从内核源码提取的字段表驱动:公共字段加当前类型的必填项。 20 种类型合计 398 个字段,全铺出来没人填得完,冷门的留给配置文件手写。 默认入站不再显示出站选择块——那一整块对它都是不可选的,画出来只是噪音, 换成一行说明。另外去掉了行里那根装饰线,导航把仪表盘挪到路由前面。
This commit is contained in:
+17
@@ -89,6 +89,20 @@ export function latestDelay(info: ProxyInfo | undefined): number | null {
|
|||||||
return last === undefined || last === 0 ? null : last
|
return last === undefined || last === 0 ? null : last
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FieldSpec {
|
||||||
|
key: string
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'string[]' | 'number[]' | 'object' | 'object[]'
|
||||||
|
required: boolean
|
||||||
|
default: unknown
|
||||||
|
enum: (string | number)[] | null
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListenerFields {
|
||||||
|
common: FieldSpec[]
|
||||||
|
types: Record<string, { note: string; fields: FieldSpec[] }>
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
readConfig: () => request<ConfigSnapshot>('GET', '/api/config'),
|
readConfig: () => request<ConfigSnapshot>('GET', '/api/config'),
|
||||||
|
|
||||||
@@ -102,6 +116,9 @@ export const api = {
|
|||||||
|
|
||||||
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
|
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
|
||||||
|
|
||||||
|
/** 入站类型的字段表,从内核源码提取,用来驱动表单。 */
|
||||||
|
listenerFields: () => request<ListenerFields>('GET', '/api/meta/listener-fields'),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 所有出站的运行时状态:协议类型、策略组当前选中了谁、最近一次延迟。
|
* 所有出站的运行时状态:协议类型、策略组当前选中了谁、最近一次延迟。
|
||||||
* 这些配置文件里都没有,只能问运行中的内核。
|
* 这些配置文件里都没有,只能问运行中的内核。
|
||||||
|
|||||||
+2
-3
@@ -1,8 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 左侧导航。
|
* 左侧导航。
|
||||||
*
|
*
|
||||||
* 顺序按使用频率排:路由是这个工具存在的理由,放在最上面;仪表盘和连接是「看一眼」
|
* 仪表盘打头(打开先看一眼状态),路由紧随其后(这个工具存在的理由),设置垫底。
|
||||||
* 类的,中间;设置很少动,垫底。
|
|
||||||
*/
|
*/
|
||||||
import IconRoute from '~icons/ri/route-line'
|
import IconRoute from '~icons/ri/route-line'
|
||||||
import IconDashboard from '~icons/ri/dashboard-line'
|
import IconDashboard from '~icons/ri/dashboard-line'
|
||||||
@@ -21,8 +20,8 @@ export interface NavItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const NAV: NavItem[] = [
|
export const NAV: NavItem[] = [
|
||||||
{ key: 'router', label: '路由', icon: IconRoute },
|
|
||||||
{ key: 'dashboard', label: '仪表盘', icon: IconDashboard, pending: true },
|
{ key: 'dashboard', label: '仪表盘', icon: IconDashboard, pending: true },
|
||||||
|
{ key: 'router', label: '路由', icon: IconRoute },
|
||||||
{ key: 'config', label: '配置', icon: IconConfig, pending: true },
|
{ key: 'config', label: '配置', icon: IconConfig, pending: true },
|
||||||
{ key: 'requests', label: '请求', icon: IconRequests, pending: true },
|
{ key: 'requests', label: '请求', icon: IconRequests, pending: true },
|
||||||
{ key: 'connections', label: '连接', icon: IconConnections, pending: true },
|
{ key: 'connections', label: '连接', icon: IconConnections, pending: true },
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { computed } from 'vue'
|
|||||||
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
|
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
|
||||||
import { latestDelay } from '../api'
|
import { latestDelay } from '../api'
|
||||||
import type { ProxyInfo } from '../api'
|
import type { ProxyInfo } from '../api'
|
||||||
import IconLock from '~icons/ri/lock-line'
|
|
||||||
import IconOffline from '~icons/ri/cloud-off-line'
|
import IconOffline from '~icons/ri/cloud-off-line'
|
||||||
|
import IconEdit from '~icons/ri/edit-line'
|
||||||
|
import IconDelete from '~icons/ri/delete-bin-line'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
inbound: Inbound
|
inbound: Inbound
|
||||||
@@ -17,7 +18,7 @@ const props = defineProps<{
|
|||||||
saving: boolean
|
saving: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{ pick: [mode: OutboundMode] }>()
|
const emit = defineEmits<{ pick: [mode: OutboundMode]; edit: []; remove: [] }>()
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
key: string
|
key: string
|
||||||
@@ -111,6 +112,13 @@ function delayClass(ms: number | null): string {
|
|||||||
<span class="port mono">{{ inbound.port }}</span>
|
<span class="port mono">{{ inbound.port }}</span>
|
||||||
<span class="name">{{ inbound.name }}</span>
|
<span class="name">{{ inbound.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="!inbound.isDefault" class="acts">
|
||||||
|
<button type="button" :disabled="saving" @click="emit('edit')"><IconEdit /> 编辑</button>
|
||||||
|
<button type="button" class="danger" :disabled="saving" @click="emit('remove')">
|
||||||
|
<IconDelete /> 删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<dl class="facts">
|
<dl class="facts">
|
||||||
<dt>类型</dt>
|
<dt>类型</dt>
|
||||||
<dd class="mono">{{ inbound.type }}</dd>
|
<dd class="mono">{{ inbound.type }}</dd>
|
||||||
@@ -121,13 +129,12 @@ function delayClass(ms: number | null): string {
|
|||||||
</dl>
|
</dl>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="pick">
|
<p v-if="locked" class="aside-note locked-note">
|
||||||
<h3>出站</h3>
|
由 <code>{{ inbound.legacyKey }}</code> 声明,这种写法没有 proxy 字段,出站固定是 RULE
|
||||||
|
</p>
|
||||||
|
|
||||||
<p v-if="locked" class="aside-note">
|
<section v-else class="pick">
|
||||||
<IconLock />
|
<h3>出站</h3>
|
||||||
由 <code>{{ inbound.legacyKey }}</code> 声明,没有 proxy 字段,只能走 RULE
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<button
|
<button
|
||||||
@@ -261,6 +268,43 @@ function delayClass(ms: number | null): string {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.acts {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s-2);
|
||||||
|
margin-bottom: var(--s-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acts button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1);
|
||||||
|
padding: var(--s-1) var(--s-3);
|
||||||
|
border: 1px solid var(--c-border-strong);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--c-surface);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acts button:hover:not(:disabled) {
|
||||||
|
border-color: var(--c-accent);
|
||||||
|
color: var(--c-accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acts button.danger:hover:not(:disabled) {
|
||||||
|
border-color: var(--c-danger);
|
||||||
|
color: var(--c-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acts button:disabled {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locked-note {
|
||||||
|
margin: var(--s-4) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.pick {
|
.pick {
|
||||||
padding-top: var(--s-4);
|
padding-top: var(--s-4);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
<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>
|
||||||
+101
-39
@@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { collectInbounds, collectOutboundNames, outboundToProxyField } from '@shared/inbounds.ts'
|
import { collectInbounds, collectOutboundNames, outboundToProxyField } from '@shared/inbounds.ts'
|
||||||
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
|
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
|
||||||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||||
import { ApiError, api } from '../api'
|
import { ApiError, api } from '../api'
|
||||||
import type { ProxyInfo } from '../api'
|
import type { ListenerFields, ProxyInfo } from '../api'
|
||||||
import { density } from '../prefs'
|
import { density } from '../prefs'
|
||||||
import InboundDetail from './InboundDetail.vue'
|
import InboundDetail from './InboundDetail.vue'
|
||||||
|
import InboundForm from './InboundForm.vue'
|
||||||
import IconAdd from '~icons/ri/add-line'
|
import IconAdd from '~icons/ri/add-line'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -25,6 +26,18 @@ const outbounds = computed(() => collectOutboundNames(props.data))
|
|||||||
const selectedId = ref<string | null>(null)
|
const selectedId = ref<string | null>(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const failure = ref<{ message: string; hint: string } | null>(null)
|
const failure = ref<{ message: string; hint: string } | null>(null)
|
||||||
|
const done = ref('')
|
||||||
|
const fields = ref<ListenerFields | null>(null)
|
||||||
|
const formOpen = ref(false)
|
||||||
|
const formEditing = ref<Inbound | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
fields.value = await api.listenerFields()
|
||||||
|
} catch {
|
||||||
|
fields.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 默认选中第一行。空面板没有信息量,不如直接展示一路入站。
|
// 默认选中第一行。空面板没有信息量,不如直接展示一路入站。
|
||||||
watch(
|
watch(
|
||||||
@@ -36,6 +49,7 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const selected = computed(() => inbounds.value.find((i) => i.id === selectedId.value) ?? null)
|
const selected = computed(() => inbounds.value.find((i) => i.id === selectedId.value) ?? null)
|
||||||
|
const takenNames = computed(() => inbounds.value.map((i) => i.name))
|
||||||
|
|
||||||
const TYPE_LABEL: Record<string, string> = {
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
mixed: 'HTTP + SOCKS5',
|
mixed: 'HTTP + SOCKS5',
|
||||||
@@ -56,31 +70,21 @@ function outboundLabel(mode: OutboundMode): { text: string; muted: boolean } {
|
|||||||
return { text: mode.name, muted: false }
|
return { text: mode.name, muted: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 改一路命名入站的出站,然后走完整的落盘链路。 */
|
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
|
||||||
async function pickOutbound(mode: OutboundMode) {
|
|
||||||
const target = selected.value
|
|
||||||
if (!target || target.listenerIndex === null || saving.value) return
|
|
||||||
|
|
||||||
const next = JSON.parse(JSON.stringify(props.data)) as ConfigObject
|
|
||||||
const listeners = next.listeners as Array<Record<string, unknown>>
|
|
||||||
const entry = listeners[target.listenerIndex]!
|
|
||||||
const proxy = outboundToProxyField(mode)
|
|
||||||
// RULE 是「没有这个字段」而不是「字段为空」,所以是删掉而不是置空。
|
|
||||||
if (proxy === undefined) delete entry.proxy
|
|
||||||
else entry.proxy = proxy
|
|
||||||
|
|
||||||
|
/** 所有改动都走这一条路:落盘失败时按停在哪一步说不同的话。 */
|
||||||
|
async function commit(next: ConfigObject, success: string) {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
failure.value = null
|
failure.value = null
|
||||||
|
done.value = ''
|
||||||
try {
|
try {
|
||||||
await api.applyConfig(next, props.revision)
|
await api.applyConfig(next, props.revision)
|
||||||
|
done.value = success
|
||||||
emit('changed')
|
emit('changed')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError) {
|
||||||
const payload = error.payload as { message?: string; hint?: string }
|
const payload = error.payload as { message?: string; hint?: string }
|
||||||
failure.value = {
|
failure.value = { message: payload.message ?? error.message, hint: payload.hint ?? '' }
|
||||||
message: payload.message ?? error.message,
|
|
||||||
hint: payload.hint ?? '',
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
failure.value = { message: String(error), hint: '' }
|
failure.value = { message: String(error), hint: '' }
|
||||||
}
|
}
|
||||||
@@ -88,6 +92,60 @@ async function pickOutbound(mode: OutboundMode) {
|
|||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pickOutbound(mode: OutboundMode) {
|
||||||
|
const target = selected.value
|
||||||
|
if (!target || target.listenerIndex === null || saving.value) return
|
||||||
|
|
||||||
|
const next = clone()
|
||||||
|
const entry = (next.listeners as ConfigObject[])[target.listenerIndex]!
|
||||||
|
const proxy = outboundToProxyField(mode)
|
||||||
|
// RULE 是「没有这个字段」而不是「字段为空」,所以是删掉而不是置空。
|
||||||
|
if (proxy === undefined) delete entry.proxy
|
||||||
|
else entry.proxy = proxy
|
||||||
|
|
||||||
|
await commit(next, `${target.name} 的出站改成了 ${outboundLabel(mode).text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
formEditing.value = null
|
||||||
|
formOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit() {
|
||||||
|
if (!selected.value || selected.value.isDefault) return
|
||||||
|
formEditing.value = selected.value
|
||||||
|
formOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm(entry: ConfigObject) {
|
||||||
|
const next = clone()
|
||||||
|
const listeners = Array.isArray(next.listeners) ? (next.listeners as ConfigObject[]) : []
|
||||||
|
const editingIndex = formEditing.value?.listenerIndex ?? null
|
||||||
|
|
||||||
|
if (editingIndex === null) listeners.push(entry)
|
||||||
|
else listeners[editingIndex] = entry
|
||||||
|
next.listeners = listeners
|
||||||
|
|
||||||
|
formOpen.value = false
|
||||||
|
await commit(next, editingIndex === null ? `新增了 ${String(entry.name)}` : '改动已保存')
|
||||||
|
if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSelected() {
|
||||||
|
const target = selected.value
|
||||||
|
if (!target || target.listenerIndex === null || saving.value) return
|
||||||
|
if (!confirm(`删除入站「${target.name}」?端口 ${target.port} 会立刻停止监听。`)) return
|
||||||
|
|
||||||
|
const next = clone()
|
||||||
|
const listeners = (next.listeners as ConfigObject[]).filter((_, i) => i !== target.listenerIndex)
|
||||||
|
// 删到一个不剩时把整个键去掉,别在文件里留一个空数组
|
||||||
|
if (listeners.length === 0) delete next.listeners
|
||||||
|
else next.listeners = listeners
|
||||||
|
|
||||||
|
selectedId.value = null
|
||||||
|
await commit(next, `删除了 ${target.name}`)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -108,7 +166,7 @@ async function pickOutbound(mode: OutboundMode) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="add" type="button" disabled>
|
<button class="add" type="button" :disabled="saving" @click="openAdd">
|
||||||
<IconAdd />
|
<IconAdd />
|
||||||
<span v-if="density === 'full'">新增</span>
|
<span v-if="density === 'full'">新增</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -118,6 +176,7 @@ async function pickOutbound(mode: OutboundMode) {
|
|||||||
<strong>{{ failure.message }}</strong>
|
<strong>{{ failure.message }}</strong>
|
||||||
<span v-if="failure.hint">{{ failure.hint }}</span>
|
<span v-if="failure.hint">{{ failure.hint }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-else-if="done" class="done">{{ done }}</p>
|
||||||
|
|
||||||
<div class="split">
|
<div class="split">
|
||||||
<ol class="rows">
|
<ol class="rows">
|
||||||
@@ -133,7 +192,6 @@ async function pickOutbound(mode: OutboundMode) {
|
|||||||
{{ inbound.name }}
|
{{ inbound.name }}
|
||||||
<span v-if="inbound.isDefault" class="tag">默认</span>
|
<span v-if="inbound.isDefault" class="tag">默认</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="wire" aria-hidden="true"></span>
|
|
||||||
<span :class="['out', outboundLabel(inbound.outbound).muted && 'is-muted']">
|
<span :class="['out', outboundLabel(inbound.outbound).muted && 'is-muted']">
|
||||||
{{ outboundLabel(inbound.outbound).text }}
|
{{ outboundLabel(inbound.outbound).text }}
|
||||||
</span>
|
</span>
|
||||||
@@ -151,10 +209,22 @@ async function pickOutbound(mode: OutboundMode) {
|
|||||||
:kernel-online="kernelOnline"
|
:kernel-online="kernelOnline"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
@pick="pickOutbound"
|
@pick="pickOutbound"
|
||||||
|
@edit="openEdit"
|
||||||
|
@remove="removeSelected"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="inbounds.length === 0" class="empty">没有入站。mihomo 不会监听任何端口。</p>
|
<p v-if="inbounds.length === 0" class="empty">没有入站。mihomo 不会监听任何端口。</p>
|
||||||
|
|
||||||
|
<InboundForm
|
||||||
|
:open="formOpen"
|
||||||
|
:editing="formEditing"
|
||||||
|
:fields="fields"
|
||||||
|
:taken-names="takenNames"
|
||||||
|
:saving="saving"
|
||||||
|
@close="formOpen = false"
|
||||||
|
@submit="submitForm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -200,6 +270,10 @@ async function pickOutbound(mode: OutboundMode) {
|
|||||||
background: var(--c-surface);
|
background: var(--c-surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.add:hover:not(:disabled) {
|
||||||
|
border-color: var(--c-accent);
|
||||||
|
color: var(--c-accent-text);
|
||||||
|
}
|
||||||
.add:disabled {
|
.add:disabled {
|
||||||
color: var(--c-text-faint);
|
color: var(--c-text-faint);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -217,6 +291,12 @@ async function pickOutbound(mode: OutboundMode) {
|
|||||||
font-size: var(--t-sm);
|
font-size: var(--t-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.done {
|
||||||
|
color: var(--c-ok);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
margin-bottom: var(--s-4);
|
||||||
|
}
|
||||||
|
|
||||||
/* 左列表右详情。窄屏时改成上下,详情不再吸顶 */
|
/* 左列表右详情。窄屏时改成上下,详情不再吸顶 */
|
||||||
.split {
|
.split {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -245,11 +325,10 @@ async function pickOutbound(mode: OutboundMode) {
|
|||||||
border-top: 1px solid var(--c-border);
|
border-top: 1px solid var(--c-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 线占据弹性列,名字按内容宽——这样那根线是真的把两头连起来,而不是一小截装饰 */
|
|
||||||
.line {
|
.line {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 4.5rem 6.5rem auto minmax(var(--s-5), 1fr) minmax(5rem, auto);
|
grid-template-columns: 4.5rem 6.5rem 1fr minmax(5rem, auto);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s-3);
|
gap: var(--s-3);
|
||||||
padding: var(--s-3) var(--s-4);
|
padding: var(--s-3) var(--s-4);
|
||||||
@@ -297,23 +376,6 @@ async function pickOutbound(mode: OutboundMode) {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* 一段接线的意象,纯装饰,不承载任何数据。
|
|
||||||
* 它原本占满整个弹性列,结果被误认成折线图——一个看起来像信息的东西如果什么都不表示,
|
|
||||||
* 那就是缺陷。所以限制宽度,让它明确读作「一段线」而不是「一条曲线」。
|
|
||||||
*/
|
|
||||||
.wire {
|
|
||||||
height: 1px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 72px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: linear-gradient(
|
|
||||||
to right,
|
|
||||||
var(--c-border-strong),
|
|
||||||
color-mix(in srgb, var(--c-accent) 60%, transparent)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.out {
|
.out {
|
||||||
font-family: var(--f-mono);
|
font-family: var(--f-mono);
|
||||||
font-size: var(--t-sm);
|
font-size: var(--t-sm);
|
||||||
|
|||||||
Reference in New Issue
Block a user