路由页改成主从布局,出站可以真的改了
右侧详情面板取代原来的手风琴展开:空间用起来了,选中状态更清楚,行也不会被推来推去。 出站选择做成分组的按钮网格而不是下拉框——一眼看得到全部可选项和当前状态。 点一下就走完整的落盘链路(校验、备份、写入、重载),失败时按停在哪一步显示不同的话。 订阅线路那一档暂时禁用:选中它需要先自动建一个筛选组,那部分还没做。 顺带两处从截图里看出来的问题:连接线原本是固定 40px 的短横,读起来像装饰不像连接, 改成占据弹性列真的把两头连起来;默认入站的名字原本是「HTTP 和 SOCKS5 共用」, 和类型列重复,改成它来自的配置键 mixed-port。
This commit is contained in:
@@ -28,7 +28,7 @@ describe('合并两套写法的入站', () => {
|
|||||||
'mixed-port': 7777,
|
'mixed-port': 7777,
|
||||||
listeners: [{ name: '容器A', type: 'mixed', port: 7778, proxy: 'AUTO' }],
|
listeners: [{ name: '容器A', type: 'mixed', port: 7778, proxy: 'AUTO' }],
|
||||||
})
|
})
|
||||||
expect(list.map((i) => i.name)).toEqual(['HTTP 和 SOCKS5 共用', '容器A'])
|
expect(list.map((i) => i.name)).toEqual(['mixed-port', '容器A'])
|
||||||
expect(list[0]!.isDefault).toBe(true)
|
expect(list[0]!.isDefault).toBe(true)
|
||||||
expect(list[1]!.isDefault).toBe(false)
|
expect(list[1]!.isDefault).toBe(false)
|
||||||
expect(list[1]!.listenerIndex).toBe(0)
|
expect(list[1]!.listenerIndex).toBe(0)
|
||||||
|
|||||||
+10
-8
@@ -23,12 +23,12 @@ export const BUILTIN_OUTBOUNDS = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** 旧写法的顶层端口字段,以及它们各自等价于哪种入站类型。 */
|
/** 旧写法的顶层端口字段,以及它们各自等价于哪种入站类型。 */
|
||||||
const LEGACY_PORTS: Array<{ key: string; type: string; label: string }> = [
|
const LEGACY_PORTS: Array<{ key: string; type: string }> = [
|
||||||
{ key: 'mixed-port', type: 'mixed', label: 'HTTP 和 SOCKS5 共用' },
|
{ key: 'mixed-port', type: 'mixed' },
|
||||||
{ key: 'port', type: 'http', label: 'HTTP' },
|
{ key: 'port', type: 'http' },
|
||||||
{ key: 'socks-port', type: 'socks', label: 'SOCKS5' },
|
{ key: 'socks-port', type: 'socks' },
|
||||||
{ key: 'redir-port', type: 'redir', label: '透明代理(REDIRECT)' },
|
{ key: 'redir-port', type: 'redir' },
|
||||||
{ key: 'tproxy-port', type: 'tproxy', label: '透明代理(TPROXY)' },
|
{ key: 'tproxy-port', type: 'tproxy' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 一路入站的出站去向。四选一,对应界面上那个下拉框。 */
|
/** 一路入站的出站去向。四选一,对应界面上那个下拉框。 */
|
||||||
@@ -77,14 +77,16 @@ export function outboundToProxyField(mode: OutboundMode): string | undefined {
|
|||||||
export function collectInbounds(data: ConfigObject): Inbound[] {
|
export function collectInbounds(data: ConfigObject): Inbound[] {
|
||||||
const result: Inbound[] = []
|
const result: Inbound[] = []
|
||||||
|
|
||||||
for (const { key, type, label } of LEGACY_PORTS) {
|
for (const { key, type } of LEGACY_PORTS) {
|
||||||
const raw = data[key]
|
const raw = data[key]
|
||||||
const port = typeof raw === 'number' ? raw : Number(raw)
|
const port = typeof raw === 'number' ? raw : Number(raw)
|
||||||
// 0 和缺省都表示没启用这个端口
|
// 0 和缺省都表示没启用这个端口
|
||||||
if (!Number.isFinite(port) || port <= 0) continue
|
if (!Number.isFinite(port) || port <= 0) continue
|
||||||
result.push({
|
result.push({
|
||||||
id: `legacy:${key}`,
|
id: `legacy:${key}`,
|
||||||
name: label,
|
// 名字直接用它来自的那个配置键。比「HTTP 和 SOCKS5 共用」这种描述好:
|
||||||
|
// 类型那一列已经写着协议了,名字再说一遍是重复,而配置键能直接对上文件里的行。
|
||||||
|
name: key,
|
||||||
type,
|
type,
|
||||||
port: String(port),
|
port: String(port),
|
||||||
listen: null,
|
listen: null,
|
||||||
|
|||||||
+25
-2
@@ -4,13 +4,14 @@ import { ApiError, api, getToken, setToken } from './api'
|
|||||||
import type { ConfigSnapshot } from './api'
|
import type { ConfigSnapshot } from './api'
|
||||||
import { NAV } from './nav'
|
import { NAV } from './nav'
|
||||||
import { density, toggleDensity } from './prefs'
|
import { density, toggleDensity } from './prefs'
|
||||||
import InboundList from './views/InboundList.vue'
|
import RouterPage from './views/RouterPage.vue'
|
||||||
import TokenGate from './views/TokenGate.vue'
|
import TokenGate from './views/TokenGate.vue'
|
||||||
import IconFold from '~icons/ri/sidebar-fold-line'
|
import IconFold from '~icons/ri/sidebar-fold-line'
|
||||||
import IconUnfold from '~icons/ri/sidebar-unfold-line'
|
import IconUnfold from '~icons/ri/sidebar-unfold-line'
|
||||||
|
|
||||||
const snapshot = ref<ConfigSnapshot | null>(null)
|
const snapshot = ref<ConfigSnapshot | null>(null)
|
||||||
const kernel = ref<{ online: boolean; version?: string }>({ online: false })
|
const kernel = ref<{ online: boolean; version?: string }>({ online: false })
|
||||||
|
const providerLines = ref<string[]>([])
|
||||||
const needsToken = ref(!getToken())
|
const needsToken = ref(!getToken())
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -24,6 +25,9 @@ async function load() {
|
|||||||
snapshot.value = config
|
snapshot.value = config
|
||||||
kernel.value = status
|
kernel.value = status
|
||||||
needsToken.value = false
|
needsToken.value = false
|
||||||
|
// 订阅里有哪些线路只有运行中的内核知道,配置文件里只有一个订阅地址。
|
||||||
|
// 拿不到不算错误——界面照常能编辑,只是订阅线路那一档不可选。
|
||||||
|
providerLines.value = status.online ? await readProviderLines() : []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError && error.unauthorized) {
|
if (error instanceof ApiError && error.unauthorized) {
|
||||||
needsToken.value = true
|
needsToken.value = true
|
||||||
@@ -35,6 +39,18 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 只取真正的订阅。内核会给每个用了 proxies 的策略组造一个内部 provider,那些不是订阅。 */
|
||||||
|
async function readProviderLines(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const { providers } = await api.providers()
|
||||||
|
return Object.values(providers)
|
||||||
|
.filter((p) => p.vehicleType !== 'Compatible')
|
||||||
|
.flatMap((p) => p.proxies.map((line) => line.name))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function submitToken(value: string) {
|
function submitToken(value: string) {
|
||||||
setToken(value)
|
setToken(value)
|
||||||
void load()
|
void load()
|
||||||
@@ -109,7 +125,14 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InboundList v-if="active === 'router'" :data="snapshot.data" />
|
<RouterPage
|
||||||
|
v-if="active === 'router'"
|
||||||
|
:data="snapshot.data"
|
||||||
|
:revision="snapshot.revision"
|
||||||
|
:kernel-online="kernel.online"
|
||||||
|
:provider-lines="providerLines"
|
||||||
|
@changed="load"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-else class="note">
|
<div v-else class="note">
|
||||||
<component :is="current?.icon" class="note-icon" />
|
<component :is="current?.icon" class="note-icon" />
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
|
||||||
|
import IconLock from '~icons/ri/lock-line'
|
||||||
|
import IconOffline from '~icons/ri/cloud-off-line'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
inbound: Inbound
|
||||||
|
/** 策略组名字 */
|
||||||
|
groups: string[]
|
||||||
|
/** 顶层 proxies 里手写的线路 */
|
||||||
|
ownProxies: string[]
|
||||||
|
/** 订阅带来的线路。只有内核在线时才拿得到 */
|
||||||
|
providerLines: string[]
|
||||||
|
kernelOnline: boolean
|
||||||
|
saving: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ pick: [mode: OutboundMode] }>()
|
||||||
|
|
||||||
|
const BUILTINS: Array<{ mode: OutboundMode; label: string; hint: string }> = [
|
||||||
|
{ mode: { kind: 'rules' }, label: 'RULE', hint: '不指定出站,流量走分流规则' },
|
||||||
|
{ mode: { kind: 'direct' }, label: 'DIRECT', hint: '不经过任何代理' },
|
||||||
|
{ mode: { kind: 'global' }, label: 'GLOBAL', hint: '跟随 GLOBAL 组当前选中的出站' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const current = computed(() => props.inbound.outbound)
|
||||||
|
|
||||||
|
function isCurrent(mode: OutboundMode): boolean {
|
||||||
|
const now = current.value
|
||||||
|
if (now.kind !== mode.kind) return false
|
||||||
|
return now.kind !== 'named' || now.name === (mode as { name: string }).name
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认入站没有 proxy 字段可写,出站只能是 RULE。 */
|
||||||
|
const locked = computed(() => props.inbound.isDefault)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="detail">
|
||||||
|
<header class="top">
|
||||||
|
<div class="title">
|
||||||
|
<span class="port mono">{{ inbound.port }}</span>
|
||||||
|
<span class="name">{{ inbound.name }}</span>
|
||||||
|
</div>
|
||||||
|
<dl class="facts">
|
||||||
|
<dt>类型</dt>
|
||||||
|
<dd class="mono">{{ inbound.type }}</dd>
|
||||||
|
<dt>监听</dt>
|
||||||
|
<dd class="mono">{{ inbound.listen ?? '0.0.0.0' }}</dd>
|
||||||
|
<dt>附加规则</dt>
|
||||||
|
<dd class="mono">{{ inbound.ruleSet ?? '—' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="pick">
|
||||||
|
<h3>出站</h3>
|
||||||
|
|
||||||
|
<p v-if="locked" class="locked">
|
||||||
|
<IconLock />
|
||||||
|
由 <code>{{ inbound.legacyKey }}</code> 声明,这种写法没有 proxy 字段,只能走 RULE
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="chips">
|
||||||
|
<button
|
||||||
|
v-for="b in BUILTINS"
|
||||||
|
:key="b.label"
|
||||||
|
type="button"
|
||||||
|
:class="['chip', isCurrent(b.mode) && 'is-on']"
|
||||||
|
:disabled="locked || saving"
|
||||||
|
:title="b.hint"
|
||||||
|
@click="emit('pick', b.mode)"
|
||||||
|
>
|
||||||
|
{{ b.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="groups.length">
|
||||||
|
<h4>策略组</h4>
|
||||||
|
<div class="chips">
|
||||||
|
<button
|
||||||
|
v-for="g in groups"
|
||||||
|
:key="g"
|
||||||
|
type="button"
|
||||||
|
:class="['chip', isCurrent({ kind: 'named', name: g }) && 'is-on']"
|
||||||
|
:disabled="locked || saving"
|
||||||
|
@click="emit('pick', { kind: 'named', name: g })"
|
||||||
|
>
|
||||||
|
{{ g }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="ownProxies.length">
|
||||||
|
<h4>自有线路</h4>
|
||||||
|
<div class="chips">
|
||||||
|
<button
|
||||||
|
v-for="p in ownProxies"
|
||||||
|
:key="p"
|
||||||
|
type="button"
|
||||||
|
:class="['chip', isCurrent({ kind: 'named', name: p }) && 'is-on']"
|
||||||
|
:disabled="locked || saving"
|
||||||
|
@click="emit('pick', { kind: 'named', name: p })"
|
||||||
|
>
|
||||||
|
{{ p }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<h4>订阅线路</h4>
|
||||||
|
<p v-if="!kernelOnline" class="unavailable">
|
||||||
|
<IconOffline />
|
||||||
|
内核未连接。订阅里有哪些线路只有运行中的内核知道,配置文件里只有订阅地址
|
||||||
|
</p>
|
||||||
|
<p v-else-if="providerLines.length === 0" class="unavailable">订阅里没有线路</p>
|
||||||
|
<div v-else class="chips">
|
||||||
|
<button
|
||||||
|
v-for="line in providerLines"
|
||||||
|
:key="line"
|
||||||
|
type="button"
|
||||||
|
class="chip is-todo"
|
||||||
|
disabled
|
||||||
|
title="选中会自动建一个只装这条线路的筛选组——这部分还没做"
|
||||||
|
>
|
||||||
|
{{ line }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail {
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--c-surface);
|
||||||
|
padding: var(--s-4) var(--s-5) var(--s-5);
|
||||||
|
align-self: start;
|
||||||
|
position: sticky;
|
||||||
|
top: var(--s-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
padding-bottom: var(--s-4);
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s-3);
|
||||||
|
margin-bottom: var(--s-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.port {
|
||||||
|
font-size: var(--t-xl);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: var(--s-1) var(--s-4);
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts dt {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pick {
|
||||||
|
padding-top: var(--s-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
margin-bottom: var(--s-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
margin: var(--s-4) 0 var(--s-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
padding: var(--s-1) var(--s-3);
|
||||||
|
border: 1px solid var(--c-border-strong);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--c-surface);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover:not(:disabled) {
|
||||||
|
border-color: var(--c-accent);
|
||||||
|
color: var(--c-accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.is-on {
|
||||||
|
background: var(--c-accent-soft);
|
||||||
|
border-color: var(--c-accent);
|
||||||
|
color: var(--c-accent-text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
border-color: var(--c-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.is-on:disabled {
|
||||||
|
color: var(--c-accent-text);
|
||||||
|
border-color: color-mix(in srgb, var(--c-accent) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locked,
|
||||||
|
.unavailable {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
margin-bottom: var(--s-3);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locked code {
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { collectInbounds, collectOutboundNames } from '@shared/inbounds.ts'
|
|
||||||
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
|
|
||||||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
|
||||||
import { density } from '../prefs'
|
|
||||||
import IconAdd from '~icons/ri/add-line'
|
|
||||||
import IconExpand from '~icons/ri/arrow-right-s-line'
|
|
||||||
import IconEdit from '~icons/ri/edit-line'
|
|
||||||
import IconDelete from '~icons/ri/delete-bin-line'
|
|
||||||
|
|
||||||
const props = defineProps<{ data: ConfigObject }>()
|
|
||||||
|
|
||||||
const inbounds = computed(() => collectInbounds(props.data))
|
|
||||||
const outbounds = computed(() => collectOutboundNames(props.data))
|
|
||||||
const expanded = ref<string | null>(null)
|
|
||||||
|
|
||||||
function toggle(id: string) {
|
|
||||||
expanded.value = expanded.value === id ? null : id
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 出站在行上怎么显示。RULE 是未指定时的默认状态,弱化处理。 */
|
|
||||||
function outboundLabel(mode: OutboundMode): { text: string; muted: boolean } {
|
|
||||||
if (mode.kind === 'rules') return { text: 'RULE', muted: true }
|
|
||||||
if (mode.kind === 'direct') return { text: 'DIRECT', muted: false }
|
|
||||||
if (mode.kind === 'global') return { text: 'GLOBAL', muted: false }
|
|
||||||
return { text: mode.name, muted: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
const TYPE_LABEL: Record<string, string> = {
|
|
||||||
mixed: 'HTTP + SOCKS5',
|
|
||||||
http: 'HTTP',
|
|
||||||
socks: 'SOCKS5',
|
|
||||||
redir: 'REDIR',
|
|
||||||
tproxy: 'TPROXY',
|
|
||||||
}
|
|
||||||
|
|
||||||
function typeLabel(inbound: Inbound): string {
|
|
||||||
return TYPE_LABEL[inbound.type] ?? inbound.type.toUpperCase()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section>
|
|
||||||
<!-- 概览 -->
|
|
||||||
<header class="head">
|
|
||||||
<div class="sums">
|
|
||||||
<div class="sum">
|
|
||||||
<span class="sum-n">{{ inbounds.length }}</span>
|
|
||||||
<span class="sum-k">入站</span>
|
|
||||||
</div>
|
|
||||||
<div class="sum">
|
|
||||||
<span class="sum-n">{{ outbounds.groups.length }}</span>
|
|
||||||
<span class="sum-k">策略组</span>
|
|
||||||
</div>
|
|
||||||
<div class="sum">
|
|
||||||
<span class="sum-n">{{ outbounds.ownProxies.length }}</span>
|
|
||||||
<span class="sum-k">自有线路</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="add" type="button" disabled>
|
|
||||||
<IconAdd />
|
|
||||||
<span v-if="density === 'full'">新增</span>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- 明细 -->
|
|
||||||
<ol class="rows">
|
|
||||||
<li v-for="inbound in inbounds" :key="inbound.id" class="row">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:class="['line', expanded === inbound.id && 'is-open']"
|
|
||||||
@click="toggle(inbound.id)"
|
|
||||||
>
|
|
||||||
<IconExpand class="caret" />
|
|
||||||
|
|
||||||
<span class="port mono">{{ inbound.port }}</span>
|
|
||||||
<span class="type">{{ typeLabel(inbound) }}</span>
|
|
||||||
|
|
||||||
<span class="name">
|
|
||||||
{{ inbound.name }}
|
|
||||||
<span v-if="inbound.isDefault" class="tag" title="由配置顶层的端口字段声明,不可删除">
|
|
||||||
默认
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="wire" aria-hidden="true"></span>
|
|
||||||
|
|
||||||
<span :class="['out', outboundLabel(inbound.outbound).muted && 'is-muted']">
|
|
||||||
{{ outboundLabel(inbound.outbound).text }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div v-if="expanded === inbound.id" class="detail">
|
|
||||||
<dl>
|
|
||||||
<dt>类型</dt>
|
|
||||||
<dd class="mono">{{ inbound.type }}</dd>
|
|
||||||
<dt>监听</dt>
|
|
||||||
<dd class="mono">{{ inbound.listen ?? '0.0.0.0' }}</dd>
|
|
||||||
<dt>出站</dt>
|
|
||||||
<dd class="mono">{{ outboundLabel(inbound.outbound).text }}</dd>
|
|
||||||
<dt>附加规则</dt>
|
|
||||||
<dd class="mono">{{ inbound.ruleSet ?? '—' }}</dd>
|
|
||||||
<dt v-if="inbound.legacyKey">来源</dt>
|
|
||||||
<dd v-if="inbound.legacyKey" class="mono">{{ inbound.legacyKey }}</dd>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div class="acts">
|
|
||||||
<button type="button" disabled><IconEdit /> 编辑</button>
|
|
||||||
<button type="button" disabled :title="inbound.isDefault ? '默认入站不可删除' : ''">
|
|
||||||
<IconDelete /> 删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<p v-if="inbounds.length === 0" class="empty">没有入站。mihomo 不会监听任何端口。</p>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--s-4);
|
|
||||||
margin-bottom: var(--s-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sums {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--s-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sum {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: var(--s-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sum-n {
|
|
||||||
font-size: var(--t-2xl);
|
|
||||||
font-weight: 600;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sum-k {
|
|
||||||
color: var(--c-text-soft);
|
|
||||||
font-size: var(--t-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--s-2);
|
|
||||||
padding: var(--s-2) var(--s-3);
|
|
||||||
border: 1px solid var(--c-border-strong);
|
|
||||||
border-radius: var(--r-sm);
|
|
||||||
background: var(--c-surface);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.add:disabled {
|
|
||||||
color: var(--c-text-faint);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rows {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid var(--c-border);
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
background: var(--c-surface);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row + .row {
|
|
||||||
border-top: 1px solid var(--c-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.line {
|
|
||||||
width: 100%;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 16px 4.5rem 7rem 1fr 2.5rem minmax(6rem, auto);
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--s-3);
|
|
||||||
padding: var(--s-3) var(--s-4);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line:hover {
|
|
||||||
background: var(--c-surface-sunken);
|
|
||||||
}
|
|
||||||
|
|
||||||
.caret {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
color: var(--c-text-faint);
|
|
||||||
transition: transform 0.12s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line.is-open .caret {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.port {
|
|
||||||
font-size: var(--t-lg);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type {
|
|
||||||
font-size: var(--t-xs);
|
|
||||||
color: var(--c-text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--s-2);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
padding: 0 var(--s-2);
|
|
||||||
border: 1px solid var(--c-border-strong);
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: var(--t-xs);
|
|
||||||
color: var(--c-text-soft);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire {
|
|
||||||
height: 1px;
|
|
||||||
background: linear-gradient(
|
|
||||||
to right,
|
|
||||||
var(--c-border-strong),
|
|
||||||
color-mix(in srgb, var(--c-accent) 55%, transparent)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.out {
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
font-size: var(--t-sm);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.out.is-muted {
|
|
||||||
color: var(--c-text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--s-5);
|
|
||||||
padding: var(--s-2) var(--s-4) var(--s-4) calc(16px + var(--s-3) + var(--s-4));
|
|
||||||
background: var(--c-surface-sunken);
|
|
||||||
border-top: 1px solid var(--c-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
dl {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto auto;
|
|
||||||
gap: var(--s-1) var(--s-4);
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--t-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
dt {
|
|
||||||
color: var(--c-text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.acts {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--s-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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:disabled {
|
|
||||||
color: var(--c-text-faint);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
color: var(--c-text-faint);
|
|
||||||
font-size: var(--t-sm);
|
|
||||||
margin-top: var(--s-4);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { collectInbounds, collectOutboundNames, outboundToProxyField } from '@shared/inbounds.ts'
|
||||||
|
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
|
||||||
|
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||||
|
import { ApiError, api } from '../api'
|
||||||
|
import { density } from '../prefs'
|
||||||
|
import InboundDetail from './InboundDetail.vue'
|
||||||
|
import IconAdd from '~icons/ri/add-line'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: ConfigObject
|
||||||
|
revision: string
|
||||||
|
kernelOnline: boolean
|
||||||
|
providerLines: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ changed: [] }>()
|
||||||
|
|
||||||
|
const inbounds = computed(() => collectInbounds(props.data))
|
||||||
|
const outbounds = computed(() => collectOutboundNames(props.data))
|
||||||
|
|
||||||
|
const selectedId = ref<string | null>(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
const failure = ref<{ message: string; hint: string } | null>(null)
|
||||||
|
|
||||||
|
// 默认选中第一行。空面板没有信息量,不如直接展示一路入站。
|
||||||
|
watch(
|
||||||
|
inbounds,
|
||||||
|
(list) => {
|
||||||
|
if (!list.some((i) => i.id === selectedId.value)) selectedId.value = list[0]?.id ?? null
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const selected = computed(() => inbounds.value.find((i) => i.id === selectedId.value) ?? null)
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
|
mixed: 'HTTP + SOCKS5',
|
||||||
|
http: 'HTTP',
|
||||||
|
socks: 'SOCKS5',
|
||||||
|
redir: 'REDIR',
|
||||||
|
tproxy: 'TPROXY',
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(inbound: Inbound): string {
|
||||||
|
return TYPE_LABEL[inbound.type] ?? inbound.type.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function outboundLabel(mode: OutboundMode): { text: string; muted: boolean } {
|
||||||
|
if (mode.kind === 'rules') return { text: 'RULE', muted: true }
|
||||||
|
if (mode.kind === 'direct') return { text: 'DIRECT', muted: false }
|
||||||
|
if (mode.kind === 'global') return { text: 'GLOBAL', muted: false }
|
||||||
|
return { text: mode.name, muted: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 改一路命名入站的出站,然后走完整的落盘链路。 */
|
||||||
|
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
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
failure.value = null
|
||||||
|
try {
|
||||||
|
await api.applyConfig(next, props.revision)
|
||||||
|
emit('changed')
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
const payload = error.payload as { message?: string; hint?: string }
|
||||||
|
failure.value = {
|
||||||
|
message: payload.message ?? error.message,
|
||||||
|
hint: payload.hint ?? '',
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
failure.value = { message: String(error), hint: '' }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<header class="head">
|
||||||
|
<div class="sums">
|
||||||
|
<div class="sum">
|
||||||
|
<span class="sum-n">{{ inbounds.length }}</span>
|
||||||
|
<span class="sum-k">入站</span>
|
||||||
|
</div>
|
||||||
|
<div class="sum">
|
||||||
|
<span class="sum-n">{{ outbounds.groups.length }}</span>
|
||||||
|
<span class="sum-k">策略组</span>
|
||||||
|
</div>
|
||||||
|
<div class="sum">
|
||||||
|
<span class="sum-n">{{ providerLines.length }}</span>
|
||||||
|
<span class="sum-k">订阅线路</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="add" type="button" disabled>
|
||||||
|
<IconAdd />
|
||||||
|
<span v-if="density === 'full'">新增</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="failure" class="failure">
|
||||||
|
<strong>{{ failure.message }}</strong>
|
||||||
|
<span v-if="failure.hint">{{ failure.hint }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split">
|
||||||
|
<ol class="rows">
|
||||||
|
<li v-for="inbound in inbounds" :key="inbound.id">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['line', selectedId === inbound.id && 'is-on']"
|
||||||
|
@click="selectedId = inbound.id"
|
||||||
|
>
|
||||||
|
<span class="port mono">{{ inbound.port }}</span>
|
||||||
|
<span class="type">{{ typeLabel(inbound) }}</span>
|
||||||
|
<span class="name">
|
||||||
|
{{ inbound.name }}
|
||||||
|
<span v-if="inbound.isDefault" class="tag">默认</span>
|
||||||
|
</span>
|
||||||
|
<span class="wire" aria-hidden="true"></span>
|
||||||
|
<span :class="['out', outboundLabel(inbound.outbound).muted && 'is-muted']">
|
||||||
|
{{ outboundLabel(inbound.outbound).text }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<InboundDetail
|
||||||
|
v-if="selected"
|
||||||
|
:inbound="selected"
|
||||||
|
:groups="outbounds.groups"
|
||||||
|
:own-proxies="outbounds.ownProxies"
|
||||||
|
:provider-lines="providerLines"
|
||||||
|
:kernel-online="kernelOnline"
|
||||||
|
:saving="saving"
|
||||||
|
@pick="pickOutbound"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="inbounds.length === 0" class="empty">没有入站。mihomo 不会监听任何端口。</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s-4);
|
||||||
|
margin-bottom: var(--s-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sums {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sum {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sum-n {
|
||||||
|
font-size: var(--t-2xl);
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sum-k {
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
|
padding: var(--s-2) var(--s-3);
|
||||||
|
border: 1px solid var(--c-border-strong);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--c-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.add:disabled {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s-1);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--c-danger-soft);
|
||||||
|
padding: var(--s-3) var(--s-4);
|
||||||
|
margin-bottom: var(--s-4);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左列表右详情。窄屏时改成上下,详情不再吸顶 */
|
||||||
|
.split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 1fr);
|
||||||
|
gap: var(--s-5);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.split {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--c-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows li + li {
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 线占据弹性列,名字按内容宽——这样那根线是真的把两头连起来,而不是一小截装饰 */
|
||||||
|
.line {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 4.5rem 6.5rem auto minmax(var(--s-5), 1fr) minmax(5rem, auto);
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-3);
|
||||||
|
padding: var(--s-3) var(--s-4);
|
||||||
|
border: none;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line:hover {
|
||||||
|
background: var(--c-surface-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.is-on {
|
||||||
|
background: var(--c-accent-soft);
|
||||||
|
border-left-color: var(--c-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.port {
|
||||||
|
font-size: var(--t-lg);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type {
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
flex: none;
|
||||||
|
padding: 0 var(--s-2);
|
||||||
|
border: 1px solid var(--c-border-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wire {
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
var(--c-border-strong),
|
||||||
|
color-mix(in srgb, var(--c-accent) 60%, transparent)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.out {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.out.is-muted {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
margin-top: var(--s-4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user