10 Commits
Author SHA1 Message Date
im0o 6b208847a2 修掉审查查出的五个问题
保存失败会丢掉整张表单:对话框在 await commit 之前就关了,重开是空的。改成只有成功才关。

错误响应原本有两种形状({error} 和 {message,hint}),前端只读 message,于是服务端
写好的中文提示一律退化成「请求失败(HTTP 4xx)」。统一成 { message, hint? }。

路由处理器没有兜底:请求体不是合法 JSON、配置文件运行中被删,都会返回一个没有响应体
的 500。这条链路的立意是出事时把用户救回来,而出事时给的偏偏是最没信息量的一句话。

延迟测试失败被写进 done,而 done 的样式是成功绿。拆出中性的 notice——测不通是关于
线路的结论,既不是操作失败也不是成功。

刷新原本只有一个动词,测一条线路的延迟会导致整份配置被重读、重新解析、跑一遍引用校验,
外加四个往返。拆成 saved 和 runtime-changed 两个事件,运行时动作只重读运行时状态。
2026-08-10 14:29:46 +08:00
im0o 007d137b5d 修掉开发模式下前端加载不出来的问题
Vite 的代理规则写成字符串 '/api' 是前缀匹配,会把 web/api.ts 这个模块的请求
(路径 /api.ts)也转发给后端,于是开发模式下整个页面都起不来。改成正则 '^/api/'。
实测 /api.ts、/App.vue、/main.ts 现在都返回 200 text/javascript。
2026-08-10 14:18:58 +08:00
im0o 6029eacf50 修掉资源 404 被当成页面返回的 bug;加一条命令起开发模式
单页应用的兜底原本对任何找不到的路径都回 index.html,包括带扩展名的资源。
资源文件是带哈希的,每次打包换名字,浏览器缓存了旧 index.html 时会去请求一个已经
不存在的 .js,然后拿到一份 HTML,报出「MIME 类型不对」——一句完全指不到病根的错误。
现在带扩展名的路径找不到就回 404,index.html 也不再允许缓存。

另外 dev:all 一条命令同时起后端(--hot)和前端(Vite),省掉记两个终端和两个端口。
2026-08-10 14:15:22 +08:00
im0o 860da5d28f 订阅线路的类型、延迟和测速都改走 provider 接口
404 不是 bug。内核的 /proxies 只返回全局名字表(内置出站、自有线路、策略组),
订阅线路不在里面——跟「订阅线路不能被入站直接引用」是同一条约束,我没想到它同样
适用于运行时接口。所以 /proxies/{订阅线路}/delay 一律 404,而订阅线路的卡片也一直
没有协议和延迟,因为那些数据同样查不到。

现在运行时状态从两处合成:/proxies 给内置出站和策略组,/providers/proxies 给订阅线路
(它返回的线路本来就带完整的协议和延迟历史)。测速按线路归属选接口,订阅线路走
{订阅}/{线路}/healthcheck。实测该接口对同一条线路返回 200 和 325 毫秒。
2026-08-10 13:41:42 +08:00
im0o 71adcd60ef 界面文案改成中性措辞;延迟测试报出真实原因
原来那句「X 测不通」把两种要查不同地方的情况混在了一起。内核的语义是精确的
(hub/route/proxies.go:122-145):504 表示在给定超时内没有完成,503 表示连接建立失败
或测得延迟为 0。现在分开报,超时会写明是几秒,503 且内核没给具体错误时说明可能是
线路失效或本机到该服务器网络不通。

其余文案统一过一遍:「本机之外也能连」「这个名字已经被占用了」「还没做」这类口语改成
「允许来自其它主机的连接」「名称已存在」「尚未实现」。
2026-08-10 13:38:02 +08:00
im0o e8b8181ef6 本地开发换成真订阅;闪电挪到卡片上;忙碌状态改成按卡片
占位线路删了。它们指向 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 的输出直接能用。

闪电从组标题挪到每张卡的左下角,也就是延迟显示的位置:测过显示毫秒数,没测过显示闪电,
两种都能点,点了测这一条。忙碌状态从「禁用整个界面」改成只有正在提交的那张卡显示虚线边框。
2026-08-10 13:21:27 +08:00
im0o 5cdc183190 去掉转换入口,加测延迟,改掉刷新时丢状态的毛病
最要紧的是第三条:之前每次改完都会先切到「读取中」,页面被拆掉重建,
选中的那一路入站随之丢失,改个设置还得重新找回原来的位置。现在只有首次加载显示占位,
之后都是原地更新。切换策略组还做成了乐观更新——那一步不写配置文件,
没必要让点击等一个往返,真失败了随后的刷新会纠正回来。

测延迟按钮让内核并发测整个策略组。测不通是关于线路的结论而不是 Patchbay 出了问题,
所以全部超时用中性提示,不弹红框。
2026-08-10 12:53:56 +08:00
im0o 0062a261f7 补上真正日常要点的那个动作:切换策略组当前选中谁
之前的面板只做了「这个入站指向哪个出站」,那是改配置文件。但 metacubexd 里点一条线路
改的根本不是入站,是策略组的当前选中——调 PUT /proxies/{组},不写文件、立刻生效。
mixed-port 走规则表,末尾 MATCH,PROXY 把流量交给 PROXY 组,所以换那个组的选中就等于
换了这个端口的出口。这是每天都要点的动作,之前整个漏了。

面板顶部现在显示完整链条(PROXY → AUTO → 某条线路),并让人在最后一个策略组上换选中。
只显示第一层等于没回答「这个端口现在走哪条线」。

透传的白名单里为此开了唯一一个写操作,单独一条路由而不是放开整个转发。
2026-08-10 12:44:31 +08:00
im0o f97981b146 默认入站:显示可选出站,并给一条转成命名入站的路
上一版把默认入站的整个出站块都藏了,结果策略组也看不见。改成只藏 RULE/DIRECT/GLOBAL
那三张卡(它固定是 RULE),策略组和线路照常列出来当上下文,只是不可点。

顺带修一个显示错误:老式顶层端口没有 listen 字段,实际地址是内核从 allow-lan 和
bind-address 推出来的(listener/listener.go:709-718)——allow-lan 关着时绑的是
127.0.0.1,而界面一律显示 0.0.0.0,会让人以为局域网连得上。

转换时会把老式端口字段删掉并把推导出的地址显式写进 listen:不删会导致同一个端口被声明
两次内核起不来,不写 listen 则以后改 allow-lan 会连带影响这个端口。
2026-08-10 12:37:33 +08:00
im0o cf24755375 路由页做完:新增、编辑、删除入站
弹窗用原生 <dialog>,焦点捕获和 Esc 关闭是浏览器自带的,自己实现只会更差。
表单字段由那张从内核源码提取的字段表驱动:公共字段加当前类型的必填项。
20 种类型合计 398 个字段,全铺出来没人填得完,冷门的留给配置文件手写。

默认入站不再显示出站选择块——那一整块对它都是不可选的,画出来只是噪音,
换成一行说明。另外去掉了行里那根装饰线,导航把仪表盘挪到路由前面。
2026-08-10 12:27:39 +08:00
19 changed files with 1410 additions and 327 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 用户名@服务器地址
+12 -6
View File
@@ -24,18 +24,24 @@ bun run start # 打包前端并启动,默认 http://127.0.0.1:7580
## 开发 ## 开发
改前端时用 Vite,它会把接口请求转发给后端: 改代码的时候用这个,前后端都不用重启:
```bash ```bash
bun run dev # 后端,带热重载 bun run dev:kernel # 终端 1:本地测试内核,开着不用管
bun run dev:web # 前端,http://localhost:5173 bun run dev:all # 终端 2:后端 + 前端,改了自动生效
``` ```
本地有一份测试用的内核配置(`dev/config.yaml`,全是占位地址,端口刻意避开 mihomo 的默认值),可以对着它跑完整的「写回 → 校验 → 重载 → 失败还原」: 然后访问 **http://localhost:5173**(不是 7580)。后端代码改动由 Bun 的 `--hot` 重载,
界面改动由 Vite 直接更新浏览器。
`bun run start` 不具备这个能力——它把前端打成静态文件再由后端提供,代码一改就得重新
打包重启。那种方式适合最终验证和部署。
本地测试内核的配置由模板生成(`dev/config.yaml` 进 git,订阅地址从 `.env` 取,
生成的 `dev/home/config.yaml` 不进 git):
```bash ```bash
bun run dev:kernel # 启动本地测试内核,管理接口 19090 bun run dev:reset # 第一次,或想把测试配置恢复成模板时
bun run dev:reset # 把测试配置恢复成模板的样子
``` ```
## 检查 ## 检查
+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:
# 本机和内网地址一律直连 # 本机和内网地址一律直连
+2 -1
View File
@@ -7,9 +7,10 @@
"scripts": { "scripts": {
"start": "vite build && bun run server/index.ts", "start": "vite build && bun run server/index.ts",
"dev": "bun run --hot server/index.ts", "dev": "bun run --hot server/index.ts",
"dev:all": "bun run scripts/dev-all.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",
+25
View File
@@ -0,0 +1,25 @@
/**
* 开发模式:一条命令同时起后端和前端。
*
* 后端带 --hot,改了服务端代码自己重载;前端是 Vite,改了界面代码浏览器立刻更新,
* 两边都不用手动重启。访问 Vite 那个地址(5173),它会把接口请求转发给后端。
*
* 打包后的 `bun run start` 不具备这个能力:它把前端打成静态文件再由后端提供,
* 代码一改就得重新打包重启。那种方式适合最终验证和部署。
*/
export {}
const procs = [
Bun.spawn(['bun', 'run', '--hot', 'server/index.ts'], { stdio: ['inherit', 'inherit', 'inherit'] }),
Bun.spawn(['bunx', 'vite'], { stdio: ['inherit', 'inherit', 'inherit'] }),
]
const stopAll = () => {
for (const p of procs) p.kill()
process.exit(0)
}
process.on('SIGINT', stopAll)
process.on('SIGTERM', stopAll)
await Promise.race(procs.map((p) => p.exited))
stopAll()
+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} 已从模板重新生成`)
+24
View File
@@ -95,6 +95,30 @@ export async function reloadConfig(env: Env, configPath: string): Promise<Valida
} }
} }
/**
* 切换某个策略组当前选中哪个成员。
*
* 这是运行时状态,不写配置文件——所以不走 config-store 那条保护链路,也不需要备份和还原。
* 内核重启后会不会记住,取决于配置里的 profile.store-selected。
*/
export async function selectProxy(
env: Env,
group: string,
member: string,
): Promise<ValidateResult> {
try {
const res = await callApi(env, `/proxies/${encodeURIComponent(group)}`, {
method: 'PUT',
body: JSON.stringify({ name: member }),
signal: AbortSignal.timeout(10_000),
})
if (res.ok) return { ok: true, message: '' }
return { ok: false, message: (await res.text()) || `管理接口返回 ${String(res.status)}` }
} catch (error) {
return { ok: false, message: `连不上管理接口:${String(error)}` }
}
}
/** 透传一个管理接口的 GET 请求,用于运行时数据(线路、连接、日志等)。 */ /** 透传一个管理接口的 GET 请求,用于运行时数据(线路、连接、日志等)。 */
export async function proxyGet(env: Env, path: string): Promise<Response> { export async function proxyGet(env: Env, path: string): Promise<Response> {
return callApi(env, path, { signal: AbortSignal.timeout(15_000) }) return callApi(env, path, { signal: AbortSignal.timeout(15_000) })
+84 -10
View File
@@ -11,11 +11,25 @@
*/ */
import listenerFields from '../shared/listener-fields.json' with { type: 'json' } import listenerFields from '../shared/listener-fields.json' with { type: 'json' }
import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts' import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
import { kernelVersion, proxyGet } from './kernel.ts' import { kernelVersion, proxyGet, selectProxy } from './kernel.ts'
import { tokenFromRequest, tokenMatches } from './auth.ts' import { tokenFromRequest, tokenMatches } from './auth.ts'
import type { ConfigObject } from '../vendor/config-editor/index.ts' import type { ConfigObject } from '../vendor/config-editor/index.ts'
import type { Env } from './env.ts' import type { Env } from './env.ts'
/**
* 延迟测试。名字里可能有斜杠和中文,所以按前缀匹配而不是列进上面那个精确白名单。
* 它是 GET 但会真的发起网络连接,属于「读」里比较重的一类,单独列出来提醒这一点。
*/
const DELAY_PATH = /^\/(proxies|group)\/.+\/delay$/
/**
* 订阅线路的健康检查。
*
* 订阅线路不在内核的全局名字表里,所以 /proxies/{名字}/delay 对它们一律 404——
* 要测只能从它所属的订阅进去。整条订阅测用 {订阅}/healthcheck,单条用 {订阅}/{线路}/healthcheck。
*/
const HEALTHCHECK_PATH = /^\/providers\/proxies\/[^/]+(\/[^/]+)?\/healthcheck$/
/** 允许透传的管理接口路径。白名单而不是黑名单——多开一个口子要有意为之。 */ /** 允许透传的管理接口路径。白名单而不是黑名单——多开一个口子要有意为之。 */
const READABLE_KERNEL_PATHS = new Set([ const READABLE_KERNEL_PATHS = new Set([
'/version', '/version',
@@ -47,29 +61,74 @@ const WEB_DIST = new URL('../web-dist/', import.meta.url).pathname.replace(/^\/(
/** /**
* 静态文件不需要令牌——它们只是一个空壳,所有数据都要带令牌才拿得到。 * 静态文件不需要令牌——它们只是一个空壳,所有数据都要带令牌才拿得到。
* 单页应用的路由由前端接管,所以找不到的路径一律回 index.html。 *
* 单页应用的路由由前端接管,所以找不到的路径回 index.html。但这条**不能**用在
* 带扩展名的路径上:资源文件是带哈希的,每次打包都换名字,浏览器要是缓存了旧的
* index.html,它引用的那个 .js 就已经不存在了。这时候回 index.html 等于把一份 HTML
* 当作 JavaScript 交给浏览器,报出来的是「MIME 类型不对」——一句完全指不到病根的错误。
* 老老实实回 404,浏览器才会重新去取 index.html。
*/ */
const HAS_EXTENSION = /\.[a-z0-9]+$/i
async function serveStatic(path: string): Promise<Response> { async function serveStatic(path: string): Promise<Response> {
const shellHeaders = {
'Content-Type': 'text/html; charset=utf-8',
// 空壳不缓存,否则打包后旧的 index.html 会一直引用已经不存在的资源
'Cache-Control': 'no-cache',
}
const target = Bun.file(WEB_DIST + (path === '/' ? 'index.html' : path.slice(1))) const target = Bun.file(WEB_DIST + (path === '/' ? 'index.html' : path.slice(1)))
if (await target.exists()) return new Response(target) if (await target.exists()) {
return path === '/' ? new Response(target, { headers: shellHeaders }) : new Response(target)
}
if (HAS_EXTENSION.test(path)) {
return new Response(`找不到 ${path}`, {
status: 404,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
})
}
const shell = Bun.file(`${WEB_DIST}index.html`) const shell = Bun.file(`${WEB_DIST}index.html`)
if (await shell.exists()) return new Response(shell) if (await shell.exists()) return new Response(shell, { headers: shellHeaders })
return new Response('前端还没打包。先运行 bun run build,或者开发时用 bun run dev:web。', { return new Response('前端还没打包。运行 bun run build,或者开发时用 bun run dev:web。', {
status: 404, status: 404,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }, headers: { 'Content-Type': 'text/plain; charset=utf-8' },
}) })
} }
export function createRouter(env: Env, token: string) { export function createRouter(env: Env, token: string) {
const route = makeRoute(env, token)
/**
* 兜底。没有它的话,请求体不是合法 JSON、或者配置文件在运行中被删,都会抛出一个
* 没有响应体的 500,前端只能显示「请求失败(HTTP 500)」——这条链路的全部立意是
* 出事时把用户救回来,而出事时给出的偏偏是最没信息量的一句话。
*/
return async function handle(req: Request): Promise<Response> { return async function handle(req: Request): Promise<Response> {
try {
return await route(req)
} catch (error) {
return json(
{
message: String(error),
hint: '这是 Patchbay 自身的故障,配置文件没有被改动。',
},
500,
)
}
}
}
function makeRoute(env: Env, token: string) {
return async function route(req: Request): Promise<Response> {
const url = new URL(req.url) const url = new URL(req.url)
const path = url.pathname const path = url.pathname
if (!path.startsWith('/api/')) return serveStatic(path) if (!path.startsWith('/api/')) return serveStatic(path)
if (!tokenMatches(token, tokenFromRequest(req))) { if (!tokenMatches(token, tokenFromRequest(req))) {
return json({ error: '令牌不对。它在配置文件旁边的 .patchbay-token 里。' }, 401) return json({ message: '令牌不正确', hint: '它在服务端配置文件的同级目录 .patchbay-token 里。' }, 401)
} }
// ── 配置 ──────────────────────────────────────────── // ── 配置 ────────────────────────────────────────────
@@ -81,7 +140,7 @@ export function createRouter(env: Env, token: string) {
if (path === '/api/config' && req.method === 'PUT') { if (path === '/api/config' && req.method === 'PUT') {
const body = (await req.json()) as { data?: ConfigObject; baseRevision?: string } const body = (await req.json()) as { data?: ConfigObject; baseRevision?: string }
if (!body.data || typeof body.data !== 'object') { if (!body.data || typeof body.data !== 'object') {
return json({ error: '请求里没有 data 字段' }, 400) return json({ message: '请求缺少 data 字段' }, 400)
} }
const result = await applyConfig(env, body.data, body.baseRevision) const result = await applyConfig(env, body.data, body.baseRevision)
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage)) return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
@@ -103,10 +162,25 @@ export function createRouter(env: Env, token: string) {
return json(await kernelVersion(env)) return json(await kernelVersion(env))
} }
// 切换某个策略组当前选中哪个成员。这是白名单里唯一的写操作,单独开而不是放开整个透传:
// 它只改内核的运行时状态,不碰配置文件,而且是这个工具最日常的动作。
const selectMatch = /^\/api\/kernel\/proxies\/([^/]+)$/.exec(path)
if (selectMatch && req.method === 'PUT') {
const group = decodeURIComponent(selectMatch[1]!)
const body = (await req.json()) as { name?: string }
if (!body.name) return json({ message: '请求缺少 name 字段' }, 400)
const upstream = await selectProxy(env, group, body.name)
return upstream.ok
? json({ ok: true })
: json({ message: upstream.message || '内核拒绝了这次切换' }, 422)
}
if (path.startsWith('/api/kernel/') && req.method === 'GET') { if (path.startsWith('/api/kernel/') && req.method === 'GET') {
const target = path.slice('/api/kernel'.length) const target = path.slice('/api/kernel'.length)
if (!READABLE_KERNEL_PATHS.has(target)) { const allowed =
return json({ error: `不允许透传这个路径:${target}` }, 403) READABLE_KERNEL_PATHS.has(target) || DELAY_PATH.test(target) || HEALTHCHECK_PATH.test(target)
if (!allowed) {
return json({ message: `不允许访问该路径:${target}` }, 403)
} }
const upstream = await proxyGet(env, target + url.search) const upstream = await proxyGet(env, target + url.search)
return new Response(upstream.body, { return new Response(upstream.body, {
@@ -120,6 +194,6 @@ export function createRouter(env: Env, token: string) {
return json(listenerFields) return json(listenerFields)
} }
return json({ error: '没有这个接口' }, 404) return json({ message: '没有这个接口' }, 404)
} }
} }
+62 -1
View File
@@ -2,7 +2,13 @@ import { describe, expect, it } from 'bun:test'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { openDocument } from '../vendor/config-editor/index.ts' import { openDocument } from '../vendor/config-editor/index.ts'
import { collectInbounds, collectOutboundNames, outboundToProxyField } from './inbounds.ts' import {
collectInbounds,
collectOutboundNames,
fallbackTarget,
outboundToProxyField,
resolveDestination,
} from './inbounds.ts'
const fixture = openDocument( const fixture = openDocument(
readFileSync(join(import.meta.dir, '__fixtures__', 'config-with-comments.yaml'), 'utf8'), readFileSync(join(import.meta.dir, '__fixtures__', 'config-with-comments.yaml'), 'utf8'),
@@ -39,6 +45,28 @@ describe('合并两套写法的入站', () => {
}) })
}) })
describe('老式端口实际绑在哪', () => {
// 内核源码 listener/listener.go:709-718。界面上一律显示 0.0.0.0 会骗人。
it('allow-lan 没开时只绑本机', () => {
expect(collectInbounds({ 'mixed-port': 7777 })[0]!.listen).toBe('127.0.0.1')
expect(collectInbounds({ 'mixed-port': 7777, 'allow-lan': false })[0]!.listen).toBe('127.0.0.1')
})
it('allow-lan 开了但没指定地址时绑所有网卡', () => {
expect(collectInbounds({ 'mixed-port': 7777, 'allow-lan': true })[0]!.listen).toBe('0.0.0.0')
expect(
collectInbounds({ 'mixed-port': 7777, 'allow-lan': true, 'bind-address': '*' })[0]!.listen,
).toBe('0.0.0.0')
})
it('指定了地址就用那个地址', () => {
expect(
collectInbounds({ 'mixed-port': 7777, 'allow-lan': true, 'bind-address': '192.168.1.5' })[0]!
.listen,
).toBe('192.168.1.5')
})
})
describe('出站去向的四种形态', () => { describe('出站去向的四种形态', () => {
const of = (proxy?: string) => { const of = (proxy?: string) => {
// 用扩展而不是直接写 proxy: undefined —— 配置里「没有这个键」和「这个键是空」 // 用扩展而不是直接写 proxy: undefined —— 配置里「没有这个键」和「这个键是空」
@@ -84,3 +112,36 @@ describe('能被入站引用的出站名字', () => {
expect([...names.groups, ...names.ownProxies, ...names.builtins]).not.toContain('subs') expect([...names.groups, ...names.ownProxies, ...names.builtins]).not.toContain('subs')
}) })
}) })
describe('流量最终去哪', () => {
const cfg = {
'mixed-port': 7777,
rules: ['IP-CIDR,127.0.0.0/8,DIRECT,no-resolve', 'MATCH,PROXY'],
listeners: [
{ name: '固定', type: 'mixed', port: 7778, proxy: 'AUTO' },
{ name: '直连', type: 'mixed', port: 7779, proxy: 'DIRECT' },
],
}
it('走规则的入站要继续查到规则表末尾的 MATCH', () => {
// 用户想知道的是流量去哪,不是 proxy 字段写没写
const legacy = collectInbounds(cfg)[0]!
expect(legacy.outbound).toEqual({ kind: 'rules' })
expect(resolveDestination(cfg, legacy)).toBe('PROXY')
})
it('指定了出站的入站就是那个出站', () => {
const list = collectInbounds(cfg)
expect(resolveDestination(cfg, list[1]!)).toBe('AUTO')
expect(resolveDestination(cfg, list[2]!)).toBe('DIRECT')
})
it('MATCH 之后的规则到不了,取第一条', () => {
expect(fallbackTarget({ rules: ['MATCH,A', 'MATCH,B'] })).toBe('A')
})
it('没有 MATCH 时没有兜底', () => {
expect(fallbackTarget({ rules: ['MATCH-NOT-REALLY,X'] })).toBeNull()
expect(fallbackTarget({})).toBeNull()
})
})
+49 -1
View File
@@ -74,8 +74,25 @@ export function outboundToProxyField(mode: OutboundMode): string | undefined {
return mode.name return mode.name
} }
/**
* 老式顶层端口实际绑在哪个地址上。
*
* 它没有自己的 listen 字段,地址是内核从两个全局开关推出来的
* (内核源码 `listener/listener.go:709-718`):
* allow-lan 关 → 127.0.0.1,只有本机连得上
* allow-lan 开 → bind-address 指定的地址,写 * 或没写就是所有网卡
* 界面上不能一律显示 0.0.0.0,那会让人以为局域网连得上而实际连不上。
*/
export function legacyListenAddress(data: ConfigObject): string {
if (data['allow-lan'] !== true) return '127.0.0.1'
const bind = data['bind-address']
if (typeof bind !== 'string' || bind === '' || bind === '*') return '0.0.0.0'
return bind
}
export function collectInbounds(data: ConfigObject): Inbound[] { export function collectInbounds(data: ConfigObject): Inbound[] {
const result: Inbound[] = [] const result: Inbound[] = []
const legacyListen = legacyListenAddress(data)
for (const { key, type } of LEGACY_PORTS) { for (const { key, type } of LEGACY_PORTS) {
const raw = data[key] const raw = data[key]
@@ -89,7 +106,7 @@ export function collectInbounds(data: ConfigObject): Inbound[] {
name: key, name: key,
type, type,
port: String(port), port: String(port),
listen: null, listen: legacyListen,
outbound: { kind: 'rules' }, outbound: { kind: 'rules' },
ruleSet: null, ruleSet: null,
isDefault: true, isDefault: true,
@@ -121,6 +138,37 @@ export function collectInbounds(data: ConfigObject): Inbound[] {
return result return result
} }
/**
* 走规则表的流量最后交给谁。
*
* `MATCH` 匹配一切,所以第一条 MATCH 之后的规则都到不了,它就是兜底出站。
* 没有 MATCH 时内核会直连,返回 null。
*/
export function fallbackTarget(data: ConfigObject): string | null {
const rules = Array.isArray(data.rules) ? data.rules : []
for (const rule of rules) {
if (typeof rule !== 'string') continue
const parts = rule.split(',').map((p) => p.trim())
if (parts[0]?.toUpperCase() === 'MATCH' && parts[1]) return parts[1]
}
return null
}
/**
* 这一路入站的流量最终交给哪个出站。
*
* 和 `inbound.outbound` 的区别在于「跟着规则走」会被继续解析下去:那种情况下真正决定
* 去向的是规则表末尾的 MATCH。界面上要显示的是这个结果——用户想知道的是流量去哪,
* 不是配置里那个字段写没写。
*/
export function resolveDestination(data: ConfigObject, inbound: Inbound): string | null {
const mode = inbound.outbound
if (mode.kind === 'direct') return 'DIRECT'
if (mode.kind === 'global') return 'GLOBAL'
if (mode.kind === 'named') return mode.name
return fallbackTarget(data)
}
/** /**
* 所有可以被入站的出站字段引用的名字。 * 所有可以被入站的出站字段引用的名字。
* *
+3 -1
View File
@@ -25,7 +25,9 @@ export default defineConfig({
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
'/api': { // 必须是正则而不是字符串 '/api':字符串规则按前缀匹配,会把 web/api.ts
// 这个模块的请求(/api.ts)也转发给后端,于是开发模式下整个页面加载不出来。
'^/api/': {
target: 'http://127.0.0.1:7580', target: 'http://127.0.0.1:7580',
changeOrigin: false, changeOrigin: false,
}, },
+77 -22
View File
@@ -12,14 +12,24 @@ 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 providerLines = ref<string[]>([])
/** 订阅线路 → 它所属的订阅名。测延迟时要用它选对接口 */
const lineProvider = ref<Record<string, string>>({})
const runtime = ref<Record<string, ProxyInfo>>({}) const runtime = ref<Record<string, ProxyInfo>>({})
const needsToken = ref(!getToken()) const needsToken = ref(!getToken())
const loadError = ref('') const loadError = ref('')
const loading = ref(false) const booted = ref(false)
const refreshing = ref(false)
const active = ref('router') const active = ref('router')
/**
* 重新读一遍状态。
*
* 关键在于**不能把页面拆掉重建**:之前每次刷新都会先切到「读取中」,
* 于是路由页被卸载、选中的那一路入站丢失,改完一个设置还要重新找回原来的位置。
* 所以只有首次加载显示占位,之后都是原地更新。
*/
async function load() { async function load() {
loading.value = true refreshing.value = true
loadError.value = '' loadError.value = ''
try { try {
const [config, status] = await Promise.all([api.readConfig(), api.kernel()]) const [config, status] = await Promise.all([api.readConfig(), api.kernel()])
@@ -28,7 +38,6 @@ async function load() {
needsToken.value = false needsToken.value = false
// 订阅里有哪些线路只有运行中的内核知道,配置文件里只有一个订阅地址。 // 订阅里有哪些线路只有运行中的内核知道,配置文件里只有一个订阅地址。
// 拿不到不算错误——界面照常能编辑,只是订阅线路那一档不可选。 // 拿不到不算错误——界面照常能编辑,只是订阅线路那一档不可选。
providerLines.value = status.online ? await readProviderLines() : []
runtime.value = status.online ? await readRuntime() : {} runtime.value = status.online ? await readRuntime() : {}
} catch (error) { } catch (error) {
if (error instanceof ApiError && error.unauthorized) { if (error instanceof ApiError && error.unauthorized) {
@@ -37,29 +46,72 @@ async function load() {
loadError.value = error instanceof Error ? error.message : String(error) loadError.value = error instanceof Error ? error.message : String(error)
} }
} finally { } finally {
loading.value = false refreshing.value = false
booted.value = true
} }
} }
/** 只取真正的订阅。内核会给每个用了 proxies 的策略组造一个内部 provider,那些不是订阅。 */ /**
async function readProviderLines(): Promise<string[]> { * 出站的运行时状态:协议类型、策略组当前选中了谁、最近一次延迟。
*
* 要读两处。/proxies 只包含内核全局名字表里的东西——内置出站、自有线路、策略组;
* 订阅线路不在那张表上,它们的类型和延迟只能从 /providers/proxies 拿。
* 两边合成一张表,界面上就不用关心某个名字是从哪来的了。
*
* 顺带记下每条订阅线路属于哪个订阅:测它的延迟必须从所属订阅进去。
*/
async function readRuntime(): Promise<Record<string, ProxyInfo>> {
const merged: Record<string, ProxyInfo> = {}
const lines: string[] = []
const owner: Record<string, string> = {}
try {
Object.assign(merged, (await api.proxies()).proxies)
} catch {
// 拿不到就当没有,界面照常显示,只是没有延迟数字
}
try { try {
const { providers } = await api.providers() const { providers } = await api.providers()
return Object.values(providers) for (const [name, provider] of Object.entries(providers)) {
.filter((p) => p.vehicleType !== 'Compatible') // 内核会给每个用了 proxies 的策略组造一个内部 provider,那些不是订阅
.flatMap((p) => p.proxies.map((line) => line.name)) if (provider.vehicleType === 'Compatible') continue
} catch { for (const line of provider.proxies) {
return [] merged[line.name] = line
lines.push(line.name)
owner[line.name] = name
} }
} }
} catch {
// 同上
}
/** 出站的运行时状态:协议类型、策略组当前选中了谁、最近一次延迟。 */ providerLines.value = lines
async function readRuntime(): Promise<Record<string, ProxyInfo>> { lineProvider.value = owner
try { return merged
return (await api.proxies()).proxies
} catch {
return {}
} }
/**
* 切换策略组时先在本地改掉,不等内核回话。
*
* 这一步不写配置文件,失败率极低;而等一个往返再更新会让点击感觉发木。
* 真失败了随后的刷新会把状态纠正回来。
*/
function optimisticSelect(group: string, member: string) {
const info = runtime.value[group]
if (info) info.now = member
}
/**
* 只重读运行时状态,不碰配置文件。
*
* 切换策略组、测一条线路的延迟,改变的只是内核的运行状态。走完整的 load() 会让服务端
* 重读整份配置、重新解析、再跑一遍引用校验,外加两个多余的往返——最便宜的动作触发了
* 最贵的操作。
*/
async function reloadRuntime() {
if (!kernel.value.online) return
runtime.value = await readRuntime()
} }
function submitToken(value: string) { function submitToken(value: string) {
@@ -119,16 +171,16 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
</nav> </nav>
<main class="main"> <main class="main">
<p v-if="loading" class="note">读取中</p> <p v-if="!booted && refreshing" class="note">载入中</p>
<div v-else-if="loadError" class="alert"> <div v-else-if="loadError" class="alert">
<h2>读不到配置</h2> <h2>配置读取失败</h2>
<p>{{ loadError }}</p> <p>{{ loadError }}</p>
</div> </div>
<template v-else-if="snapshot"> <template v-else-if="snapshot">
<div v-if="problems.length" class="alert"> <div v-if="problems.length" class="alert">
<h2>{{ problems.length }} 处问题</h2> <h2>配置存在 {{ problems.length }} 处错误</h2>
<ul> <ul>
<li v-for="(p, i) in problems" :key="i"> <li v-for="(p, i) in problems" :key="i">
<code>{{ p.path.join('.') }}</code> {{ p.message }} <code>{{ p.path.join('.') }}</code> {{ p.message }}
@@ -142,13 +194,16 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
:revision="snapshot.revision" :revision="snapshot.revision"
:kernel-online="kernel.online" :kernel-online="kernel.online"
:provider-lines="providerLines" :provider-lines="providerLines"
:line-provider="lineProvider"
:runtime="runtime" :runtime="runtime"
@changed="load" @saved="load"
@optimistic-select="optimisticSelect"
@runtime-changed="reloadRuntime"
/> />
<div v-else class="note"> <div v-else class="note">
<component :is="current?.icon" class="note-icon" /> <component :is="current?.icon" class="note-icon" />
<p>{{ current?.label }}还没做</p> <p>{{ current?.label }} 尚未实现</p>
</div> </div>
</template> </template>
</main> </main>
+68 -4
View File
@@ -8,6 +8,9 @@ import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/ind
const TOKEN_KEY = 'patchbay.token' const TOKEN_KEY = 'patchbay.token'
/** 测延迟用的目标。204 空响应,体积最小,全球都有部署。 */
const TEST_URL = 'https://www.gstatic.com/generate_204'
export function getToken(): string { export function getToken(): string {
return localStorage.getItem(TOKEN_KEY) ?? '' return localStorage.getItem(TOKEN_KEY) ?? ''
} }
@@ -20,16 +23,30 @@ export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(TOKEN_KEY)
} }
/** 接口报错时抛这个,界面据此决定是提示重填令牌还是显示错误内容。 */ /**
* 接口报错时抛这个。
*
* 服务端的错误响应统一是 { message, hint? }:message 说发生了什么,hint 说下一步该干嘛。
* 界面直接用这两个字段,不需要认得每个端点各自的错误形状。
*/
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
readonly status: number, readonly status: number,
readonly payload: Record<string, unknown>, readonly payload: Record<string, unknown>,
) { ) {
super(typeof payload.message === 'string' ? payload.message : `请求失败(${String(status)})`) super(
typeof payload.message === 'string'
? payload.message
: `请求失败(HTTP ${String(status)})`,
)
this.name = 'ApiError' this.name = 'ApiError'
} }
/** 服务端给的下一步建议,没有就是空串。 */
get hint(): string {
return typeof this.payload.hint === 'string' ? this.payload.hint : ''
}
get unauthorized(): boolean { get unauthorized(): boolean {
return this.status === 401 return this.status === 401
} }
@@ -73,6 +90,8 @@ export interface ApplyFailure {
} }
export interface ProxyInfo { export interface ProxyInfo {
/** 出站的名字。/proxies 用键名标识,provider 里的线路则带在对象上 */
name: string
/** 协议类型(Vmess、Vless…)或策略组类型(Selector、URLTest…) */ /** 协议类型(Vmess、Vless…)或策略组类型(Selector、URLTest…) */
type: string type: string
/** 策略组当前选中的成员。不是策略组就没有这个字段 */ /** 策略组当前选中的成员。不是策略组就没有这个字段 */
@@ -89,6 +108,20 @@ export function latestDelay(info: ProxyInfo | undefined): number | null {
return last === undefined || last === 0 ? null : last return last === undefined || last === 0 ? null : last
} }
export interface FieldSpec {
key: string
type: 'string' | 'number' | 'boolean' | 'string[]' | 'number[]' | 'object' | 'object[]'
required: boolean
default: unknown
enum: (string | number)[] | null
note: string
}
export interface ListenerFields {
common: FieldSpec[]
types: Record<string, { note: string; fields: FieldSpec[] }>
}
export const api = { export const api = {
readConfig: () => request<ConfigSnapshot>('GET', '/api/config'), readConfig: () => request<ConfigSnapshot>('GET', '/api/config'),
@@ -102,15 +135,46 @@ export const api = {
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'), kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
/** 入站类型的字段表,从内核源码提取,用来驱动表单。 */
listenerFields: () => request<ListenerFields>('GET', '/api/meta/listener-fields'),
/** /**
* 所有出站的运行时状态:协议类型、策略组当前选中了谁、最近一次延迟。 * 所有出站的运行时状态:协议类型、策略组当前选中了谁、最近一次延迟。
* 这些配置文件里都没有,只能问运行中的内核。 * 这些配置文件里都没有,只能问运行中的内核。
*/ */
proxies: () => request<{ proxies: Record<string, ProxyInfo> }>('GET', '/api/kernel/proxies'), proxies: () => request<{ proxies: Record<string, ProxyInfo> }>('GET', '/api/kernel/proxies'),
/** 订阅里当前有哪些线路。配置文件给不出这个答案,只能问运行中的内核。 */ /**
* 切换某个策略组当前选中哪个成员。
* 这是运行时状态,不写配置文件,立刻生效——metacubexd 里点一条线路做的就是这件事。
*/
selectProxy: (group: string, member: string) =>
request<{ ok: true }>('PUT', `/api/kernel/proxies/${encodeURIComponent(group)}`, {
name: member,
}),
/**
* 测一条出站的延迟。
*
* 走哪个接口取决于它在不在内核的全局名字表里:策略组和自有线路在表里,用 /proxies;
* 订阅线路不在表里(那正是它们无法被入站直接引用的原因),只能从所属订阅进去测。
*/
testLine: (name: string, provider?: string, timeout = 5000) => {
const q = `url=${encodeURIComponent(TEST_URL)}&timeout=${String(timeout)}`
const path = provider
? `/api/kernel/providers/proxies/${encodeURIComponent(provider)}/${encodeURIComponent(name)}/healthcheck?${q}`
: `/api/kernel/proxies/${encodeURIComponent(name)}/delay?${q}`
return request<{ delay: number }>('GET', path)
},
/**
* 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。
*
* 返回的线路带完整的运行时信息(协议、延迟历史),因为这些线路不在 /proxies 那张
* 全局名字表里,这是拿到它们类型和延迟的唯一途径。
*/
providers: () => providers: () =>
request<{ providers: Record<string, { vehicleType: string; proxies: { name: string }[] }> }>( request<{ providers: Record<string, { vehicleType: string; proxies: ProxyInfo[] }> }>(
'GET', 'GET',
'/api/kernel/providers/proxies', '/api/kernel/providers/proxies',
), ),
+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>
+2 -3
View File
@@ -1,8 +1,7 @@
/** /**
* 左侧导航。 * 左侧导航。
* *
* 顺序按使用频率排:路由是这个工具存在的理由,放在最上面;仪表盘和连接是「看一眼」 * 仪表盘打头(打开先看一眼状态),路由紧随其后(这个工具存在的理由),设置垫底。
* 类的,中间;设置很少动,垫底。
*/ */
import IconRoute from '~icons/ri/route-line' import IconRoute from '~icons/ri/route-line'
import IconDashboard from '~icons/ri/dashboard-line' import IconDashboard from '~icons/ri/dashboard-line'
@@ -21,8 +20,8 @@ export interface NavItem {
} }
export const NAV: NavItem[] = [ export const NAV: NavItem[] = [
{ key: 'router', label: '路由', icon: IconRoute },
{ key: 'dashboard', label: '仪表盘', icon: IconDashboard, pending: true }, { key: 'dashboard', label: '仪表盘', icon: IconDashboard, pending: true },
{ key: 'router', label: '路由', icon: IconRoute },
{ key: 'config', label: '配置', icon: IconConfig, pending: true }, { key: 'config', label: '配置', icon: IconConfig, pending: true },
{ key: 'requests', label: '请求', icon: IconRequests, pending: true }, { key: 'requests', label: '请求', icon: IconRequests, pending: true },
{ key: 'connections', label: '连接', icon: IconConnections, pending: true }, { key: 'connections', label: '连接', icon: IconConnections, pending: true },
+217 -210
View File
@@ -1,107 +1,95 @@
<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 IconLock from '~icons/ri/lock-line' 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 IconDelete from '~icons/ri/delete-bin-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> runtime: Record<string, ProxyInfo>
/** 流量最终交给哪个出站。走规则的入站要查到规则表末尾的 MATCH */
destination: string | null
kernelOnline: boolean kernelOnline: boolean
saving: boolean /** 正在提交的那个出站的名字。只有它显示忙碌,其余照常能点 */
pendingOutbound: string | null
/** 正在测延迟的线路 */
testing: string[]
}>() }>()
const emit = defineEmits<{ pick: [mode: OutboundMode] }>() const emit = defineEmits<{
pick: [mode: OutboundMode]
edit: []
remove: []
select: [group: string, member: string]
test: [name: string]
}>()
interface Option { /** 默认入站没有 proxy 字段可写,「出站」那一块对它是只读的。 */
key: string
mode: OutboundMode
label: string
/** 卡片第二行。策略组显示「类型(当前选中)」,线路显示协议 */
sub: string
delay: number | null
disabled: boolean
/** 不可选时的原因,鼠标悬停显示 */
reason?: string
}
/** 默认入站没有 proxy 字段可写,整块都不可选。 */
const locked = computed(() => props.inbound.isDefault) const locked = computed(() => props.inbound.isDefault)
function describe(name: string): { sub: string; delay: number | null } { function describe(name: string): string {
const info = props.runtime[name] const info = props.runtime[name]
if (!info) return { sub: '', delay: null } if (!info) return ''
const sub = info.now ? `${info.type}(${info.now})` : info.type return info.now ? `${info.type}(${info.now})` : info.type
return { sub, delay: latestDelay(info) }
} }
const builtins = computed<Option[]>(() => const delayOf = (name: string) => latestDelay(props.runtime[name])
( const isTesting = (name: string) => props.testing.includes(name)
[
{ 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, const chain = computed(() => {
sub, const hops: string[] = []
delay, let cursor = props.destination
disabled: locked.value, while (cursor && !hops.includes(cursor)) {
hops.push(cursor)
cursor = props.runtime[cursor]?.now ?? null
} }
return hops
}) })
/** 链条上最后一个还是策略组的环节——用户能在这里换选中。 */
const switchable = computed(() => {
for (let i = chain.value.length - 1; i >= 0; i -= 1) {
const name = chain.value[i]!
const info = props.runtime[name]
if (info?.all?.length) return { group: name, members: info.all, now: info.now ?? '' }
} }
return null
})
const groupOptions = computed(() => named(props.groups)) function isCurrentOutbound(mode: OutboundMode): boolean {
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: '跟随 GLOBAL 组' },
return 'd-bad' ]
}
const LINE_LOCKED = '订阅线路无法被入站直接引用,需要通过筛选组间接引入。该功能尚未实现。'
const NO_PROXY_FIELD = '该入站没有 proxy 字段'
</script> </script>
<template> <template>
@@ -111,6 +99,12 @@ 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="!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"> <dl class="facts">
<dt>类型</dt> <dt>类型</dt>
<dd class="mono">{{ inbound.type }}</dd> <dd class="mono">{{ inbound.type }}</dd>
@@ -121,89 +115,114 @@ function delayClass(ms: number | null): string {
</dl> </dl>
</header> </header>
<section 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>
</template>
</p>
<template v-if="switchable">
<h4>{{ switchable.group }} 的当前选中</h4>
<div class="grid">
<OutboundCard
v-for="m in switchable.members"
:key="m"
: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="block">
<h3>出站</h3> <h3>出站</h3>
<p v-if="locked" class="hint">
<p v-if="locked" class="aside-note"> 由 <code>{{ inbound.legacyKey }}</code> 声明。该写法没有 proxy 字段,出站固定为 RULE。
<IconLock />
由 <code>{{ inbound.legacyKey }}</code> 声明,没有 proxy 字段,只能走 RULE
</p> </p>
<div 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>策略组</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>自有线路</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>订阅线路</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>
@@ -245,6 +264,34 @@ function delayClass(ms: number | null): string {
white-space: nowrap; white-space: nowrap;
} }
.acts {
display: flex;
gap: var(--s-2);
margin-bottom: var(--s-3);
}
.acts button {
display: flex;
align-items: center;
gap: var(--s-1);
padding: var(--s-1) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-sm);
cursor: pointer;
}
.acts button:hover {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.acts button.danger:hover {
border-color: var(--c-danger);
color: var(--c-danger);
}
.facts { .facts {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
@@ -261,10 +308,15 @@ function delayClass(ms: number | null): string {
margin: 0; margin: 0;
} }
.pick { .block {
padding-top: var(--s-4); padding-top: var(--s-4);
} }
.block + .block {
margin-top: var(--s-5);
border-top: 1px solid var(--c-border);
}
h3 { h3 {
font-size: var(--t-sm); font-size: var(--t-sm);
font-weight: 600; font-weight: 600;
@@ -279,90 +331,45 @@ h4 {
margin: var(--s-5) 0 var(--s-2); margin: var(--s-5) 0 var(--s-2);
} }
.chain {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-2);
font-size: var(--t-sm);
color: var(--c-text-soft);
margin-bottom: var(--s-2);
}
.chain .arrow {
color: var(--c-text-faint);
}
.chain .tail {
color: var(--c-accent-text);
font-weight: 500;
}
.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>
+319
View File
@@ -0,0 +1,319 @@
<script setup lang="ts">
/**
* 新增或编辑一路命名入站。
*
* 用原生 <dialog>:焦点捕获、Esc 关闭、背景遮罩都是浏览器自带的行为,
* 自己实现一遍只会做得更差。
*
* 表单字段来自从内核源码提取的字段表。只呈现公共字段加该类型的必填项——
* 20 种类型合计 398 个字段,全铺出来没人填得完,冷门字段留给配置文件手写。
*/
import { computed, ref, watch } from 'vue'
import type { Inbound } from '@shared/inbounds.ts'
import type { FieldSpec, ListenerFields } from '../api'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import IconClose from '~icons/ri/close-line'
const props = defineProps<{
open: boolean
/** 传了就是编辑,没传就是新增 */
editing: Inbound | null
fields: ListenerFields | null
/** 已被占用的名字,用来挡重名 */
takenNames: string[]
saving: boolean
}>()
const emit = defineEmits<{
close: []
submit: [entry: ConfigObject]
}>()
/** 常见的客户端入站排在前面——给容器开端口用的就是这几种。 */
const COMMON_TYPES = ['mixed', 'http', 'socks', 'redir', 'tproxy', 'tunnel', 'tun']
const dialog = ref<HTMLDialogElement | null>(null)
const name = ref('')
const type = ref('mixed')
const port = ref('')
const listen = ref('')
const extras = ref<Record<string, string>>({})
const allTypes = computed(() => Object.keys(props.fields?.types ?? {}))
const serverTypes = computed(() => allTypes.value.filter((t) => !COMMON_TYPES.includes(t)))
/** 当前类型的必填字段,公共字段之外的那些。 */
const requiredExtras = computed<FieldSpec[]>(
() => props.fields?.types[type.value]?.fields.filter((f) => f.required && !f.key.includes('.')) ?? [],
)
const typeNote = computed(() => props.fields?.types[type.value]?.note ?? '')
const nameTaken = computed(
() =>
name.value.trim() !== '' &&
props.takenNames.includes(name.value.trim()) &&
name.value.trim() !== props.editing?.name,
)
const portInvalid = computed(() => {
const raw = port.value.trim()
if (raw === '') return false
// 端口支持 "7890"、"200,302"、"401-429" 这几种写法
return !/^\d+([,-]\d+)*$/.test(raw)
})
const canSubmit = computed(
() =>
name.value.trim() !== '' &&
port.value.trim() !== '' &&
!nameTaken.value &&
!portInvalid.value &&
requiredExtras.value.every((f) => (extras.value[f.key] ?? '').trim() !== '') &&
!props.saving,
)
watch(
() => props.open,
(open) => {
if (open) {
const e = props.editing
name.value = e?.name ?? ''
type.value = e?.type ?? 'mixed'
port.value = e?.port ?? ''
listen.value = e?.listen ?? ''
extras.value = {}
dialog.value?.showModal()
} else {
dialog.value?.close()
}
},
)
/** 把表单值转成配置里该有的形状。空字段不写,避免生成一堆没意义的键。 */
function submit() {
if (!canSubmit.value) return
const entry: ConfigObject = {
name: name.value.trim(),
type: type.value,
port: /^\d+$/.test(port.value.trim()) ? Number(port.value.trim()) : port.value.trim(),
}
if (listen.value.trim()) entry.listen = listen.value.trim()
for (const field of requiredExtras.value) {
const raw = (extras.value[field.key] ?? '').trim()
if (!raw) continue
if (field.type === 'number') entry[field.key] = Number(raw)
else if (field.type === 'string[]') entry[field.key] = raw.split(/[,\s]+/).filter(Boolean)
else if (field.type === 'boolean') entry[field.key] = raw === 'true'
else entry[field.key] = raw
}
// 编辑时保留原来的出站和附加规则——它们在右侧面板改,不归这个表单管
if (props.editing?.outbound.kind === 'named') entry.proxy = props.editing.outbound.name
else if (props.editing?.outbound.kind === 'direct') entry.proxy = 'DIRECT'
else if (props.editing?.outbound.kind === 'global') entry.proxy = 'GLOBAL'
if (props.editing?.ruleSet) entry.rule = props.editing.ruleSet
emit('submit', entry)
}
</script>
<template>
<dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')">
<form method="dialog" @submit.prevent="submit">
<header class="dlg-head">
<h2>{{ editing ? '编辑入站' : '新增入站' }}</h2>
<button type="button" class="x" @click="emit('close')"><IconClose /></button>
</header>
<div class="body">
<label class="row">
<span class="k">名称</span>
<span class="v">
<input v-model="name" type="text" placeholder="容器A" autocomplete="off" />
<span v-if="nameTaken" class="bad">名称已存在</span>
</span>
</label>
<label class="row">
<span class="k">类型</span>
<span class="v">
<select v-model="type">
<optgroup label="客户端入站">
<option v-for="t in COMMON_TYPES" :key="t" :value="t">{{ t }}</option>
</optgroup>
<optgroup label="服务端入站">
<option v-for="t in serverTypes" :key="t" :value="t">{{ t }}</option>
</optgroup>
</select>
<span v-if="typeNote" class="note">{{ typeNote }}</span>
</span>
</label>
<label class="row">
<span class="k">端口</span>
<span class="v">
<input v-model="port" class="mono" type="text" placeholder="7778" autocomplete="off" />
<span v-if="portInvalid" class="bad">支持单个端口、逗号分隔的多个端口、连字符表示的区间</span>
</span>
</label>
<label class="row">
<span class="k">监听</span>
<span class="v">
<input v-model="listen" class="mono" type="text" placeholder="0.0.0.0" autocomplete="off" />
<span class="note">留空为 0.0.0.0,允许来自其它主机的连接</span>
</span>
</label>
<label v-for="f in requiredExtras" :key="f.key" class="row">
<span class="k">{{ f.key }}</span>
<span class="v">
<select v-if="f.enum" v-model="extras[f.key]">
<option value="" disabled>请选择</option>
<option v-for="opt in f.enum" :key="String(opt)" :value="String(opt)">{{ opt }}</option>
</select>
<input v-else v-model="extras[f.key]" class="mono" type="text" autocomplete="off" />
<span v-if="f.note" class="note">{{ f.note }}</span>
</span>
</label>
</div>
<footer class="dlg-foot">
<button type="button" class="btn" @click="emit('close')">取消</button>
<button type="submit" class="btn btn-go" :disabled="!canSubmit">
{{ saving ? '保存中' : '保存' }}
</button>
</footer>
</form>
</dialog>
</template>
<style scoped>
.dlg {
width: min(520px, calc(100vw - 2 * var(--s-5)));
padding: 0;
border: 1px solid var(--c-border-strong);
border-radius: var(--r-lg);
background: var(--c-surface);
color: var(--c-text);
}
.dlg::backdrop {
background: rgb(0 0 0 / 0.45);
}
.dlg-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--s-4) var(--s-5);
border-bottom: 1px solid var(--c-border);
}
.dlg-head h2 {
font-size: var(--t-lg);
font-weight: 600;
}
.x {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.x:hover {
background: var(--c-surface-sunken);
color: var(--c-text);
}
.body {
display: flex;
flex-direction: column;
gap: var(--s-4);
padding: var(--s-5);
max-height: min(60vh, 520px);
overflow-y: auto;
}
.row {
display: grid;
grid-template-columns: 5rem 1fr;
gap: var(--s-4);
align-items: baseline;
}
.k {
color: var(--c-text-soft);
font-size: var(--t-sm);
}
.v {
display: flex;
flex-direction: column;
gap: var(--s-1);
min-width: 0;
}
input,
select {
width: 100%;
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-bg);
}
input:focus,
select:focus {
border-color: var(--c-accent);
outline: none;
}
.note {
font-size: var(--t-xs);
color: var(--c-text-faint);
line-height: 1.5;
}
.bad {
font-size: var(--t-xs);
color: var(--c-danger);
}
.dlg-foot {
display: flex;
justify-content: flex-end;
gap: var(--s-2);
padding: var(--s-4) var(--s-5);
border-top: 1px solid var(--c-border);
}
.btn {
padding: var(--s-2) var(--s-4);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
cursor: pointer;
}
.btn-go {
background: var(--c-accent);
border-color: transparent;
color: #fff;
font-weight: 500;
}
.btn-go:disabled {
background: var(--c-surface-sunken);
border-color: var(--c-border);
color: var(--c-text-faint);
cursor: not-allowed;
}
</style>
+212 -47
View File
@@ -1,12 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { collectInbounds, collectOutboundNames, outboundToProxyField } from '@shared/inbounds.ts' import {
collectInbounds,
collectOutboundNames,
outboundToProxyField,
resolveDestination,
} from '@shared/inbounds.ts'
import type { Inbound, OutboundMode } from '@shared/inbounds.ts' import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts' import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import { ApiError, api } from '../api' import { ApiError, api } from '../api'
import type { ProxyInfo } from '../api' import type { ListenerFields, ProxyInfo } from '../api'
import { density } from '../prefs' import { density } from '../prefs'
import InboundDetail from './InboundDetail.vue' import InboundDetail from './InboundDetail.vue'
import InboundForm from './InboundForm.vue'
import IconAdd from '~icons/ri/add-line' import IconAdd from '~icons/ri/add-line'
const props = defineProps<{ const props = defineProps<{
@@ -14,10 +20,18 @@ const props = defineProps<{
revision: string revision: string
kernelOnline: boolean kernelOnline: boolean
providerLines: string[] providerLines: string[]
/** 订阅线路 → 所属订阅 */
lineProvider: Record<string, string>
runtime: Record<string, ProxyInfo> runtime: Record<string, ProxyInfo>
}>() }>()
const emit = defineEmits<{ changed: [] }>() const emit = defineEmits<{
/** 配置文件变了,需要重读整份配置 */
saved: []
/** 只有内核的运行时状态变了,不用重读配置 */
runtimeChanged: []
optimisticSelect: [group: string, member: string]
}>()
const inbounds = computed(() => collectInbounds(props.data)) const inbounds = computed(() => collectInbounds(props.data))
const outbounds = computed(() => collectOutboundNames(props.data)) const outbounds = computed(() => collectOutboundNames(props.data))
@@ -25,6 +39,25 @@ const outbounds = computed(() => collectOutboundNames(props.data))
const selectedId = ref<string | null>(null) const selectedId = ref<string | null>(null)
const saving = ref(false) const saving = ref(false)
const failure = ref<{ message: string; hint: string } | null>(null) const failure = ref<{ message: string; hint: string } | null>(null)
/** 成功的结果,绿色 */
const done = ref('')
/** 中性的告知,比如某条线路测不通——那是关于线路的结论,不是操作失败 */
const notice = ref('')
const fields = ref<ListenerFields | null>(null)
const formOpen = ref(false)
const formEditing = ref<Inbound | null>(null)
/** 正在提交的那个出站的名字。只有它显示忙碌,其余卡片照常能点 */
const pendingOutbound = ref<string | null>(null)
/** 正在测延迟的线路 */
const testing = ref<string[]>([])
onMounted(async () => {
try {
fields.value = await api.listenerFields()
} catch {
fields.value = null
}
})
// 默认选中第一行。空面板没有信息量,不如直接展示一路入站。 // 默认选中第一行。空面板没有信息量,不如直接展示一路入站。
watch( watch(
@@ -36,6 +69,7 @@ watch(
) )
const selected = computed(() => inbounds.value.find((i) => i.id === selectedId.value) ?? null) const selected = computed(() => inbounds.value.find((i) => i.id === selectedId.value) ?? null)
const takenNames = computed(() => inbounds.value.map((i) => i.name))
const TYPE_LABEL: Record<string, string> = { const TYPE_LABEL: Record<string, string> = {
mixed: 'HTTP + SOCKS5', mixed: 'HTTP + SOCKS5',
@@ -56,38 +90,154 @@ function outboundLabel(mode: OutboundMode): { text: string; muted: boolean } {
return { text: mode.name, muted: false } return { text: mode.name, muted: false }
} }
/** 改一路命名入站的出站,然后走完整的落盘链路。 */ const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
/** 所有改动都走这一条路:落盘失败时按停在哪一步说不同的话。 */
async function commit(next: ConfigObject, success: string) {
saving.value = true
failure.value = null
done.value = ''
notice.value = ''
try {
await api.applyConfig(next, props.revision)
done.value = success
emit('saved')
} catch (error) {
failure.value =
error instanceof ApiError
? { message: error.message, hint: error.hint }
: { message: String(error), hint: '' }
} finally {
saving.value = false
}
}
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 = JSON.parse(JSON.stringify(props.data)) as ConfigObject const next = clone()
const listeners = next.listeners as Array<Record<string, unknown>> const entry = (next.listeners as ConfigObject[])[target.listenerIndex]!
const entry = listeners[target.listenerIndex]!
const proxy = outboundToProxyField(mode) const proxy = outboundToProxyField(mode)
// RULE 是「没有这个字段」而不是「字段为空」,所以是删掉而不是置空。 // RULE 是「没有这个字段」而不是「字段为空」,所以是删掉而不是置空。
if (proxy === undefined) delete entry.proxy if (proxy === undefined) delete entry.proxy
else entry.proxy = proxy else entry.proxy = proxy
saving.value = true
failure.value = null
try { try {
await api.applyConfig(next, props.revision) await commit(next, `${target.name} 的出站已设为 ${outboundLabel(mode).text}`)
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 { } finally {
saving.value = false pendingOutbound.value = null
} }
} }
function openAdd() {
formEditing.value = null
formOpen.value = true
}
function openEdit() {
if (!selected.value || selected.value.isDefault) return
formEditing.value = selected.value
formOpen.value = true
}
async function submitForm(entry: ConfigObject) {
const next = clone()
const listeners = Array.isArray(next.listeners) ? (next.listeners as ConfigObject[]) : []
const editingIndex = formEditing.value?.listenerIndex ?? null
if (editingIndex === null) listeners.push(entry)
else listeners[editingIndex] = entry
next.listeners = listeners
await commit(next, editingIndex === null ? `已新增 ${String(entry.name)}` : '已保存')
// 失败时不能关对话框——关了再打开就是空表单,用户填的名称、端口、类型全没了
if (failure.value) return
formOpen.value = false
if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
}
/** 切换策略组当前选中谁。运行时动作,不写配置文件,所以不走那条落盘链路。 */
async function selectMember(group: string, member: string) {
if (saving.value) return
// 先本地改掉,别让点击等一个往返。失败了随后的刷新会纠正回来。
emit('optimisticSelect', group, member)
failure.value = null
done.value = ''
notice.value = ''
try {
await api.selectProxy(group, member)
emit('runtimeChanged')
} catch (error) {
failure.value = {
message: error instanceof ApiError ? error.message : String(error),
hint: '该操作仅修改内核运行状态,配置文件未被修改。',
}
}
}
/** 延迟测试用的超时,毫秒。也用在失败提示里,让「超时」这个词有具体含义。 */
const TEST_TIMEOUT_MS = 5000
/**
* 把内核的延迟测试结果翻译成一句说明原因的话。
*
* 内核的语义是精确的(hub/route/proxies.go:122-145):
* 504 在给定超时内没有完成
* 503 连接建立失败,或者测得的延迟为 0
* 笼统说一句「测不通」会把这两种情况混在一起,而它们要查的地方不一样。
*/
function describeTestFailure(name: string, error: unknown): string {
if (!(error instanceof ApiError)) return `${name} 延迟测试失败:${String(error)}`
const seconds = String(TEST_TIMEOUT_MS / 1000)
if (error.status === 504) return `${name} 延迟测试超时,${seconds} 秒内没有取到测试地址`
const detail = String(error.payload.message ?? '')
const generic = detail === '' || detail === 'An error occurred in the delay test'
if (error.status === 404) {
return `${name} 不在内核的出站表里,无法测试。订阅线路需要从所属订阅进去测。`
}
if (error.status === 503) {
return generic
? `${name} 无法建立连接,线路可能已失效,或本机到该服务器的网络不通`
: `${name} 无法建立连接:${detail}`
}
return `${name} 延迟测试失败(HTTP ${String(error.status)})${generic ? '' : `:${detail}`}`
}
/** 测一条线路的延迟。纯读操作,不影响任何配置,所以不禁用任何东西。 */
async function testLine(name: string) {
if (testing.value.includes(name)) return
testing.value = [...testing.value, name]
try {
await api.testLine(name, props.lineProvider[name], TEST_TIMEOUT_MS)
emit('runtimeChanged')
} catch (error) {
// 测不通是关于线路的结论,不是 Patchbay 出了故障。既不该用错误框,
// 也不该用成功的绿色——它需要第三种中性的样子。
notice.value = describeTestFailure(name, error)
} finally {
testing.value = testing.value.filter((n) => n !== name)
}
}
async function removeSelected() {
const target = selected.value
if (!target || target.listenerIndex === null || saving.value) return
if (!confirm(`删除入站「${target.name}」?端口 ${target.port} 将停止监听。`)) return
const next = clone()
const listeners = (next.listeners as ConfigObject[]).filter((_, i) => i !== target.listenerIndex)
// 删到一个不剩时把整个键去掉,别在文件里留一个空数组
if (listeners.length === 0) delete next.listeners
else next.listeners = listeners
selectedId.value = null
await commit(next, `已删除 ${target.name}`)
}
</script> </script>
<template> <template>
@@ -108,7 +258,7 @@ async function pickOutbound(mode: OutboundMode) {
</div> </div>
</div> </div>
<button class="add" type="button" disabled> <button class="add" type="button" :disabled="saving" @click="openAdd">
<IconAdd /> <IconAdd />
<span v-if="density === 'full'">新增</span> <span v-if="density === 'full'">新增</span>
</button> </button>
@@ -118,6 +268,8 @@ async function pickOutbound(mode: OutboundMode) {
<strong>{{ failure.message }}</strong> <strong>{{ failure.message }}</strong>
<span v-if="failure.hint">{{ failure.hint }}</span> <span v-if="failure.hint">{{ failure.hint }}</span>
</div> </div>
<p v-else-if="done" class="done">{{ done }}</p>
<p v-else-if="notice" class="notice">{{ notice }}</p>
<div class="split"> <div class="split">
<ol class="rows"> <ol class="rows">
@@ -133,7 +285,6 @@ async function pickOutbound(mode: OutboundMode) {
{{ inbound.name }} {{ inbound.name }}
<span v-if="inbound.isDefault" class="tag">默认</span> <span v-if="inbound.isDefault" class="tag">默认</span>
</span> </span>
<span class="wire" aria-hidden="true"></span>
<span :class="['out', outboundLabel(inbound.outbound).muted && 'is-muted']"> <span :class="['out', outboundLabel(inbound.outbound).muted && 'is-muted']">
{{ outboundLabel(inbound.outbound).text }} {{ outboundLabel(inbound.outbound).text }}
</span> </span>
@@ -147,14 +298,30 @@ async function pickOutbound(mode: OutboundMode) {
:groups="outbounds.groups" :groups="outbounds.groups"
:own-proxies="outbounds.ownProxies" :own-proxies="outbounds.ownProxies"
:provider-lines="providerLines" :provider-lines="providerLines"
:destination="resolveDestination(data, selected)"
:runtime="runtime" :runtime="runtime"
:kernel-online="kernelOnline" :kernel-online="kernelOnline"
:saving="saving"
@pick="pickOutbound" @pick="pickOutbound"
@edit="openEdit"
@remove="removeSelected"
@select="selectMember"
@test="testLine"
:testing="testing"
:pending-outbound="pendingOutbound"
/> />
</div> </div>
<p v-if="inbounds.length === 0" class="empty">没有入站。mihomo 不会监听任何端口。</p> <p v-if="inbounds.length === 0" class="empty">没有入站,内核不会监听任何端口。</p>
<InboundForm
:open="formOpen"
:editing="formEditing"
:fields="fields"
:taken-names="takenNames"
:saving="saving"
@close="formOpen = false"
@submit="submitForm"
/>
</div> </div>
</template> </template>
@@ -200,6 +367,10 @@ async function pickOutbound(mode: OutboundMode) {
background: var(--c-surface); background: var(--c-surface);
cursor: pointer; cursor: pointer;
} }
.add:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.add:disabled { .add:disabled {
color: var(--c-text-faint); color: var(--c-text-faint);
cursor: not-allowed; cursor: not-allowed;
@@ -217,6 +388,18 @@ async function pickOutbound(mode: OutboundMode) {
font-size: var(--t-sm); font-size: var(--t-sm);
} }
.done {
color: var(--c-ok);
font-size: var(--t-sm);
margin-bottom: var(--s-4);
}
.notice {
color: var(--c-text-soft);
font-size: var(--t-sm);
margin-bottom: var(--s-4);
}
/* 左列表右详情。窄屏时改成上下,详情不再吸顶 */ /* 左列表右详情。窄屏时改成上下,详情不再吸顶 */
.split { .split {
display: grid; display: grid;
@@ -245,11 +428,10 @@ async function pickOutbound(mode: OutboundMode) {
border-top: 1px solid var(--c-border); border-top: 1px solid var(--c-border);
} }
/* 线占据弹性列,名字按内容宽——这样那根线是真的把两头连起来,而不是一小截装饰 */
.line { .line {
width: 100%; width: 100%;
display: grid; display: grid;
grid-template-columns: 4.5rem 6.5rem auto minmax(var(--s-5), 1fr) minmax(5rem, auto); grid-template-columns: 4.5rem 6.5rem 1fr minmax(5rem, auto);
align-items: center; align-items: center;
gap: var(--s-3); gap: var(--s-3);
padding: var(--s-3) var(--s-4); padding: var(--s-3) var(--s-4);
@@ -297,23 +479,6 @@ async function pickOutbound(mode: OutboundMode) {
line-height: 1.6; line-height: 1.6;
} }
/*
* 一段接线的意象,纯装饰,不承载任何数据。
* 它原本占满整个弹性列,结果被误认成折线图——一个看起来像信息的东西如果什么都不表示,
* 那就是缺陷。所以限制宽度,让它明确读作「一段线」而不是「一条曲线」。
*/
.wire {
height: 1px;
width: 100%;
max-width: 72px;
margin: 0 auto;
background: linear-gradient(
to right,
var(--c-border-strong),
color-mix(in srgb, var(--c-accent) 60%, transparent)
);
}
.out { .out {
font-family: var(--f-mono); font-family: var(--f-mono);
font-size: var(--t-sm); font-size: var(--t-sm);
+2 -2
View File
@@ -9,7 +9,7 @@ const value = ref('')
<div class="gate"> <div class="gate">
<form class="box" @submit.prevent="emit('submit', value)"> <form class="box" @submit.prevent="emit('submit', value)">
<h1>Patchbay</h1> <h1>Patchbay</h1>
<p class="lead">输入访问令牌。</p> <p class="lead">需要访问令牌。</p>
<input <input
v-model="value" v-model="value"
class="field mono" class="field mono"
@@ -21,7 +21,7 @@ const value = ref('')
/> />
<button class="go" type="submit" :disabled="!value.trim()">进入</button> <button class="go" type="submit" :disabled="!value.trim()">进入</button>
<p class="where"> <p class="where">
令牌在服务端配置文件旁边的 <code>.patchbay-token</code> 里,启动时也会打印在控制台上。 令牌位于服务端配置文件的同级目录 <code>.patchbay-token</code>,启动时也会输出到控制台。
</p> </p>
</form> </form>
</div> </div>