本地开发换成真订阅;闪电挪到卡片上;忙碌状态改成按卡片

占位线路删了。它们指向 127.0.0.1:1080,从来没测通过,而 URLTest 只在你选的那条
「对测试地址是活的」时才认账(urltest.go:106-111)——所以点了立刻被切回去,
一个正常行为看起来像 bug。现在本地内核直接用真订阅,通过隧道拉。

订阅地址带访问令牌,不能进 git:dev/config.yaml 变成模板,dev:reset 从 .env 取值生成
dev/home/config.yaml。顺带确认内核认 base64 分享链接格式(provider.go:384-390 回退到
ConvertsV2Ray),所以 Sub-Store 的输出直接能用。

闪电从组标题挪到每张卡的左下角,也就是延迟显示的位置:测过显示毫秒数,没测过显示闪电,
两种都能点,点了测这一条。忙碌状态从「禁用整个界面」改成只有正在提交的那张卡显示虚线边框。
This commit is contained in:
im0o
2026-08-10 13:21:27 +08:00
parent 5cdc183190
commit e8b8181ef6
7 changed files with 429 additions and 361 deletions
+4
View File
@@ -10,6 +10,10 @@ PATCHBAY_CONFIG=./dev/home/config.yaml
PATCHBAY_MIHOMO_BIN=../binary/mihomo-windows-amd64-v3-go125.exe PATCHBAY_MIHOMO_BIN=../binary/mihomo-windows-amd64-v3-go125.exe
PATCHBAY_MIHOMO_HOME=./dev/home PATCHBAY_MIHOMO_HOME=./dev/home
# 本地测试内核用的订阅地址,bun run dev:reset 会把它填进 dev/home/config.yaml。
# 开发时一般是 SSH 隧道映射过来的 Sub-Store:ssh -N -L 3001:127.0.0.1:3001 用户名@服务器
DEV_SUBSCRIPTION_URL=
# ── 想对着真实机器调试时改成这一套 ────────────────────── # ── 想对着真实机器调试时改成这一套 ──────────────────────
# 先开隧道把管理接口映射到本机: # 先开隧道把管理接口映射到本机:
# ssh -N -f -L 9090:127.0.0.1:9090 用户名@服务器地址 # ssh -N -f -L 9090:127.0.0.1:9090 用户名@服务器地址
+23 -16
View File
@@ -1,7 +1,12 @@
# 本地开发用的测试配置 — 不含任何真实信息 # 本地开发用的测试配置模板。
# 端口刻意避开默认值(7890 / 9090),免得和机器上其它 mihomo 打架。 #
# 里面的服务器地址全是占位的,从不会真的连上去;这份配置只用来验证 # 这是模板,不是实际运行的文件:`bun run dev:reset` 会把 __SUBSCRIPTION_URL__ 换成
# 「写回 → 校验 → 重载 → 失败还原」这条链路,不用来真的代理流量。 # .env 里的 DEV_SUBSCRIPTION_URL,生成 dev/home/config.yaml(那个文件不进 git)。
# 订阅地址里通常带访问令牌,不能提交。
#
# 端口刻意避开 mihomo 的默认值(7890 / 9090),免得和机器上其它内核打架。
# 结构照着真实那台机器来:没有手写线路,全部通过订阅引入——这样开发时遇到的
# 约束和真实环境一致(比如订阅线路无法被入站直接引用)。
mixed-port: 17890 # 代理端口,避开默认的 7890 mixed-port: 17890 # 代理端口,避开默认的 7890
allow-lan: false # 本地测试不对局域网开放 allow-lan: false # 本地测试不对局域网开放
@@ -12,28 +17,30 @@ ipv6: false
external-controller: 127.0.0.1:19090 # 管理接口,避开默认的 9090 external-controller: 127.0.0.1:19090 # 管理接口,避开默认的 9090
secret: "dev-secret-local-only" secret: "dev-secret-local-only"
# 关掉自动更新,避免测试时下载 GEO 数据库 geo-auto-update: false # 关掉自动更新,避免测试时下载 GEO 数据库
geo-auto-update: false
proxies: proxy-providers:
- name: 占位线路甲 subs:
type: socks5 type: http
server: 127.0.0.1 url: "__SUBSCRIPTION_URL__"
port: 1080 path: ./providers/subs.yaml
- name: 占位线路乙 interval: 86400
type: socks5 health-check:
server: 127.0.0.1 enable: true
port: 1081 url: https://www.gstatic.com/generate_204
interval: 300
proxy-groups: proxy-groups:
- name: PROXY # 总开关,手动选 - name: PROXY # 总开关,手动选
type: select type: select
proxies: [AUTO, DIRECT] proxies: [AUTO, DIRECT]
use: [subs]
- name: AUTO # 自动挑延迟最低的 - name: AUTO # 自动挑延迟最低的
type: url-test type: url-test
proxies: [占位线路甲, 占位线路乙] use: [subs]
url: https://www.gstatic.com/generate_204 url: https://www.gstatic.com/generate_204
interval: 300 interval: 300
tolerance: 50
rules: rules:
# 本机和内网地址一律直连 # 本机和内网地址一律直连
+1 -1
View File
@@ -9,7 +9,7 @@
"dev": "bun run --hot server/index.ts", "dev": "bun run --hot server/index.ts",
"dev:web": "vite", "dev:web": "vite",
"dev:kernel": "../binary/mihomo-windows-amd64-v3-go125.exe -d dev/home -f dev/home/config.yaml", "dev:kernel": "../binary/mihomo-windows-amd64-v3-go125.exe -d dev/home -f dev/home/config.yaml",
"dev:reset": "cp dev/config.yaml dev/home/config.yaml", "dev:reset": "bun run scripts/dev-reset.ts",
"build": "vite build", "build": "vite build",
"test": "bun test", "test": "bun test",
"smoke": "bun run scripts/api-smoke.ts", "smoke": "bun run scripts/api-smoke.ts",
+26
View File
@@ -0,0 +1,26 @@
/**
* 从模板生成本地测试内核的配置。
*
* 模板 dev/config.yaml 进 git,生成的 dev/home/config.yaml 不进——因为订阅地址里
* 通常带访问令牌。所以模板里写占位符,这个脚本从 .env 取真值替换。
*
* 用法:bun run dev:reset
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
const TEMPLATE = './dev/config.yaml'
const TARGET = './dev/home/config.yaml'
const PLACEHOLDER = '__SUBSCRIPTION_URL__'
const url = Bun.env.DEV_SUBSCRIPTION_URL
const template = await readFile(TEMPLATE, 'utf8')
if (template.includes(PLACEHOLDER) && !url) {
console.error(`模板里有 ${PLACEHOLDER},但 .env 里没有 DEV_SUBSCRIPTION_URL。`)
console.error('填一个能拉到线路的订阅地址;开发时通常是 SSH 隧道映射过来的 Sub-Store。')
process.exit(1)
}
await mkdir('./dev/home', { recursive: true })
await writeFile(TARGET, template.replaceAll(PLACEHOLDER, url ?? ''), 'utf8')
console.log(`${TARGET} 已从模板重新生成`)
+196
View File
@@ -0,0 +1,196 @@
<script setup lang="ts">
/**
* 一个出站选项:策略组、线路,或者 RULE/DIRECT 这类内置出站。
*
* 左下角那格是延迟:测过就显示毫秒数,没测过显示一个闪电。两种情况都能点,
* 点了就测这一条——「没数字」和「想重测」需要的是同一个动作,没必要做成两个控件。
*/
import IconFlash from '~icons/ri/flashlight-line'
import IconLoad from '~icons/ri/loader-4-line'
defineProps<{
name: string
/** 第二行。策略组显示「类型(当前选中)」,线路显示协议 */
sub: string
/** 最近一次延迟,毫秒。没测过或测失败是 null */
delay: number | null
selected: boolean
/** 不可选时的原因。有值就说明这张卡点不了 */
disabledReason?: string
/** 这张卡的改动正在提交 */
pending?: boolean
/** 这条线路正在测延迟 */
testing?: boolean
/** 能不能测延迟。内置出站没得测 */
testable?: boolean
}>()
const emit = defineEmits<{ pick: []; test: [] }>()
function delayClass(ms: number | null): string {
if (ms === null) return ''
if (ms < 200) return 'd-ok'
if (ms < 500) return 'd-warn'
return 'd-bad'
}
</script>
<template>
<div :class="['card', selected && 'is-on', disabledReason && 'is-off', pending && 'is-pending']">
<button
type="button"
class="hit"
:disabled="!!disabledReason"
:title="disabledReason"
@click="emit('pick')"
>
<span class="name">{{ name }}</span>
<span class="sub">{{ sub || '—' }}</span>
</button>
<div class="foot">
<button
v-if="testable"
type="button"
:class="['delay', delayClass(delay)]"
:disabled="testing"
:title="delay === null ? '测这条线路的延迟' : '重新测'"
@click="emit('test')"
>
<IconLoad v-if="testing" class="spin" />
<template v-else-if="delay !== null">{{ delay }} ms</template>
<IconFlash v-else />
</button>
<span v-else class="delay is-blank"></span>
<IconLoad v-if="pending" class="spin pend" />
</div>
</div>
</template>
<style scoped>
.card {
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid var(--c-border);
border-radius: var(--r-sm);
background: var(--c-surface-sunken);
}
.card.is-on {
border-color: var(--c-accent);
background: var(--c-accent-soft);
}
.card:not(.is-off):hover {
border-color: var(--c-accent);
}
.card.is-off {
opacity: 0.5;
}
.card.is-pending {
border-style: dashed;
}
.hit {
display: flex;
flex-direction: column;
gap: var(--s-1);
min-width: 0;
padding: var(--s-3) var(--s-3) var(--s-1);
border: none;
background: none;
text-align: left;
cursor: pointer;
}
.hit:disabled {
cursor: not-allowed;
}
.name {
font-size: var(--t-sm);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card.is-on .name {
color: var(--c-accent-text);
}
.sub {
font-size: var(--t-xs);
color: var(--c-text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-2);
padding: 0 var(--s-3) var(--s-2);
}
.delay {
display: flex;
align-items: center;
min-height: 20px;
padding: 0 var(--s-1);
margin-left: calc(-1 * var(--s-1));
border: 1px solid transparent;
border-radius: var(--r-sm);
background: none;
font-family: var(--f-mono);
font-size: var(--t-xs);
font-variant-numeric: tabular-nums;
color: var(--c-text-faint);
cursor: pointer;
}
.delay:hover:not(:disabled) {
background: var(--c-surface);
color: var(--c-accent-text);
}
.delay:disabled {
cursor: progress;
}
.delay.is-blank {
cursor: default;
}
.d-ok {
color: var(--c-ok);
}
.d-warn {
color: var(--c-warn);
}
.d-bad {
color: var(--c-danger);
}
.spin {
animation: spin 0.9s linear infinite;
}
.pend {
color: var(--c-text-faint);
width: 14px;
height: 14px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
+155 -324
View File
@@ -1,26 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* 选中那一路入站的详情。
*
* 分两块,因为它们是两种不同性质的动作,不该长得一样也不该给一样的反馈:
* 流量去向 —— 换策略组当前选中谁。不写配置文件,点了立刻变。日常点的就是这个。
* 出站 —— 换这路入站指向哪个出站。要改配置文件,走校验和重载,要等几秒。
*/
import { computed } from 'vue' 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 OutboundCard from '../components/OutboundCard.vue'
import IconOffline from '~icons/ri/cloud-off-line' import IconOffline from '~icons/ri/cloud-off-line'
import IconEdit from '~icons/ri/edit-line' import IconEdit from '~icons/ri/edit-line'
import IconDelete from '~icons/ri/delete-bin-line' import IconDelete from '~icons/ri/delete-bin-line'
import IconFlash from '~icons/ri/flashlight-line'
const props = defineProps<{ const props = defineProps<{
inbound: Inbound inbound: Inbound
groups: string[] groups: string[]
ownProxies: string[] ownProxies: string[]
providerLines: string[] providerLines: string[]
runtime: Record<string, ProxyInfo>
/** 流量最终交给哪个出站。走规则的入站要查到规则表末尾的 MATCH */ /** 流量最终交给哪个出站。走规则的入站要查到规则表末尾的 MATCH */
destination: string | null destination: string | null
/** 运行时状态,键是出站名字。内核没连接时是空的 */
runtime: Record<string, ProxyInfo>
kernelOnline: boolean kernelOnline: boolean
saving: boolean /** 正在提交的那个出站的名字。只有它显示忙碌,其余照常能点 */
/** 正在测延迟 */ pendingOutbound: string | null
testing: boolean /** 正在测延迟的线路 */
testing: string[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -28,26 +35,36 @@ const emit = defineEmits<{
edit: [] edit: []
remove: [] remove: []
select: [group: string, member: string] select: [group: string, member: string]
test: [group: string] test: [name: string]
}>() }>()
/** 默认入站没有 proxy 字段可写,「出站」那一块对它是只读的。 */
const locked = computed(() => props.inbound.isDefault)
function describe(name: string): string {
const info = props.runtime[name]
if (!info) return ''
return info.now ? `${info.type}(${info.now})` : info.type
}
const delayOf = (name: string) => latestDelay(props.runtime[name])
const isTesting = (name: string) => props.testing.includes(name)
/** /**
* 从落脚的出站一路跟着「当前选中」往下走,直到走到一条真正的线路。 * 从落脚的出站一路跟着「当前选中」往下走,直到走到一条真正的线路。
* *
* 策略组可以选中另一个策略组,所以「这个端口现在走哪条线」往往要跳好几层: * 策略组可以选中另一个策略组,所以「这个端口现在走哪条线」往往要跳好几层。
* PROXY 选了 AUTO,AUTO 选了某条线路。只显示第一层等于没回答问题。 * 只显示第一层等于没回答问题。
*/ */
function resolveChain(start: string | null): string[] { const chain = computed(() => {
const chain: string[] = [] const hops: string[] = []
let cursor = start let cursor = props.destination
while (cursor && !chain.includes(cursor)) { while (cursor && !hops.includes(cursor)) {
chain.push(cursor) hops.push(cursor)
cursor = props.runtime[cursor]?.now ?? null cursor = props.runtime[cursor]?.now ?? null
} }
return chain return hops
} })
const chain = computed(() => resolveChain(props.destination))
/** 链条上最后一个还是策略组的环节——用户能在这里换选中。 */ /** 链条上最后一个还是策略组的环节——用户能在这里换选中。 */
const switchable = computed(() => { const switchable = computed(() => {
@@ -59,89 +76,20 @@ const switchable = computed(() => {
return null return null
}) })
interface Option { function isCurrentOutbound(mode: OutboundMode): boolean {
key: string
mode: OutboundMode
label: string
/** 卡片第二行。策略组显示「类型(当前选中)」,线路显示协议 */
sub: string
delay: number | null
disabled: boolean
/** 不可选时的原因,鼠标悬停显示 */
reason?: string
}
/** 默认入站没有 proxy 字段可写,整块都不可选。 */
const locked = computed(() => props.inbound.isDefault)
function describe(name: string): { sub: string; delay: number | null } {
const info = props.runtime[name]
if (!info) return { sub: '', delay: null }
const sub = info.now ? `${info.type}(${info.now})` : info.type
return { sub, delay: latestDelay(info) }
}
const builtins = computed<Option[]>(() =>
(
[
{ mode: { kind: 'rules' } as OutboundMode, label: 'RULE', sub: '走分流规则' },
{ mode: { kind: 'direct' } as OutboundMode, label: 'DIRECT', sub: '不经过代理' },
{ mode: { kind: 'global' } as OutboundMode, label: 'GLOBAL', sub: '跟随全局选择' },
] as const
).map((b) => ({
key: b.label,
mode: b.mode,
label: b.label,
sub: b.sub,
delay: null,
disabled: locked.value,
})),
)
function named(names: string[]): Option[] {
return names.map((name) => {
const { sub, delay } = describe(name)
return {
key: name,
mode: { kind: 'named', name } as OutboundMode,
label: name,
sub,
delay,
disabled: locked.value,
}
})
}
const groupOptions = computed(() => named(props.groups))
const ownOptions = computed(() => named(props.ownProxies))
const lineOptions = computed<Option[]>(() =>
props.providerLines.map((name) => {
const { sub, delay } = describe(name)
return {
key: name,
mode: { kind: 'named', name } as OutboundMode,
label: name,
sub,
delay,
disabled: true,
reason: '订阅线路不能被直接引用,选中它需要自动建一个筛选组——这部分还没做',
}
}),
)
function isCurrent(mode: OutboundMode): boolean {
const now = props.inbound.outbound const now = props.inbound.outbound
if (now.kind !== mode.kind) return false if (now.kind !== mode.kind) return false
return now.kind !== 'named' || now.name === (mode as { name: string }).name return now.kind !== 'named' || now.name === (mode as { name: string }).name
} }
function delayClass(ms: number | null): string { const BUILTINS: Array<{ mode: OutboundMode; label: string; sub: string }> = [
if (ms === null) return 'd-none' { mode: { kind: 'rules' }, label: 'RULE', sub: '走分流规则' },
if (ms < 200) return 'd-ok' { mode: { kind: 'direct' }, label: 'DIRECT', sub: '不经过代理' },
if (ms < 500) return 'd-warn' { mode: { kind: 'global' }, label: 'GLOBAL', sub: '跟随全局选择' },
return 'd-bad' ]
}
const LINE_LOCKED = '订阅线路不能被入站直接引用,选中它需要自动建一个筛选组——这部分还没做'
const NO_PROXY_FIELD = '这路入站没有 proxy 字段'
</script> </script>
<template> <template>
@@ -151,11 +99,10 @@ 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> <div v-if="!locked" class="acts">
<button type="button" class="danger" :disabled="saving" @click="emit('remove')"> <button type="button" @click="emit('edit')"><IconEdit /> 编辑</button>
<IconDelete /> 删除 <button type="button" class="danger" @click="emit('remove')"><IconDelete /> 删除</button>
</button>
</div> </div>
<dl class="facts"> <dl class="facts">
@@ -168,128 +115,114 @@ function delayClass(ms: number | null): string {
</dl> </dl>
</header> </header>
<section v-if="chain.length" class="pick"> <!-- 运行时:换线路。不写文件,点了立刻变 -->
<section v-if="chain.length" class="block">
<h3>流量去向</h3> <h3>流量去向</h3>
<p class="chain mono"> <p class="chain mono">
<template v-for="(hop, i) in chain" :key="hop"> <template v-for="(hop, i) in chain" :key="hop">
<span v-if="i" class="arrow">→</span><span :class="i === chain.length - 1 && 'tail'">{{ hop }}</span> <span v-if="i" class="arrow">→</span>
<span :class="i === chain.length - 1 && 'tail'">{{ hop }}</span>
</template> </template>
</p> </p>
<template v-if="switchable"> <template v-if="switchable">
<div class="sub-head">
<h4>{{ switchable.group }} 当前选中</h4> <h4>{{ switchable.group }} 当前选中</h4>
<button
type="button"
class="flash"
:disabled="testing"
:title="`测试 ${switchable.group} 里所有线路的延迟`"
@click="emit('test', switchable.group)"
>
<IconFlash />
</button>
</div>
<div class="grid"> <div class="grid">
<button <OutboundCard
v-for="m in switchable.members" v-for="m in switchable.members"
:key="m" :key="m"
type="button" :name="m"
:class="['card', m === switchable.now && 'is-on']" :sub="describe(m)"
:disabled="saving" :delay="delayOf(m)"
@click="emit('select', switchable.group, m)" :selected="m === switchable.now"
> :testing="isTesting(m)"
<span class="card-name">{{ m }}</span> testable
<span class="card-sub">{{ runtime[m]?.now ? `${runtime[m]!.type}(${runtime[m]!.now})` : (runtime[m]?.type ?? '—') }}</span> @pick="emit('select', switchable.group, m)"
<span :class="['card-delay', delayClass(latestDelay(runtime[m]))]"> @test="emit('test', m)"
{{ latestDelay(runtime[m]) === null ? '' : `${latestDelay(runtime[m])} ms` }} />
</span>
</button>
</div> </div>
</template> </template>
</section> </section>
<section class="pick"> <!-- 配置:换这路入站指向谁。要写文件、要校验 -->
<section class="block">
<h3>出站</h3> <h3>出站</h3>
<p v-if="locked" class="hint">
<p v-if="locked" class="aside-note"> 由 <code>{{ inbound.legacyKey }}</code> 声明,这种写法没有 proxy 字段,固定走 RULE
由 <code>{{ inbound.legacyKey }}</code> 声明,这种写法没有 proxy 字段,出站固定是 RULE
</p> </p>
<div v-if="!locked" class="grid"> <div v-if="!locked" class="grid">
<button <OutboundCard
v-for="opt in builtins" v-for="b in BUILTINS"
:key="opt.key" :key="b.label"
type="button" :name="b.label"
:class="['card', isCurrent(opt.mode) && 'is-on']" :sub="b.sub"
:disabled="opt.disabled || saving" :delay="null"
@click="emit('pick', opt.mode)" :selected="isCurrentOutbound(b.mode)"
> :pending="pendingOutbound === b.label"
<span class="card-name">{{ opt.label }}</span> @pick="emit('pick', b.mode)"
<span class="card-sub">{{ opt.sub }}</span> />
</button>
</div> </div>
<template v-if="groupOptions.length"> <template v-if="groups.length">
<h4 :class="locked && 'is-dim'">策略组</h4> <h4 :class="locked && 'is-dim'">策略组</h4>
<div class="grid"> <div class="grid">
<button <OutboundCard
v-for="opt in groupOptions" v-for="g in groups"
:key="opt.key" :key="g"
type="button" :name="g"
:class="['card', isCurrent(opt.mode) && 'is-on']" :sub="describe(g)"
:disabled="opt.disabled || saving" :delay="delayOf(g)"
@click="emit('pick', opt.mode)" :selected="isCurrentOutbound({ kind: 'named', name: g })"
> :disabled-reason="locked ? NO_PROXY_FIELD : undefined"
<span class="card-name">{{ opt.label }}</span> :pending="pendingOutbound === g"
<span class="card-sub">{{ opt.sub || '—' }}</span> :testing="isTesting(g)"
<span :class="['card-delay', delayClass(opt.delay)]"> testable
{{ opt.delay === null ? '' : `${opt.delay} ms` }} @pick="emit('pick', { kind: 'named', name: g })"
</span> @test="emit('test', g)"
</button> />
</div> </div>
</template> </template>
<template v-if="ownOptions.length"> <template v-if="ownProxies.length">
<h4 :class="locked && 'is-dim'">自有线路</h4> <h4 :class="locked && 'is-dim'">自有线路</h4>
<div class="grid"> <div class="grid">
<button <OutboundCard
v-for="opt in ownOptions" v-for="p in ownProxies"
:key="opt.key" :key="p"
type="button" :name="p"
:class="['card', isCurrent(opt.mode) && 'is-on']" :sub="describe(p)"
:disabled="opt.disabled || saving" :delay="delayOf(p)"
@click="emit('pick', opt.mode)" :selected="isCurrentOutbound({ kind: 'named', name: p })"
> :disabled-reason="locked ? NO_PROXY_FIELD : undefined"
<span class="card-name">{{ opt.label }}</span> :pending="pendingOutbound === p"
<span class="card-sub">{{ opt.sub || '—' }}</span> :testing="isTesting(p)"
<span :class="['card-delay', delayClass(opt.delay)]"> testable
{{ opt.delay === null ? '' : `${opt.delay} ms` }} @pick="emit('pick', { kind: 'named', name: p })"
</span> @test="emit('test', p)"
</button> />
</div> </div>
</template> </template>
<h4 :class="locked && 'is-dim'">订阅线路</h4> <h4 :class="locked && 'is-dim'">订阅线路</h4>
<p v-if="!kernelOnline" class="aside-note"> <p v-if="!kernelOnline" class="hint">
<IconOffline /> <IconOffline />
内核未连接。订阅里有哪些线路只有运行中的内核知道 内核未连接,看不到订阅里有哪些线路
</p> </p>
<p v-else-if="lineOptions.length === 0" class="aside-note">订阅里没有线路</p> <p v-else-if="providerLines.length === 0" class="hint">订阅里没有线路</p>
<div v-else class="grid"> <div v-else class="grid">
<button <OutboundCard
v-for="opt in lineOptions" v-for="line in providerLines"
:key="opt.key" :key="line"
type="button" :name="line"
class="card" :sub="describe(line)"
disabled :delay="delayOf(line)"
:title="opt.reason" :selected="false"
> :disabled-reason="LINE_LOCKED"
<span class="card-name">{{ opt.label }}</span> :testing="isTesting(line)"
<span class="card-sub">{{ opt.sub || '—' }}</span> testable
<span :class="['card-delay', delayClass(opt.delay)]"> @test="emit('test', line)"
{{ opt.delay === null ? '' : `${opt.delay} ms` }} />
</span>
</button>
</div> </div>
</section> </section>
</aside> </aside>
@@ -331,22 +264,6 @@ function delayClass(ms: number | null): string {
white-space: nowrap; 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;
}
.acts { .acts {
display: flex; display: flex;
gap: var(--s-2); gap: var(--s-2);
@@ -365,63 +282,55 @@ function delayClass(ms: number | null): string {
cursor: pointer; cursor: pointer;
} }
.acts button:hover:not(:disabled) { .acts button:hover {
border-color: var(--c-accent); border-color: var(--c-accent);
color: var(--c-accent-text); color: var(--c-accent-text);
} }
.acts button.danger:hover:not(:disabled) { .acts button.danger:hover {
border-color: var(--c-danger); border-color: var(--c-danger);
color: var(--c-danger); color: var(--c-danger);
} }
.acts button:disabled { .facts {
color: var(--c-text-faint);
cursor: not-allowed;
}
.is-dim {
opacity: 0.6;
}
.sub-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-2);
}
.flash {
display: grid; display: grid;
place-items: center; grid-template-columns: auto 1fr;
width: 26px; gap: var(--s-1) var(--s-4);
height: 26px; margin: 0;
border: 1px solid transparent; font-size: var(--t-sm);
border-radius: var(--r-sm); }
background: none;
.facts dt {
color: var(--c-text-faint); color: var(--c-text-faint);
cursor: pointer;
} }
.flash:hover:not(:disabled) { .facts dd {
background: var(--c-accent-soft); margin: 0;
color: var(--c-accent-text);
} }
.flash:disabled { .block {
cursor: progress;
opacity: 0.5;
}
.pick {
padding-top: var(--s-4); padding-top: var(--s-4);
} }
.pick + .pick { .block + .block {
margin-top: var(--s-5); margin-top: var(--s-5);
border-top: 1px solid var(--c-border); border-top: 1px solid var(--c-border);
} }
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-5) 0 var(--s-2);
}
.chain { .chain {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -441,104 +350,26 @@ function delayClass(ms: number | null): string {
font-weight: 500; font-weight: 500;
} }
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-5) 0 var(--s-2);
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: var(--s-2); gap: var(--s-2);
} }
.card { .hint {
display: flex;
flex-direction: column;
gap: var(--s-1);
min-width: 0;
padding: var(--s-3);
border: 1px solid var(--c-border);
border-radius: var(--r-sm);
background: var(--c-surface-sunken);
text-align: left;
cursor: pointer;
}
.card:hover:not(:disabled) {
border-color: var(--c-accent);
}
.card.is-on {
border-color: var(--c-accent);
background: var(--c-accent-soft);
}
.card:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.card-name {
font-size: var(--t-sm);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card.is-on .card-name {
color: var(--c-accent-text);
}
.card-sub {
font-size: var(--t-xs);
color: var(--c-text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-delay {
font-family: var(--f-mono);
font-size: var(--t-xs);
font-variant-numeric: tabular-nums;
min-height: 1.2em;
}
.d-ok {
color: var(--c-ok);
}
.d-warn {
color: var(--c-warn);
}
.d-bad {
color: var(--c-danger);
}
.d-none {
color: transparent;
}
.aside-note {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--s-2); gap: var(--s-2);
color: var(--c-text-faint); color: var(--c-text-faint);
font-size: var(--t-sm); font-size: var(--t-sm);
margin-bottom: var(--s-3);
line-height: 1.6; line-height: 1.6;
} }
.aside-note code { .hint code {
color: var(--c-text-soft); color: var(--c-text-soft);
} }
.is-dim {
opacity: 0.6;
}
</style> </style>
+22 -18
View File
@@ -35,7 +35,10 @@ const done = ref('')
const fields = ref<ListenerFields | null>(null) const fields = ref<ListenerFields | null>(null)
const formOpen = ref(false) const formOpen = ref(false)
const formEditing = ref<Inbound | null>(null) const formEditing = ref<Inbound | null>(null)
const testing = ref(false) /** 正在提交的那个出站的名字。只有它显示忙碌,其余卡片照常能点 */
const pendingOutbound = ref<string | null>(null)
/** 正在测延迟的线路 */
const testing = ref<string[]>([])
onMounted(async () => { onMounted(async () => {
try { try {
@@ -101,7 +104,8 @@ async function commit(next: ConfigObject, success: string) {
async function pickOutbound(mode: OutboundMode) { async function pickOutbound(mode: OutboundMode) {
const target = selected.value const target = selected.value
if (!target || target.listenerIndex === null || saving.value) return if (!target || target.listenerIndex === null || pendingOutbound.value) return
pendingOutbound.value = outboundLabel(mode).text
const next = clone() const next = clone()
const entry = (next.listeners as ConfigObject[])[target.listenerIndex]! const entry = (next.listeners as ConfigObject[])[target.listenerIndex]!
@@ -110,7 +114,11 @@ async function pickOutbound(mode: OutboundMode) {
if (proxy === undefined) delete entry.proxy if (proxy === undefined) delete entry.proxy
else entry.proxy = proxy else entry.proxy = proxy
try {
await commit(next, `${target.name} 的出站改成了 ${outboundLabel(mode).text}`) await commit(next, `${target.name} 的出站改成了 ${outboundLabel(mode).text}`)
} finally {
pendingOutbound.value = null
}
} }
function openAdd() { function openAdd() {
@@ -155,24 +163,20 @@ async function selectMember(group: string, member: string) {
} }
} }
/** 让内核并发测一个策略组里所有成员的延迟,测完刷新一次拿到新数字。 */ /** 测一条线路的延迟。纯读操作,不影响任何配置,所以谁都不用禁用。 */
async function testGroup(group: string) { async function testLine(name: string) {
if (testing.value) return if (testing.value.includes(name)) return
testing.value = true testing.value = [...testing.value, name]
failure.value = null
done.value = ''
try { try {
const result = await api.testGroup(group) await api.testLine(name)
const ok = Object.values(result).filter((ms) => ms > 0).length
done.value = `${group} 测完了,${ok} / ${Object.keys(result).length} 条通`
emit('changed') emit('changed')
} catch (error) { } catch (error) {
// 测不通是关于线路的结论,不是 Patchbay 出了问题,所以用中性提示而不是错误框。 // 测不通是关于线路的结论,不是 Patchbay 出了问题,不弹错误框
// 内核在全部超时时回 504,单条测失败回 503,两种都属于「测出来了,结果不好」。 const raw =
const raw = error instanceof ApiError ? String(error.payload.message ?? error.message) : String(error) error instanceof ApiError ? String(error.payload.message ?? error.message) : String(error)
done.value = raw.includes('timeout') ? `${group} 里的线路全部超时` : `测试没能完成:${raw}` done.value = raw.includes('timeout') ? `${name} 超时` : `${name} 测不通`
} finally { } finally {
testing.value = false testing.value = testing.value.filter((n) => n !== name)
} }
} }
@@ -252,13 +256,13 @@ async function removeSelected() {
:destination="resolveDestination(data, selected)" :destination="resolveDestination(data, selected)"
:runtime="runtime" :runtime="runtime"
:kernel-online="kernelOnline" :kernel-online="kernelOnline"
:saving="saving"
@pick="pickOutbound" @pick="pickOutbound"
@edit="openEdit" @edit="openEdit"
@remove="removeSelected" @remove="removeSelected"
@select="selectMember" @select="selectMember"
@test="testGroup" @test="testLine"
:testing="testing" :testing="testing"
:pending-outbound="pendingOutbound"
/> />
</div> </div>