5 Commits
Author SHA1 Message Date
im0o 7d2a3afff0 接入品牌素材
release / release (push) Successful in 27s
标签页图标只声明 favicon.ico。它带 16/32/48/64 四个尺寸、是照着小尺寸调过
的;而这套素材里 .ico 是深底浅图、PNG 那一组是白底黑图,两个都声明的话浏览器
会按显示密度在两者间挑,同一个标签页会换样子。

素材里的 favicon.svg 没有采用:里面是一张 512px 的 PNG 用 base64 塞在
<image> 里,不是矢量,比 favicon.png 还大 3.4KB。
2026-08-14 14:06:24 +08:00
im0o ea0efd4468 令牌输错时给出提示
登录页在提交前后都是同一个组件,不重建、输入框也不清空,401 静默处理的话
点了「进入」界面一个字都不变,看着像没反应。

只在提交令牌时提示:存着的令牌失效而弹回登录页时,页面本身就是说明。
2026-08-14 14:06:24 +08:00
im0o 0f0ac3c4b7 订阅列表可以调顺序
每行末尾两个箭头,鼠标划过那一行才显现。proxy-providers 是映射,顺序只
影响界面呈现,内核不看;交换的是两个用户订阅在映射里的位置,中间夹着的
链式副本留在原地——它们不在列表上,跟着挪只会把 diff 撑大。

顺序写在配置文件里,所以走保存链路,不是运行时动作。
2026-08-14 14:06:11 +08:00
im0o 23aa28fbea 订阅详情可以立刻检查线路
走内核的 provider healthcheck:逐条 URLTest,测通与否和延迟一起记进历史
(adapter/provider/healthcheck.go:123-189)。用的是订阅自己的
health-check.url——这个接口不收 url 参数,设置页那个测速地址传不进去。

它是同步的,测完全部线路才回话,一批 10 条、每条最多 5 秒
(healthcheck.go:132、181、201),线路多又都测不通时远超透传默认的
15 秒,所以 proxyGet 的超时改成可传,健康检查这条路径放到 120 秒。
2026-08-14 14:05:24 +08:00
im0o 56546b20c5 新增入站不再预填订阅
来源一个都不选时 AUTO 和 FALLBACK 就是空组,内核会拒绝整份配置
(adapter/outboundgroup/parser.go:117-119),所以 applySetup 这时只建
SELECT、成员只有 DIRECT;用户在配置里选好订阅再保存,那两个组建回来。
链式代理打开时同样不预填入口订阅。

「改成逐条选线路」保持全给上:那一路本来就在把流量送到某个出站,
接管不该顺手把它改成直连。
2026-08-14 14:04:47 +08:00
21 changed files with 414 additions and 48 deletions
+7 -2
View File
@@ -184,8 +184,13 @@ export async function updateGeoDatabases(env: Env): Promise<ValidateResult> {
* 持续吐 JSON 的分块流(内核源码 hub/route/server.go:380-383),给它们设超时等于 * 持续吐 JSON 的分块流(内核源码 hub/route/server.go:380-383),给它们设超时等于
* 每隔一会儿就把连接掐断一次。 * 每隔一会儿就把连接掐断一次。
*/ */
export async function proxyGet(env: Env, path: string, stream = false): Promise<Response> { export async function proxyGet(
return callApi(env, path, stream ? {} : { signal: AbortSignal.timeout(15_000) }) env: Env,
path: string,
stream = false,
timeoutMs = 15_000,
): Promise<Response> {
return callApi(env, path, stream ? {} : { signal: AbortSignal.timeout(timeoutMs) })
} }
/** 透传一个写操作。目前只有断开连接和刷新订阅两处。 */ /** 透传一个写操作。目前只有断开连接和刷新订阅两处。 */
+9 -1
View File
@@ -366,7 +366,15 @@ function makeRoute(env: Env, token: string) {
if (!allowed) { if (!allowed) {
return json({ message: `不允许访问该路径:${target}` }, 403) return json({ message: `不允许访问该路径:${target}` }, 403)
} }
const upstream = await proxyGet(env, target + url.search, STREAM_PATHS.has(target)) // 健康检查是同步的:内核测完才回话,一批 10 条、每条最多 5 秒
// (内核源码 adapter/provider/healthcheck.go:132、181、201),
// 整条订阅测且线路多又都测不通时会远超默认的 15 秒
const upstream = await proxyGet(
env,
target + url.search,
STREAM_PATHS.has(target),
HEALTHCHECK_PATH.test(target) ? 120_000 : undefined,
)
return new Response(upstream.body, { return new Response(upstream.body, {
status: upstream.status, status: upstream.status,
headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' }, headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' },
+22
View File
@@ -115,6 +115,28 @@ describe('一路入站的三个组', () => {
expect((groups[2] as ConfigObject).type).toBe('fallback') expect((groups[2] as ConfigObject).type).toBe('fallback')
}) })
it('一个来源都没选时只有 SELECT,成员只有 DIRECT', () => {
// AUTO 和 FALLBACK 只有 use: 没有点名成员,来源为空就是空组,内核会拒绝
// 整份配置(parser.go:117-119)。新增入站落在这个状态上,等用户自己挑订阅
const empty: OutboundSetup = { proxy: { sources: [], filter: null }, chain: null }
const groups = applySetup(base(), 'test1', empty)['proxy-groups'] as ConfigObject[]
expect(groups).toEqual([{ name: 'patchbay:test1', type: 'select', proxies: ['DIRECT'] }])
})
it('从空来源选上订阅后,AUTO 和 FALLBACK 建回来', () => {
const empty: OutboundSetup = { proxy: { sources: [], filter: null }, chain: null }
const data = applySetup(applySetup(base(), 'test1', empty), 'test1', plain)
const groups = data['proxy-groups'] as ConfigObject[]
expect(groups.map((g) => g.name)).toEqual([
'patchbay:test1',
'patchbay:test1 AUTO',
'patchbay:test1 FALLBACK',
])
// 反过来把订阅全取消,那两个组要跟着消失,否则留下的是内核不收的空组
const back = applySetup(data, 'test1', empty)['proxy-groups'] as ConfigObject[]
expect(back.map((g) => g.name)).toEqual(['patchbay:test1'])
})
it('tab 的顺序是 SELECT、AUTO、FALLBACK,只有 SELECT 能点', () => { it('tab 的顺序是 SELECT、AUTO、FALLBACK,只有 SELECT 能点', () => {
// 另外两个自己决定用哪条,手动指定会被它们自己撤销,见 outbound-chain.ts // 另外两个自己决定用哪条,手动指定会被它们自己撤销,见 outbound-chain.ts
const data = applySetup(base(), 'test1', plain) const data = applySetup(base(), 'test1', plain)
+11
View File
@@ -390,6 +390,16 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
const { filter } = setup.proxy const { filter } = setup.proxy
let groups = groupsOf(next) let groups = groupsOf(next)
if (use.length === 0) {
// 一个来源都没选。AUTO 和 FALLBACK 只有 `use:` 没有点名成员,来源为空就是空组,
// 内核会拒绝整份配置(内核源码 adapter/outboundgroup/parser.go:117-119),所以这时
// 只建 SELECT、成员只有 DIRECT。新增入站落在这个状态上,等用户自己挑订阅。
// filter 一并不写:没有来源可筛。
groups = upsert(groups, makeGroup(proxyGroupName(inbound), 'select', [], null, ['DIRECT']))
groups = groups.filter(
(g) => g.name !== autoGroupName(inbound) && g.name !== fallbackGroupName(inbound),
)
} else {
// AUTO 排第一位:内核给没有选中记录的组默认用第一个成员,选中的线路被订阅刷新删掉时 // AUTO 排第一位:内核给没有选中记录的组默认用第一个成员,选中的线路被订阅刷新删掉时
// 也静默回退到第一个成员(内核源码 adapter/outboundgroup/selector.go:103-112)。这两种 // 也静默回退到第一个成员(内核源码 adapter/outboundgroup/selector.go:103-112)。这两种
// 情况都该落到自动测速上;排 DIRECT 就是静默直连,对按端口定向出站的场景是泄露。 // 情况都该落到自动测速上;排 DIRECT 就是静默直连,对按端口定向出站的场景是泄露。
@@ -403,6 +413,7 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
) )
groups = upsert(groups, makeGroup(autoGroupName(inbound), 'url-test', use, filter)) groups = upsert(groups, makeGroup(autoGroupName(inbound), 'url-test', use, filter))
groups = upsert(groups, makeGroup(fallbackGroupName(inbound), 'fallback', use, filter)) groups = upsert(groups, makeGroup(fallbackGroupName(inbound), 'fallback', use, filter))
}
if (chained) { if (chained) {
groups = upsert( groups = upsert(
+43
View File
@@ -5,6 +5,7 @@ import {
addFileSubscription, addFileSubscription,
addSubscription, addSubscription,
listSubscriptions, listSubscriptions,
moveSubscription,
providerNames, providerNames,
removeSubscription, removeSubscription,
renameSubscription, renameSubscription,
@@ -280,6 +281,48 @@ describe('删除前的引用检查', () => {
}) })
}) })
describe('调整订阅顺序', () => {
/** 三个用户订阅,外加一份接在中间的订阅副本。 */
function three(): ConfigObject {
let data: ConfigObject = {}
for (const name of ['A', 'B', 'C']) data = addSubscription(data, name, `https://${name}.example`)
return data
}
it('上移和下移交换相邻两个订阅', () => {
expect(listSubscriptions(moveSubscription(three(), 'B', -1)).map((s) => s.name)).toEqual([
'B',
'A',
'C',
])
expect(listSubscriptions(moveSubscription(three(), 'B', 1)).map((s) => s.name)).toEqual([
'A',
'C',
'B',
])
})
it('到头了原样返回,配置对象不变', () => {
const data = three()
expect(moveSubscription(data, 'A', -1)).toBe(data)
expect(moveSubscription(data, 'C', 1)).toBe(data)
expect(moveSubscription(data, '不存在', 1)).toBe(data)
})
it('夹在中间的订阅副本留在原地', () => {
const withCopy = applySetup(
{ ...three(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
'test1',
{ proxy: { sources: ['B'], filter: null }, chain: { sources: ['B'], filter: null } },
)
const copy = chainProviderName('test1', 'B')
const before = Object.keys(withCopy['proxy-providers'] as ConfigObject).indexOf(copy)
const after = moveSubscription(withCopy, 'A', 1)
expect(listSubscriptions(after).map((s) => s.name)).toEqual(['B', 'A', 'C'])
expect(Object.keys(after['proxy-providers'] as ConfigObject).indexOf(copy)).toBe(before)
})
})
describe('缓存路径撞名', () => { describe('缓存路径撞名', () => {
it('名字只差符号时净化成同一个路径,能认出来', () => { it('名字只差符号时净化成同一个路径,能认出来', () => {
const data = addSubscription({}, '订阅 A', 'https://a.example/sub') const data = addSubscription({}, '订阅 A', 'https://a.example/sub')
+30
View File
@@ -238,6 +238,36 @@ export function renameSubscription(
return next return next
} }
/**
* 把一个订阅在列表里上移或下移一位。
*
* 顺序只是呈现顺序,内核不看 `proxy-providers` 里条目的先后。交换的是两个用户订阅在
* 映射里的位置,中间夹着的副本留在原地——它们不在列表上,跟着挪只会把 diff 撑大。
* 已经在头(尾)还要上(下)移时原样返回,调用方据此不发起保存。
*/
export function moveSubscription(
data: ConfigObject,
name: string,
delta: -1 | 1,
): ConfigObject {
const entries = Object.entries(providersOf(data))
const keys = entries.map(([key]) => key)
const mine = keys.filter((key) => !isGenerated(key))
const at = mine.indexOf(name)
const target = mine[at + delta]
if (at < 0 || target === undefined) return data
const from = keys.indexOf(name)
const onto = keys.indexOf(target)
const order = [...entries]
order[from] = entries[onto]!
order[onto] = entries[from]!
const next = { ...data }
next['proxy-providers'] = Object.fromEntries(order)
return next
}
/** 删除一个订阅。删到最后一个时把整个键去掉,不在文件里留空映射。 */ /** 删除一个订阅。删到最后一个时把整个键去掉,不在文件里留空映射。 */
export function removeSubscription(data: ConfigObject, name: string): ConfigObject { export function removeSubscription(data: ConfigObject, name: string): ConfigObject {
const providers = { ...providersOf(data) } const providers = { ...providersOf(data) }
+15 -5
View File
@@ -53,7 +53,7 @@ function openInbound(id: string) {
* 选中的那一路入站丢失,改完一个设置还要重新找回原来的位置。所以只有首次加载显示占位, * 选中的那一路入站丢失,改完一个设置还要重新找回原来的位置。所以只有首次加载显示占位,
* 之后都是原地更新。 * 之后都是原地更新。
*/ */
async function load() { async function load(): Promise<'ok' | 'unauthorized' | 'error'> {
refreshing.value = true refreshing.value = true
loadError.value = '' loadError.value = ''
try { try {
@@ -64,22 +64,32 @@ async function load() {
needsToken.value = false needsToken.value = false
// 撤回/重做按钮的可用性跟着每次重读走 // 撤回/重做按钮的可用性跟着每次重读走
void refreshHistory() void refreshHistory()
return 'ok'
} catch (error) { } catch (error) {
if (error instanceof ApiError && error.unauthorized) { if (error instanceof ApiError && error.unauthorized) {
clearToken() clearToken()
needsToken.value = true needsToken.value = true
} else { return 'unauthorized'
loadError.value = error instanceof Error ? error.message : String(error)
} }
loadError.value = error instanceof Error ? error.message : String(error)
return 'error'
} finally { } finally {
refreshing.value = false refreshing.value = false
booted.value = true booted.value = true
} }
} }
function submitToken(value: string) { /**
* 提交登录页填的令牌。
*
* 401 必须当场说一声:登录页在提交前后都是它自己,组件不重建、输入框也不清空,
* 不提示的话点了「进入」界面一个字都不变,看着像没反应。
* 只在这里提示,不在 load() 里——存着的令牌失效时弹回登录页,页面本身就是说明。
*/
async function submitToken(value: string) {
setToken(value) setToken(value)
void load() // 令牌在哪儿取,登录页上就写着,不必在提示里再说一遍
if ((await load()) === 'unauthorized') toast('令牌不正确', { kind: 'error' })
} }
function logout() { function logout() {
+13
View File
@@ -310,6 +310,19 @@ export const api = {
refreshProvider: (name: string) => refreshProvider: (name: string) =>
request<unknown>('PUT', `/api/kernel/providers/proxies/${encodeURIComponent(name)}`), request<unknown>('PUT', `/api/kernel/providers/proxies/${encodeURIComponent(name)}`),
/**
* 让内核立刻对一个订阅的全部线路做一次健康检查:逐条 URLTest,测通与否和延迟一起
* 记进历史(内核源码 adapter/provider/healthcheck.go:123-189)。
*
* 用的是这个订阅 `health-check.url` 里的地址而不是设置页的测速地址——内核这个接口
* 不收 url 参数。它是同步的,测完全部线路才回话,所以可能要等上几十秒。
*/
checkProviderLines: (name: string) =>
request<unknown>(
'GET',
`/api/kernel/providers/proxies/${encodeURIComponent(name)}/healthcheck`,
),
/** /**
* 每个规则组的运行时状态:装了多少条规则、上次什么时候更新。 * 每个规则组的运行时状态:装了多少条规则、上次什么时候更新。
* 配置文件里只有声明,这两个数字只有运行中的内核知道。 * 配置文件里只有声明,这两个数字只有运行中的内核知道。
+84 -17
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 订阅列表。每行一个订阅:名字、当前线路数、上次更新时间。 * 订阅列表。每行一个订阅:名字、当前线路数、上次更新时间,末尾是调顺序的两个箭头。
* *
* 只管显示和选中,不碰任何数据。 * 只管显示、选中和「往哪边挪一位」,不碰任何数据。
*/ */
import IconUp from '~icons/ri/arrow-up-line'
import IconDown from '~icons/ri/arrow-down-line'
defineProps<{ defineProps<{
/** 一行要显示的东西,由页面按配置和运行时数据拼好 */ /** 一行要显示的东西,由页面按配置和运行时数据拼好 */
@@ -14,9 +16,14 @@ defineProps<{
/** 上次拉取成功的时刻。null 表示不知道或从没拉取过 */ /** 上次拉取成功的时刻。null 表示不知道或从没拉取过 */
updated: Date | null updated: Date | null
}> }>
/** 配置正在保存。调顺序要写配置文件,保存期间不能再点 */
saving: boolean
}>() }>()
const selected = defineModel<string | null>('selected', { required: true }) const selected = defineModel<string | null>('selected', { required: true })
/** 把这个订阅在列表里挪一位。写配置文件,由页面走保存链路。 */
const emit = defineEmits<{ move: [name: string, delta: -1 | 1] }>()
const pad = (n: number) => String(n).padStart(2, '0') const pad = (n: number) => String(n).padStart(2, '0')
function formatUpdated(at: Date): string { function formatUpdated(at: Date): string {
@@ -26,12 +33,12 @@ function formatUpdated(at: Date): string {
<template> <template>
<ol class="rows"> <ol class="rows">
<li v-for="row in rows" :key="row.name"> <li
<button v-for="(row, i) in rows"
type="button" :key="row.name"
:class="['line', selected === row.name && 'is-on']" :class="['row', selected === row.name && 'is-on']"
@click="selected = row.name"
> >
<button type="button" class="line" @click="selected = row.name">
<span class="name">{{ row.name }}</span> <span class="name">{{ row.name }}</span>
<span class="lines mono"> <span class="lines mono">
<template v-if="row.lines !== null">{{ row.lines }} 条</template> <template v-if="row.lines !== null">{{ row.lines }} 条</template>
@@ -39,6 +46,24 @@ function formatUpdated(at: Date): string {
</span> </span>
<span class="updated mono">{{ row.updated ? formatUpdated(row.updated) : '—' }}</span> <span class="updated mono">{{ row.updated ? formatUpdated(row.updated) : '—' }}</span>
</button> </button>
<span class="acts">
<button
type="button"
title="上移"
:disabled="saving || i === 0"
@click="emit('move', row.name, -1)"
>
<IconUp />
</button>
<button
type="button"
title="下移"
:disabled="saving || i === rows.length - 1"
@click="emit('move', row.name, 1)"
>
<IconDown />
</button>
</span>
</li> </li>
</ol> </ol>
</template> </template>
@@ -58,27 +83,34 @@ function formatUpdated(at: Date): string {
border-top: 1px solid var(--c-border); border-top: 1px solid var(--c-border);
} }
.row {
display: flex;
align-items: center;
padding-right: var(--s-3);
}
.row:hover {
background: var(--c-surface-sunken);
}
.row.is-on {
background: var(--c-accent-soft);
}
.line { .line {
width: 100%; flex: 1;
min-width: 0;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 4rem 6.5rem; grid-template-columns: minmax(0, 1fr) 4rem 6.5rem;
align-items: center; align-items: center;
gap: var(--s-3); gap: var(--s-3);
padding: var(--s-3) var(--s-4); padding: var(--s-3) var(--s-2) var(--s-3) var(--s-4);
border: none; border: none;
background: none; background: none;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
} }
.line:hover {
background: var(--c-surface-sunken);
}
.line.is-on {
background: var(--c-accent-soft);
}
.name { .name {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -93,4 +125,39 @@ function formatUpdated(at: Date): string {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--c-text-soft); color: var(--c-text-soft);
} }
/* 平时不显示:调顺序不是常用动作,每行常驻两个箭头只是噪声。
隐藏用 opacity 而不是 display,位置一直占着,鼠标划过时右边一列才不会跳 */
.acts {
flex: none;
display: inline-flex;
gap: 1px;
opacity: 0;
transition: opacity 0.15s ease;
}
.row:hover .acts,
.acts:focus-within {
opacity: 1;
}
.acts button {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.acts button:hover:not(:disabled) {
background: var(--c-surface);
color: var(--c-text);
}
.acts button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
</style> </style>
+9
View File
@@ -4,6 +4,15 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<!-- 标签页图标只声明 .ico:它里面有 16/32/48/64 四个尺寸,是照着小尺寸调过的。
再声明一个大 PNG 的话,浏览器会按显示密度在两者之间挑,而这套素材的 .ico 是
深底浅图、PNG 是白底黑图,挑到哪个不一定,同一个标签页会换样子。 -->
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48 64x64" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<!-- 移动端浏览器地址栏的底色,跟着系统主题走,和 tokens.css 里的 --c-bg 一致 -->
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fbfbfc" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#16181c" />
<title>Patchbay</title> <title>Patchbay</title>
</head> </head>
<body> <body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+34
View File
@@ -0,0 +1,34 @@
{
"name": "Patchbay",
"short_name": "Patchbay",
"start_url": "/",
"display": "standalone",
"background_color": "#fbfbfc",
"theme_color": "#fbfbfc",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/maskable-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

+44 -1
View File
@@ -13,6 +13,7 @@ import {
addFileSubscription, addFileSubscription,
addSubscription, addSubscription,
listSubscriptions, listSubscriptions,
moveSubscription,
providerNames, providerNames,
removeSubscription, removeSubscription,
renameSubscription, renameSubscription,
@@ -50,6 +51,8 @@ const formEditing = ref<Subscription | null>(null)
const removing = ref<Subscription | null>(null) const removing = ref<Subscription | null>(null)
/** 正在等内核重新拉取的订阅名 */ /** 正在等内核重新拉取的订阅名 */
const refreshing = ref<string | null>(null) const refreshing = ref<string | null>(null)
/** 正在等内核测完线路的订阅名 */
const checking = ref<string | null>(null)
/** /**
* 每个订阅的运行时数据:当前线路、上次更新时间。 * 每个订阅的运行时数据:当前线路、上次更新时间。
@@ -247,6 +250,39 @@ async function refresh() {
} }
} }
/**
* 让内核立刻测一遍选中订阅的线路。运行时动作,不写配置文件,所以不走 commit()。
*
* 内核是测完全部线路才回话的,线路多又都测不通时要等上几十秒,期间按钮转着。
*/
async function check() {
const target = selected.value
if (!target || checking.value !== null) return
checking.value = target.name
try {
await api.checkProviderLines(target.name)
await reloadProviders()
toast(`已检查 ${target.name} 的线路`)
} catch (error) {
toast(error instanceof ApiError ? error.message : String(error), {
kind: 'error',
hint: '该操作仅修改内核运行状态,配置文件未被修改。',
})
} finally {
checking.value = null
}
}
/** 把一个订阅在列表里挪一位。顺序在配置文件里,所以要走 commit()。 */
async function move(name: string, delta: -1 | 1) {
if (saving.value) return
const before = clone()
const next = moveSubscription(before, name, delta)
// 到头了 moveSubscription 原样返回,这时不该发起一次什么都没改的保存
if (next === before) return
await commit(next, '顺序已保存')
}
const refreshingAll = ref(false) const refreshingAll = ref(false)
/** 挨个让内核重新拉取所有订阅。失败的点名,成功的不逐个报。 */ /** 挨个让内核重新拉取所有订阅。失败的点名,成功的不逐个报。 */
@@ -301,7 +337,12 @@ async function refreshAll() {
</header> </header>
<div class="split"> <div class="split">
<ProviderList v-model:selected="selectedName" :rows="rows" /> <ProviderList
v-model:selected="selectedName"
:rows="rows"
:saving="saving"
@move="move"
/>
<SubscriptionDetail <SubscriptionDetail
v-if="selected" v-if="selected"
@@ -309,10 +350,12 @@ async function refreshAll() {
:info="providerInfo[selected.name]" :info="providerInfo[selected.name]"
:online="kernel.online" :online="kernel.online"
:refreshing="refreshing === selected.name" :refreshing="refreshing === selected.name"
:checking="checking === selected.name"
:saving="saving" :saving="saving"
@edit="openEdit" @edit="openEdit"
@remove="askRemove" @remove="askRemove"
@refresh="refresh" @refresh="refresh"
@check="check"
@toggle-health-check="toggleHealthCheck" @toggle-health-check="toggleHealthCheck"
/> />
</div> </div>
+14 -5
View File
@@ -285,7 +285,9 @@ watch([() => props.inbound.id, () => JSON.stringify(props.setup)], reset, { imme
const chainOn = computed({ const chainOn = computed({
get: () => form.value.chain !== null, get: () => form.value.chain !== null,
set: (on: boolean) => { set: (on: boolean) => {
form.value.chain = on ? { sources: [...props.subscriptions], filter: null } : null // 入口订阅不预设:入口是「先落到哪一跳」,用户心里有具体的那一条,
// 把订阅全给上只是让他保存前先去取消勾选
form.value.chain = on ? { sources: [], filter: null } : null
}, },
}) })
@@ -426,9 +428,15 @@ const flowPreview = computed(
pendingEntry.value !== null, pendingEntry.value !== null,
) )
/** /**
* 至少要选一个订阅。AUTO 和 FALLBACK 只有 `use:` 没有点名成员,一个订阅都不选就是空组, * 两边都至少要选一个订阅。
* 内核会直接拒绝整份配置(内核源码 adapter/outboundgroup/parser.go:117-119)。 *
* 在这里拦住,比让用户按下保存再读一段内核报错好。 * 入口组只有 `use:` 没有点名成员,一个订阅都不选就是空组,内核会直接拒绝整份配置
* (内核源码 adapter/outboundgroup/parser.go:117-119)。在这里拦住,比让用户按下
* 保存再读一段内核报错好。
*
* 代理这边空来源是能落盘的(applySetup 只建 SELECT、成员只有 DIRECT,新增入站就停在
* 这个状态),但那是「还没配」的起点,不该是取消勾选保存出来的结果——一路已经在走代理
* 的入站不能这么悄悄变成直连。
*/ */
const filled = (spec: { sources: string[] }) => spec.sources.length > 0 const filled = (spec: { sources: string[] }) => spec.sources.length > 0
const complete = computed( const complete = computed(
@@ -586,7 +594,8 @@ const complete = computed(
</h3> </h3>
<!-- 草稿态:显示保存后的成员。现有成员点了立刻生效,保存后才加入的点了是预选 --> <!-- 草稿态:显示保存后的成员。现有成员点了立刻生效,保存后才加入的点了是预选 -->
<div v-if="chainDraft" class="grid"> <p v-if="chainDraft && form.chain.sources.length === 0" class="hint">还没有选订阅</p>
<div v-else-if="chainDraft" class="grid">
<OutboundCard <OutboundCard
v-for="card in chainCards" v-for="card in chainCards"
:key="card.name" :key="card.name"
+12 -2
View File
@@ -54,12 +54,22 @@ const emit = defineEmits<{ saved: []; 'focus-done': [] }>()
const inbounds = computed(() => collectInbounds(props.data)) const inbounds = computed(() => collectInbounds(props.data))
const subscriptions = computed(() => userProviders(props.data)) const subscriptions = computed(() => userProviders(props.data))
/** 刚接管一路入站时给它什么:订阅全给上,不筛。三个组由 applySetup 一并建出来。 */ /**
* 接管一路已有入站时给它什么:订阅全给上,不筛。三个组由 applySetup 一并建出来。
* 来源不留空,是因为这一路本来就在把流量送到某个出站上,接管不该顺手把它改成直连。
*/
const defaultSetup = (): OutboundSetup => ({ const defaultSetup = (): OutboundSetup => ({
proxy: { sources: [...subscriptions.value], filter: null }, proxy: { sources: [...subscriptions.value], filter: null },
chain: null, chain: null,
}) })
/**
* 新增入站时给它什么:一个订阅都不预设。全新的端口还没有人在用,替用户挑一批订阅
* 只会让他保存完再回来取消勾选。来源为空的专属组只有 DIRECT(见 applySetup),
* 用户在详情页的配置里选好订阅再保存。
*/
const emptySetup = (): OutboundSetup => ({ proxy: { sources: [], filter: null }, chain: null })
const selectedId = ref<string | null>(null) const selectedId = ref<string | null>(null)
const saving = ref(false) const saving = ref(false)
const fields = ref<ListenerFields | null>(null) const fields = ref<ListenerFields | null>(null)
@@ -225,7 +235,7 @@ async function submitForm(entry: ConfigObject) {
// 新增的入站直接进专属组模式。否则新建完看到的是一块「这一路不由 Patchbay 管理」 // 新增的入站直接进专属组模式。否则新建完看到的是一块「这一路不由 Patchbay 管理」
// 的提示,还得再点一次才能用。 // 的提示,还得再点一次才能用。
const name = String(entry.name) const name = String(entry.name)
const withGroup = editingIndex === null ? applySetup(next, name, defaultSetup()) : next const withGroup = editingIndex === null ? applySetup(next, name, emptySetup()) : next
const ok = await commit(withGroup, editingIndex === null ? `已新增 ${name}` : '已保存') const ok = await commit(withGroup, editingIndex === null ? `已新增 ${name}` : '已保存')
// 失败时不能关对话框——关了再打开就是空表单,用户填的名称、端口、类型全没了 // 失败时不能关对话框——关了再打开就是空表单,用户填的名称、端口、类型全没了
+53 -1
View File
@@ -17,6 +17,8 @@ import IconDelete from '~icons/ri/delete-bin-line'
import IconRefresh from '~icons/ri/refresh-line' import IconRefresh from '~icons/ri/refresh-line'
import IconOffline from '~icons/ri/cloud-off-line' import IconOffline from '~icons/ri/cloud-off-line'
import IconPulse from '~icons/ri/pulse-line' import IconPulse from '~icons/ri/pulse-line'
import IconSync from '~icons/ri/loop-right-line'
import IconLoad from '~icons/ri/loader-4-line'
const props = defineProps<{ const props = defineProps<{
subscription: Subscription subscription: Subscription
@@ -25,6 +27,8 @@ const props = defineProps<{
online: boolean online: boolean
/** 正在等内核重新拉取 */ /** 正在等内核重新拉取 */
refreshing: boolean refreshing: boolean
/** 正在等内核测完这个订阅的线路 */
checking: boolean
/** 配置正在保存 */ /** 配置正在保存 */
saving: boolean saving: boolean
}>() }>()
@@ -34,6 +38,8 @@ const emit = defineEmits<{
remove: [] remove: []
/** 让内核立刻重新拉取。运行时动作,不写配置文件 */ /** 让内核立刻重新拉取。运行时动作,不写配置文件 */
refresh: [] refresh: []
/** 让内核立刻测一遍这个订阅的线路。运行时动作,不写配置文件 */
check: []
/** 开关健康检查。写配置文件,走保存链路 */ /** 开关健康检查。写配置文件,走保存链路 */
toggleHealthCheck: [] toggleHealthCheck: []
}>() }>()
@@ -86,7 +92,19 @@ function delayClass(ms: number | null): string {
</header> </header>
<section class="block"> <section class="block">
<h3>线路</h3> <h3>
线路
<button
type="button"
class="check"
:title="checking ? '检查中' : '检查全部线路的延迟'"
:disabled="!online || checking || lines.length === 0"
@click="emit('check')"
>
<IconLoad v-if="checking" class="spin" />
<IconSync v-else />
</button>
</h3>
<p v-if="!online" class="hint"><IconOffline /> 内核未连接</p> <p v-if="!online" class="hint"><IconOffline /> 内核未连接</p>
<p v-else-if="lines.length === 0" class="hint">这个订阅现在没有线路</p> <p v-else-if="lines.length === 0" class="hint">这个订阅现在没有线路</p>
@@ -195,6 +213,8 @@ function delayClass(ms: number | null): string {
} }
h3 { h3 {
display: flex;
align-items: center;
font-size: var(--t-sm); font-size: var(--t-sm);
font-weight: 600; font-weight: 600;
color: var(--c-text); color: var(--c-text);
@@ -202,6 +222,38 @@ h3 {
margin-bottom: var(--s-3); margin-bottom: var(--s-3);
} }
.check {
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
width: 22px;
height: 22px;
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.check:hover:not(:disabled) {
background: var(--c-surface-sunken);
color: var(--c-text);
}
.check:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.spin {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.hint { .hint {
display: flex; display: flex;
align-items: center; align-items: center;