Compare commits
10
Commits
e678e7716b
...
6b208847a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b208847a2 | ||
|
|
007d137b5d | ||
|
|
6029eacf50 | ||
|
|
860da5d28f | ||
|
|
71adcd60ef | ||
|
|
e8b8181ef6 | ||
|
|
5cdc183190 | ||
|
|
0062a261f7 | ||
|
|
f97981b146 | ||
|
|
cf24755375 |
@@ -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 用户名@服务器地址
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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} 已从模板重新生成`)
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user