本地开发换成真订阅;闪电挪到卡片上;忙碌状态改成按卡片
占位线路删了。它们指向 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:
@@ -10,6 +10,10 @@ PATCHBAY_CONFIG=./dev/home/config.yaml
|
||||
PATCHBAY_MIHOMO_BIN=../binary/mihomo-windows-amd64-v3-go125.exe
|
||||
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 用户名@服务器地址
|
||||
|
||||
+23
-16
@@ -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
|
||||
allow-lan: false # 本地测试不对局域网开放
|
||||
@@ -12,28 +17,30 @@ ipv6: false
|
||||
external-controller: 127.0.0.1:19090 # 管理接口,避开默认的 9090
|
||||
secret: "dev-secret-local-only"
|
||||
|
||||
# 关掉自动更新,避免测试时下载 GEO 数据库
|
||||
geo-auto-update: false
|
||||
geo-auto-update: false # 关掉自动更新,避免测试时下载 GEO 数据库
|
||||
|
||||
proxies:
|
||||
- name: 占位线路甲
|
||||
type: socks5
|
||||
server: 127.0.0.1
|
||||
port: 1080
|
||||
- name: 占位线路乙
|
||||
type: socks5
|
||||
server: 127.0.0.1
|
||||
port: 1081
|
||||
proxy-providers:
|
||||
subs:
|
||||
type: http
|
||||
url: "__SUBSCRIPTION_URL__"
|
||||
path: ./providers/subs.yaml
|
||||
interval: 86400
|
||||
health-check:
|
||||
enable: true
|
||||
url: https://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
|
||||
proxy-groups:
|
||||
- name: PROXY # 总开关,手动选
|
||||
type: select
|
||||
proxies: [AUTO, DIRECT]
|
||||
use: [subs]
|
||||
- name: AUTO # 自动挑延迟最低的
|
||||
type: url-test
|
||||
proxies: [占位线路甲, 占位线路乙]
|
||||
use: [subs]
|
||||
url: https://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
tolerance: 50
|
||||
|
||||
rules:
|
||||
# 本机和内网地址一律直连
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"dev": "bun run --hot server/index.ts",
|
||||
"dev:web": "vite",
|
||||
"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",
|
||||
"test": "bun test",
|
||||
"smoke": "bun run scripts/api-smoke.ts",
|
||||
|
||||
@@ -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} 已从模板重新生成`)
|
||||
@@ -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>
|
||||
+156
-325
@@ -1,26 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 选中那一路入站的详情。
|
||||
*
|
||||
* 分两块,因为它们是两种不同性质的动作,不该长得一样也不该给一样的反馈:
|
||||
* 流量去向 —— 换策略组当前选中谁。不写配置文件,点了立刻变。日常点的就是这个。
|
||||
* 出站 —— 换这路入站指向哪个出站。要改配置文件,走校验和重载,要等几秒。
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
|
||||
import { latestDelay } from '../api'
|
||||
import type { ProxyInfo } from '../api'
|
||||
import OutboundCard from '../components/OutboundCard.vue'
|
||||
import IconOffline from '~icons/ri/cloud-off-line'
|
||||
import IconEdit from '~icons/ri/edit-line'
|
||||
import IconDelete from '~icons/ri/delete-bin-line'
|
||||
import IconFlash from '~icons/ri/flashlight-line'
|
||||
|
||||
const props = defineProps<{
|
||||
inbound: Inbound
|
||||
groups: string[]
|
||||
ownProxies: string[]
|
||||
providerLines: string[]
|
||||
runtime: Record<string, ProxyInfo>
|
||||
/** 流量最终交给哪个出站。走规则的入站要查到规则表末尾的 MATCH */
|
||||
destination: string | null
|
||||
/** 运行时状态,键是出站名字。内核没连接时是空的 */
|
||||
runtime: Record<string, ProxyInfo>
|
||||
kernelOnline: boolean
|
||||
saving: boolean
|
||||
/** 正在测延迟 */
|
||||
testing: boolean
|
||||
/** 正在提交的那个出站的名字。只有它显示忙碌,其余照常能点 */
|
||||
pendingOutbound: string | null
|
||||
/** 正在测延迟的线路 */
|
||||
testing: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -28,26 +35,36 @@ const emit = defineEmits<{
|
||||
edit: []
|
||||
remove: []
|
||||
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: string[] = []
|
||||
let cursor = start
|
||||
while (cursor && !chain.includes(cursor)) {
|
||||
chain.push(cursor)
|
||||
const chain = computed(() => {
|
||||
const hops: string[] = []
|
||||
let cursor = props.destination
|
||||
while (cursor && !hops.includes(cursor)) {
|
||||
hops.push(cursor)
|
||||
cursor = props.runtime[cursor]?.now ?? null
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
const chain = computed(() => resolveChain(props.destination))
|
||||
return hops
|
||||
})
|
||||
|
||||
/** 链条上最后一个还是策略组的环节——用户能在这里换选中。 */
|
||||
const switchable = computed(() => {
|
||||
@@ -59,89 +76,20 @@ const switchable = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
interface Option {
|
||||
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 {
|
||||
function isCurrentOutbound(mode: OutboundMode): boolean {
|
||||
const now = props.inbound.outbound
|
||||
if (now.kind !== mode.kind) return false
|
||||
return now.kind !== 'named' || now.name === (mode as { name: string }).name
|
||||
}
|
||||
|
||||
function delayClass(ms: number | null): string {
|
||||
if (ms === null) return 'd-none'
|
||||
if (ms < 200) return 'd-ok'
|
||||
if (ms < 500) return 'd-warn'
|
||||
return 'd-bad'
|
||||
}
|
||||
const BUILTINS: Array<{ mode: OutboundMode; label: string; sub: string }> = [
|
||||
{ mode: { kind: 'rules' }, label: 'RULE', sub: '走分流规则' },
|
||||
{ mode: { kind: 'direct' }, label: 'DIRECT', sub: '不经过代理' },
|
||||
{ mode: { kind: 'global' }, label: 'GLOBAL', sub: '跟随全局选择' },
|
||||
]
|
||||
|
||||
const LINE_LOCKED = '订阅线路不能被入站直接引用,选中它需要自动建一个筛选组——这部分还没做'
|
||||
const NO_PROXY_FIELD = '这路入站没有 proxy 字段'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -151,11 +99,10 @@ function delayClass(ms: number | null): string {
|
||||
<span class="port mono">{{ inbound.port }}</span>
|
||||
<span class="name">{{ inbound.name }}</span>
|
||||
</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 v-if="!locked" class="acts">
|
||||
<button type="button" @click="emit('edit')"><IconEdit /> 编辑</button>
|
||||
<button type="button" class="danger" @click="emit('remove')"><IconDelete /> 删除</button>
|
||||
</div>
|
||||
|
||||
<dl class="facts">
|
||||
@@ -168,128 +115,114 @@ function delayClass(ms: number | null): string {
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<section v-if="chain.length" class="pick">
|
||||
<!-- 运行时:换线路。不写文件,点了立刻变 -->
|
||||
<section v-if="chain.length" class="block">
|
||||
<h3>流量去向</h3>
|
||||
<p class="chain mono">
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<template v-if="switchable">
|
||||
<div class="sub-head">
|
||||
<h4>{{ switchable.group }} 当前选中</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="flash"
|
||||
:disabled="testing"
|
||||
:title="`测试 ${switchable.group} 里所有线路的延迟`"
|
||||
@click="emit('test', switchable.group)"
|
||||
>
|
||||
<IconFlash />
|
||||
</button>
|
||||
</div>
|
||||
<h4>{{ switchable.group }} 当前选中</h4>
|
||||
<div class="grid">
|
||||
<button
|
||||
<OutboundCard
|
||||
v-for="m in switchable.members"
|
||||
:key="m"
|
||||
type="button"
|
||||
:class="['card', m === switchable.now && 'is-on']"
|
||||
:disabled="saving"
|
||||
@click="emit('select', switchable.group, m)"
|
||||
>
|
||||
<span class="card-name">{{ m }}</span>
|
||||
<span class="card-sub">{{ runtime[m]?.now ? `${runtime[m]!.type}(${runtime[m]!.now})` : (runtime[m]?.type ?? '—') }}</span>
|
||||
<span :class="['card-delay', delayClass(latestDelay(runtime[m]))]">
|
||||
{{ latestDelay(runtime[m]) === null ? '' : `${latestDelay(runtime[m])} ms` }}
|
||||
</span>
|
||||
</button>
|
||||
:name="m"
|
||||
:sub="describe(m)"
|
||||
:delay="delayOf(m)"
|
||||
:selected="m === switchable.now"
|
||||
:testing="isTesting(m)"
|
||||
testable
|
||||
@pick="emit('select', switchable.group, m)"
|
||||
@test="emit('test', m)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="pick">
|
||||
<!-- 配置:换这路入站指向谁。要写文件、要校验 -->
|
||||
<section class="block">
|
||||
<h3>出站</h3>
|
||||
|
||||
<p v-if="locked" class="aside-note">
|
||||
由 <code>{{ inbound.legacyKey }}</code> 声明,这种写法没有 proxy 字段,出站固定是 RULE
|
||||
<p v-if="locked" class="hint">
|
||||
由 <code>{{ inbound.legacyKey }}</code> 声明,这种写法没有 proxy 字段,固定走 RULE
|
||||
</p>
|
||||
|
||||
<div v-if="!locked" class="grid">
|
||||
<button
|
||||
v-for="opt in builtins"
|
||||
:key="opt.key"
|
||||
type="button"
|
||||
:class="['card', isCurrent(opt.mode) && 'is-on']"
|
||||
:disabled="opt.disabled || saving"
|
||||
@click="emit('pick', opt.mode)"
|
||||
>
|
||||
<span class="card-name">{{ opt.label }}</span>
|
||||
<span class="card-sub">{{ opt.sub }}</span>
|
||||
</button>
|
||||
<OutboundCard
|
||||
v-for="b in BUILTINS"
|
||||
:key="b.label"
|
||||
:name="b.label"
|
||||
:sub="b.sub"
|
||||
:delay="null"
|
||||
:selected="isCurrentOutbound(b.mode)"
|
||||
:pending="pendingOutbound === b.label"
|
||||
@pick="emit('pick', b.mode)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="groupOptions.length">
|
||||
<template v-if="groups.length">
|
||||
<h4 :class="locked && 'is-dim'">策略组</h4>
|
||||
<div class="grid">
|
||||
<button
|
||||
v-for="opt in groupOptions"
|
||||
:key="opt.key"
|
||||
type="button"
|
||||
:class="['card', isCurrent(opt.mode) && 'is-on']"
|
||||
:disabled="opt.disabled || saving"
|
||||
@click="emit('pick', opt.mode)"
|
||||
>
|
||||
<span class="card-name">{{ opt.label }}</span>
|
||||
<span class="card-sub">{{ opt.sub || '—' }}</span>
|
||||
<span :class="['card-delay', delayClass(opt.delay)]">
|
||||
{{ opt.delay === null ? '' : `${opt.delay} ms` }}
|
||||
</span>
|
||||
</button>
|
||||
<OutboundCard
|
||||
v-for="g in groups"
|
||||
:key="g"
|
||||
:name="g"
|
||||
:sub="describe(g)"
|
||||
:delay="delayOf(g)"
|
||||
:selected="isCurrentOutbound({ kind: 'named', name: g })"
|
||||
:disabled-reason="locked ? NO_PROXY_FIELD : undefined"
|
||||
:pending="pendingOutbound === g"
|
||||
:testing="isTesting(g)"
|
||||
testable
|
||||
@pick="emit('pick', { kind: 'named', name: g })"
|
||||
@test="emit('test', g)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="ownOptions.length">
|
||||
<template v-if="ownProxies.length">
|
||||
<h4 :class="locked && 'is-dim'">自有线路</h4>
|
||||
<div class="grid">
|
||||
<button
|
||||
v-for="opt in ownOptions"
|
||||
:key="opt.key"
|
||||
type="button"
|
||||
:class="['card', isCurrent(opt.mode) && 'is-on']"
|
||||
:disabled="opt.disabled || saving"
|
||||
@click="emit('pick', opt.mode)"
|
||||
>
|
||||
<span class="card-name">{{ opt.label }}</span>
|
||||
<span class="card-sub">{{ opt.sub || '—' }}</span>
|
||||
<span :class="['card-delay', delayClass(opt.delay)]">
|
||||
{{ opt.delay === null ? '' : `${opt.delay} ms` }}
|
||||
</span>
|
||||
</button>
|
||||
<OutboundCard
|
||||
v-for="p in ownProxies"
|
||||
:key="p"
|
||||
:name="p"
|
||||
:sub="describe(p)"
|
||||
:delay="delayOf(p)"
|
||||
:selected="isCurrentOutbound({ kind: 'named', name: p })"
|
||||
:disabled-reason="locked ? NO_PROXY_FIELD : undefined"
|
||||
:pending="pendingOutbound === p"
|
||||
:testing="isTesting(p)"
|
||||
testable
|
||||
@pick="emit('pick', { kind: 'named', name: p })"
|
||||
@test="emit('test', p)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<h4 :class="locked && 'is-dim'">订阅线路</h4>
|
||||
<p v-if="!kernelOnline" class="aside-note">
|
||||
<p v-if="!kernelOnline" class="hint">
|
||||
<IconOffline />
|
||||
内核未连接。订阅里有哪些线路只有运行中的内核知道
|
||||
内核未连接,看不到订阅里有哪些线路
|
||||
</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">
|
||||
<button
|
||||
v-for="opt in lineOptions"
|
||||
:key="opt.key"
|
||||
type="button"
|
||||
class="card"
|
||||
disabled
|
||||
:title="opt.reason"
|
||||
>
|
||||
<span class="card-name">{{ opt.label }}</span>
|
||||
<span class="card-sub">{{ opt.sub || '—' }}</span>
|
||||
<span :class="['card-delay', delayClass(opt.delay)]">
|
||||
{{ opt.delay === null ? '' : `${opt.delay} ms` }}
|
||||
</span>
|
||||
</button>
|
||||
<OutboundCard
|
||||
v-for="line in providerLines"
|
||||
:key="line"
|
||||
:name="line"
|
||||
:sub="describe(line)"
|
||||
:delay="delayOf(line)"
|
||||
:selected="false"
|
||||
:disabled-reason="LINE_LOCKED"
|
||||
:testing="isTesting(line)"
|
||||
testable
|
||||
@test="emit('test', line)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
@@ -331,22 +264,6 @@ function delayClass(ms: number | null): string {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
@@ -365,63 +282,55 @@ function delayClass(ms: number | null): string {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.acts button:hover:not(:disabled) {
|
||||
.acts button:hover {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.acts button.danger:hover:not(:disabled) {
|
||||
.acts button.danger:hover {
|
||||
border-color: var(--c-danger);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.acts button:disabled {
|
||||
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 {
|
||||
.facts {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
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);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flash:hover:not(:disabled) {
|
||||
background: var(--c-accent-soft);
|
||||
color: var(--c-accent-text);
|
||||
.facts dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.flash:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.pick {
|
||||
.block {
|
||||
padding-top: var(--s-4);
|
||||
}
|
||||
|
||||
.pick + .pick {
|
||||
.block + .block {
|
||||
margin-top: var(--s-5);
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -441,104 +350,26 @@ function delayClass(ms: number | null): string {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.card {
|
||||
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 {
|
||||
.hint {
|
||||
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;
|
||||
}
|
||||
|
||||
.aside-note code {
|
||||
.hint code {
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
.is-dim {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
+23
-19
@@ -35,7 +35,10 @@ const done = ref('')
|
||||
const fields = ref<ListenerFields | null>(null)
|
||||
const formOpen = ref(false)
|
||||
const formEditing = ref<Inbound | null>(null)
|
||||
const testing = ref(false)
|
||||
/** 正在提交的那个出站的名字。只有它显示忙碌,其余卡片照常能点 */
|
||||
const pendingOutbound = ref<string | null>(null)
|
||||
/** 正在测延迟的线路 */
|
||||
const testing = ref<string[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -101,7 +104,8 @@ async function commit(next: ConfigObject, success: string) {
|
||||
|
||||
async function pickOutbound(mode: OutboundMode) {
|
||||
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 entry = (next.listeners as ConfigObject[])[target.listenerIndex]!
|
||||
@@ -110,7 +114,11 @@ async function pickOutbound(mode: OutboundMode) {
|
||||
if (proxy === undefined) delete entry.proxy
|
||||
else entry.proxy = proxy
|
||||
|
||||
await commit(next, `${target.name} 的出站改成了 ${outboundLabel(mode).text}`)
|
||||
try {
|
||||
await commit(next, `${target.name} 的出站改成了 ${outboundLabel(mode).text}`)
|
||||
} finally {
|
||||
pendingOutbound.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
@@ -155,24 +163,20 @@ async function selectMember(group: string, member: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 让内核并发测一个策略组里所有成员的延迟,测完刷新一次拿到新数字。 */
|
||||
async function testGroup(group: string) {
|
||||
if (testing.value) return
|
||||
testing.value = true
|
||||
failure.value = null
|
||||
done.value = ''
|
||||
/** 测一条线路的延迟。纯读操作,不影响任何配置,所以谁都不用禁用。 */
|
||||
async function testLine(name: string) {
|
||||
if (testing.value.includes(name)) return
|
||||
testing.value = [...testing.value, name]
|
||||
try {
|
||||
const result = await api.testGroup(group)
|
||||
const ok = Object.values(result).filter((ms) => ms > 0).length
|
||||
done.value = `${group} 测完了,${ok} / ${Object.keys(result).length} 条通`
|
||||
await api.testLine(name)
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
// 测不通是关于线路的结论,不是 Patchbay 出了问题,所以用中性提示而不是错误框。
|
||||
// 内核在全部超时时回 504,单条测失败回 503,两种都属于「测出来了,结果不好」。
|
||||
const raw = error instanceof ApiError ? String(error.payload.message ?? error.message) : String(error)
|
||||
done.value = raw.includes('timeout') ? `${group} 里的线路全部超时` : `测试没能完成:${raw}`
|
||||
// 测不通是关于线路的结论,不是 Patchbay 出了问题,不弹错误框
|
||||
const raw =
|
||||
error instanceof ApiError ? String(error.payload.message ?? error.message) : String(error)
|
||||
done.value = raw.includes('timeout') ? `${name} 超时` : `${name} 测不通`
|
||||
} finally {
|
||||
testing.value = false
|
||||
testing.value = testing.value.filter((n) => n !== name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,13 +256,13 @@ async function removeSelected() {
|
||||
:destination="resolveDestination(data, selected)"
|
||||
:runtime="runtime"
|
||||
:kernel-online="kernelOnline"
|
||||
:saving="saving"
|
||||
@pick="pickOutbound"
|
||||
@edit="openEdit"
|
||||
@remove="removeSelected"
|
||||
@select="selectMember"
|
||||
@test="testGroup"
|
||||
@test="testLine"
|
||||
:testing="testing"
|
||||
:pending-outbound="pendingOutbound"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user