Compare commits
73
Commits
e9e2eb555d
...
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d7304dd22 | ||
|
|
7d2a3afff0 | ||
|
|
ea0efd4468 | ||
|
|
0f0ac3c4b7 | ||
|
|
23aa28fbea | ||
|
|
56546b20c5 | ||
|
|
b1b251b897 | ||
|
|
d6942c5702 | ||
|
|
b28fc572f7 | ||
|
|
5c924db235 | ||
|
|
fbb2a824dc | ||
|
|
944da9fee4 | ||
|
|
27ebdac034 | ||
|
|
9c302b2a33 | ||
|
|
57690a159c | ||
|
|
f57eb55328 | ||
|
|
129a76696b | ||
|
|
a40bd6a01b | ||
|
|
48e97bd91f | ||
|
|
955fa340af | ||
|
|
c3c0f56e56 | ||
|
|
d1214d4cbd | ||
|
|
8d4579f987 | ||
|
|
6ac0a63f28 | ||
|
|
14d26bec71 | ||
|
|
91243354de | ||
|
|
25d60f9718 | ||
|
|
158dc9fca7 | ||
|
|
275f536429 | ||
|
|
b276c4a8fe | ||
|
|
f761d13579 | ||
|
|
2580c03bfa | ||
|
|
8eb98e2e9a | ||
|
|
013f537eaa | ||
|
|
7d08f37248 | ||
|
|
4c43756208 | ||
|
|
f927f9fff7 | ||
|
|
60fb015e36 | ||
|
|
07a43b5a56 | ||
|
|
eec5b791c8 | ||
|
|
ce181e9ae5 | ||
|
|
5dede01c3e | ||
|
|
35eb463633 | ||
|
|
bd087aabea | ||
|
|
5bd4e8d609 | ||
|
|
8026c432e7 | ||
|
|
5c39f666dc | ||
|
|
76460c7074 | ||
|
|
5b6ea8469f | ||
|
|
fdf3bfb659 | ||
|
|
dd100cfc97 | ||
|
|
9e87517a4e | ||
|
|
0eef680fba | ||
|
|
0eb8d05ed8 | ||
|
|
c9a0b87af4 | ||
|
|
97c3943403 | ||
|
|
fb1dd401b2 | ||
|
|
6c8a1c4e0e | ||
|
|
d028e63a74 | ||
|
|
4e504e9003 | ||
|
|
58d317afce | ||
|
|
272a0931a1 | ||
|
|
8bc97eb47a | ||
|
|
d41012a0ab | ||
|
|
0ddefd0d5d | ||
|
|
5a04968e17 | ||
|
|
de3a4c9bcd | ||
|
|
a44f410074 | ||
|
|
4da501ded6 | ||
|
|
a4d08892f7 | ||
|
|
6bedce215c | ||
|
|
17f8b3f3fa | ||
|
|
49038ce5f9 |
@@ -0,0 +1,2 @@
|
||||
# 所有文本文件统一 LF:并行 worktree 曾把 fixture 检出成 CRLF,逐字节比较的测试全挂
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,57 @@
|
||||
# 发布流程:推送 v* 标签 → 测试 → 构建 → 把产物挂到 Gitea Release 上。
|
||||
#
|
||||
# 产物 patchbay.tar.gz 与 CPU 架构无关(纯 JS + 静态文件),
|
||||
# 架构差异由目标机器上的 bun 运行时吸收,安装侧见仓库根目录的 install.sh。
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# 构建用的 bun 版本和 install.sh 里的 BUN_VERSION 保持一致,升级时两处一起改
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.3.14'
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bun test
|
||||
- run: bun run typecheck
|
||||
|
||||
# 布局必须保持 server/ 与 web-dist/ 平级:
|
||||
# server/routes.ts 里 WEB_DIST 用 import.meta.url 相对定位 ../web-dist/
|
||||
- name: 构建并打包
|
||||
run: |
|
||||
bun run build
|
||||
bun build --target=bun --minify server/index.ts --outfile dist/patchbay/server/index.js
|
||||
cp -r web-dist dist/patchbay/web-dist
|
||||
echo "${GITHUB_REF_NAME}" > dist/patchbay/version.txt
|
||||
tar -czf patchbay.tar.gz -C dist patchbay
|
||||
|
||||
# 直接 curl Gitea 的 API 而不用现成 action:gitea.com/actions/release-action
|
||||
# 是 Go 程序,runner 镜像里没有 Go 工具链,跑不起来
|
||||
- name: 发布 Release
|
||||
env:
|
||||
# 默认令牌建不了 Release 时(老版本 Gitea 的权限限制),
|
||||
# 在仓库 Settings → Actions → Secrets 里加一个 RELEASE_TOKEN
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || github.token }}
|
||||
run: |
|
||||
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
auth="Authorization: token ${TOKEN}"
|
||||
# 建 Release;已存在(同一个 tag 重跑)就查回它的 id
|
||||
resp="$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\"}" \
|
||||
"$api/releases" \
|
||||
|| curl -fsS -H "$auth" "$api/releases/tags/${GITHUB_REF_NAME}")"
|
||||
# 响应里第一个 "id" 就是 release 自己的(嵌套对象如 author 排在它后面)
|
||||
id="$(printf '%s' "$resp" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)"
|
||||
[ -n "$id" ] || { echo "没拿到 release id,响应: $resp"; exit 1; }
|
||||
curl -fsS -X POST -H "$auth" -F "attachment=@patchbay.tar.gz" \
|
||||
"$api/releases/${id}/assets?name=patchbay.tar.gz" >/dev/null
|
||||
echo "已上传 patchbay.tar.gz"
|
||||
+30
-10
@@ -10,9 +10,9 @@
|
||||
mihomo 上的一个监听端口,客户端连上它把流量交进来。
|
||||
_Avoid_: inbound、监听器、listener、进站
|
||||
|
||||
**默认入站**:
|
||||
由配置文件顶层端口字段声明的入站(`mixed-port`、`port`、`socks-port`、`redir-port`、`tproxy-port`)。每种类型只能有一个,且无法单独指定出站。界面上不可删除。
|
||||
_Avoid_: 主端口、老式端口、默认端口
|
||||
**legacy 入站**:
|
||||
由配置文件顶层端口字段声明的入站(`mixed-port`、`port`、`socks-port`、`redir-port`、`tproxy-port`)。每种类型只能有一个,且无法单独指定出站。界面上不可删除,可升级为命名入站。
|
||||
_Avoid_: 默认入站、主端口、老式端口、默认端口
|
||||
|
||||
**命名入站**:
|
||||
由 `listeners:` 声明的入站。数量不限,每个可以有自己的出站和附加规则。
|
||||
@@ -52,9 +52,25 @@ _Avoid_: provider、机场、订阅链接、节点池
|
||||
一组线路加一个挑选方式(手动选、自动测速选最快等)。策略组本身也可以作为出站被引用。
|
||||
_Avoid_: 分组、group、代理组、策略
|
||||
|
||||
**筛选组**:
|
||||
Patchbay 自动创建的策略组,用筛选条件从订阅里只挑出用户指定的那几条线路。它的存在是为了让入站能固定到具体线路——因为订阅线路无法被直接引用。
|
||||
_Avoid_: 隐藏组、临时组、影子组
|
||||
**专属组**:
|
||||
Patchbay 为一路入站自动创建的三个策略组:手动选的 SELECT、自动测速的 AUTO、故障转移的 FALLBACK。入站的 `proxy:` 永久指向 SELECT,日常换线是改 SELECT 的当前选中,属于运行时动作,不改配置文件。名字带 `patchbay:` 前缀。
|
||||
_Avoid_: 筛选组、隐藏组、影子组、自动组
|
||||
|
||||
**链式代理**:
|
||||
让一路入站的流量先经过一跳入口,再从出口线路离开。在订阅副本上用 `override.dialer-proxy` 实现,因为内核不接受写在策略组上的 `dialer-proxy`。
|
||||
_Avoid_: 中转、前置代理、relay
|
||||
|
||||
**入口组**:
|
||||
链式代理的入口,一个手动选的策略组,订阅副本的 `dialer-proxy` 指向它。它只能引用原订阅,不能引用订阅副本,否则会形成内核查不出来的环。
|
||||
_Avoid_: 前置组、跳板组
|
||||
|
||||
**订阅副本**:
|
||||
开链式代理时,Patchbay 按「入站乘以来源订阅」的组合生成的订阅。`type: file`,读原订阅的缓存文件,带上指向入口组的 `dialer-proxy`。它不联网,原订阅刷新后内核的文件监视让它自动跟上。和哪个用户订阅共用同一个缓存文件,来源就是哪个。
|
||||
_Avoid_: 派生订阅、克隆订阅、镜像订阅
|
||||
|
||||
**公网 IP**:
|
||||
一路入站的流量离开本机后,在外部服务眼里来自哪个地址。由 Patchbay 服务端把本机对应端口设为代理、访问 ip.sb 得到。它是观测结果,不是配置里的任何字段。
|
||||
_Avoid_: 出口 IP、落地 IP、出口
|
||||
|
||||
## 名字与引用
|
||||
|
||||
@@ -69,9 +85,13 @@ _Avoid_: 无效引用、悬空引用、坏引用
|
||||
## 规则
|
||||
|
||||
**分流规则**:
|
||||
配置文件 `rules:` 里的有序列表,自上而下匹配,决定一条连接走哪个出站。顺序本身是语义的一部分。
|
||||
自上而下匹配、决定一条连接走哪个出站的有序规则列表,第一条命中即生效。顺序本身是语义的一部分。全局规则和附加规则都是分流规则。
|
||||
_Avoid_: 路由规则、规则表
|
||||
|
||||
**全局规则**:
|
||||
配置文件顶层 `rules:` 里的分流规则表。只对没有附加规则的入站生效——带附加规则的入站整表改查自己的表,不再看它。
|
||||
_Avoid_: 顶层规则、主规则、默认规则
|
||||
|
||||
**附加规则**:
|
||||
挂在某一路入站上的一组分流规则,只对从该入站进来的流量生效。
|
||||
_Avoid_: sub-rules、子规则、私有规则
|
||||
@@ -82,6 +102,6 @@ _Avoid_: sub-rules、子规则、私有规则
|
||||
Patchbay 负责读写的配置段落。其余段落原样保留、界面上也不呈现。
|
||||
_Avoid_: 托管段落、受控段落
|
||||
|
||||
**默认路由**:
|
||||
默认入站在界面上的呈现。它对应配置文件顶层的端口字段而非 `listeners:`,因此不可删除。
|
||||
_Avoid_: 主路由、根路由
|
||||
**legacy 路由**:
|
||||
legacy 入站在界面上的呈现。它对应配置文件顶层的端口字段而非 `listeners:`,因此不可删除。
|
||||
_Avoid_: 默认路由、主路由、根路由
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
一个跑在服务器上的网页,用来编辑那台机器上 mihomo 的 `config.yaml` 并让改动生效。
|
||||
|
||||
核心动作是把某一路入站接到某一个出站上——名字取自录音棚的插线板。典型用途是给每个容器开一个端口,各自指定走哪条线路。
|
||||
核心动作是把某一路入站接到某一个出站上,名字取自录音棚的插线板。典型用途是给每个容器开一个端口,各自指定走哪条线路。
|
||||
|
||||
聚合订阅不在范围内(那是 Sub-Store 的活),安装升级内核也不在(那是 `mihomo-up.sh` 的活)。
|
||||
|
||||
术语以 [CONTEXT.md](./CONTEXT.md) 为准。为什么这么设计记在仓库外面的 `docs/adr/`。
|
||||
聚合订阅不在范围内(那是 Sub-Store 的活),安装升级内核也不在(那是 `mihomo-up.sh` 的活)。术语以 [CONTEXT.md](./CONTEXT.md) 为准。
|
||||
|
||||
## 跑起来
|
||||
|
||||
@@ -18,49 +16,36 @@ cp .env.example .env # 按里面的注释填
|
||||
bun run start # 打包前端并启动,默认 http://127.0.0.1:7580
|
||||
```
|
||||
|
||||
首次启动会生成一个访问令牌,打印在控制台上,同时存到配置文件旁边的 `.patchbay-token`。
|
||||
首次启动会生成访问令牌,打印在控制台上,也存在配置文件旁边的 `.patchbay-token`。
|
||||
|
||||
**内核不在线也能用**,只是看不到订阅里有哪些线路、也没法让改动立刻生效。这是有意的——配置写坏导致内核起不来的时候,正是最需要这个界面的时候。
|
||||
内核不在线也能用,只是看不到订阅里有哪些线路,改动也要等内核启动才生效。这是有意的:配置写坏导致内核起不来的时候,正是最需要这个界面的时候。
|
||||
|
||||
## 开发
|
||||
|
||||
改代码的时候用这个,前后端都不用重启:
|
||||
|
||||
```bash
|
||||
bun run dev:kernel # 终端 1:本地测试内核,开着不用管
|
||||
bun run dev:all # 终端 2:后端 + 前端,改了自动生效
|
||||
```
|
||||
|
||||
然后访问 **http://localhost:5173**(不是 7580)。后端代码改动由 Bun 的 `--hot` 重载,
|
||||
界面改动由 Vite 直接更新浏览器。
|
||||
|
||||
`bun run start` 不具备这个能力——它把前端打成静态文件再由后端提供,代码一改就得重新
|
||||
打包重启。那种方式适合最终验证和部署。
|
||||
|
||||
本地测试内核的配置由模板生成(`dev/config.yaml` 进 git,订阅地址从 `.env` 取,
|
||||
生成的 `dev/home/config.yaml` 不进 git):
|
||||
|
||||
```bash
|
||||
bun run dev:reset # 第一次,或想把测试配置恢复成模板时
|
||||
```
|
||||
然后访问 http://localhost:5173(不是 7580)。第一次先运行 `bun run dev:reset` 生成测试配置:模板在 `dev/config.yaml`,订阅地址从 `.env` 取。
|
||||
|
||||
## 检查
|
||||
|
||||
```bash
|
||||
bun test # 单元测试,不需要内核
|
||||
bun run smoke # 接口冒烟测试,需要内核和后端都在跑
|
||||
bun test # 单元测试,不需要内核
|
||||
bun run smoke # 接口冒烟测试,需要内核和后端都在跑
|
||||
bun run typecheck
|
||||
```
|
||||
|
||||
另有两个一次性脚本:`scripts/roundtrip.ts` 验证改一处配置之后 diff 里只出现那一处,`scripts/safety-net.ts` 验证配置写坏时能不能把用户救回来。
|
||||
`scripts/` 里另有几个手动验证脚本(写回保真、落盘保护链路、链式代理的生成产物),用法写在各文件头部。
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
server/ 后端。config-store.ts 是核心,那条保护链路在里面
|
||||
web/ 前端。styles/tokens.css 顶部写着设计基调的三条规则
|
||||
server/ 后端。config-store.ts 是核心,落盘保护链路在里面
|
||||
web/ 前端。styles/tokens.css 顶部写着设计基调
|
||||
shared/ 两边共用的领域逻辑,纯函数,带测试
|
||||
vendor/ 从别的项目复制来的代码,来源和本地改动记在各自的 ORIGIN.md
|
||||
scripts/ 验证用的脚本,不参与打包
|
||||
scripts/ 验证脚本,不参与打包
|
||||
dev/ 本地测试内核的配置
|
||||
```
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"": {
|
||||
"name": "patchbay",
|
||||
"dependencies": {
|
||||
"@tanstack/vue-virtual": "^3.13.35",
|
||||
"echarts": "^6.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"yaml": "^2.9.0",
|
||||
},
|
||||
@@ -153,6 +155,10 @@
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q=="],
|
||||
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.7", "", {}, "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA=="],
|
||||
|
||||
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.35", "", { "dependencies": { "@tanstack/virtual-core": "3.17.7" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-lOfSPvgPdlaH6Qy+CyIc3XpycitaSQ9GECndGpTuDiu+uDA1am+90yWXwzDSd/20ZM196ggWJLS+Qb6WjVd/OA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
@@ -197,6 +203,8 @@
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"echarts": ["echarts@6.1.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.1.0" } }, "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||
@@ -247,6 +255,8 @@
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
|
||||
@@ -269,6 +279,8 @@
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
"zrender": ["zrender@6.1.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ=="],
|
||||
|
||||
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
|
||||
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# install.sh — Patchbay 的安装 / 更新 / 卸载脚本
|
||||
#
|
||||
# 它管四样东西:
|
||||
# 1. bun 运行时(私有副本,版本钉死) 默认 /opt/patchbay/bun/bun
|
||||
# 2. 应用本体(来自 Gitea Release) 默认 /opt/patchbay/app/
|
||||
# 3. 环境变量文件 /etc/patchbay/env
|
||||
# 4. systemd 服务 /etc/systemd/system/patchbay.service
|
||||
#
|
||||
# bash <(curl -fsSL https://git.im0o.top/im0o/mihomo-patchbay/raw/branch/main/install.sh)
|
||||
# └─ 安装;已装过就更新到最新 Release(环境变量文件保持不动)
|
||||
# install.sh check # 只查询有没有新版,不改动任何东西,退出码见 --help
|
||||
# install.sh uninstall # 卸载;--purge 连环境变量文件和访问令牌一起删除
|
||||
#
|
||||
# 前提:机器上已经用 mihomo-up.sh 装好了 mihomo(Patchbay 要读它的配置文件、
|
||||
# 调它的二进制做校验、连它的管理接口)。还没装先去装那个。
|
||||
#
|
||||
set -Eeuo pipefail
|
||||
|
||||
SELF_VERSION="1.0.0"
|
||||
|
||||
REPO_URL="https://git.im0o.top/im0o/mihomo-patchbay"
|
||||
API_URL="https://git.im0o.top/api/v1/repos/im0o/mihomo-patchbay"
|
||||
# bun 版本钉死,避免运行时行为漂移;和 .gitea/workflows/release.yml 的构建版本一起改
|
||||
BUN_VERSION="${PATCHBAY_BUN_VERSION:-1.3.14}"
|
||||
|
||||
PREFIX="${PATCHBAY_PREFIX:-/opt/patchbay}"
|
||||
ENV_FILE="/etc/patchbay/env"
|
||||
SERVICE="${PATCHBAY_SERVICE:-patchbay}"
|
||||
UNIT_FILE="/etc/systemd/system/${SERVICE}.service"
|
||||
|
||||
# mihomo-up.sh 装出来的默认布局;mihomo 装在别处时用这两个环境变量覆盖
|
||||
MIHOMO_BIN="${MIHOMO_BIN:-/usr/local/bin/mihomo}"
|
||||
MIHOMO_CONF_DIR="${MIHOMO_CONF_DIR:-/etc/mihomo}"
|
||||
|
||||
# GitHub 下载前缀(bun 从 GitHub 下载)。直连不通时填个反代,例如 https://ghfast.top/
|
||||
MIRROR="${MIHOMO_MIRROR:-}"
|
||||
|
||||
WANT_VERSION=""; FORCE=0; ASSUME_YES=0; PURGE=0
|
||||
TMPDIR_=""
|
||||
|
||||
# ───────────────────────── 输出 ─────────────────────────
|
||||
if [[ -t 1 ]]; then
|
||||
R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; W=$'\033[1m'; D=$'\033[2m'; N=$'\033[0m'
|
||||
else
|
||||
R=""; G=""; Y=""; W=""; D=""; N=""
|
||||
fi
|
||||
|
||||
log() { printf '%s\n' "$*" >&2; }
|
||||
info() { log "${D}··${N} $*"; }
|
||||
ok() { log "${G}✓${N} $*"; }
|
||||
warn() { log "${Y}!${N} $*"; }
|
||||
die() { printf '%s✗%s %s\n' "$R" "$N" "$*" >&2; exit 1; }
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
trap - ERR
|
||||
[[ -n "${TMPDIR_:-}" && -d "$TMPDIR_" ]] && rm -rf "$TMPDIR_"
|
||||
return $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'die "第 $LINENO 行出错,已中止"' ERR
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
install.sh — 安装 / 更新 / 卸载 Patchbay
|
||||
|
||||
install.sh 安装;已装过就更新到最新 Release
|
||||
install.sh check 只查询有没有新版,不改动任何东西
|
||||
install.sh uninstall [--purge]
|
||||
|
||||
选项:
|
||||
-y, --yes 非交互确认,所有询问都当作同意(用默认值)
|
||||
--force 版本相同也重新安装
|
||||
--version <v> 装指定版本的 Release,如 v0.1.0;也用于回滚
|
||||
--mirror <url> GitHub 下载前缀(下载 bun 用),如 https://ghfast.top/
|
||||
--purge uninstall 时连环境变量文件和访问令牌一起删除
|
||||
-v 打印本脚本自身的版本号
|
||||
|
||||
check 退出码: 0 = 已是最新 10 = 有新版或未安装 1 = 出错
|
||||
EOF
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
# 从 /dev/tty 读,这样 bash <(curl ...) 这种 stdin 被占用的场景也能正常按键
|
||||
TTY_IN="/dev/stdin"
|
||||
[[ -r /dev/tty ]] && TTY_IN="/dev/tty"
|
||||
|
||||
ask_default() { # ask_default "提示" "默认值" varname
|
||||
local __adf_def="$2" __adf_var="$3" __adf_ans=""
|
||||
if (( ASSUME_YES )); then printf -v "$__adf_var" '%s' "$__adf_def"; return 0; fi
|
||||
printf '%s %s[%s]%s ' "$1" "$D" "$__adf_def" "$N" >&2
|
||||
read -r __adf_ans < "$TTY_IN" || __adf_ans=""
|
||||
[[ -z "$__adf_ans" ]] && __adf_ans="$__adf_def"
|
||||
printf -v "$__adf_var" '%s' "$__adf_ans"
|
||||
}
|
||||
|
||||
# ───────────────────────── 基础能力 ─────────────────────────
|
||||
need() { command -v "$1" >/dev/null 2>&1 || die "缺少命令: $1"; }
|
||||
|
||||
require_root() {
|
||||
[[ ${EUID:-$(id -u)} -eq 0 ]] || die "$1 需要 root 权限,请用 sudo 重新运行"
|
||||
}
|
||||
|
||||
gh_url() { # gh_url "owner/repo/releases/..." -> 完整下载地址
|
||||
local u="https://github.com/$1"
|
||||
[[ -n "$MIRROR" ]] && u="${MIRROR%/}/$u"
|
||||
printf '%s' "$u"
|
||||
}
|
||||
|
||||
# 这台 Gitea 的网页路由没有 /releases/latest/download/<asset> 这种别名(实测 404),
|
||||
# 所以下载地址必须带具体版本号,「最新」由 latest_version 先问 API
|
||||
release_url() { # release_url <版本> <asset> -> 下载地址
|
||||
printf '%s/releases/download/%s/%s' "$REPO_URL" "$1" "$2"
|
||||
}
|
||||
|
||||
# 读 config.yaml 顶层的简单键(形如 `secret: "xxx"`),套路取自 mihomo-up.sh 的 cfg_get
|
||||
conf_get() {
|
||||
local key="$1" f="$MIHOMO_CONF_DIR/config.yaml"
|
||||
[[ -f "$f" ]] || return 0
|
||||
sed -n -E "s/^${key}:[[:space:]]*//p" "$f" | sed -n '1p' \
|
||||
| sed -E 's/[[:space:]]*#.*$//; s/^"(.*)"$/\1/; s/^'"'"'(.*)'"'"'$/\1/'
|
||||
}
|
||||
|
||||
installed_version() {
|
||||
[[ -f "$PREFIX/app/version.txt" ]] && tr -d '[:space:]' < "$PREFIX/app/version.txt" || printf ''
|
||||
}
|
||||
|
||||
latest_version() {
|
||||
local resp v
|
||||
resp="$(curl -fsSL --retry 2 --connect-timeout 5 --max-time 15 "$API_URL/releases/latest")" \
|
||||
|| die "查询最新 Release 失败: $API_URL/releases/latest(还没发布过 Release?网络不通?)"
|
||||
# 响应里 tag_name 只出现一次(release 对象自己的),不装 jq 也能取
|
||||
v="$(printf '%s' "$resp" | grep -o '"tag_name":"[^"]*"' | head -n1 | cut -d'"' -f4)"
|
||||
[[ "$v" =~ ^v[0-9]+\.[0-9]+ ]] || die "拿到的版本号格式不对: ${v:-空}"
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
svc_running() { systemctl is-active --quiet "$SERVICE" 2>/dev/null; }
|
||||
|
||||
# ───────────────────────── bun 运行时 ─────────────────────────
|
||||
bun_asset() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64)
|
||||
# bun 的常规 x64 构建要求 CPU 支持 AVX2,老机器要用 baseline 构建
|
||||
if grep -qw avx2 /proc/cpuinfo 2>/dev/null; then echo "bun-linux-x64"
|
||||
else echo "bun-linux-x64-baseline"; fi ;;
|
||||
aarch64|arm64) echo "bun-linux-aarch64" ;;
|
||||
*) die "未适配的架构: $(uname -m)(bun 只发布 linux x64 / aarch64)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
install_bun() {
|
||||
local bun="$PREFIX/bun/bun"
|
||||
if [[ -x "$bun" && "$("$bun" --version 2>/dev/null || true)" == "$BUN_VERSION" ]]; then
|
||||
info "bun $BUN_VERSION 已就位,跳过"
|
||||
return 0
|
||||
fi
|
||||
need unzip
|
||||
local asset url
|
||||
asset="$(bun_asset)"
|
||||
url="$(gh_url "oven-sh/bun/releases/download/bun-v${BUN_VERSION}/${asset}.zip")"
|
||||
|
||||
info "下载 ${asset}.zip(约 30 MB)"
|
||||
curl -fL -# --retry 3 --connect-timeout 15 --max-time 600 -o "$TMPDIR_/bun.zip" "$url" \
|
||||
|| die "下载失败: $url(GitHub 不通就加 --mirror 换个下载源)"
|
||||
unzip -q -o "$TMPDIR_/bun.zip" -d "$TMPDIR_" || die "解压失败,下载到的不是有效的 zip"
|
||||
|
||||
# 闸门: 新 bun 能运行起来,且自报版本号和目标一致(也能暴露架构选错)
|
||||
local got
|
||||
got="$("$TMPDIR_/$asset/bun" --version 2>/dev/null || true)"
|
||||
[[ "$got" == "$BUN_VERSION" ]] \
|
||||
|| die "bun 校验失败: 期望 $BUN_VERSION,实际 ${got:-执行不了}(多半是架构不对)"
|
||||
|
||||
mkdir -p "$PREFIX/bun"
|
||||
install -m 0755 "$TMPDIR_/$asset/bun" "$bun"
|
||||
ok "bun $BUN_VERSION → $bun"
|
||||
}
|
||||
|
||||
# ───────────────────────── 应用本体 ─────────────────────────
|
||||
install_app() { # $1 = 目标版本
|
||||
local target="$1" url
|
||||
url="$(release_url "$target" patchbay.tar.gz)"
|
||||
|
||||
info "下载 patchbay.tar.gz"
|
||||
curl -fL -# --retry 3 --connect-timeout 15 --max-time 300 -o "$TMPDIR_/app.tar.gz" "$url" \
|
||||
|| die "下载失败: $url"
|
||||
mkdir -p "$TMPDIR_/unpack"
|
||||
tar -xzf "$TMPDIR_/app.tar.gz" -C "$TMPDIR_/unpack" || die "解压失败,下载到的不是有效的 tar.gz"
|
||||
|
||||
# 闸门: 包里的东西齐不齐。server/ 和 web-dist/ 必须平级,服务端靠相对路径找前端
|
||||
[[ -f "$TMPDIR_/unpack/patchbay/server/index.js" && -d "$TMPDIR_/unpack/patchbay/web-dist" ]] \
|
||||
|| die "包内布局和预期不符(应有 patchbay/server/index.js 和 patchbay/web-dist/),已放弃"
|
||||
|
||||
# 应用目录无状态(配置和令牌都在别处),留一份旧版用于出问题时手动换回
|
||||
rm -rf "$PREFIX/app.prev"
|
||||
[[ -d "$PREFIX/app" ]] && mv "$PREFIX/app" "$PREFIX/app.prev"
|
||||
mv "$TMPDIR_/unpack/patchbay" "$PREFIX/app"
|
||||
ok "已安装 $target → $PREFIX/app"
|
||||
}
|
||||
|
||||
# ───────────────────────── 环境变量文件 ─────────────────────────
|
||||
# 各项的含义见仓库里的 server/env.ts;这里从 mihomo 的现有配置直接推导,
|
||||
# 装完即可用,不用人工填一遍已经存在的信息。
|
||||
write_env() {
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
info "环境变量文件已存在,保持不动: $ENV_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local conf="$MIHOMO_CONF_DIR/config.yaml"
|
||||
[[ -f "$conf" ]] || die "找不到 $conf。先用 mihomo-up.sh 装好 mihomo 再来装 Patchbay"
|
||||
[[ -x "$MIHOMO_BIN" ]] || die "找不到 mihomo 二进制 $MIHOMO_BIN(装在别处就用 MIHOMO_BIN=路径 覆盖)"
|
||||
|
||||
local ctrl secret
|
||||
ctrl="$(conf_get 'external-controller')"
|
||||
secret="$(conf_get 'secret')"
|
||||
[[ -n "$ctrl" && "$ctrl" != '""' ]] \
|
||||
|| die "配置里没开管理接口(external-controller),Patchbay 靠它读线路和重载。先用 mihomo-up.sh 的配置向导开起来"
|
||||
|
||||
# Patchbay 和内核在同一台机器上,管理接口绑了 0.0.0.0 也从环回地址连
|
||||
local api_host="${ctrl%:*}" api_port="${ctrl##*:}"
|
||||
[[ "$api_host" == "0.0.0.0" || -z "$api_host" ]] && api_host="127.0.0.1"
|
||||
|
||||
local port
|
||||
ask_default " Patchbay 监听端口" "7580" port
|
||||
[[ "$port" =~ ^[0-9]+$ ]] || die "端口要是数字,收到的是「$port」"
|
||||
|
||||
mkdir -p "$(dirname "$ENV_FILE")"
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
# Patchbay 环境变量 — 由 install.sh v${SELF_VERSION} 生成于 $(date '+%F %T')
|
||||
# 各项含义见 ${REPO_URL}(server/env.ts)。改完 systemctl restart ${SERVICE} 生效。
|
||||
MIHOMO_API=http://${api_host}:${api_port}
|
||||
MIHOMO_SECRET=${secret}
|
||||
PATCHBAY_CONFIG=${conf}
|
||||
PATCHBAY_MIHOMO_BIN=${MIHOMO_BIN}
|
||||
PATCHBAY_MIHOMO_HOME=${MIHOMO_CONF_DIR}
|
||||
PATCHBAY_HOST=127.0.0.1
|
||||
PATCHBAY_PORT=${port}
|
||||
EOF
|
||||
# 里面有管理接口的密钥,不让其它用户读
|
||||
chmod 0640 "$ENV_FILE"
|
||||
ok "已生成 $ENV_FILE ${D}(权限 0640,密钥在里面)${N}"
|
||||
}
|
||||
|
||||
# ───────────────────────── systemd 服务 ─────────────────────────
|
||||
write_unit() {
|
||||
cat > "$1" <<EOF
|
||||
[Unit]
|
||||
Description=Patchbay — mihomo 配置的网页界面
|
||||
Documentation=${REPO_URL}
|
||||
# 只是排序不是依赖: 内核连不上时 Patchbay 照样能起来编辑配置
|
||||
After=network-online.target mihomo.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=${ENV_FILE}
|
||||
ExecStart=${PREFIX}/bun/bun ${PREFIX}/app/server/index.js
|
||||
WorkingDirectory=${PREFIX}/app
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
|
||||
install_unit() {
|
||||
command -v systemctl >/dev/null 2>&1 \
|
||||
|| die "这台机器没有 systemd。手动运行: $PREFIX/bun/bun $PREFIX/app/server/index.js(环境变量见 $ENV_FILE)"
|
||||
local tmp; tmp="$(mktemp)"
|
||||
write_unit "$tmp"
|
||||
if [[ -f "$UNIT_FILE" ]] && cmp -s "$tmp" "$UNIT_FILE"; then
|
||||
rm -f "$tmp"; info "服务文件已是最新,没改动"
|
||||
else
|
||||
install -m 0644 "$tmp" "$UNIT_FILE"; rm -f "$tmp"
|
||||
ok "已写入 $UNIT_FILE"
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
}
|
||||
|
||||
start_service() {
|
||||
systemctl enable "$SERVICE" >/dev/null 2>&1 && info "已设为开机自启"
|
||||
systemctl restart "$SERVICE" || true
|
||||
sleep 2
|
||||
if svc_running; then
|
||||
ok "$SERVICE 已启动"
|
||||
else
|
||||
warn "$SERVICE 没有启动成功。看具体原因: journalctl -u $SERVICE -n 50 --no-pager"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
show_access() {
|
||||
local port token=""
|
||||
port="$(sed -n -E 's/^PATCHBAY_PORT=//p' "$ENV_FILE" 2>/dev/null | sed -n '1p')"
|
||||
# 访问令牌由服务端首次启动时生成,存在配置文件旁边(见 server/auth.ts)
|
||||
[[ -f "$MIHOMO_CONF_DIR/.patchbay-token" ]] && token="$(tr -d '[:space:]' < "$MIHOMO_CONF_DIR/.patchbay-token")"
|
||||
|
||||
log ""
|
||||
log " ${D}只监听本机。从你自己的电脑访问,先开一条 SSH 隧道:${N}"
|
||||
log " ${W}ssh -N -L ${port:-7580}:127.0.0.1:${port:-7580} 用户名@这台机器${N}"
|
||||
log " ${D}然后浏览器打开:${N}"
|
||||
log " ${W}http://127.0.0.1:${port:-7580}${N}"
|
||||
if [[ -n "$token" ]]; then
|
||||
log " ${D}访问令牌${N} ${W}${token}${N}"
|
||||
log " ${D}令牌存在 $MIHOMO_CONF_DIR/.patchbay-token,忘了随时回来看。${N}"
|
||||
else
|
||||
log " ${D}访问令牌在服务首次启动时生成: journalctl -u $SERVICE | grep 令牌${N}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ───────────────────────── 命令 ─────────────────────────
|
||||
do_install() {
|
||||
require_root "安装到 $PREFIX 并写入 systemd 服务"
|
||||
need curl; need tar
|
||||
TMPDIR_="$(mktemp -d)"
|
||||
|
||||
local target cur
|
||||
target="${WANT_VERSION:-$(latest_version)}"
|
||||
cur="$(installed_version)"
|
||||
if [[ "$cur" == "$target" && $FORCE -eq 0 ]]; then
|
||||
ok "已是 $cur,无需操作(--force 可重装)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "${W}Patchbay ${target}${N} ${D}${cur:+(当前 $cur)}→ $PREFIX${N}"
|
||||
install_bun
|
||||
install_app "$target"
|
||||
write_env
|
||||
install_unit
|
||||
start_service || return 1
|
||||
show_access
|
||||
}
|
||||
|
||||
do_check() {
|
||||
local cur latest
|
||||
cur="$(installed_version)"
|
||||
latest="$(latest_version)"
|
||||
if [[ -z "$cur" ]]; then
|
||||
log "未安装(最新 Release 是 $latest)"; exit 10
|
||||
elif [[ "$cur" == "$latest" ]]; then
|
||||
log "已是最新 ($cur)"; exit 0
|
||||
else
|
||||
log "有新版: $cur → $latest"; exit 10
|
||||
fi
|
||||
}
|
||||
|
||||
do_uninstall() {
|
||||
require_root "卸载 Patchbay"
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl disable --now "$SERVICE" >/dev/null 2>&1 || true
|
||||
rm -f "$UNIT_FILE"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
rm -rf "$PREFIX"
|
||||
ok "已删除 $PREFIX 和 systemd 服务"
|
||||
if (( PURGE )); then
|
||||
rm -f "$ENV_FILE" "$MIHOMO_CONF_DIR/.patchbay-token"
|
||||
rmdir "$(dirname "$ENV_FILE")" 2>/dev/null || true
|
||||
ok "已删除环境变量文件和访问令牌"
|
||||
else
|
||||
info "留下了 $ENV_FILE 和访问令牌,重装可以继续用;不要了加 --purge"
|
||||
fi
|
||||
}
|
||||
|
||||
# ───────────────────────── 入口 ─────────────────────────
|
||||
CMD="install"
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
install|check|uninstall) CMD="$1" ;;
|
||||
-y|--yes) ASSUME_YES=1 ;;
|
||||
--force) FORCE=1 ;;
|
||||
--purge) PURGE=1 ;;
|
||||
--version) shift; WANT_VERSION="${1:-}"; [[ -n "$WANT_VERSION" ]] || die "--version 后面要跟版本号" ;;
|
||||
--mirror) shift; MIRROR="${1:-}"; [[ -n "$MIRROR" ]] || die "--mirror 后面要跟地址" ;;
|
||||
-h|--help) usage ;;
|
||||
-v) printf '%s\n' "$SELF_VERSION"; exit 0 ;;
|
||||
*) die "不认识的参数: $1(--help 看用法)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
case "$CMD" in
|
||||
install) do_install ;;
|
||||
check) do_check ;;
|
||||
uninstall) do_uninstall ;;
|
||||
esac
|
||||
+4
-2
@@ -17,8 +17,10 @@
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"yaml": "^2.9.0",
|
||||
"vue": "^3.5.13"
|
||||
"@tanstack/vue-virtual": "^3.13.35",
|
||||
"echarts": "^6.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/ri": "^1.2.10",
|
||||
|
||||
@@ -41,9 +41,9 @@ writeFileSync(out, rewriteYaml(source, pruneGenerated(next)), 'utf8')
|
||||
|
||||
console.log('生成的策略组:')
|
||||
console.log(JSON.stringify(next['proxy-groups'], null, 2))
|
||||
console.log('\n生成的订阅副本(只看结构,url 不回显):')
|
||||
// 副本是 type: file,makeChainProvider 已去掉 url,整条回显没有泄露订阅地址的风险
|
||||
console.log('\n生成的订阅副本:')
|
||||
for (const [name, entry] of Object.entries(next['proxy-providers'] as ConfigObject)) {
|
||||
if (!name.startsWith('patchbay:')) continue
|
||||
const { url, ...rest } = entry as ConfigObject
|
||||
console.log(name, JSON.stringify({ ...rest, url: '<同源订阅>' }))
|
||||
console.log(name, JSON.stringify(entry))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { afterAll, describe, expect, it } from 'bun:test'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
applyConfig,
|
||||
backupDiff,
|
||||
importConfig,
|
||||
listBackups,
|
||||
listRedo,
|
||||
readConfig,
|
||||
redoConfig,
|
||||
restoreBackup,
|
||||
undoConfig,
|
||||
} from './config-store.ts'
|
||||
import type { Env } from './env.ts'
|
||||
|
||||
/**
|
||||
* 落盘保护链路的测试,跑在一个假内核上:/version 报在线,PUT /configs 默认放行。
|
||||
* mihomo -t 那条离线路径这里测不了(需要真的内核二进制),离线行为只能靠人工验证。
|
||||
*/
|
||||
let rejectNextReload = false
|
||||
let reloadCount = 0
|
||||
const kernel = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/version') return Response.json({ version: 'test' })
|
||||
if (url.pathname === '/configs' && req.method === 'PUT') {
|
||||
reloadCount += 1
|
||||
if (rejectNextReload) {
|
||||
rejectNextReload = false
|
||||
return new Response('parse error: something wrong', { status: 400 })
|
||||
}
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
afterAll(() => kernel.stop())
|
||||
|
||||
const ORIGINAL = 'mixed-port: 7890\n'
|
||||
|
||||
function makeEnv(): Env {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'patchbay-store-'))
|
||||
const configPath = join(dir, 'config.yaml')
|
||||
writeFileSync(configPath, ORIGINAL, 'utf8')
|
||||
return {
|
||||
mihomoApi: `http://127.0.0.1:${String(kernel.port)}`,
|
||||
mihomoSecret: 'x',
|
||||
configPath,
|
||||
mihomoBin: join(dir, '不存在的内核'),
|
||||
mihomoHome: dir,
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
}
|
||||
}
|
||||
|
||||
describe('落盘保护链路(内核在线)', () => {
|
||||
it('正常保存:写盘、留一份改动前的备份、通知重载', async () => {
|
||||
const env = makeEnv()
|
||||
const { data, revision } = await readConfig(env)
|
||||
const before = reloadCount
|
||||
|
||||
const result = await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.reloaded).toBe(true)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7891')
|
||||
expect(reloadCount).toBe(before + 1)
|
||||
|
||||
const backups = await listBackups(env)
|
||||
expect(backups).toHaveLength(1)
|
||||
const backupBody = readFileSync(
|
||||
join(env.configPath, '..', '.patchbay-backups', backups[0]!),
|
||||
'utf8',
|
||||
)
|
||||
expect(backupBody).toBe(ORIGINAL)
|
||||
})
|
||||
|
||||
it('基准过时被拦住,文件一个字节不动', async () => {
|
||||
const env = makeEnv()
|
||||
const { data } = await readConfig(env)
|
||||
|
||||
const result = await applyConfig(env, { ...data, 'mixed-port': 7891 }, '基准已过时')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.stage).toBe('stale')
|
||||
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
|
||||
expect(await listBackups(env)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('内容没变直接返回,不备份也不重载', async () => {
|
||||
const env = makeEnv()
|
||||
// 先落一次盘让文件带上 Patchbay 标记——没有标记的文件第一次保存必有
|
||||
// 「补标记」这一处内容变化,短路只发生在稳态
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, first.data, first.revision)
|
||||
const { data, revision } = await readConfig(env)
|
||||
const before = reloadCount
|
||||
const backupsBefore = (await listBackups(env)).length
|
||||
|
||||
const result = await applyConfig(env, data, revision)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.reloaded).toBe(false)
|
||||
expect(result.reloadMessage).toBe('内容没有变化')
|
||||
}
|
||||
expect(reloadCount).toBe(before)
|
||||
expect((await listBackups(env)).length).toBe(backupsBefore)
|
||||
})
|
||||
|
||||
it('自检拦下断引用:入站的 proxy 指向不存在的出站', async () => {
|
||||
// mihomo -t 和重载都不检查这种引用(实测 v1.19.29),只有这层自检拦得住
|
||||
const env = makeEnv()
|
||||
const { data, revision } = await readConfig(env)
|
||||
const next = {
|
||||
...data,
|
||||
listeners: [{ name: 'x', type: 'mixed', port: 1080, proxy: '不存在的组' }],
|
||||
}
|
||||
|
||||
const result = await applyConfig(env, next, revision)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.stage).toBe('self-check')
|
||||
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
|
||||
})
|
||||
|
||||
it('内核拒绝重载时把文件还原,并让内核载回原样', async () => {
|
||||
const env = makeEnv()
|
||||
const { data, revision } = await readConfig(env)
|
||||
|
||||
rejectNextReload = true
|
||||
const result = await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.stage).toBe('reload')
|
||||
expect(result.restored).toBe(true)
|
||||
expect(result.message).toContain('parse error')
|
||||
}
|
||||
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
|
||||
})
|
||||
|
||||
it('恢复备份走同一条保护链路', async () => {
|
||||
const env = makeEnv()
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
|
||||
const [backup] = await listBackups(env)
|
||||
|
||||
const result = await restoreBackup(env, backup!)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
// 还原也走落盘链路,所以会重新打上 Patchbay 标记——内容回去了,但不逐字节等于旧文件
|
||||
const body = readFileSync(env.configPath, 'utf8')
|
||||
expect(body).toContain('mixed-port: 7890')
|
||||
expect(body).not.toContain('7891')
|
||||
expect(body).toContain('patchbay:')
|
||||
})
|
||||
|
||||
it('覆盖导入整份替换,兼容导入只搬接管段落', async () => {
|
||||
const env = makeEnv()
|
||||
const uploaded = 'mixed-port: 9999\nrules:\n - MATCH,DIRECT\n'
|
||||
|
||||
const compat = await importConfig(env, uploaded, 'compatible')
|
||||
expect(compat.ok).toBe(true)
|
||||
let body = readFileSync(env.configPath, 'utf8')
|
||||
// rules 是接管段落被搬过来,mixed-port 属于机器底座维持当前值
|
||||
expect(body).toContain('MATCH,DIRECT')
|
||||
expect(body).toContain('mixed-port: 7890')
|
||||
expect(body).not.toContain('9999')
|
||||
|
||||
const over = await importConfig(env, uploaded, 'override')
|
||||
expect(over.ok).toBe(true)
|
||||
body = readFileSync(env.configPath, 'utf8')
|
||||
expect(body).toContain('mixed-port: 9999')
|
||||
})
|
||||
|
||||
it('导入解析不过的文件时一个字节都不动', async () => {
|
||||
const env = makeEnv()
|
||||
const result = await importConfig(env, '{{{ 不是 yaml', 'override')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.stage).toBe('self-check')
|
||||
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
|
||||
})
|
||||
|
||||
it('恢复只认列表里的名字,路径穿越进不来', async () => {
|
||||
const env = makeEnv()
|
||||
const result = await restoreBackup(env, '../config.yaml')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.message).toContain('没有这个备份')
|
||||
})
|
||||
|
||||
it('撤回回到改动前,重做拿回改动,备份链不被撤回污染', async () => {
|
||||
const env = makeEnv()
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
|
||||
|
||||
const undone = await undoConfig(env)
|
||||
expect(undone.ok).toBe(true)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
|
||||
// 备份被消费、当前状态进了重做栈
|
||||
expect(await listBackups(env)).toHaveLength(0)
|
||||
expect(await listRedo(env)).toHaveLength(1)
|
||||
|
||||
const redone = await redoConfig(env)
|
||||
expect(redone.ok).toBe(true)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7891')
|
||||
expect(await listRedo(env)).toHaveLength(0)
|
||||
expect(await listBackups(env)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('栈空时撤回和重做被拦住,文件不动', async () => {
|
||||
const env = makeEnv()
|
||||
const undone = await undoConfig(env)
|
||||
expect(undone.ok).toBe(false)
|
||||
if (!undone.ok) expect(undone.message).toContain('没有可以撤回')
|
||||
|
||||
const redone = await redoConfig(env)
|
||||
expect(redone.ok).toBe(false)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
|
||||
})
|
||||
|
||||
it('新的保存清空重做栈', async () => {
|
||||
const env = makeEnv()
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
|
||||
await undoConfig(env)
|
||||
expect(await listRedo(env)).toHaveLength(1)
|
||||
|
||||
const { data, revision } = await readConfig(env)
|
||||
await applyConfig(env, { ...data, 'mixed-port': 7892 }, revision)
|
||||
expect(await listRedo(env)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('备份最多留 10 份,旧的被清掉', async () => {
|
||||
const env = makeEnv()
|
||||
const dir = join(env.configPath, '..', '.patchbay-backups')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
for (let i = 10; i < 22; i += 1) {
|
||||
writeFileSync(join(dir, `config.yaml.bak-20200101-0000${String(i)}`), 'old', 'utf8')
|
||||
}
|
||||
|
||||
const { data, revision } = await readConfig(env)
|
||||
await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
|
||||
|
||||
const backups = await listBackups(env)
|
||||
expect(backups).toHaveLength(10)
|
||||
// 名字按时间戳排序,留下的是最新的:刚生成的那份在最前,2020 年的只剩最近的九份
|
||||
expect(backups[0]!.startsWith('config.yaml.bak-2020')).toBe(false)
|
||||
expect(backups).not.toContain('config.yaml.bak-20200101-000010')
|
||||
})
|
||||
})
|
||||
|
||||
describe('连续保存的合并窗(opts.merge)', () => {
|
||||
const stampName = (at: Date) => {
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
return (
|
||||
`config.yaml.bak-${String(at.getFullYear())}${p(at.getMonth() + 1)}${p(at.getDate())}` +
|
||||
`-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}`
|
||||
)
|
||||
}
|
||||
|
||||
it('窗内的第二次保存不新建备份,一步撤回回到这一串之前', async () => {
|
||||
const env = makeEnv()
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision, { merge: true })
|
||||
const second = await readConfig(env)
|
||||
await applyConfig(env, { ...second.data, 'mixed-port': 7892 }, second.revision, { merge: true })
|
||||
|
||||
expect(await listBackups(env)).toHaveLength(1)
|
||||
|
||||
const undone = await undoConfig(env)
|
||||
expect(undone.ok).toBe(true)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
|
||||
})
|
||||
|
||||
it('最新备份出了窗就正常新建', async () => {
|
||||
const env = makeEnv()
|
||||
const dir = join(env.configPath, '..', '.patchbay-backups')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, stampName(new Date(Date.now() - 60_000))), 'old', 'utf8')
|
||||
|
||||
const { data, revision } = await readConfig(env)
|
||||
await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision, { merge: true })
|
||||
|
||||
expect(await listBackups(env)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('不带 merge 的保存(还原、导入)不合并,窗内照样各留一条', async () => {
|
||||
const env = makeEnv()
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision, { merge: true })
|
||||
const [backup] = await listBackups(env)
|
||||
|
||||
const result = await restoreBackup(env, backup!)
|
||||
expect(result.ok).toBe(true)
|
||||
// 还原自己也备份了「还原前」的状态。名字按秒生成,和上一份同秒时会覆盖而不是
|
||||
// 新增,所以这里断言的是「至少还有一份、且文件回到了原样」
|
||||
expect((await listBackups(env)).length).toBeGreaterThanOrEqual(1)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
|
||||
})
|
||||
})
|
||||
|
||||
describe('历史记录的对比原文', () => {
|
||||
it('最新一条对比当前文件,改动前后各归各', async () => {
|
||||
const env = makeEnv()
|
||||
const { data, revision } = await readConfig(env)
|
||||
await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
|
||||
|
||||
const [newest] = await listBackups(env)
|
||||
const pair = await backupDiff(env, newest!)
|
||||
expect(pair).not.toBeNull()
|
||||
expect(pair!.before).toBe(ORIGINAL)
|
||||
expect(pair!.after).toContain('mixed-port: 7891')
|
||||
})
|
||||
|
||||
it('更老的一条对比它前面那份备份', async () => {
|
||||
const env = makeEnv()
|
||||
const dir = join(env.configPath, '..', '.patchbay-backups')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'config.yaml.bak-20200101-000001'), '第一份\n', 'utf8')
|
||||
writeFileSync(join(dir, 'config.yaml.bak-20200101-000002'), '第二份\n', 'utf8')
|
||||
|
||||
const pair = await backupDiff(env, 'config.yaml.bak-20200101-000001')
|
||||
expect(pair).toEqual({ before: '第一份\n', after: '第二份\n' })
|
||||
})
|
||||
|
||||
it('名字不在备份列表里返回 null', async () => {
|
||||
const env = makeEnv()
|
||||
expect(await backupDiff(env, '../../etc/passwd')).toBeNull()
|
||||
})
|
||||
})
|
||||
+265
-27
@@ -8,8 +8,14 @@
|
||||
*
|
||||
* 顺序是:
|
||||
* 读当前文件 → 检查是否被外部改过 → 生成新内容 → 自己检查一遍
|
||||
* → 写到临时文件 → mihomo -t 校验 → 备份原文件 → 写入 → 通知内核重载
|
||||
* 最后两步中任何一步出问题,都从备份还原并让内核载回原样。
|
||||
* → 备份原文件 → 写入 → 让内核重载,被拒就从备份还原并载回原样
|
||||
* 重载这一步自带校验:内核先完整解析、解析失败什么都不应用并返回 400
|
||||
* (内核源码 hub/route/configs.go:430-438),还原链路补上「把文件退回去」。
|
||||
*
|
||||
* 内核不在线时没有这层校验,换成:
|
||||
* → 写到临时文件 → mihomo -t 校验 → 备份 → 写入,不重载,内核下次启动时生效
|
||||
* 配置写坏导致内核起不来的时候正是最需要这个界面的时候,这条路必须格外稳,
|
||||
* 也绝不能因为「重载失败」把刚修好的文件又还原回去。
|
||||
*
|
||||
* 「自己检查一遍」不能省。实测 v1.19.29:入站的 `proxy:` 指向一个不存在的
|
||||
* 出站时,`mihomo -t` 会通过、重载会成功,直到有流量走到那个入站才在日志里
|
||||
@@ -24,12 +30,30 @@ import {
|
||||
} from '../vendor/config-editor/index.ts'
|
||||
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
import { restoreInlineCommentAlignment } from '../shared/yaml-cosmetics.ts'
|
||||
import { validateConfigFile, reloadConfig } from './kernel.ts'
|
||||
import { backupDate } from '../shared/backups.ts'
|
||||
import { withMarker } from '../shared/config-marker.ts'
|
||||
import { kernelVersion, validateConfigFile, reloadConfig } from './kernel.ts'
|
||||
import type { Env } from './env.ts'
|
||||
|
||||
/** 备份保留的份数。再多没有意义——真要回到很久以前的版本,那是版本控制的活。 */
|
||||
const KEEP_BACKUPS = 10
|
||||
|
||||
/**
|
||||
* 界面上的连续保存合并成一条历史的时间窗:最新备份还在窗内就不再新建,
|
||||
* 这一串操作共享一条记录、一步撤回。连点删除不再刷出一屏历史。
|
||||
* 顺带解决了备份名只精确到秒的隐患——窗内不建新备份,同秒的覆盖不会发生。
|
||||
*/
|
||||
const MERGE_WINDOW_MS = 5_000
|
||||
|
||||
/** 最新备份是否还在合并窗内。时间戳从备份名里解析,时钟往回跳时当不在窗内。 */
|
||||
async function hasRecentBackup(env: Env): Promise<boolean> {
|
||||
const [newest] = await listBackups(env)
|
||||
const at = newest ? backupDate(newest) : null
|
||||
if (!at) return false
|
||||
const age = Date.now() - at.getTime()
|
||||
return age >= 0 && age < MERGE_WINDOW_MS
|
||||
}
|
||||
|
||||
export interface ConfigSnapshot {
|
||||
/** 解析出来的配置数据 */
|
||||
data: ConfigObject
|
||||
@@ -99,11 +123,19 @@ export function rewriteYaml(currentYaml: string, nextData: ConfigObject): string
|
||||
*
|
||||
* `baseRevision` 是客户端读到这份配置时拿到的指纹。传 undefined 表示
|
||||
* 「不管别人改没改,以我为准」,仅用于还原备份这类明确要覆盖的场景。
|
||||
*
|
||||
* `opts.restoreFrom`:撤回/重做专用。给了它就不再新建备份(否则备份链会被
|
||||
* 撤回本身污染,撤两次等于原地打转),重载失败时用它当还原源;同时不清空
|
||||
* 重做栈——只有「正常的新改动」才让已撤回的未来失效。
|
||||
*
|
||||
* `opts.merge`:界面上的普通保存专用。最新备份还在合并窗内时不新建备份,
|
||||
* 连续操作共享一条历史。还原备份、上传导入这类独立动作不传,各占一条。
|
||||
*/
|
||||
export async function applyConfig(
|
||||
env: Env,
|
||||
nextData: ConfigObject,
|
||||
baseRevision?: string,
|
||||
opts?: { restoreFrom?: string; merge?: boolean },
|
||||
): Promise<ApplyOutcome> {
|
||||
const current = await readDocument(env)
|
||||
|
||||
@@ -127,7 +159,10 @@ export async function applyConfig(
|
||||
return `${stage} ${String(ms)}ms`
|
||||
}
|
||||
|
||||
const nextYaml = rewriteYaml(current.yaml, nextData)
|
||||
// 每次落盘都打上 Patchbay 标记(顶层 patchbay: 键,内核忽略未知键)。
|
||||
// 上传配置时靠它认「这是 Patchbay 管理过的配置」。标记是幂等的,
|
||||
// 已带标记的配置重打一遍内容不变,不影响下面的「内容没变就短路」
|
||||
const nextYaml = rewriteYaml(current.yaml, withMarker(nextData))
|
||||
|
||||
// 内容没变就不要走后面那一串——不必要的备份和重载只会制造噪音。
|
||||
if (nextYaml === current.yaml) {
|
||||
@@ -146,29 +181,39 @@ export async function applyConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// 先写到临时文件交给内核校验,别碰真文件。
|
||||
const tempPath = join(dirname(env.configPath), `.patchbay-check-${String(Date.now())}.yaml`)
|
||||
// 内核在线时不跑 mihomo -t:起一个进程把配置重新解析一遍占整条链路九成以上的耗时
|
||||
// (实测保存 4-10 秒里它占 4-9.4 秒,重载只要几十到几百毫秒),而重载请求自带同一套
|
||||
// 解析、失败时什么都不应用,还原链路补上「把文件退回去」,效果等同校验。代价是写盘到
|
||||
// 还原之间的短暂窗口里磁盘上存在一份可能有错的配置,进程恰好在这时被杀才会留下事故。
|
||||
// 内核不在线时 mihomo -t 是唯一的校验手段。
|
||||
const kernel = await kernelVersion(env)
|
||||
let checkTiming = ''
|
||||
try {
|
||||
await writeFile(tempPath, nextYaml, 'utf8')
|
||||
const checked = await validateConfigFile(env, tempPath)
|
||||
checkTiming = lap('内核校验')
|
||||
if (!checked.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'kernel-check',
|
||||
message: checked.message,
|
||||
hint: '内核拒绝了这份配置,真文件一个字节都没动,你可以直接继续改。',
|
||||
restored: false,
|
||||
if (!kernel.online) {
|
||||
// 先写到临时文件交给内核校验,别碰真文件。
|
||||
const tempPath = join(dirname(env.configPath), `.patchbay-check-${String(Date.now())}.yaml`)
|
||||
try {
|
||||
await writeFile(tempPath, nextYaml, 'utf8')
|
||||
const checked = await validateConfigFile(env, tempPath)
|
||||
checkTiming = lap('内核校验')
|
||||
if (!checked.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'kernel-check',
|
||||
message: checked.message,
|
||||
hint: '内核拒绝了这份配置,真文件一个字节都没动,你可以直接继续改。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(tempPath, { force: true })
|
||||
}
|
||||
} finally {
|
||||
await rm(tempPath, { force: true })
|
||||
}
|
||||
|
||||
// 校验过了才动真文件。备份放在写入之前。
|
||||
const backupPath = await createBackup(env)
|
||||
const backupTiming = lap('备份')
|
||||
// 校验过了才动真文件。备份放在写入之前;合并窗内跳过新建,重载失败的还原
|
||||
// 不受影响——没有备份文件时用刚读出的原文写回(见下面的还原分支)。
|
||||
const merged = opts?.merge === true && opts.restoreFrom === undefined && (await hasRecentBackup(env))
|
||||
const backupPath = opts?.restoreFrom ?? (merged ? null : await createBackup(env))
|
||||
const backupTiming = backupPath !== null && opts?.restoreFrom === undefined ? lap('备份') : ''
|
||||
try {
|
||||
await writeFile(env.configPath, nextYaml, 'utf8')
|
||||
} catch (error) {
|
||||
@@ -182,14 +227,32 @@ export async function applyConfig(
|
||||
}
|
||||
|
||||
const writeTiming = lap('写盘')
|
||||
|
||||
if (!kernel.online) {
|
||||
// mihomo -t 已经把过关,文件留给内核下次启动时读。
|
||||
await pruneBackups(env)
|
||||
if (!opts?.restoreFrom) await clearRedo(env)
|
||||
console.log(
|
||||
`[apply] 共 ${String(Date.now() - clock.start)}ms(${checkTiming},${backupTiming},${writeTiming},未重载)`,
|
||||
)
|
||||
return {
|
||||
ok: true,
|
||||
snapshot: await readConfig(env),
|
||||
reloaded: false,
|
||||
reloadMessage: '内核未连接,改动已写入文件,等内核启动时生效',
|
||||
}
|
||||
}
|
||||
|
||||
const reloaded = await reloadConfig(env, env.configPath)
|
||||
const reloadTiming = lap('重载')
|
||||
if (!reloaded.ok) {
|
||||
// 文件已经写进去了但内核没接受。把文件还原,再让内核载回原样,
|
||||
// 避免留下「文件是新的、内核跑的是旧的」这种最难排查的状态。
|
||||
// 合并窗内没有备份文件,还原源用刚读出的原文。
|
||||
let restored = false
|
||||
try {
|
||||
await copyFile(backupPath, env.configPath)
|
||||
if (backupPath !== null) await copyFile(backupPath, env.configPath)
|
||||
else await writeFile(env.configPath, current.yaml, 'utf8')
|
||||
await reloadConfig(env, env.configPath)
|
||||
restored = true
|
||||
} catch {
|
||||
@@ -201,15 +264,17 @@ export async function applyConfig(
|
||||
message: reloaded.message,
|
||||
hint: restored
|
||||
? '已经把配置还原成改动前的样子,内核也载回去了。改动没有生效。'
|
||||
: `还原也失败了。改动前的配置留在 ${backupPath},需要手动拷回 ${env.configPath}。`,
|
||||
: backupPath !== null
|
||||
? `还原也失败了。改动前的配置留在 ${backupPath},需要手动拷回 ${env.configPath}。`
|
||||
: '还原也失败了。历史记录里最新的备份和改动前至多差几秒内的操作,可以从它还原。',
|
||||
restored,
|
||||
}
|
||||
}
|
||||
|
||||
await pruneBackups(env)
|
||||
console.log(
|
||||
`[apply] 共 ${String(Date.now() - clock.start)}ms(${checkTiming},${backupTiming},${writeTiming},${reloadTiming})`,
|
||||
)
|
||||
if (!opts?.restoreFrom) await clearRedo(env)
|
||||
const timings = [checkTiming, backupTiming, writeTiming, reloadTiming].filter(Boolean).join(',')
|
||||
console.log(`[apply] 共 ${String(Date.now() - clock.start)}ms(${timings})`)
|
||||
return {
|
||||
ok: true,
|
||||
snapshot: await readConfig(env),
|
||||
@@ -254,6 +319,179 @@ async function pruneBackups(env: Env): Promise<void> {
|
||||
await Promise.all(stale.map((name) => unlink(join(dir, name)).catch(() => {})))
|
||||
}
|
||||
|
||||
export type ImportMode = 'override' | 'compatible'
|
||||
|
||||
/**
|
||||
* 兼容模式搬运的段落:Patchbay 接管的那几个(决策记录 0003)。
|
||||
* 老式顶层端口、dns、tun 这类属于机器本身的设置不在其中——兼容恢复的语义是
|
||||
* 「把我在 Patchbay 里做的接线搬过来」,不动当前机器的底座。
|
||||
*/
|
||||
const MANAGED_KEYS = ['listeners', 'proxy-groups', 'rules', 'proxy-providers', 'rule-providers']
|
||||
|
||||
/**
|
||||
* 用上传的 YAML 替换当前配置。
|
||||
*
|
||||
* `override` 整份替换;`compatible` 只把上传文件里 Patchbay 接管的段落搬到
|
||||
* 当前配置上(上传文件里没有的段落维持当前的样子)。解析用和读配置同一个引擎,
|
||||
* 之后走 applyConfig 完整的保护链路(自检、备份、重载、失败回退)。
|
||||
* 没有 baseRevision——替换本来就是覆盖语义,同还原备份。
|
||||
*/
|
||||
export async function importConfig(
|
||||
env: Env,
|
||||
content: string,
|
||||
mode: ImportMode,
|
||||
): Promise<ApplyOutcome> {
|
||||
let uploaded: ConfigObject
|
||||
try {
|
||||
const doc = openDocument(content)
|
||||
const broken = doc.diagnostics.filter((d) => d.severity === 'error')
|
||||
if (broken.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'self-check',
|
||||
message: broken.map((p) => `${p.path.join('.')}:${p.message}`).join('\n'),
|
||||
hint: '上传的文件解析不过,当前配置没有被改动。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
uploaded = doc.data
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'self-check',
|
||||
message: `上传的文件不是合法的 YAML:${String(error)}`,
|
||||
hint: '当前配置没有被改动。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'override') return applyConfig(env, uploaded)
|
||||
|
||||
const current = await readDocument(env)
|
||||
const merged: ConfigObject = { ...current.data }
|
||||
for (const key of MANAGED_KEYS) {
|
||||
const value = uploaded[key]
|
||||
if (value !== undefined) merged[key] = value
|
||||
}
|
||||
return applyConfig(env, merged)
|
||||
}
|
||||
|
||||
/* ── 撤回 / 重做 ──────────────────────────────────
|
||||
*
|
||||
* 撤回栈就是备份链:每次成功落盘都留了「改动前」的备份。撤回把最新备份应用
|
||||
* 回去并**消费掉它**(从链里删除),同时把撤回前的状态推进重做栈;重做反向。
|
||||
* 任何正常的新改动清空重做栈——和编辑器撤回的通行语义一致,不留分叉历史。
|
||||
*/
|
||||
|
||||
async function redoDir(env: Env): Promise<string> {
|
||||
const dir = join(dirname(env.configPath), '.patchbay-redo')
|
||||
await mkdir(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
/** 重做条目按名字排序就是按时间排序。带毫秒:连续快速撤回不能互相覆盖。 */
|
||||
function redoName(configPath: string, at: Date): string {
|
||||
const p = (n: number, w = 2) => String(n).padStart(w, '0')
|
||||
const stamp =
|
||||
`${String(at.getFullYear())}${p(at.getMonth() + 1)}${p(at.getDate())}` +
|
||||
`-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}${p(at.getMilliseconds(), 3)}`
|
||||
return `${basename(configPath)}.redo-${stamp}`
|
||||
}
|
||||
|
||||
/** 新的在前,同 listBackups。 */
|
||||
export async function listRedo(env: Env): Promise<string[]> {
|
||||
const dir = await redoDir(env)
|
||||
const prefix = `${basename(env.configPath)}.redo-`
|
||||
const entries = await readdir(dir)
|
||||
return entries.filter((name) => name.startsWith(prefix)).sort().reverse()
|
||||
}
|
||||
|
||||
async function clearRedo(env: Env): Promise<void> {
|
||||
const dir = await redoDir(env)
|
||||
const entries = await listRedo(env)
|
||||
await Promise.all(entries.map((name) => unlink(join(dir, name)).catch(() => {})))
|
||||
}
|
||||
|
||||
/** 当前文件进重做栈,返回落点路径。 */
|
||||
async function pushRedo(env: Env): Promise<string> {
|
||||
const dir = await redoDir(env)
|
||||
const path = join(dir, redoName(env.configPath, new Date()))
|
||||
await copyFile(env.configPath, path)
|
||||
// 和备份同一个上限。裁掉的是最「未来」的状态,同备份链裁掉最远的过去
|
||||
const stale = (await listRedo(env)).slice(KEEP_BACKUPS)
|
||||
await Promise.all(stale.map((name) => unlink(join(dir, name)).catch(() => {})))
|
||||
return path
|
||||
}
|
||||
|
||||
/** 撤回最近一次改动:应用最新备份并消费掉它,撤回前的状态进重做栈。 */
|
||||
export async function undoConfig(env: Env): Promise<ApplyOutcome> {
|
||||
const [target] = await listBackups(env)
|
||||
if (!target) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'self-check',
|
||||
message: '没有可以撤回的改动',
|
||||
hint: '每次保存都会把改动前的状态留进撤回栈,现在栈是空的。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
const dir = await backupDir(env)
|
||||
const targetYaml = await readFile(join(dir, target), 'utf8')
|
||||
const redoPath = await pushRedo(env)
|
||||
const result = await applyConfig(env, openDocument(targetYaml).data, undefined, {
|
||||
restoreFrom: redoPath,
|
||||
})
|
||||
if (result.ok) await unlink(join(dir, target)).catch(() => {})
|
||||
else await unlink(redoPath).catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
/** 重做最近一次被撤回的改动。当前状态回到撤回栈,重做条目被消费。 */
|
||||
export async function redoConfig(env: Env): Promise<ApplyOutcome> {
|
||||
const [target] = await listRedo(env)
|
||||
if (!target) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'self-check',
|
||||
message: '没有可以重做的改动',
|
||||
hint: '只有撤回过的改动才能重做,新的保存会清空重做栈。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
const dir = await redoDir(env)
|
||||
const targetYaml = await readFile(join(dir, target), 'utf8')
|
||||
const backupPath = await createBackup(env)
|
||||
const result = await applyConfig(env, openDocument(targetYaml).data, undefined, {
|
||||
restoreFrom: backupPath,
|
||||
})
|
||||
if (result.ok) await unlink(join(dir, target)).catch(() => {})
|
||||
else await unlink(backupPath).catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条历史记录展开时要对比的两份原文。
|
||||
*
|
||||
* 备份存的是「改动前」,所以这次改动的对比对象是更新一份的状态:列表里
|
||||
* 它前面那份备份,最新一条则对比当前文件。名字对着 listBackups 校验,
|
||||
* 同 restoreBackup,防路径穿越。没有这个备份时返回 null。
|
||||
*/
|
||||
export async function backupDiff(
|
||||
env: Env,
|
||||
name: string,
|
||||
): Promise<{ before: string; after: string } | null> {
|
||||
const backups = await listBackups(env)
|
||||
const index = backups.indexOf(name)
|
||||
if (index < 0) return null
|
||||
const dir = await backupDir(env)
|
||||
const before = await readFile(join(dir, name), 'utf8')
|
||||
const after =
|
||||
index === 0
|
||||
? await readFile(env.configPath, 'utf8')
|
||||
: await readFile(join(dir, backups[index - 1]!), 'utf8')
|
||||
return { before, after }
|
||||
}
|
||||
|
||||
/** 把某个备份恢复成当前配置。走的是和普通改动同一条保护链路。 */
|
||||
export async function restoreBackup(env: Env, name: string): Promise<ApplyOutcome> {
|
||||
const dir = await backupDir(env)
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* GEO 数据库文件的本地状态:多大、多久没更新了。
|
||||
*
|
||||
* 内核不提供这个信息,但文件就躺在它的工作目录里,和 Patchbay 服务端
|
||||
* 在同一台机器上,直接 stat 就是。
|
||||
*/
|
||||
import { readdir, rename, stat, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { DEFAULT_GEOX_URLS, GEO_FILES, looksLikeGeodata, looksLikeMmdb } from '../shared/geo.ts'
|
||||
import type { GeoFileStatus } from '../shared/geo.ts'
|
||||
import type { Env } from './env.ts'
|
||||
|
||||
/** 配置里写了就用配置的,否则退回内核默认。 */
|
||||
function urlFor(geoxUrl: Record<string, unknown>, configKey: string): string {
|
||||
const configured = geoxUrl[configKey]
|
||||
return typeof configured === 'string' && configured !== ''
|
||||
? configured
|
||||
: (DEFAULT_GEOX_URLS[configKey] ?? '')
|
||||
}
|
||||
|
||||
export async function geoStatus(
|
||||
env: Env,
|
||||
geoxUrl: Record<string, unknown>,
|
||||
): Promise<GeoFileStatus[]> {
|
||||
const entries = await readdir(env.mihomoHome).catch(() => [] as string[])
|
||||
return Promise.all(
|
||||
GEO_FILES.map(async ({ key, configKey, file }) => {
|
||||
// 内核找 GeoIP.dat/GeoSite.dat 时大小写不敏感(constant/path.go:193-224),这里照做
|
||||
const actual = entries.find((name) => name.toLowerCase() === file.toLowerCase())
|
||||
let size: number | null = null
|
||||
let updatedAt: string | null = null
|
||||
if (actual) {
|
||||
const info = await stat(join(env.mihomoHome, actual)).catch(() => null)
|
||||
if (info) {
|
||||
size = info.size
|
||||
updatedAt = info.mtime.toISOString()
|
||||
}
|
||||
}
|
||||
return { key, configKey, url: urlFor(geoxUrl, configKey), size, updatedAt }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function download(url: string, proxy: string | null): Promise<Uint8Array> {
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(180_000),
|
||||
redirect: 'follow',
|
||||
...(proxy ? { proxy } : {}),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`)
|
||||
return new Uint8Array(await res.arrayBuffer())
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载单个 GEO 文件写进内核工作目录。
|
||||
*
|
||||
* 内核的更新接口是全量的(POST /configs/geo,按 enable 标记更新它用到的类型),
|
||||
* 单文件只能 Patchbay 自己下。代价是拿不到内核那套完整校验,只能做形状检查
|
||||
* (shared/geo.ts),也不触发热加载——内核用 mmap 缓存着旧数据,重启后才换新。
|
||||
*
|
||||
* `proxy` 给的话先经它出去(内核自己更新 GEO 时走的是它自己的分流,直连
|
||||
* GitHub 不一定通,Patchbay 借内核的混合端口能拿到同样的通路),失败再直连。
|
||||
*/
|
||||
export async function syncGeoFile(
|
||||
env: Env,
|
||||
configKey: string,
|
||||
geoxUrl: Record<string, unknown>,
|
||||
proxy: string | null,
|
||||
): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||
const spec = GEO_FILES.find((g) => g.configKey === configKey)
|
||||
if (!spec) return { ok: false, message: `未知的 GEO 类型:${configKey}` }
|
||||
const url = urlFor(geoxUrl, configKey)
|
||||
|
||||
let bytes: Uint8Array
|
||||
try {
|
||||
bytes = await download(url, proxy)
|
||||
} catch (viaProxy) {
|
||||
if (!proxy) return { ok: false, message: `下载失败:${String(viaProxy)}` }
|
||||
try {
|
||||
bytes = await download(url, null)
|
||||
} catch (direct) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `经内核代理(${String(viaProxy)})和直连(${String(direct)})都没能下载。`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shapeOk =
|
||||
configKey === 'mmdb' || configKey === 'asn' ? looksLikeMmdb(bytes) : looksLikeGeodata(bytes)
|
||||
if (!shapeOk) {
|
||||
return { ok: false, message: `下载到的内容不像 ${spec.key} 文件,已丢弃,本地文件没有动。` }
|
||||
}
|
||||
|
||||
// 沿用目录里现有的大小写写法,内核就是这样找文件的
|
||||
const entries = await readdir(env.mihomoHome).catch(() => [] as string[])
|
||||
const actual = entries.find((name) => name.toLowerCase() === spec.file.toLowerCase()) ?? spec.file
|
||||
const finalPath = join(env.mihomoHome, actual)
|
||||
const tempPath = `${finalPath}.patchbay-tmp`
|
||||
try {
|
||||
await writeFile(tempPath, bytes)
|
||||
await rename(tempPath, finalPath)
|
||||
} catch (error) {
|
||||
return { ok: false, message: `写入失败:${String(error)}` }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 主机信息,以及经由某一路入站发出的探测。
|
||||
*
|
||||
* Patchbay 服务端和 mihomo 跑在同一台机器上,所以这几件事由它代答:
|
||||
* - 主机指标:mihomo 的管理接口只报自己的内存,CPU、主机内存、网关这些
|
||||
* 从 os 模块和 /proc 里读(解析在 shared/host-info.ts,那边有测试)
|
||||
* - 公网 IP 检测:浏览器没法「经由某个入站端口」发请求,服务端可以——把本机
|
||||
* 对应端口设成代理去访问 ip.sb,返回的就是那一路在外面的地址
|
||||
* - 可访问性测试:同一条路子,换成访问几个常用 AI 服务,判定在 shared/reachability.ts
|
||||
*/
|
||||
import os from 'node:os'
|
||||
import {
|
||||
cpuUsageBetween,
|
||||
parseGateway,
|
||||
parseMemAvailable,
|
||||
parseMemTotal,
|
||||
parseNameservers,
|
||||
} from '../shared/host-info.ts'
|
||||
import type { CpuTimes, ExitProbe, HostInfo } from '../shared/host-info.ts'
|
||||
import { describeProbeFailure, judge } from '../shared/reachability.ts'
|
||||
import type { ReachResult, ReachTarget } from '../shared/reachability.ts'
|
||||
|
||||
function cpuTimesNow(): CpuTimes {
|
||||
let busy = 0
|
||||
let total = 0
|
||||
for (const cpu of os.cpus()) {
|
||||
const t = cpu.times
|
||||
busy += t.user + t.nice + t.sys + t.irq
|
||||
total += t.user + t.nice + t.sys + t.irq + t.idle
|
||||
}
|
||||
return { busy, total }
|
||||
}
|
||||
|
||||
/** 上一次请求时的 CPU 快照。使用率是两次请求之间的平均,第一次没有可比对象。 */
|
||||
let lastCpu: CpuTimes | null = null
|
||||
|
||||
async function readProc(path: string): Promise<string> {
|
||||
try {
|
||||
return await Bun.file(path).text()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export async function hostInfo(): Promise<HostInfo> {
|
||||
const now = cpuTimesNow()
|
||||
const cpuUsage = lastCpu ? cpuUsageBetween(lastCpu, now) : null
|
||||
lastCpu = now
|
||||
|
||||
const lanAddresses: Array<{ iface: string; address: string }> = []
|
||||
for (const [iface, addrs] of Object.entries(os.networkInterfaces())) {
|
||||
for (const addr of addrs ?? []) {
|
||||
if (addr.family === 'IPv4' && !addr.internal) lanAddresses.push({ iface, address: addr.address })
|
||||
}
|
||||
}
|
||||
|
||||
const meminfo = await readProc('/proc/meminfo')
|
||||
const load = os.loadavg()[0] ?? 0
|
||||
|
||||
return {
|
||||
cpuUsage,
|
||||
cpuCount: os.cpus().length,
|
||||
memTotal: parseMemTotal(meminfo) ?? os.totalmem(),
|
||||
memAvailable: parseMemAvailable(meminfo) ?? os.freemem(),
|
||||
// loadavg 在 Windows 上恒为 0,那不是「负载为零」而是「没有这个概念」
|
||||
load1: process.platform === 'win32' ? null : load,
|
||||
uptimeSeconds: os.uptime(),
|
||||
platform: process.platform === 'linux' ? `Linux ${os.release()}` : `${os.type()} ${os.release()}`,
|
||||
release: os.release(),
|
||||
arch: os.arch(),
|
||||
lanAddresses,
|
||||
gateway: parseGateway(await readProc('/proc/net/route')),
|
||||
nameservers: parseNameservers(await readProc('/etc/resolv.conf')),
|
||||
}
|
||||
}
|
||||
|
||||
/** 公网 IP 检测问谁。ip.sb 的这个接口返回 JSON,带 IP 和归属地。 */
|
||||
const EXIT_URL = 'https://api.ip.sb/geoip'
|
||||
|
||||
/**
|
||||
* 经由本机某个入站端口探测公网 IP。只对说 HTTP 的入站有效(http/mixed),
|
||||
* 纯 socks 端口这条路走不通。
|
||||
*/
|
||||
export async function probeExit(port: number): Promise<ExitProbe> {
|
||||
const started = Date.now()
|
||||
try {
|
||||
const res = await fetch(EXIT_URL, {
|
||||
proxy: `http://127.0.0.1:${String(port)}`,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!res.ok) return { ok: false, message: `探测目标返回 HTTP ${String(res.status)}` }
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
return {
|
||||
ok: true,
|
||||
ip: typeof body.ip === 'string' ? body.ip : '',
|
||||
country: typeof body.country === 'string' ? body.country : '',
|
||||
countryCode: typeof body.country_code === 'string' ? body.country_code : '',
|
||||
ms: Date.now() - started,
|
||||
}
|
||||
} catch (error) {
|
||||
return { ok: false, message: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/** 单个目标的探测超时,毫秒。超时的说法由服务端生成,所以只有这里需要这个数。 */
|
||||
const REACH_TIMEOUT_MS = 10_000
|
||||
|
||||
/**
|
||||
* 经由本机某个入站端口探测一个目标能不能访问。同 probeExit,只对说 HTTP 的入站有效。
|
||||
*
|
||||
* 不带任何鉴权头,判定全交给 shared/reachability.ts 的 judge()。正文只读前 8 KB:
|
||||
* 判定只需要认拦截页开头的标记,而目标本身可能返回很大的 JSON。
|
||||
*/
|
||||
export async function probeReach(port: number, target: ReachTarget): Promise<ReachResult> {
|
||||
const started = Date.now()
|
||||
try {
|
||||
const res = await fetch(target.url, {
|
||||
proxy: `http://127.0.0.1:${String(port)}`,
|
||||
headers: { Accept: 'application/json,text/html,*/*' },
|
||||
signal: AbortSignal.timeout(REACH_TIMEOUT_MS),
|
||||
})
|
||||
const body = (await res.text()).slice(0, 8192)
|
||||
const { verdict, message } = judge(target, res.status, res.headers, body)
|
||||
return { target: target.id, verdict, status: res.status, ms: Date.now() - started, message }
|
||||
} catch (error) {
|
||||
return {
|
||||
target: target.id,
|
||||
verdict: 'unreachable',
|
||||
status: null,
|
||||
ms: Date.now() - started,
|
||||
message: describeProbeFailure(port, REACH_TIMEOUT_MS, error),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ const handle = createRouter(env, token)
|
||||
const server = Bun.serve({
|
||||
hostname: env.host,
|
||||
port: env.port,
|
||||
// Bun 默认 10 秒空闲就掐连接,而透传的内核日志流在没有日志产生时一个字节都不发
|
||||
// ——连响应头都没有(内核源码 hub/route/server.go:510-534,头随第一行日志才写出)。
|
||||
// 被掐的连接经 vite 代理会变成无响应体的 500,界面上是一句指不到病根的「请求失败」。
|
||||
// 这是只监听本机的工具,不设空闲超时没有被拖死的风险
|
||||
idleTimeout: 0,
|
||||
fetch: handle,
|
||||
})
|
||||
|
||||
|
||||
+49
-4
@@ -54,8 +54,8 @@ export async function validateConfigFile(
|
||||
* 启动时先把内核二进制跑一次,什么都不做。
|
||||
*
|
||||
* `mihomo -t` 的第一次调用要 4 秒,之后 55 毫秒(在目标机器那种规格上实测差距只会更大)。
|
||||
* 差的是把这个几十兆的可执行文件读进系统文件缓存的时间,跟配置内容无关。不预热的话,
|
||||
* 这 4 秒会落在用户按下的第一次保存上,而那正是他最容易以为「这个工具很慢」的时刻。
|
||||
* 差的是把这个几十兆的可执行文件读进系统文件缓存的时间,跟配置内容无关。现在只有内核
|
||||
* 不在线时保存才走 `mihomo -t`,而那正是用户最着急的场景,这 4 秒不该落在他头上。
|
||||
*
|
||||
* 失败不用管:内核二进制不可用这件事,第一次真正校验时会带着完整错误信息报出来。
|
||||
*/
|
||||
@@ -95,6 +95,10 @@ export async function kernelVersion(env: Env): Promise<{ online: boolean; versio
|
||||
/**
|
||||
* 让内核重新加载配置文件,不重启进程。
|
||||
*
|
||||
* 这一步同时就是校验:内核先完整解析,解析失败什么都不应用并返回 400,响应体是解析
|
||||
* 错误原文(内核源码 hub/route/configs.go:430-438)。落盘链路在内核在线时靠它把关,
|
||||
* 不再单独跑 `mihomo -t`。
|
||||
*
|
||||
* `force=true` 意味着连端口这类需要重开监听的改动也一并生效——正是本项目改入站时需要的。
|
||||
* 注意 `path` 是**内核所在机器上的路径**:部署时 Patchbay 和内核在同一台机器上所以没有问题,
|
||||
* 但开发时如果 Patchbay 在本机、内核在远端,这个调用不会加载到本地的文件。
|
||||
@@ -137,6 +141,42 @@ export async function selectProxy(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 让内核重启进程(POST /restart,内核源码 hub/route/restart.go:20-43)。
|
||||
*
|
||||
* 内核先回 {"status":"ok"} 再自我 exec。Linux 上用的是 syscall.Exec,PID 不变,
|
||||
* systemd 托管不受影响;策略组的选中靠 profile.store-selected 恢复。
|
||||
*/
|
||||
export async function restartKernel(env: Env): Promise<ValidateResult> {
|
||||
try {
|
||||
const res = await callApi(env, '/restart', {
|
||||
method: 'POST',
|
||||
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)}` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 让内核更新 GEO 数据库(POST /configs/geo,内核源码 hub/route/configs.go:30,442-452)。
|
||||
* 同步接口,下载完才回话,超时要给足——GEO 文件几十兆、走的还是外网。
|
||||
*/
|
||||
export async function updateGeoDatabases(env: Env): Promise<ValidateResult> {
|
||||
try {
|
||||
const res = await callApi(env, '/configs/geo', {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(300_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 请求。
|
||||
*
|
||||
@@ -144,8 +184,13 @@ export async function selectProxy(
|
||||
* 持续吐 JSON 的分块流(内核源码 hub/route/server.go:380-383),给它们设超时等于
|
||||
* 每隔一会儿就把连接掐断一次。
|
||||
*/
|
||||
export async function proxyGet(env: Env, path: string, stream = false): Promise<Response> {
|
||||
return callApi(env, path, stream ? {} : { signal: AbortSignal.timeout(15_000) })
|
||||
export async function proxyGet(
|
||||
env: Env,
|
||||
path: string,
|
||||
stream = false,
|
||||
timeoutMs = 15_000,
|
||||
): Promise<Response> {
|
||||
return callApi(env, path, stream ? {} : { signal: AbortSignal.timeout(timeoutMs) })
|
||||
}
|
||||
|
||||
/** 透传一个写操作。目前只有断开连接和刷新订阅两处。 */
|
||||
|
||||
+182
-5
@@ -9,9 +9,32 @@
|
||||
* 透传只开放 GET。写操作(切换节点、关连接)属于运行时面板的功能,等做到那一步再单独开,
|
||||
* 现在不留一个「什么都能转发」的口子。
|
||||
*/
|
||||
import { resolve, sep } from 'node:path'
|
||||
import listenerFields from '../shared/listener-fields.json' with { type: 'json' }
|
||||
import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
|
||||
import { kernelVersion, proxyGet, proxyWrite, selectProxy } from './kernel.ts'
|
||||
import { isProviderFilePath } from '../shared/provider-files.ts'
|
||||
import {
|
||||
applyConfig,
|
||||
backupDiff,
|
||||
importConfig,
|
||||
listBackups,
|
||||
listRedo,
|
||||
readConfig,
|
||||
redoConfig,
|
||||
restoreBackup,
|
||||
undoConfig,
|
||||
} from './config-store.ts'
|
||||
import { geoStatus, syncGeoFile } from './geo.ts'
|
||||
import { hostInfo, probeExit, probeReach } from './host.ts'
|
||||
import { findTarget } from '../shared/reachability.ts'
|
||||
import {
|
||||
kernelVersion,
|
||||
proxyGet,
|
||||
proxyWrite,
|
||||
reloadConfig,
|
||||
restartKernel,
|
||||
selectProxy,
|
||||
updateGeoDatabases,
|
||||
} from './kernel.ts'
|
||||
import { tokenFromRequest, tokenMatches } from './auth.ts'
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
import type { Env } from './env.ts'
|
||||
@@ -47,6 +70,8 @@ const WRITABLE: Array<{ method: 'PUT' | 'DELETE'; pattern: RegExp }> = [
|
||||
{ method: 'DELETE', pattern: /^\/connections(\/[^/]+)?$/ },
|
||||
// 让内核重新拉取某个订阅
|
||||
{ method: 'PUT', pattern: /^\/providers\/proxies\/[^/]+$/ },
|
||||
// 让内核重新拉取某个规则组
|
||||
{ method: 'PUT', pattern: /^\/providers\/rules\/[^/]+$/ },
|
||||
]
|
||||
|
||||
/** 允许透传的管理接口路径。白名单而不是黑名单——多开一个口子要有意为之。 */
|
||||
@@ -59,6 +84,8 @@ const READABLE_KERNEL_PATHS = new Set([
|
||||
'/providers/rules',
|
||||
'/connections',
|
||||
'/memory',
|
||||
'/traffic',
|
||||
'/logs',
|
||||
])
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
@@ -161,12 +188,58 @@ function makeRoute(env: Env, token: string) {
|
||||
if (!body.data || typeof body.data !== 'object') {
|
||||
return json({ message: '请求缺少 data 字段' }, 400)
|
||||
}
|
||||
const result = await applyConfig(env, body.data, body.baseRevision)
|
||||
// merge:界面上的连续保存在时间窗内合并成一条历史。还原、导入不走这里
|
||||
const result = await applyConfig(env, body.data, body.baseRevision, { merge: true })
|
||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||
}
|
||||
|
||||
if (path === '/api/config/backups' && req.method === 'GET') {
|
||||
return json({ backups: await listBackups(env) })
|
||||
// redo 是重做栈深度,侧栏用它决定重做按钮亮不亮
|
||||
return json({ backups: await listBackups(env), redo: (await listRedo(env)).length })
|
||||
}
|
||||
|
||||
// 撤回/重做。走 config-store 的保护链路,备份链的进出由它们自己管
|
||||
if (path === '/api/config/undo' && req.method === 'POST') {
|
||||
const result = await undoConfig(env)
|
||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||
}
|
||||
|
||||
if (path === '/api/config/redo' && req.method === 'POST') {
|
||||
const result = await redoConfig(env)
|
||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||
}
|
||||
|
||||
// 配置文件原文,给「下载当前配置」用。带 attachment 让浏览器按下载处理
|
||||
if (path === '/api/config/raw' && req.method === 'GET') {
|
||||
const file = Bun.file(env.configPath)
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
'Content-Type': 'text/yaml; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="config.yaml"',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 上传的 YAML 替换当前配置(mode=override 整份,compatible 只搬接管段落)。
|
||||
// 走 config-store 完整的保护链路
|
||||
if (path === '/api/config/import' && req.method === 'POST') {
|
||||
const body = (await req.json()) as { content?: string; mode?: string }
|
||||
if (typeof body.content !== 'string' || body.content.trim() === '') {
|
||||
return json({ message: '请求缺少 content 字段' }, 400)
|
||||
}
|
||||
if (body.mode !== 'override' && body.mode !== 'compatible') {
|
||||
return json({ message: 'mode 必须是 override 或 compatible' }, 400)
|
||||
}
|
||||
const result = await importConfig(env, body.content, body.mode)
|
||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||
}
|
||||
|
||||
// 一条历史记录的对比原文:这份备份(改动前)和它之后的状态(改动后)
|
||||
const diffMatch = /^\/api\/config\/backups\/([^/]+)\/diff$/.exec(path)
|
||||
if (diffMatch && req.method === 'GET') {
|
||||
const pair = await backupDiff(env, decodeURIComponent(diffMatch[1]!))
|
||||
if (!pair) return json({ message: '没有这个备份', hint: '刷新一下历史记录再试。' }, 404)
|
||||
return json(pair)
|
||||
}
|
||||
|
||||
const restoreMatch = /^\/api\/config\/backups\/([^/]+)\/restore$/.exec(path)
|
||||
@@ -176,6 +249,55 @@ function makeRoute(env: Env, token: string) {
|
||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||
}
|
||||
|
||||
// ── 清单文件 ────────────────────────────────────────
|
||||
// 从界面上传的订阅/规则清单,写进内核工作目录的 providers/ 下。
|
||||
// 路径白名单校验 + 解析后确认没出工作目录,两道都过才落盘
|
||||
if (path === '/api/provider-files' && req.method === 'POST') {
|
||||
const body = (await req.json()) as { path?: string; content?: string }
|
||||
if (typeof body.path !== 'string' || typeof body.content !== 'string') {
|
||||
return json({ message: '请求缺少 path 或 content 字段' }, 400)
|
||||
}
|
||||
if (!isProviderFilePath(body.path)) {
|
||||
return json({ message: `不允许写入该路径:${body.path}` }, 400)
|
||||
}
|
||||
const home = resolve(env.mihomoHome)
|
||||
const target = resolve(home, body.path)
|
||||
if (!target.startsWith(home + sep)) {
|
||||
return json({ message: `不允许写入该路径:${body.path}` }, 400)
|
||||
}
|
||||
await Bun.write(target, body.content)
|
||||
return json({ ok: true })
|
||||
}
|
||||
|
||||
// ── 主机 ────────────────────────────────────────────
|
||||
// 服务端和内核在同一台机器上,所以主机指标由 Patchbay 自己代答
|
||||
if (path === '/api/host' && req.method === 'GET') {
|
||||
return json(await hostInfo())
|
||||
}
|
||||
|
||||
// 经由某个入站端口探测公网 IP。GET 但会发起真实的外网请求,和延迟测试同一类
|
||||
if (path === '/api/host/exit' && req.method === 'GET') {
|
||||
const port = Number(url.searchParams.get('port'))
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return json({ message: 'port 参数必须是 1-65535 的端口号' }, 400)
|
||||
}
|
||||
return json(await probeExit(port))
|
||||
}
|
||||
|
||||
// 经由某个入站端口探测一个目标能不能访问。一次一个目标,前端并发发起,
|
||||
// 这样每一行各自落定,不必等最慢的那个才整表出现
|
||||
if (path === '/api/host/reach' && req.method === 'GET') {
|
||||
const port = Number(url.searchParams.get('port'))
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return json({ message: 'port 参数必须是 1-65535 的端口号' }, 400)
|
||||
}
|
||||
const target = findTarget(url.searchParams.get('target') ?? '')
|
||||
if (!target) {
|
||||
return json({ message: `没有名为 ${url.searchParams.get('target') ?? ''} 的探测目标` }, 400)
|
||||
}
|
||||
return json(await probeReach(port, target))
|
||||
}
|
||||
|
||||
// ── 内核 ────────────────────────────────────────────
|
||||
if (path === '/api/kernel' && req.method === 'GET') {
|
||||
return json(await kernelVersion(env))
|
||||
@@ -194,6 +316,53 @@ function makeRoute(env: Env, token: string) {
|
||||
: json({ message: upstream.message || '内核拒绝了这次切换' }, 422)
|
||||
}
|
||||
|
||||
// 让内核重新加载磁盘上的配置文件。给 SSH 手改过配置的场景用;运行时动作
|
||||
if (path === '/api/kernel/reload' && req.method === 'POST') {
|
||||
const upstream = await reloadConfig(env, env.configPath)
|
||||
return upstream.ok
|
||||
? json({ ok: true })
|
||||
: json({ message: upstream.message || '内核重载失败' }, 502)
|
||||
}
|
||||
|
||||
// GEO 数据库文件的本地状态(大小、更新时间)和生效中的下载地址
|
||||
if (path === '/api/geo' && req.method === 'GET') {
|
||||
const snapshot = await readConfig(env)
|
||||
const geox = (snapshot.data['geox-url'] ?? {}) as Record<string, unknown>
|
||||
return json({ files: await geoStatus(env, geox) })
|
||||
}
|
||||
|
||||
// 单个 GEO 文件的下载。服务端自己下(内核只有全量更新的口子),重启后生效
|
||||
if (path === '/api/geo/sync' && req.method === 'POST') {
|
||||
const body = (await req.json()) as { key?: string }
|
||||
if (typeof body.key !== 'string') return json({ message: '请求缺少 key 字段' }, 400)
|
||||
const snapshot = await readConfig(env)
|
||||
const geox = (snapshot.data['geox-url'] ?? {}) as Record<string, unknown>
|
||||
// 内核在线且开着老式 http 入口时借它的通路下载,直连 GitHub 不一定通
|
||||
const port = [snapshot.data['mixed-port'], snapshot.data.port].find(
|
||||
(p): p is number => typeof p === 'number' && p > 0,
|
||||
)
|
||||
const kernelUp = port !== undefined && (await kernelVersion(env)).online
|
||||
const proxy = kernelUp ? `http://127.0.0.1:${String(port)}` : null
|
||||
const result = await syncGeoFile(env, body.key, geox, proxy)
|
||||
return result.ok ? json({ ok: true }) : json({ message: result.message }, 502)
|
||||
}
|
||||
|
||||
// 更新 GEO 数据库。运行时动作;同步接口,透传层给了长超时
|
||||
if (path === '/api/kernel/geo' && req.method === 'POST') {
|
||||
const upstream = await updateGeoDatabases(env)
|
||||
return upstream.ok
|
||||
? json({ ok: true })
|
||||
: json({ message: upstream.message || '内核更新 GEO 数据库失败' }, 502)
|
||||
}
|
||||
|
||||
// 重启内核进程。运行时动作,不碰配置文件;和其它写操作一样单独点名,不开通用口子
|
||||
if (path === '/api/kernel/restart' && req.method === 'POST') {
|
||||
const upstream = await restartKernel(env)
|
||||
return upstream.ok
|
||||
? json({ ok: true })
|
||||
: json({ message: upstream.message || '内核拒绝了重启请求' }, 502)
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/kernel/') && (req.method === 'PUT' || req.method === 'DELETE')) {
|
||||
const target = path.slice('/api/kernel'.length)
|
||||
const rule = WRITABLE.find((w) => w.method === req.method && w.pattern.test(target))
|
||||
@@ -212,7 +381,15 @@ function makeRoute(env: Env, token: string) {
|
||||
if (!allowed) {
|
||||
return json({ message: `不允许访问该路径:${target}` }, 403)
|
||||
}
|
||||
const upstream = await proxyGet(env, target + url.search, STREAM_PATHS.has(target))
|
||||
// 健康检查是同步的:内核测完才回话,一批 10 条、每条最多 5 秒
|
||||
// (内核源码 adapter/provider/healthcheck.go:132、181、201),
|
||||
// 整条订阅测且线路多又都测不通时会远超默认的 15 秒
|
||||
const upstream = await proxyGet(
|
||||
env,
|
||||
target + url.search,
|
||||
STREAM_PATHS.has(target),
|
||||
HEALTHCHECK_PATH.test(target) ? 120_000 : undefined,
|
||||
)
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' },
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { backupTime } from './backups.ts'
|
||||
|
||||
test('标准备份名排成可读时间', () => {
|
||||
expect(backupTime('config.yaml.bak-20260811-031000')).toBe('2026-08-11 03:10:00')
|
||||
})
|
||||
|
||||
test('配置文件名不同也能解析,只看结尾的时间戳', () => {
|
||||
expect(backupTime('mihomo.yml.bak-20260101-235959')).toBe('2026-01-01 23:59:59')
|
||||
})
|
||||
|
||||
test('不合预期的名字返回 null', () => {
|
||||
expect(backupTime('config.yaml.bak')).toBeNull()
|
||||
expect(backupTime('config.yaml.bak-2026081-031000')).toBeNull()
|
||||
expect(backupTime('notes.txt')).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 备份文件名里的时间戳。
|
||||
*
|
||||
* 名字由服务端生成(server/config-store.ts 的 backupName),形如
|
||||
* `config.yaml.bak-20260811-031000`,时间戳是服务端的本地时间。
|
||||
*/
|
||||
|
||||
const STAMP = /\.bak-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/
|
||||
|
||||
/** 把备份文件名排成可读时间。名字不合预期时返回 null,调用方退回显示原名。 */
|
||||
export function backupTime(name: string): string | null {
|
||||
const m = STAMP.exec(name)
|
||||
if (!m) return null
|
||||
return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}`
|
||||
}
|
||||
|
||||
/** 备份文件名里的时间戳解析成 Date(服务端本地时间)。名字不合预期时返回 null。 */
|
||||
export function backupDate(name: string): Date | null {
|
||||
const m = STAMP.exec(name)
|
||||
if (!m) return null
|
||||
return new Date(
|
||||
Number(m[1]),
|
||||
Number(m[2]) - 1,
|
||||
Number(m[3]),
|
||||
Number(m[4]),
|
||||
Number(m[5]),
|
||||
Number(m[6]),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { MARKER_KEY, hasValidMarker, withMarker } from './config-marker.ts'
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
|
||||
describe('配置标记', () => {
|
||||
test('打上标记后校验通过,其余键不动', () => {
|
||||
const data: ConfigObject = { 'mixed-port': 7890 }
|
||||
const marked = withMarker(data)
|
||||
expect(hasValidMarker(marked)).toBe(true)
|
||||
expect(marked['mixed-port']).toBe(7890)
|
||||
})
|
||||
|
||||
test('没有标记、标记形状不对、sig 对不上都算无效', () => {
|
||||
expect(hasValidMarker({})).toBe(false)
|
||||
expect(hasValidMarker({ [MARKER_KEY]: 'yes' })).toBe(false)
|
||||
expect(hasValidMarker({ [MARKER_KEY]: { v: 1, sig: 'deadbeef' } })).toBe(false)
|
||||
})
|
||||
|
||||
test('打两次标记结果一样(落盘链路的内容不变检查靠它短路)', () => {
|
||||
const once = withMarker({})
|
||||
expect(withMarker(once)).toEqual(once)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 配置文件里的 Patchbay 标记。
|
||||
*
|
||||
* 每次落盘都在顶层写一个 `patchbay:` 键(内核对未知顶层键不报错:解析用的
|
||||
* `yaml.Unmarshal` 没开严格模式,config/config.go:612)。上传配置文件时靠它
|
||||
* 区分「Patchbay 管理过的配置」和随便一份 mihomo 配置——前者直接还原,
|
||||
* 后者提示走导入。
|
||||
*
|
||||
* sig 是盐加版本号的哈希,回答的是「这个标记是不是 Patchbay 写的」,不是防伪
|
||||
* ——在用户自己的机器上没有对手。刻意不对配置内容做哈希:这个项目只接管部分
|
||||
* 段落,手改其余段落是正常用法(决策记录 0003),内容哈希会把合法的手改
|
||||
* 判成无效。
|
||||
*/
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
|
||||
export const MARKER_KEY = 'patchbay'
|
||||
|
||||
const VERSION = 1
|
||||
const SALT = 'patchbay-config-salt'
|
||||
|
||||
/** FNV-1a。只做标记指纹,不是安全用途。 */
|
||||
function fnv(text: string): string {
|
||||
let h = 0x811c9dc5
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h ^= text.charCodeAt(i)
|
||||
h = Math.imul(h, 0x01000193)
|
||||
}
|
||||
return (h >>> 0).toString(16).padStart(8, '0')
|
||||
}
|
||||
|
||||
const sigOf = (version: number): string => fnv(`${SALT}:${String(version)}`)
|
||||
|
||||
/** 给一份配置打上标记。已有的旧标记会被换成当前版本的。 */
|
||||
export function withMarker(data: ConfigObject): ConfigObject {
|
||||
return { ...data, [MARKER_KEY]: { v: VERSION, sig: sigOf(VERSION) } }
|
||||
}
|
||||
|
||||
/** 这份配置带不带有效的 Patchbay 标记。 */
|
||||
export function hasValidMarker(data: ConfigObject): boolean {
|
||||
const marker = data[MARKER_KEY]
|
||||
if (typeof marker !== 'object' || marker === null || Array.isArray(marker)) return false
|
||||
const { v, sig } = marker as Record<string, unknown>
|
||||
return typeof v === 'number' && sig === sigOf(v)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
chainLabel,
|
||||
filterConnections,
|
||||
formatDuration,
|
||||
groupByInbound,
|
||||
hostLabel,
|
||||
ruleLabel,
|
||||
sortConnections,
|
||||
} from './connections.ts'
|
||||
import type { ConnectionItem, ConnectionMeta } from './connections.ts'
|
||||
|
||||
const NOW = Date.parse('2026-08-11T18:40:00+08:00')
|
||||
|
||||
function meta(over: Partial<ConnectionMeta> = {}): ConnectionMeta {
|
||||
return {
|
||||
host: 'example.com',
|
||||
destinationIP: '93.184.216.34',
|
||||
destinationPort: '443',
|
||||
inboundName: 'lxc-web',
|
||||
inboundPort: '17891',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function conn(over: Partial<ConnectionItem> = {}): ConnectionItem {
|
||||
return {
|
||||
id: 'a',
|
||||
upload: 0,
|
||||
download: 0,
|
||||
start: '2026-08-11T18:39:00+08:00',
|
||||
chains: ['[vless]某线路', 'AUTO', 'PROXY'],
|
||||
rule: 'DomainSuffix',
|
||||
rulePayload: 'example.com',
|
||||
metadata: meta(),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('主机标签', () => {
|
||||
it('有域名用域名,带端口', () => {
|
||||
expect(hostLabel(meta())).toBe('example.com:443')
|
||||
})
|
||||
|
||||
it('没域名退回 IP', () => {
|
||||
expect(hostLabel(meta({ host: '' }))).toBe('93.184.216.34:443')
|
||||
})
|
||||
|
||||
it('没端口就只给主机', () => {
|
||||
expect(hostLabel(meta({ destinationPort: '' }))).toBe('example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('出站链', () => {
|
||||
it('倒过来显示:最先接触的在左,最终出口在右', () => {
|
||||
expect(chainLabel(['[vless]某线路', 'AUTO', 'PROXY'])).toBe('PROXY → AUTO → [vless]某线路')
|
||||
})
|
||||
|
||||
it('单跳就是直连', () => {
|
||||
expect(chainLabel(['DIRECT'])).toBe('DIRECT')
|
||||
})
|
||||
})
|
||||
|
||||
describe('规则标签', () => {
|
||||
it('类型和载荷用空格连起来', () => {
|
||||
expect(ruleLabel('DomainSuffix', 'example.com')).toBe('DomainSuffix example.com')
|
||||
})
|
||||
|
||||
it('兜底规则没有载荷', () => {
|
||||
expect(ruleLabel('Match', '')).toBe('Match')
|
||||
})
|
||||
})
|
||||
|
||||
describe('持续时间', () => {
|
||||
it('不满一分钟按秒', () => {
|
||||
expect(formatDuration('2026-08-11T18:39:18+08:00', NOW)).toBe('42 秒')
|
||||
})
|
||||
|
||||
it('满一分钟带分', () => {
|
||||
expect(formatDuration('2026-08-11T18:36:36+08:00', NOW)).toBe('3 分 24 秒')
|
||||
})
|
||||
|
||||
it('满一小时不再给秒', () => {
|
||||
expect(formatDuration('2026-08-11T16:35:00+08:00', NOW)).toBe('2 小时 5 分')
|
||||
})
|
||||
|
||||
it('start 带时区偏移,跨时区解析不能算错', () => {
|
||||
// 同一时刻写成 UTC 和 +08:00,算出来的持续时间必须一样
|
||||
const local = formatDuration('2026-08-11T18:39:00+08:00', NOW)
|
||||
const utc = formatDuration('2026-08-11T10:39:00Z', NOW)
|
||||
expect(local).toBe(utc)
|
||||
})
|
||||
|
||||
it('坏数据返回空串,未来的时间按 0 算', () => {
|
||||
expect(formatDuration('not-a-date', NOW)).toBe('')
|
||||
expect(formatDuration('2026-08-11T19:00:00+08:00', NOW)).toBe('0 秒')
|
||||
})
|
||||
})
|
||||
|
||||
describe('排序', () => {
|
||||
const list = [
|
||||
conn({ id: 'small-new', download: 100, start: '2026-08-11T18:39:50+08:00' }),
|
||||
conn({ id: 'big-new', download: 9000, start: '2026-08-11T18:39:40+08:00' }),
|
||||
conn({ id: 'small-old', download: 50, start: '2026-08-11T18:00:00+08:00' }),
|
||||
]
|
||||
|
||||
it('按下载量降序', () => {
|
||||
expect(sortConnections(list, 'download', NOW).map((c) => c.id)).toEqual([
|
||||
'big-new',
|
||||
'small-new',
|
||||
'small-old',
|
||||
])
|
||||
})
|
||||
|
||||
it('按持续时间降序:活得久的排前面', () => {
|
||||
expect(sortConnections(list, 'duration', NOW).map((c) => c.id)).toEqual([
|
||||
'small-old',
|
||||
'big-new',
|
||||
'small-new',
|
||||
])
|
||||
})
|
||||
|
||||
it('不动原数组', () => {
|
||||
const before = list.map((c) => c.id)
|
||||
sortConnections(list, 'download', NOW)
|
||||
expect(list.map((c) => c.id)).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('筛选', () => {
|
||||
const list = [
|
||||
conn({ id: 'a', metadata: meta({ host: 'speed.cloudflare.com' }) }),
|
||||
conn({ id: 'b', metadata: meta({ host: '', destinationIP: '1.2.3.4' }) }),
|
||||
]
|
||||
|
||||
it('匹配主机名,大小写不敏感', () => {
|
||||
expect(filterConnections(list, 'CLOUDFLARE').map((c) => c.id)).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('没域名时也能按 IP 搜', () => {
|
||||
expect(filterConnections(list, '1.2').map((c) => c.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('空串不筛,空白也不算条件', () => {
|
||||
expect(filterConnections(list, ' ')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('按入站聚合', () => {
|
||||
it('同一路入站计数、流量都累加,连接多的排前面', () => {
|
||||
const list = [
|
||||
conn({ id: 'a', download: 10, upload: 1 }),
|
||||
conn({ id: 'b', download: 20, upload: 2 }),
|
||||
conn({ id: 'c', metadata: meta({ inboundName: 'lxc-db', inboundPort: '17892' }) }),
|
||||
]
|
||||
expect(groupByInbound(list)).toEqual([
|
||||
{ label: 'lxc-web(17891)', count: 2, upload: 3, download: 30 },
|
||||
{ label: 'lxc-db(17892)', count: 1, upload: 0, download: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
it('legacy 入站用内核给的内部名,附上端口', () => {
|
||||
const list = [conn({ metadata: meta({ inboundName: 'DEFAULT-MIXED', inboundPort: '17890' }) })]
|
||||
expect(groupByInbound(list)[0]?.label).toBe('DEFAULT-MIXED(17890)')
|
||||
})
|
||||
|
||||
it('入站名字缺失时用端口凑', () => {
|
||||
const list = [conn({ metadata: meta({ inboundName: '' }) })]
|
||||
expect(groupByInbound(list)[0]?.label).toBe('17891')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 连接页的领域逻辑:内核当前连接的显示、筛选、排序和按入站聚合。
|
||||
*
|
||||
* 类型按 v1.19.29 管理接口 GET /connections 的实际返回写(开发内核实测),
|
||||
* 只保留界面用到的字段。`connections` 在没有连接时是 null 而不是空数组。
|
||||
*/
|
||||
|
||||
/** 一条连接里界面关心的元数据。内核返回的字段远不止这些,用不到的不收。 */
|
||||
export interface ConnectionMeta {
|
||||
host: string
|
||||
destinationIP: string
|
||||
destinationPort: string
|
||||
/** 从哪一路入站进来:命名入站是它的名字,legacy 入站是内核造的 DEFAULT-* */
|
||||
inboundName: string
|
||||
inboundPort: string
|
||||
}
|
||||
|
||||
export interface ConnectionItem {
|
||||
id: string
|
||||
upload: number
|
||||
download: number
|
||||
/** ISO 时间,带时区偏移(内核按本机时区输出) */
|
||||
start: string
|
||||
/** 经过的出站链。第一个是最终出口,最后一个是最先接触的——和直觉相反 */
|
||||
chains: string[]
|
||||
/** 命中的分流规则类型,如 DomainSuffix;兜底是 Match */
|
||||
rule: string
|
||||
rulePayload: string
|
||||
metadata: ConnectionMeta
|
||||
}
|
||||
|
||||
export interface ConnectionSnapshot {
|
||||
downloadTotal: number
|
||||
uploadTotal: number
|
||||
connections: ConnectionItem[] | null
|
||||
}
|
||||
|
||||
/** 这条连接连往哪。有域名显示域名,没有退回 IP,都带端口。 */
|
||||
export function hostLabel(meta: ConnectionMeta): string {
|
||||
const target = meta.host || meta.destinationIP
|
||||
return meta.destinationPort ? `${target}:${meta.destinationPort}` : target
|
||||
}
|
||||
|
||||
/**
|
||||
* 出站链按流量经过的顺序显示:最先接触的在左,最终出口在右。
|
||||
* 内核给的数组恰好相反,所以要倒过来。
|
||||
*/
|
||||
export function chainLabel(chains: string[]): string {
|
||||
return [...chains].reverse().join(' → ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 规则显示成「类型 载荷」,兜底规则没有载荷。
|
||||
* 类型和载荷都是配置文件 rules: 里的原词,不翻译。
|
||||
*/
|
||||
export function ruleLabel(rule: string, payload: string): string {
|
||||
return payload ? `${rule} ${payload}` : rule
|
||||
}
|
||||
|
||||
/** 持续时间。start 解析不出来(坏数据)时返回空串,由界面决定摆什么。 */
|
||||
export function formatDuration(startIso: string, now: number): string {
|
||||
const started = Date.parse(startIso)
|
||||
if (Number.isNaN(started)) return ''
|
||||
const seconds = Math.max(0, Math.floor((now - started) / 1000))
|
||||
if (seconds < 60) return `${String(seconds)} 秒`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${String(minutes)} 分 ${String(seconds % 60)} 秒`
|
||||
return `${String(Math.floor(minutes / 60))} 小时 ${String(minutes % 60)} 分`
|
||||
}
|
||||
|
||||
export type SortKey = 'download' | 'duration'
|
||||
|
||||
/**
|
||||
* 排序。都是降序:下载多的、活得久的排前面。
|
||||
* 持续时间没有现成字段,从 start 现算;排坏的 start 沉底。
|
||||
*/
|
||||
export function sortConnections(
|
||||
list: ConnectionItem[],
|
||||
key: SortKey,
|
||||
now: number,
|
||||
): ConnectionItem[] {
|
||||
const age = (c: ConnectionItem) => {
|
||||
const started = Date.parse(c.start)
|
||||
return Number.isNaN(started) ? -1 : now - started
|
||||
}
|
||||
// 平局按 id 决胜。输入顺序来自内核快照的 map 遍历,本身无序且每秒在变;
|
||||
// 空闲连接的下载量普遍相同,没有决胜键的话行会在指针(和断开按钮)下面跳
|
||||
return [...list].sort((a, b) => {
|
||||
const main = key === 'download' ? b.download - a.download : age(b) - age(a)
|
||||
return main !== 0 ? main : a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
|
||||
/** 按主机名筛选。大小写不敏感,空串不筛。 */
|
||||
export function filterConnections(list: ConnectionItem[], query: string): ConnectionItem[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return list
|
||||
return list.filter((c) => hostLabel(c.metadata).toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
/** 一路入站当前有多少条连接。 */
|
||||
export interface InboundUse {
|
||||
/** 命名入站显示名字;legacy 入站内核只给 DEFAULT-* 这类内部名,附上端口好认 */
|
||||
label: string
|
||||
count: number
|
||||
upload: number
|
||||
download: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 按入站聚合。这是连接页对本项目的独特价值:每一路入站(每个容器)现在有多少条
|
||||
* 连接、走了多少流量。
|
||||
*/
|
||||
export function groupByInbound(list: ConnectionItem[]): InboundUse[] {
|
||||
const byName = new Map<string, InboundUse>()
|
||||
for (const c of list) {
|
||||
const name = c.metadata.inboundName || c.metadata.inboundPort
|
||||
let entry = byName.get(name)
|
||||
if (!entry) {
|
||||
entry = {
|
||||
label: c.metadata.inboundName
|
||||
? `${c.metadata.inboundName}(${c.metadata.inboundPort})`
|
||||
: c.metadata.inboundPort,
|
||||
count: 0,
|
||||
upload: 0,
|
||||
download: 0,
|
||||
}
|
||||
byName.set(name, entry)
|
||||
}
|
||||
entry.count += 1
|
||||
entry.upload += c.upload
|
||||
entry.download += c.download
|
||||
}
|
||||
// 连接多的排前面,和表格的默认排序一个取向
|
||||
return [...byName.values()].sort((a, b) => b.count - a.count)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { diffHunks, diffLines } from './diff.ts'
|
||||
|
||||
const lines = (rows: ReturnType<typeof diffLines>) => rows.map((r) => `${r.kind}:${r.text}`)
|
||||
|
||||
describe('行级 diff', () => {
|
||||
it('相同的内容全是 same,折叠后没有片段', () => {
|
||||
const rows = diffLines('a\nb\nc', 'a\nb\nc')
|
||||
expect(rows.every((r) => r.kind === 'same')).toBe(true)
|
||||
expect(diffHunks(rows)).toEqual([])
|
||||
})
|
||||
|
||||
it('中间插入一行', () => {
|
||||
expect(lines(diffLines('a\nc', 'a\nb\nc'))).toEqual(['same:a', 'add:b', 'same:c'])
|
||||
})
|
||||
|
||||
it('中间删除一行', () => {
|
||||
expect(lines(diffLines('a\nb\nc', 'a\nc'))).toEqual(['same:a', 'del:b', 'same:c'])
|
||||
})
|
||||
|
||||
it('改一行等于删旧行加新行', () => {
|
||||
expect(lines(diffLines('a\nx\nc', 'a\ny\nc'))).toEqual(['same:a', 'del:x', 'add:y', 'same:c'])
|
||||
})
|
||||
|
||||
it('从空到有、从有到空', () => {
|
||||
expect(lines(diffLines('', 'a'))).toEqual(['del:', 'add:a'])
|
||||
expect(lines(diffLines('a\nb', ''))).toEqual(['del:a', 'del:b', 'add:'])
|
||||
})
|
||||
|
||||
it('公共前后缀之外的多处改动都找得到', () => {
|
||||
const before = ['p1', 'p2', 'x', 'm', 'y', 's1'].join('\n')
|
||||
const after = ['p1', 'p2', 'x2', 'm', 'y2', 's1'].join('\n')
|
||||
expect(lines(diffLines(before, after))).toEqual([
|
||||
'same:p1',
|
||||
'same:p2',
|
||||
'del:x',
|
||||
'add:x2',
|
||||
'same:m',
|
||||
'del:y',
|
||||
'add:y2',
|
||||
'same:s1',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('折叠成片段', () => {
|
||||
it('改动行带前后上下文,远处的未改动行不进片段', () => {
|
||||
const rows = diffLines(
|
||||
['1', '2', '3', '4', '5', '6', '7', '8', '9'].join('\n'),
|
||||
['1', '2', '3', 'x', '5', '6', '7', '8', '9'].join('\n'),
|
||||
)
|
||||
const hunks = diffHunks(rows, 1)
|
||||
expect(hunks).toHaveLength(1)
|
||||
expect(hunks[0]!.map((r) => r.text)).toEqual(['3', '4', 'x', '5'])
|
||||
})
|
||||
|
||||
it('相距很远的两处改动分成两个片段,挨得近就合并成一个', () => {
|
||||
const before = ['a', '1', '2', '3', '4', '5', '6', '7', 'b'].join('\n')
|
||||
const afterFar = ['a2', '1', '2', '3', '4', '5', '6', '7', 'b2'].join('\n')
|
||||
expect(diffHunks(diffLines(before, afterFar), 1)).toHaveLength(2)
|
||||
const afterNear = ['a2', '1', '2', '3', '4', '5', '6', '7b', 'b'].join('\n')
|
||||
expect(diffHunks(diffLines(before, afterNear), 4)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 行级 diff,给历史记录的「这次改动改了什么」用。
|
||||
*
|
||||
* 先掐掉公共前后缀再对中段做 LCS:配置文件的一次改动通常只碰几行,中段一般
|
||||
* 很小,LCS 的 O(n×m) 表因此撑得住。真遇到两份大面积不同的文件(中段乘积超过
|
||||
* 上限),退化成「整段删除 + 整段新增」——依旧正确,只是不够精细。
|
||||
*/
|
||||
|
||||
export interface DiffLine {
|
||||
kind: 'same' | 'add' | 'del'
|
||||
text: string
|
||||
}
|
||||
|
||||
const LCS_LIMIT = 1_000_000
|
||||
|
||||
export function diffLines(before: string, after: string): DiffLine[] {
|
||||
const a = before.split('\n')
|
||||
const b = after.split('\n')
|
||||
|
||||
let start = 0
|
||||
while (start < a.length && start < b.length && a[start] === b[start]) start += 1
|
||||
let endA = a.length
|
||||
let endB = b.length
|
||||
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
||||
endA -= 1
|
||||
endB -= 1
|
||||
}
|
||||
|
||||
return [
|
||||
...a.slice(0, start).map((text): DiffLine => ({ kind: 'same', text })),
|
||||
...diffMiddle(a.slice(start, endA), b.slice(start, endB)),
|
||||
...a.slice(endA).map((text): DiffLine => ({ kind: 'same', text })),
|
||||
]
|
||||
}
|
||||
|
||||
function diffMiddle(a: string[], b: string[]): DiffLine[] {
|
||||
const allDel = a.map((text): DiffLine => ({ kind: 'del', text }))
|
||||
const allAdd = b.map((text): DiffLine => ({ kind: 'add', text }))
|
||||
if (a.length === 0 || b.length === 0 || a.length * b.length > LCS_LIMIT) {
|
||||
return [...allDel, ...allAdd]
|
||||
}
|
||||
|
||||
// LCS 长度表倒着填,回溯时从头正着走
|
||||
const w = b.length + 1
|
||||
const table = new Uint32Array((a.length + 1) * w)
|
||||
for (let i = a.length - 1; i >= 0; i -= 1) {
|
||||
for (let j = b.length - 1; j >= 0; j -= 1) {
|
||||
table[i * w + j] =
|
||||
a[i] === b[j]
|
||||
? table[(i + 1) * w + j + 1]! + 1
|
||||
: Math.max(table[(i + 1) * w + j]!, table[i * w + j + 1]!)
|
||||
}
|
||||
}
|
||||
|
||||
const rows: DiffLine[] = []
|
||||
let i = 0
|
||||
let j = 0
|
||||
while (i < a.length && j < b.length) {
|
||||
if (a[i] === b[j]) {
|
||||
rows.push({ kind: 'same', text: a[i]! })
|
||||
i += 1
|
||||
j += 1
|
||||
} else if (table[(i + 1) * w + j]! >= table[i * w + j + 1]!) {
|
||||
rows.push({ kind: 'del', text: a[i]! })
|
||||
i += 1
|
||||
} else {
|
||||
rows.push({ kind: 'add', text: b[j]! })
|
||||
j += 1
|
||||
}
|
||||
}
|
||||
while (i < a.length) rows.push({ kind: 'del', text: a[i++]! })
|
||||
while (j < b.length) rows.push({ kind: 'add', text: b[j++]! })
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* 折叠成只含改动的片段:每个片段是改动行加前后 context 行上下文,片段之间的
|
||||
* 未改动区域丢弃(渲染端画省略号)。两份内容相同时返回空数组。
|
||||
*/
|
||||
export function diffHunks(rows: DiffLine[], context = 2): DiffLine[][] {
|
||||
const keep = new Array<boolean>(rows.length).fill(false)
|
||||
rows.forEach((row, index) => {
|
||||
if (row.kind === 'same') return
|
||||
const from = Math.max(0, index - context)
|
||||
const to = Math.min(rows.length - 1, index + context)
|
||||
for (let k = from; k <= to; k += 1) keep[k] = true
|
||||
})
|
||||
|
||||
const hunks: DiffLine[][] = []
|
||||
let current: DiffLine[] | null = null
|
||||
rows.forEach((row, index) => {
|
||||
if (!keep[index]) {
|
||||
current = null
|
||||
return
|
||||
}
|
||||
if (!current) {
|
||||
current = []
|
||||
hunks.push(current)
|
||||
}
|
||||
current.push(row)
|
||||
})
|
||||
return hunks
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { flagEmoji, formatAgo, formatBytes } from './format.ts'
|
||||
|
||||
describe('流量数字', () => {
|
||||
it('零和不满 1KB 的按字节显示,不带小数', () => {
|
||||
expect(formatBytes(0)).toBe('0 B')
|
||||
expect(formatBytes(512)).toBe('512 B')
|
||||
expect(formatBytes(1023)).toBe('1023 B')
|
||||
})
|
||||
|
||||
it('1024 的边界正好换档', () => {
|
||||
expect(formatBytes(1024)).toBe('1 KB')
|
||||
expect(formatBytes(1024 * 1024)).toBe('1 MB')
|
||||
})
|
||||
|
||||
it('不满 100 保留一位小数,够 100 取整', () => {
|
||||
expect(formatBytes(23.4 * 1024 * 1024)).toBe('23.4 MB')
|
||||
expect(formatBytes(234.7 * 1024 * 1024)).toBe('235 MB')
|
||||
})
|
||||
|
||||
it('超大数停在 TB 档', () => {
|
||||
expect(formatBytes(5 * 1024 ** 4)).toBe('5 TB')
|
||||
expect(formatBytes(5000 * 1024 ** 4)).toBe('5000 TB')
|
||||
})
|
||||
|
||||
it('脏输入不炸', () => {
|
||||
expect(formatBytes(-1)).toBe('0 B')
|
||||
expect(formatBytes(Number.NaN)).toBe('0 B')
|
||||
})
|
||||
})
|
||||
|
||||
describe('旗帜 emoji', () => {
|
||||
it('两位国家代码转成区域指示符对,大小写都认', () => {
|
||||
expect(flagEmoji('JP')).toBe('🇯🇵')
|
||||
expect(flagEmoji('us')).toBe('🇺🇸')
|
||||
})
|
||||
|
||||
it('不像国家代码的输入原样返回', () => {
|
||||
expect(flagEmoji('')).toBe('')
|
||||
expect(flagEmoji('USA')).toBe('USA')
|
||||
expect(flagEmoji('1X')).toBe('1X')
|
||||
})
|
||||
})
|
||||
|
||||
describe('相对时间', () => {
|
||||
const now = new Date('2026-08-12T12:00:00Z')
|
||||
const ago = (ms: number) => new Date(now.getTime() - ms)
|
||||
|
||||
it('一分钟内是刚刚,之后按分、时、天换档', () => {
|
||||
expect(formatAgo(ago(30_000), now)).toBe('刚刚')
|
||||
expect(formatAgo(ago(21 * 60_000), now)).toBe('21 分钟前')
|
||||
expect(formatAgo(ago(3 * 3600_000), now)).toBe('3 小时前')
|
||||
expect(formatAgo(ago(5 * 86400_000), now)).toBe('5 天前')
|
||||
})
|
||||
|
||||
it('超过 30 天给日期', () => {
|
||||
expect(formatAgo(new Date('2026-05-01T00:00:00'), now)).toBe('2026-05-01')
|
||||
})
|
||||
|
||||
it('未来的时间当成刚刚,不出负数', () => {
|
||||
expect(formatAgo(ago(-60_000), now)).toBe('刚刚')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 流量数字显示成人读的样子。连接页和仪表盘共用。
|
||||
*
|
||||
* 1024 进制。B 一档不带小数;其余不满 100 的保留一位小数,够 100 的取整——
|
||||
* 「23.4 MB」有信息量,「234.7 MB」的小数就只是噪音了。
|
||||
*/
|
||||
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < UNITS.length - 1) {
|
||||
value /= 1024
|
||||
unit += 1
|
||||
}
|
||||
const digits = unit === 0 || value >= 100 ? 0 : 1
|
||||
// Number() 去掉无意义的尾零:1.0 KB → 1 KB
|
||||
return `${String(Number(value.toFixed(digits)))} ${UNITS[unit]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个过去时刻距今多久,「21 分钟前」的形态。now 由调用方传,纯函数才测得了。
|
||||
* 超过 30 天相对时间就没有信息量了,直接给日期。
|
||||
*/
|
||||
export function formatAgo(at: Date, now: Date): string {
|
||||
const seconds = Math.max(0, (now.getTime() - at.getTime()) / 1000)
|
||||
if (seconds < 60) return '刚刚'
|
||||
if (seconds < 3600) return `${String(Math.floor(seconds / 60))} 分钟前`
|
||||
if (seconds < 86400) return `${String(Math.floor(seconds / 3600))} 小时前`
|
||||
if (seconds < 30 * 86400) return `${String(Math.floor(seconds / 86400))} 天前`
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
return `${String(at.getFullYear())}-${p(at.getMonth() + 1)}-${p(at.getDate())}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 两位国家代码转成旗帜 emoji(区域指示符对)。不像国家代码的输入原样返回,
|
||||
* 显示端至少还有字母可看。注意 Windows 的系统字体没有旗帜字形,浏览器会
|
||||
* 把区域指示符退回显示成两个字母。
|
||||
*/
|
||||
export function flagEmoji(countryCode: string): string {
|
||||
const code = countryCode.trim().toUpperCase()
|
||||
if (!/^[A-Z]{2}$/.test(code)) return countryCode
|
||||
return String.fromCodePoint(...[...code].map((ch) => 0x1f1e6 + (ch.codePointAt(0) ?? 65) - 65))
|
||||
}
|
||||
|
||||
/** 运行时长。一小时内说分钟,一天内说时分,再长说天和小时;秒级抖动没有信息量。 */
|
||||
export function formatUptime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '—'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${String(minutes)} 分钟`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${String(hours)} 小时 ${String(minutes % 60)} 分`
|
||||
return `${String(Math.floor(hours / 24))} 天 ${String(hours % 24)} 小时`
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { looksLikeGeodata, looksLikeMmdb } from './geo.ts'
|
||||
|
||||
describe('GEO 文件的形状检查', () => {
|
||||
test('末尾带 MaxMind 标记的通过,纯垃圾不通过', () => {
|
||||
const marker = new Uint8Array([
|
||||
0xab,
|
||||
0xcd,
|
||||
0xef,
|
||||
...Array.from('MaxMind.com', (c) => c.charCodeAt(0)),
|
||||
])
|
||||
const good = new Uint8Array(4096)
|
||||
good.set(marker, 4000)
|
||||
expect(looksLikeMmdb(good)).toBe(true)
|
||||
expect(looksLikeMmdb(new Uint8Array(4096))).toBe(false)
|
||||
})
|
||||
|
||||
test('标记只认末尾 128KB,太靠前算无效', () => {
|
||||
const marker = new Uint8Array([
|
||||
0xab,
|
||||
0xcd,
|
||||
0xef,
|
||||
...Array.from('MaxMind.com', (c) => c.charCodeAt(0)),
|
||||
])
|
||||
const bytes = new Uint8Array(300 * 1024)
|
||||
bytes.set(marker, 100)
|
||||
expect(looksLikeMmdb(bytes)).toBe(false)
|
||||
})
|
||||
|
||||
test('geodata 认首字节 0x0a,HTML 错误页和小文件被挡下', () => {
|
||||
const good = new Uint8Array(2048)
|
||||
good[0] = 0x0a
|
||||
expect(looksLikeGeodata(good)).toBe(true)
|
||||
|
||||
const html = new Uint8Array(2048)
|
||||
html[0] = '<'.charCodeAt(0)
|
||||
expect(looksLikeGeodata(html)).toBe(false)
|
||||
|
||||
const tiny = new Uint8Array(10)
|
||||
tiny[0] = 0x0a
|
||||
expect(looksLikeGeodata(tiny)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* GEO 数据库的四类文件。名字和默认下载地址都来自内核源码,
|
||||
* 界面(资源一节)和服务端(查文件大小和时间)共用这一份。
|
||||
*/
|
||||
|
||||
export interface GeoFileStatus {
|
||||
/** 界面上显示的名字 */
|
||||
key: string
|
||||
/** 在配置 geox-url 段落里的键名(config.go:363-368) */
|
||||
configKey: string
|
||||
/** 下载地址:配置里写了用配置的,没写是内核默认 */
|
||||
url: string
|
||||
/** 文件字节数。文件还没下载过是 null */
|
||||
size: number | null
|
||||
/** 文件修改时间,ISO 格式。没下载过是 null */
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件名与内核一致(constant/path.go:18-20、144)。
|
||||
* GeoIP.dat 和 GeoSite.dat 内核在目录里大小写不敏感地找(path.go:193-224),
|
||||
* 服务端查文件时也要同样处理。
|
||||
*/
|
||||
export const GEO_FILES = [
|
||||
{ key: 'MMDB', configKey: 'mmdb', file: 'geoip.metadb' },
|
||||
{ key: 'ASN', configKey: 'asn', file: 'ASN.mmdb' },
|
||||
{ key: 'GEOIP', configKey: 'geoip', file: 'GeoIP.dat' },
|
||||
{ key: 'GEOSITE', configKey: 'geosite', file: 'GeoSite.dat' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 下载内容的形状检查,挡住 HTML 错误页、截断的下载这类明显的坏文件。
|
||||
* Patchbay 自己下载单个 GEO 文件时用(内核的更新接口是全量的,单文件
|
||||
* 下载只能自己来,也就拿不到内核那套完整校验)。
|
||||
*/
|
||||
|
||||
/** MaxMind 格式:元数据标记按规范出现在文件末尾 128KB 内。MMDB 和 ASN 都是这个格式。 */
|
||||
export function looksLikeMmdb(bytes: Uint8Array): boolean {
|
||||
const marker = [0xab, 0xcd, 0xef, ...Array.from('MaxMind.com', (c) => c.charCodeAt(0))]
|
||||
const from = Math.max(0, bytes.length - 128 * 1024)
|
||||
outer: for (let i = from; i <= bytes.length - marker.length; i++) {
|
||||
for (let j = 0; j < marker.length; j++) {
|
||||
if (bytes[i + j] !== marker[j]) continue outer
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* geoip.dat / geosite.dat:GeoIPList 和 GeoSiteList 都是「repeated 消息字段 1」,
|
||||
* 首字节必然是 0x0a。弱校验,但足够挡住以 '<' 开头的错误页和空文件。
|
||||
*/
|
||||
export function looksLikeGeodata(bytes: Uint8Array): boolean {
|
||||
return bytes.length > 1024 && bytes[0] === 0x0a
|
||||
}
|
||||
|
||||
/** 内核的默认下载地址(config.go:578-583)。配置里没写 geox-url 时就是这些。 */
|
||||
export const DEFAULT_GEOX_URLS: Record<string, string> = {
|
||||
mmdb: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb',
|
||||
asn: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb',
|
||||
geoip: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat',
|
||||
geosite: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat',
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
cpuUsageBetween,
|
||||
parseGateway,
|
||||
parseMemAvailable,
|
||||
parseMemTotal,
|
||||
parseNameservers,
|
||||
} from './host-info.ts'
|
||||
|
||||
describe('parseMemTotal', () => {
|
||||
test('取 MemTotal 并换算成字节', () => {
|
||||
const meminfo = 'MemTotal: 2048000 kB\nMemFree: 100000 kB\nMemAvailable: 512000 kB\n'
|
||||
expect(parseMemTotal(meminfo)).toBe(2048000 * 1024)
|
||||
})
|
||||
|
||||
test('读不到 /proc/meminfo(非 Linux 平台)返回 null', () => {
|
||||
expect(parseMemTotal('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseMemAvailable', () => {
|
||||
test('取 MemAvailable 并换算成字节', () => {
|
||||
const meminfo = 'MemTotal: 2048000 kB\nMemFree: 100000 kB\nMemAvailable: 512000 kB\n'
|
||||
expect(parseMemAvailable(meminfo)).toBe(512000 * 1024)
|
||||
})
|
||||
|
||||
test('没有这一行(老内核)返回 null', () => {
|
||||
expect(parseMemAvailable('MemTotal: 1 kB\nMemFree: 1 kB\n')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseGateway', () => {
|
||||
test('默认路由的网关按小端序倒回来', () => {
|
||||
// 0102A8C0 → c0.a8.02.01 → 192.168.2.1
|
||||
const table = [
|
||||
'Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT',
|
||||
'eth0\t00000000\t0102A8C0\t0003\t0\t0\t0\t00000000\t0\t0\t0',
|
||||
'eth0\t0002A8C0\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0',
|
||||
].join('\n')
|
||||
expect(parseGateway(table)).toBe('192.168.2.1')
|
||||
})
|
||||
|
||||
test('没有默认路由返回 null', () => {
|
||||
const table = 'Iface\tDestination\tGateway\neth0\t0002A8C0\t00000000'
|
||||
expect(parseGateway(table)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseNameservers', () => {
|
||||
test('按出现顺序取 nameserver', () => {
|
||||
const conf = '# comment\nnameserver 223.5.5.5\nsearch lan\nnameserver 114.114.114.114\n'
|
||||
expect(parseNameservers(conf)).toEqual(['223.5.5.5', '114.114.114.114'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('cpuUsageBetween', () => {
|
||||
test('按两次快照的差值算百分比', () => {
|
||||
const usage = cpuUsageBetween({ busy: 100, total: 1000 }, { busy: 350, total: 2000 })
|
||||
expect(usage).toBe(25)
|
||||
})
|
||||
|
||||
test('时间没有前进时返回 null', () => {
|
||||
expect(cpuUsageBetween({ busy: 1, total: 10 }, { busy: 1, total: 10 })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 主机信息的纯解析。
|
||||
*
|
||||
* mihomo 的管理接口只报自己的内存,CPU、主机内存、网关这些要靠 Patchbay 服务端
|
||||
* 自己读 /proc 和 os 模块——服务端和内核跑在同一台机器上,这正是它能代答的原因。
|
||||
* 读文件在 server/host.ts;这里只做字符串到数字的解析,让 bun test 够得着。
|
||||
*/
|
||||
|
||||
/** GET /api/host 的响应。数据由 server/host.ts 采集,类型放这里让前后端共用。 */
|
||||
export interface HostInfo {
|
||||
/** 0-100。第一次请求没有参照快照,是 null */
|
||||
cpuUsage: number | null
|
||||
cpuCount: number
|
||||
/** 内存总量。Linux 用 MemTotal,其余平台退回 totalmem */
|
||||
memTotal: number
|
||||
/** 还能用的内存。Linux 用 MemAvailable,其余平台退回 freemem */
|
||||
memAvailable: number
|
||||
/** 1 分钟负载。Windows 上拿不到,是 null */
|
||||
load1: number | null
|
||||
uptimeSeconds: number
|
||||
platform: string
|
||||
release: string
|
||||
arch: string
|
||||
/** 非回环网卡上的 IPv4,含网卡名 */
|
||||
lanAddresses: Array<{ iface: string; address: string }>
|
||||
gateway: string | null
|
||||
nameservers: string[]
|
||||
}
|
||||
|
||||
/** GET /api/host/exit 的响应:经由某个入站端口探测到的公网 IP。 */
|
||||
export interface ExitProbe {
|
||||
ok: boolean
|
||||
ip?: string
|
||||
country?: string
|
||||
countryCode?: string
|
||||
/** 整个探测来回的毫秒数 */
|
||||
ms?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
function meminfoBytes(meminfo: string, field: string): number | null {
|
||||
const match = new RegExp(`^${field}:\\s+(\\d+)\\s*kB`, 'm').exec(meminfo)
|
||||
if (!match) return null
|
||||
return Number(match[1]) * 1024
|
||||
}
|
||||
|
||||
/**
|
||||
* /proc/meminfo 里的 MemTotal,换算成字节。
|
||||
*
|
||||
* 不能用 os.totalmem():它底层是 sysinfo() 系统调用,LXC 里的 lxcfs 只虚拟化
|
||||
* /proc 下的文件、拦不住系统调用,所以 sysinfo() 返回的是宿主机的物理内存总量。
|
||||
* 从 /proc/meminfo 读才是容器视角,且与 MemAvailable 出自同一份数据。
|
||||
*/
|
||||
export function parseMemTotal(meminfo: string): number | null {
|
||||
return meminfoBytes(meminfo, 'MemTotal')
|
||||
}
|
||||
|
||||
/** /proc/meminfo 里的 MemAvailable(内核估算的「还能用多少」),换算成字节。 */
|
||||
export function parseMemAvailable(meminfo: string): number | null {
|
||||
return meminfoBytes(meminfo, 'MemAvailable')
|
||||
}
|
||||
|
||||
/**
|
||||
* /proc/net/route 里的默认网关。
|
||||
*
|
||||
* 格式是制表分隔的表:目的网络为 00000000 的那行是默认路由,Gateway 列是
|
||||
* 小端序的十六进制 IPv4——字节序要倒回来才是人读的顺序。
|
||||
*/
|
||||
export function parseGateway(procNetRoute: string): string | null {
|
||||
for (const line of procNetRoute.split('\n').slice(1)) {
|
||||
const cols = line.trim().split(/\s+/)
|
||||
if (cols.length < 3 || cols[1] !== '00000000') continue
|
||||
const hex = cols[2]!
|
||||
if (!/^[0-9a-fA-F]{8}$/.test(hex)) continue
|
||||
const bytes = [6, 4, 2, 0].map((i) => parseInt(hex.slice(i, i + 2), 16))
|
||||
return bytes.join('.')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** /etc/resolv.conf 里的 DNS 服务器,按出现顺序。 */
|
||||
export function parseNameservers(resolvConf: string): string[] {
|
||||
const result: string[] = []
|
||||
for (const line of resolvConf.split('\n')) {
|
||||
const match = /^\s*nameserver\s+(\S+)/.exec(line)
|
||||
if (match) result.push(match[1]!)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 一次 CPU 时间快照:忙碌和总量的累计毫秒(os.cpus() 各核求和)。 */
|
||||
export interface CpuTimes {
|
||||
busy: number
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 两次快照之间的 CPU 使用率,0 到 100。
|
||||
* 时间没有前进(同一毫秒内连问两次,或计数回绕)时返回 null,不硬造一个数。
|
||||
*/
|
||||
export function cpuUsageBetween(prev: CpuTimes, next: CpuTimes): number | null {
|
||||
const total = next.total - prev.total
|
||||
const busy = next.busy - prev.busy
|
||||
if (total <= 0 || busy < 0) return null
|
||||
return Math.min(100, (busy / total) * 100)
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
applySetup,
|
||||
applySubRules,
|
||||
chainGroupName,
|
||||
chainProviderName,
|
||||
managedWiring,
|
||||
panelGroups,
|
||||
readSubRules,
|
||||
sharedPeers,
|
||||
subRuleName,
|
||||
syncGenerated,
|
||||
isGenerated,
|
||||
linesMatching,
|
||||
proxyGroupName,
|
||||
pruneGenerated,
|
||||
readSetup,
|
||||
upgradeLegacyInbound,
|
||||
} from './inbound-groups.ts'
|
||||
import type { OutboundSetup } from './inbound-groups.ts'
|
||||
import { collectInbounds } from './inbounds.ts'
|
||||
@@ -84,13 +90,14 @@ describe('筛选预览', () => {
|
||||
})
|
||||
|
||||
describe('一路入站的三个组', () => {
|
||||
it('SELECT 的成员是 DIRECT 加另外两个组,加上订阅', () => {
|
||||
// 这三种挑法是所有 clash 配置里固定的那几种,让用户从零拼一遍没有意义
|
||||
it('SELECT 的成员是另外两个组加 DIRECT,再加上订阅,AUTO 排第一', () => {
|
||||
// AUTO 必须排第一:内核给没有选中记录的组默认用第一个成员,选中的线路被订阅删掉时
|
||||
// 也回退到第一个成员。这两种情况落到 DIRECT 上就是静默直连(selector.go:103-112)
|
||||
const groups = applySetup(base(), 'test1', plain)['proxy-groups'] as ConfigObject[]
|
||||
expect(groups[0]).toEqual({
|
||||
name: 'patchbay:test1',
|
||||
type: 'select',
|
||||
proxies: ['DIRECT', 'patchbay:test1 AUTO', 'patchbay:test1 FALLBACK'],
|
||||
proxies: ['patchbay:test1 AUTO', 'patchbay:test1 FALLBACK', 'DIRECT'],
|
||||
use: ['subs'],
|
||||
})
|
||||
})
|
||||
@@ -108,6 +115,28 @@ describe('一路入站的三个组', () => {
|
||||
expect((groups[2] as ConfigObject).type).toBe('fallback')
|
||||
})
|
||||
|
||||
it('一个来源都没选时只有 SELECT,成员只有 DIRECT', () => {
|
||||
// AUTO 和 FALLBACK 只有 use: 没有点名成员,来源为空就是空组,内核会拒绝
|
||||
// 整份配置(parser.go:117-119)。新增入站落在这个状态上,等用户自己挑订阅
|
||||
const empty: OutboundSetup = { proxy: { sources: [], filter: null }, chain: null }
|
||||
const groups = applySetup(base(), 'test1', empty)['proxy-groups'] as ConfigObject[]
|
||||
expect(groups).toEqual([{ name: 'patchbay:test1', type: 'select', proxies: ['DIRECT'] }])
|
||||
})
|
||||
|
||||
it('从空来源选上订阅后,AUTO 和 FALLBACK 建回来', () => {
|
||||
const empty: OutboundSetup = { proxy: { sources: [], filter: null }, chain: null }
|
||||
const data = applySetup(applySetup(base(), 'test1', empty), 'test1', plain)
|
||||
const groups = data['proxy-groups'] as ConfigObject[]
|
||||
expect(groups.map((g) => g.name)).toEqual([
|
||||
'patchbay:test1',
|
||||
'patchbay:test1 AUTO',
|
||||
'patchbay:test1 FALLBACK',
|
||||
])
|
||||
// 反过来把订阅全取消,那两个组要跟着消失,否则留下的是内核不收的空组
|
||||
const back = applySetup(data, 'test1', empty)['proxy-groups'] as ConfigObject[]
|
||||
expect(back.map((g) => g.name)).toEqual(['patchbay:test1'])
|
||||
})
|
||||
|
||||
it('tab 的顺序是 SELECT、AUTO、FALLBACK,只有 SELECT 能点', () => {
|
||||
// 另外两个自己决定用哪条,手动指定会被它们自己撤销,见 outbound-chain.ts
|
||||
const data = applySetup(base(), 'test1', plain)
|
||||
@@ -147,6 +176,41 @@ describe('一路入站的三个组', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('两路没接管的入站落到同一个组时互为共用者', () => {
|
||||
// 「共用」标记只该出现在这种情况:在其中一路上换选中会连带改掉另一路的去向
|
||||
const data: ConfigObject = {
|
||||
...base(),
|
||||
'mixed-port': 7890,
|
||||
rules: ['MATCH,PROXY'],
|
||||
'proxy-groups': [{ name: 'PROXY', type: 'select', use: ['subs'] }],
|
||||
}
|
||||
const runtime = { PROXY: { type: 'Selector', now: 'A', all: ['A'] } }
|
||||
const inbounds = collectInbounds(data)
|
||||
const legacy = inbounds.find((i) => i.isDefault)!
|
||||
|
||||
expect(sharedPeers(data, runtime, inbounds, legacy)).toEqual(['test1'])
|
||||
})
|
||||
|
||||
it('只有自己落在那个组上时没有共用者', () => {
|
||||
const data: ConfigObject = {
|
||||
'mixed-port': 7890,
|
||||
rules: ['MATCH,PROXY'],
|
||||
'proxy-groups': [{ name: 'PROXY', type: 'select', proxies: ['DIRECT'] }],
|
||||
}
|
||||
const runtime = { PROXY: { type: 'Selector', now: 'DIRECT', all: ['DIRECT'] } }
|
||||
const inbounds = collectInbounds(data)
|
||||
|
||||
expect(sharedPeers(data, runtime, inbounds, inbounds[0]!)).toEqual([])
|
||||
})
|
||||
|
||||
it('接管过的入站用的是自己的组,谈不上共用', () => {
|
||||
const data = applySetup(base(), 'test1', plain)
|
||||
const runtime = { 'patchbay:test1': { type: 'Selector' } }
|
||||
const inbounds = collectInbounds(data)
|
||||
|
||||
expect(sharedPeers(data, runtime, inbounds, inbounds[0]!)).toEqual([])
|
||||
})
|
||||
|
||||
it('筛选条件三个组共用一份', () => {
|
||||
const groups = applySetup(base(), 'test1', {
|
||||
proxy: { sources: ['subs'], filter: '(?i)hk' },
|
||||
@@ -186,8 +250,8 @@ describe('读取设置', () => {
|
||||
expect(readSetup(applySetup(base(), 'test1', setup), 'test1')).toEqual(setup)
|
||||
})
|
||||
|
||||
it('开了链式代理时,来源从副本的 url 还原', () => {
|
||||
// 代理组的 use 这时指向副本,用户选的订阅只能靠 url 相同认回来
|
||||
it('开了链式代理时,来源从副本的 path 还原', () => {
|
||||
// 代理组的 use 这时指向副本,用户选的订阅只能靠读同一个缓存文件认回来
|
||||
const data = applySetup(base(), 'test1', chained)
|
||||
expect(readSetup(data, 'test1')).toEqual(chained)
|
||||
})
|
||||
@@ -319,3 +383,140 @@ describe('清理', () => {
|
||||
expect((pruneGenerated(orphaned)['proxy-groups'] as ConfigObject[]).length).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('附加规则', () => {
|
||||
const managed = () => applySetup(base(), 'test1', plain)
|
||||
const listenerOf = (data: ConfigObject) => (data.listeners as ConfigObject[])[0]!
|
||||
|
||||
it('写入后 listener 换成 rule 形态,表底自动补兜底 MATCH', () => {
|
||||
const next = applySubRules(managed(), 'test1', ['DOMAIN-SUFFIX,lan,DIRECT'])
|
||||
|
||||
const listener = listenerOf(next)
|
||||
expect(listener.rule).toBe(subRuleName('test1'))
|
||||
expect(listener.proxy).toBeUndefined()
|
||||
expect((next['sub-rules'] as ConfigObject)[subRuleName('test1')]).toEqual([
|
||||
'DOMAIN-SUFFIX,lan,DIRECT',
|
||||
'MATCH,patchbay:test1',
|
||||
])
|
||||
})
|
||||
|
||||
it('读出来不含兜底 MATCH,往返一致', () => {
|
||||
const rules = ['DOMAIN-SUFFIX,lan,DIRECT', 'GEOIP,CN,DIRECT']
|
||||
const next = applySubRules(managed(), 'test1', rules)
|
||||
expect(readSubRules(next, 'test1')).toEqual(rules)
|
||||
expect(readSubRules(managed(), 'test1')).toEqual([])
|
||||
})
|
||||
|
||||
it('清空后恢复 proxy 直连形态,sub-rules 键整个消失', () => {
|
||||
const withRules = applySubRules(managed(), 'test1', ['GEOIP,CN,DIRECT'])
|
||||
const cleared = applySubRules(withRules, 'test1', [])
|
||||
|
||||
const listener = listenerOf(cleared)
|
||||
expect(listener.proxy).toBe(proxyGroupName('test1'))
|
||||
expect(listener.rule).toBeUndefined()
|
||||
expect(cleared['sub-rules']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rule 形态仍被认成 Patchbay 接线,重建设置不会把 proxy 加回来', () => {
|
||||
// proxy 会短路规则匹配(tunnel.go:318-325),加回来附加规则就静默失效了
|
||||
const withRules = applySubRules(managed(), 'test1', ['GEOIP,CN,DIRECT'])
|
||||
const inbound = collectInbounds(withRules).find((i) => i.name === 'test1')!
|
||||
expect(managedWiring(withRules, inbound)).toBe(true)
|
||||
|
||||
const rebuilt = syncGenerated(withRules, collectInbounds(withRules))
|
||||
const listener = listenerOf(rebuilt)
|
||||
expect(listener.rule).toBe(subRuleName('test1'))
|
||||
expect(listener.proxy).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rule 形态下专属组靠表底 MATCH 活着,不会被当孤儿清掉', () => {
|
||||
const withRules = applySubRules(managed(), 'test1', ['GEOIP,CN,DIRECT'])
|
||||
const pruned = pruneGenerated(withRules)
|
||||
const groupNames = (pruned['proxy-groups'] as ConfigObject[]).map((g) => g.name)
|
||||
expect(groupNames).toContain(proxyGroupName('test1'))
|
||||
})
|
||||
|
||||
it('删掉入站后,附加规则表和组连锁消失', () => {
|
||||
const withRules = applySubRules(managed(), 'test1', ['GEOIP,CN,DIRECT'])
|
||||
const removed: ConfigObject = { ...withRules, listeners: [] }
|
||||
|
||||
const cleaned = pruneGenerated(removed)
|
||||
expect(cleaned['sub-rules']).toBeUndefined()
|
||||
expect(cleaned['proxy-groups']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('用户自己的 sub-rules 表不被清理', () => {
|
||||
const data: ConfigObject = { ...base(), 'sub-rules': { mine: ['MATCH,DIRECT'] } }
|
||||
expect((pruneGenerated(data)['sub-rules'] as ConfigObject).mine).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy 入站升级', () => {
|
||||
/** 形状仿照目标机器:一个 legacy 混合端口,全局规则是内网直连加 MATCH 兜底。 */
|
||||
function legacyBase(): ConfigObject {
|
||||
return {
|
||||
'mixed-port': 7890,
|
||||
'allow-lan': true,
|
||||
'proxy-providers': {
|
||||
subs: { type: 'http', url: URL, path: './providers/subs.yaml' },
|
||||
other: { type: 'http', url: `${URL}2`, path: './providers/other.yaml' },
|
||||
},
|
||||
'proxy-groups': [{ name: 'PROXY', type: 'select', proxies: ['DIRECT'], use: ['subs'] }],
|
||||
rules: [
|
||||
'IP-CIDR,192.168.0.0/16,DIRECT,no-resolve',
|
||||
'MATCH,PROXY',
|
||||
'DOMAIN,dead.example,DIRECT',
|
||||
],
|
||||
}
|
||||
}
|
||||
const legacyInbound = (data: ConfigObject) => collectInbounds(data).find((i) => i.isDefault)!
|
||||
|
||||
it('删掉顶层键,建同端口的命名入站,listen 按 allow-lan 推导写死', () => {
|
||||
const data = legacyBase()
|
||||
const next = upgradeLegacyInbound(data, legacyInbound(data))!
|
||||
expect(next['mixed-port']).toBeUndefined()
|
||||
expect((next.listeners as ConfigObject[]).at(-1)).toMatchObject({
|
||||
name: 'mixed-port',
|
||||
type: 'mixed',
|
||||
port: 7890,
|
||||
listen: '0.0.0.0',
|
||||
})
|
||||
})
|
||||
|
||||
it('allow-lan 关着时 listen 写 127.0.0.1', () => {
|
||||
const data = { ...legacyBase(), 'allow-lan': false }
|
||||
const next = upgradeLegacyInbound(data, legacyInbound(data))!
|
||||
expect(((next.listeners as ConfigObject[]).at(-1) as ConfigObject).listen).toBe('127.0.0.1')
|
||||
})
|
||||
|
||||
it('全局规则里第一条 MATCH 之前的部分复制成附加规则,全局规则原文不动', () => {
|
||||
const data = legacyBase()
|
||||
const next = upgradeLegacyInbound(data, legacyInbound(data))!
|
||||
expect(readSubRules(next, 'mixed-port')).toEqual(['IP-CIDR,192.168.0.0/16,DIRECT,no-resolve'])
|
||||
expect(next.rules).toEqual(legacyBase().rules)
|
||||
})
|
||||
|
||||
it('升级完就在专属组模式下,来源照抄 MATCH 目标组的 use', () => {
|
||||
const data = legacyBase()
|
||||
const next = upgradeLegacyInbound(data, legacyInbound(data))!
|
||||
expect(readSetup(next, 'mixed-port')).toEqual({
|
||||
proxy: { sources: ['subs'], filter: null },
|
||||
chain: null,
|
||||
})
|
||||
expect(managedWiring(next, collectInbounds(next).find((i) => i.name === 'mixed-port')!)).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('MATCH 目标组没有 use 时退回全部订阅', () => {
|
||||
const data = legacyBase()
|
||||
;(data['proxy-groups'] as ConfigObject[])[0] = { name: 'PROXY', type: 'select', proxies: ['DIRECT'] }
|
||||
const next = upgradeLegacyInbound(data, legacyInbound(data))!
|
||||
expect(readSetup(next, 'mixed-port')?.proxy.sources).toEqual(['subs', 'other'])
|
||||
})
|
||||
|
||||
it('已有同名入站时返回 null,不写出重名的 listener', () => {
|
||||
const data = { ...legacyBase(), listeners: [{ name: 'mixed-port', type: 'mixed', port: 1 }] }
|
||||
expect(upgradeLegacyInbound(data, legacyInbound(data))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
+231
-24
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 一路入站配三个组,入站的 `proxy:` 指向其中的 SELECT,换线是换那个组的当前选中,
|
||||
* 不改配置文件(决策记录 0007):
|
||||
* SELECT 手动选。它的成员是 DIRECT、下面两个组,加上订阅里的线路
|
||||
* SELECT 手动选。成员是 AUTO、FALLBACK、DIRECT 加订阅里的线路,顺序见 applySetup
|
||||
* AUTO url-test,自动挑延迟最低的
|
||||
* FALLBACK fallback,按顺序挑第一个测得通的
|
||||
* 界面上这三个是并排的 tab。做成三个而不是让用户自己配,是因为它们就是所有 clash 配置
|
||||
@@ -15,10 +15,10 @@
|
||||
* 配置里的东西属于谁全靠三条线索还原,不靠解析名字:
|
||||
* 四个组 名字由入站名推出来
|
||||
* 订阅副本 override.dialer-proxy 指向哪一路的入口组
|
||||
* 副本的来源 url 和哪个用户订阅相同
|
||||
* 副本的来源 path 和哪个用户订阅相同(读同一个缓存文件)
|
||||
*/
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
import { resolveDestination } from './inbounds.ts'
|
||||
import { fallbackTarget, legacyListenAddress, resolveDestination } from './inbounds.ts'
|
||||
import type { Inbound } from './inbounds.ts'
|
||||
import { lastSwitchableGroup, resolveChain } from './outbound-chain.ts'
|
||||
import type { RuntimeNode } from './outbound-chain.ts'
|
||||
@@ -55,6 +55,11 @@ export function chainGroupName(inbound: string): string {
|
||||
return `${GENERATED_PREFIX}${inbound} 链式`
|
||||
}
|
||||
|
||||
/** 这一路入站的附加规则表在顶层 `sub-rules:` 里的键名。listener 的 `rule:` 指向它。 */
|
||||
export function subRuleName(inbound: string): string {
|
||||
return `${GENERATED_PREFIX}${inbound} 附加`
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个出站在界面上叫什么。
|
||||
*
|
||||
@@ -69,6 +74,7 @@ export function displayName(name: string): string {
|
||||
[' AUTO', 'AUTO'],
|
||||
[' FALLBACK', 'FALLBACK'],
|
||||
[' 链式', '链式代理'],
|
||||
[' 附加', '附加规则'],
|
||||
] as const) {
|
||||
if (rest.endsWith(suffix)) return label
|
||||
}
|
||||
@@ -219,6 +225,85 @@ export function readSetup(data: ConfigObject, inbound: string): OutboundSetup |
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 附加规则 ─────────────────────────────────────
|
||||
*
|
||||
* 挂在某一路入站上的分流规则(内核的 sub-rules 机制):listener 写 `rule: <表名>`,
|
||||
* 该入站的流量改查顶层 `sub-rules:` 里同名的表(tunnel.go:713-721)。两个硬约束:
|
||||
* - `proxy:` 的优先级更高且完全短路规则匹配(tunnel.go:318-325),所以有附加规则时
|
||||
* listener 只能写 rule 不写 proxy;
|
||||
* - 子规则表没有命中时兜底是 DIRECT 而不是这一路的出站(tunnel.go match 循环末尾),
|
||||
* 所以表底那条 `MATCH,专属组` 是承重的,Patchbay 始终自动补上,不交给用户维护。
|
||||
* 于是不变式是:附加规则为空 ⇔ listener 用 proxy 直连专属组;非空 ⇔ listener 用 rule,
|
||||
* 表底 MATCH 指回专属组。两种形态对「没被规则分走的流量」效果完全相同。
|
||||
*/
|
||||
|
||||
function subRulesOf(data: ConfigObject): ConfigObject {
|
||||
return isPlainObject(data['sub-rules']) ? data['sub-rules'] : {}
|
||||
}
|
||||
|
||||
/** 这一路入站有没有 Patchbay 管理的附加规则表。 */
|
||||
export function hasSubRules(data: ConfigObject, inbound: string): boolean {
|
||||
return subRuleName(inbound) in subRulesOf(data)
|
||||
}
|
||||
|
||||
/** 表底的兜底规则。它把没被分走的流量送回这一路自己的出站。 */
|
||||
function terminalRule(inbound: string): string {
|
||||
return `MATCH,${proxyGroupName(inbound)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 读出这一路的附加规则,不含表底的兜底 MATCH——那条是生成物,界面上单独画、
|
||||
* 不参与编辑。手改过、兜底不在末尾的表原样全给,编辑保存时会重新规整。
|
||||
*/
|
||||
export function readSubRules(data: ConfigObject, inbound: string): string[] {
|
||||
const entry = subRulesOf(data)[subRuleName(inbound)]
|
||||
const rules = Array.isArray(entry)
|
||||
? entry.filter((rule): rule is string => typeof rule === 'string')
|
||||
: []
|
||||
return rules.at(-1) === terminalRule(inbound) ? rules.slice(0, -1) : rules
|
||||
}
|
||||
|
||||
/**
|
||||
* 把这一路的附加规则写回配置。
|
||||
*
|
||||
* 空表恢复 proxy 直连形态(删表、删 rule、接回专属组);非空补上兜底 MATCH、
|
||||
* listener 换成 rule。listener 上用户手写的其它字段原样保留。
|
||||
*/
|
||||
export function applySubRules(data: ConfigObject, inbound: string, rules: string[]): ConfigObject {
|
||||
const next = { ...data }
|
||||
const name = subRuleName(inbound)
|
||||
const tables = { ...subRulesOf(next) }
|
||||
|
||||
if (rules.length === 0) delete tables[name]
|
||||
else tables[name] = [...rules, terminalRule(inbound)]
|
||||
|
||||
if (Object.keys(tables).length === 0) delete next['sub-rules']
|
||||
else next['sub-rules'] = tables
|
||||
|
||||
const listeners = (Array.isArray(next.listeners) ? next.listeners : []).map((entry) => {
|
||||
if (!isPlainObject(entry) || entry.name !== inbound) return entry
|
||||
if (rules.length === 0) {
|
||||
const { rule: _rule, ...rest } = entry
|
||||
return { ...rest, proxy: proxyGroupName(inbound) }
|
||||
}
|
||||
const { proxy: _proxy, ...rest } = entry
|
||||
return { ...rest, rule: name }
|
||||
})
|
||||
if (listeners.length > 0) next.listeners = listeners
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一路入站是否由 Patchbay 接线。两种形态:proxy 直指专属组,
|
||||
* 或经附加规则表兜底到专属组。panelGroups 和 syncGenerated 共用这一个答案。
|
||||
*/
|
||||
export function managedWiring(data: ConfigObject, inbound: Inbound): boolean {
|
||||
const mode = inbound.outbound
|
||||
if (mode.kind === 'named' && mode.name === proxyGroupName(inbound.name)) return true
|
||||
return inbound.ruleSet === subRuleName(inbound.name) && hasSubRules(data, inbound.name)
|
||||
}
|
||||
|
||||
function makeGroup(
|
||||
name: string,
|
||||
type: string,
|
||||
@@ -272,7 +357,7 @@ function makeChainProvider(source: ConfigObject, inbound: string, cachePath: str
|
||||
* 把一路入站的出站设置写回配置。
|
||||
*
|
||||
* 开了链式代理时,三个组的成员都改从订阅副本里取,因为只有副本挂着 `dialer-proxy`。
|
||||
* 用户选的来源订阅仍然可以从副本的 `url` 还原出来,所以配置文件之外不需要另存状态。
|
||||
* 用户选的来源订阅仍然可以从副本的 `path` 还原出来,所以配置文件之外不需要另存状态。
|
||||
*/
|
||||
export function applySetup(data: ConfigObject, inbound: string, setup: OutboundSetup): ConfigObject {
|
||||
const next = { ...data }
|
||||
@@ -305,16 +390,30 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
|
||||
|
||||
const { filter } = setup.proxy
|
||||
let groups = groupsOf(next)
|
||||
groups = upsert(
|
||||
groups,
|
||||
makeGroup(proxyGroupName(inbound), 'select', use, filter, [
|
||||
'DIRECT',
|
||||
autoGroupName(inbound),
|
||||
fallbackGroupName(inbound),
|
||||
]),
|
||||
)
|
||||
groups = upsert(groups, makeGroup(autoGroupName(inbound), 'url-test', use, filter))
|
||||
groups = upsert(groups, makeGroup(fallbackGroupName(inbound), 'fallback', use, filter))
|
||||
if (use.length === 0) {
|
||||
// 一个来源都没选。AUTO 和 FALLBACK 只有 `use:` 没有点名成员,来源为空就是空组,
|
||||
// 内核会拒绝整份配置(内核源码 adapter/outboundgroup/parser.go:117-119),所以这时
|
||||
// 只建 SELECT、成员只有 DIRECT。新增入站落在这个状态上,等用户自己挑订阅。
|
||||
// filter 一并不写:没有来源可筛。
|
||||
groups = upsert(groups, makeGroup(proxyGroupName(inbound), 'select', [], null, ['DIRECT']))
|
||||
groups = groups.filter(
|
||||
(g) => g.name !== autoGroupName(inbound) && g.name !== fallbackGroupName(inbound),
|
||||
)
|
||||
} else {
|
||||
// AUTO 排第一位:内核给没有选中记录的组默认用第一个成员,选中的线路被订阅刷新删掉时
|
||||
// 也静默回退到第一个成员(内核源码 adapter/outboundgroup/selector.go:103-112)。这两种
|
||||
// 情况都该落到自动测速上;排 DIRECT 就是静默直连,对按端口定向出站的场景是泄露。
|
||||
groups = upsert(
|
||||
groups,
|
||||
makeGroup(proxyGroupName(inbound), 'select', use, filter, [
|
||||
autoGroupName(inbound),
|
||||
fallbackGroupName(inbound),
|
||||
'DIRECT',
|
||||
]),
|
||||
)
|
||||
groups = upsert(groups, makeGroup(autoGroupName(inbound), 'url-test', use, filter))
|
||||
groups = upsert(groups, makeGroup(fallbackGroupName(inbound), 'fallback', use, filter))
|
||||
}
|
||||
|
||||
if (chained) {
|
||||
groups = upsert(
|
||||
@@ -329,16 +428,72 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
|
||||
if (Object.keys(providers).length === 0) delete next['proxy-providers']
|
||||
else next['proxy-providers'] = providers
|
||||
|
||||
const listeners = (Array.isArray(next.listeners) ? next.listeners : []).map((entry) =>
|
||||
isPlainObject(entry) && entry.name === inbound
|
||||
? { ...entry, proxy: proxyGroupName(inbound) }
|
||||
: entry,
|
||||
)
|
||||
// 有附加规则时 listener 走 rule 形态,proxy 会短路规则匹配、不能写;
|
||||
// 表底的兜底 MATCH 已经指向专属组,接线效果一样
|
||||
const viaSubRules = hasSubRules(next, inbound)
|
||||
const listeners = (Array.isArray(next.listeners) ? next.listeners : []).map((entry) => {
|
||||
if (!isPlainObject(entry) || entry.name !== inbound) return entry
|
||||
if (viaSubRules) {
|
||||
const { proxy: _proxy, ...rest } = entry
|
||||
return { ...rest, rule: subRuleName(inbound) }
|
||||
}
|
||||
return { ...entry, proxy: proxyGroupName(inbound) }
|
||||
})
|
||||
if (listeners.length > 0) next.listeners = listeners
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一路 legacy 入站升级成命名入站,并直接进专属组模式。
|
||||
*
|
||||
* legacy 入站(顶层端口字段)没有 `proxy` 可写,只能顺全局规则走,在界面上
|
||||
* 切不了订阅。升级 = 删掉顶层端口键,在 `listeners:` 里建一个行为等价的入站:
|
||||
* 端口 原样
|
||||
* listen 按 allow-lan 推导后写死。升级后改 allow-lan 不再影响它,这是升级的
|
||||
* 语义变化之一,界面文案要说
|
||||
* udp 不写。命名入站解析时默认就是开的(内核源码 listener/parse.go:52)
|
||||
*
|
||||
* 行为等价还差分流:它原来查全局规则,升级后整表改查自己的附加规则,所以把
|
||||
* 全局规则里第一条 MATCH 之前的部分复制过来(MATCH 之后的行本来就永远轮不到)。
|
||||
* 全局规则原文不动——那是用户手写的段落,何况可能还有别的 legacy 入站在用。
|
||||
* 专属组的来源照抄原 MATCH 目标组的 `use:`,流量的实际去向尽量不变;
|
||||
* 目标组没有 `use:` 时退回全部订阅。
|
||||
*
|
||||
* 同名入站已存在时返回 null,调用方据此报错而不是写出重名的 listener。
|
||||
*/
|
||||
export function upgradeLegacyInbound(data: ConfigObject, inbound: Inbound): ConfigObject | null {
|
||||
if (inbound.legacyKey === null) return null
|
||||
const port = Number(inbound.port)
|
||||
if (!Number.isFinite(port) || port <= 0) return null
|
||||
const listeners = Array.isArray(data.listeners) ? data.listeners : []
|
||||
if (listeners.some((entry) => isPlainObject(entry) && entry.name === inbound.name)) return null
|
||||
|
||||
const next = { ...data }
|
||||
delete next[inbound.legacyKey]
|
||||
next.listeners = [
|
||||
...listeners,
|
||||
{ name: inbound.name, type: inbound.type, port, listen: legacyListenAddress(data) },
|
||||
]
|
||||
|
||||
const rules: string[] = []
|
||||
for (const rule of Array.isArray(data.rules) ? data.rules : []) {
|
||||
if (typeof rule !== 'string') continue
|
||||
if (rule.split(',')[0]?.trim().toUpperCase() === 'MATCH') break
|
||||
rules.push(rule)
|
||||
}
|
||||
|
||||
const target = fallbackTarget(data)
|
||||
const providers = new Set(userProviders(data))
|
||||
const uses = (target ? (groupUses(data, target) ?? []) : []).filter((name) => providers.has(name))
|
||||
const sources = uses.length > 0 ? uses : userProviders(data)
|
||||
|
||||
return applySetup(applySubRules(next, inbound.name, rules), inbound.name, {
|
||||
proxy: { sources, filter: null },
|
||||
chain: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** 配置里指向某个名字的地方有几处。用来判断生成的东西还有没有人在用。 */
|
||||
function referencesTo(data: ConfigObject, name: string): number {
|
||||
let count = 0
|
||||
@@ -362,6 +517,17 @@ function referencesTo(data: ConfigObject, name: string): number {
|
||||
if (typeof rule === 'string' && rule.split(',').some((part) => part.trim() === name)) count += 1
|
||||
}
|
||||
|
||||
// 附加规则表里的规则同样引用出站——表底的 MATCH,专属组 正是 rule 形态下
|
||||
// 专属组活着的唯一理由,不数它专属组会被当孤儿清掉
|
||||
for (const table of Object.values(subRulesOf(data))) {
|
||||
if (!Array.isArray(table)) continue
|
||||
for (const rule of table) {
|
||||
if (typeof rule === 'string' && rule.split(',').some((part) => part.trim() === name)) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -389,7 +555,21 @@ export function pruneGenerated(data: ConfigObject): ConfigObject {
|
||||
)
|
||||
const providersChanged = keptProviders.length !== Object.keys(providers).length
|
||||
|
||||
if (keptGroups.length === groups.length && !providersChanged) return data
|
||||
// 生成的附加规则表活着的唯一理由是有 listener 的 rule 指着它。
|
||||
// 入站被删后先清表,表里的 MATCH 引用消失,下一轮才轮到那几个组
|
||||
const listenerRules = new Set(
|
||||
(Array.isArray(data.listeners) ? data.listeners : [])
|
||||
.filter(isPlainObject)
|
||||
.map((entry) => entry.rule)
|
||||
.filter((rule): rule is string => typeof rule === 'string'),
|
||||
)
|
||||
const tables = subRulesOf(data)
|
||||
const keptTables = Object.entries(tables).filter(
|
||||
([name]) => !isGenerated(name) || listenerRules.has(name),
|
||||
)
|
||||
const tablesChanged = keptTables.length !== Object.keys(tables).length
|
||||
|
||||
if (keptGroups.length === groups.length && !providersChanged && !tablesChanged) return data
|
||||
|
||||
const next = { ...data }
|
||||
if (keptGroups.length === 0) delete next['proxy-groups']
|
||||
@@ -400,6 +580,11 @@ export function pruneGenerated(data: ConfigObject): ConfigObject {
|
||||
else next['proxy-providers'] = Object.fromEntries(keptProviders)
|
||||
}
|
||||
|
||||
if (tablesChanged) {
|
||||
if (keptTables.length === 0) delete next['sub-rules']
|
||||
else next['sub-rules'] = Object.fromEntries(keptTables)
|
||||
}
|
||||
|
||||
return pruneGenerated(next)
|
||||
}
|
||||
|
||||
@@ -414,8 +599,7 @@ export function syncGenerated(data: ConfigObject, inbounds: Inbound[]): ConfigOb
|
||||
let next = data
|
||||
for (const inbound of inbounds) {
|
||||
const setup = readSetup(next, inbound.name)
|
||||
const mode = inbound.outbound
|
||||
if (!setup || mode.kind !== 'named' || mode.name !== proxyGroupName(inbound.name)) continue
|
||||
if (!setup || !managedWiring(next, inbound)) continue
|
||||
next = applySetup(next, inbound.name, setup)
|
||||
}
|
||||
return next
|
||||
@@ -446,8 +630,7 @@ export function panelGroups(
|
||||
runtime: Record<string, RuntimeNode>,
|
||||
inbound: Inbound,
|
||||
): { groups: PanelGroup[]; shared: boolean } {
|
||||
const mode = inbound.outbound
|
||||
const managed = mode.kind === 'named' && mode.name === proxyGroupName(inbound.name)
|
||||
const managed = managedWiring(data, inbound)
|
||||
|
||||
if (managed) {
|
||||
return {
|
||||
@@ -480,6 +663,30 @@ export function panelGroups(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 还有哪些入站和这一路落在同一个共用组上。
|
||||
*
|
||||
* 「共用」这个提醒只在真有人共用时才成立:几路没接管的入站顺着规则表落到同一个手动
|
||||
* 选择组时,在其中一路上换选中会连带改掉其它几路的去向。只有自己在用就没什么可提醒的。
|
||||
*/
|
||||
export function sharedPeers(
|
||||
data: ConfigObject,
|
||||
runtime: Record<string, RuntimeNode>,
|
||||
inbounds: Inbound[],
|
||||
inbound: Inbound,
|
||||
): string[] {
|
||||
const own = panelGroups(data, runtime, inbound)
|
||||
if (!own.shared) return []
|
||||
const group = own.groups[0]?.name
|
||||
return inbounds
|
||||
.filter((other) => other.id !== inbound.id)
|
||||
.filter((other) => {
|
||||
const panel = panelGroups(data, runtime, other)
|
||||
return panel.shared && panel.groups[0]?.name === group
|
||||
})
|
||||
.map((other) => other.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选条件会留下哪些线路。界面上用来在保存前当场告诉用户「匹配到几条」。
|
||||
*
|
||||
|
||||
+20
-17
@@ -6,9 +6,9 @@ import {
|
||||
collectInbounds,
|
||||
collectOutboundNames,
|
||||
fallbackTarget,
|
||||
firstPort,
|
||||
isUnsetOutbound,
|
||||
outboundLabel,
|
||||
outboundToProxyField,
|
||||
OUTBOUND_PRESETS,
|
||||
resolveDestination,
|
||||
} from './inbounds.ts'
|
||||
@@ -43,7 +43,7 @@ describe('合并两套写法的入站', () => {
|
||||
expect(list[1]!.listenerIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('默认入站的出站永远是「跟着规则走」,因为旧写法没处写', () => {
|
||||
it('legacy 入站的出站永远是「跟着规则走」,因为旧写法没处写', () => {
|
||||
expect(collectInbounds({ 'mixed-port': 7777 })[0]!.outbound).toEqual({ kind: 'rules' })
|
||||
})
|
||||
})
|
||||
@@ -92,12 +92,6 @@ describe('出站去向的四种形态', () => {
|
||||
expect(of('AUTO')).toEqual({ kind: 'named', name: 'AUTO' })
|
||||
})
|
||||
|
||||
it('写回时能还原成原来的字段值', () => {
|
||||
expect(outboundToProxyField({ kind: 'rules' })).toBeUndefined()
|
||||
expect(outboundToProxyField({ kind: 'direct' })).toBe('DIRECT')
|
||||
expect(outboundToProxyField({ kind: 'global' })).toBe('GLOBAL')
|
||||
expect(outboundToProxyField({ kind: 'named', name: 'AUTO' })).toBe('AUTO')
|
||||
})
|
||||
})
|
||||
|
||||
describe('能被入站引用的出站名字', () => {
|
||||
@@ -149,7 +143,7 @@ describe('流量最终去哪', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('出站预设表是读写显示三者的唯一约定', () => {
|
||||
describe('出站预设表是读取和显示的唯一约定', () => {
|
||||
it('每种预设的字段值和标签一一对应', () => {
|
||||
expect(OUTBOUND_PRESETS.map((p) => [p.mode.kind, p.field, p.label])).toEqual([
|
||||
['rules', undefined, 'RULE'],
|
||||
@@ -158,14 +152,6 @@ describe('出站预设表是读写显示三者的唯一约定', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('从配置读出来再写回去,值不变', () => {
|
||||
// 这条是防止「显示的」和「写进文件的」跑偏:任何一种模式转一圈都要回到原值
|
||||
for (const preset of OUTBOUND_PRESETS) {
|
||||
expect(outboundToProxyField(preset.mode)).toBe(preset.field)
|
||||
}
|
||||
expect(outboundToProxyField({ kind: 'named', name: 'AUTO' })).toBe('AUTO')
|
||||
})
|
||||
|
||||
it('界面上的名字用配置里的原词', () => {
|
||||
expect(outboundLabel({ kind: 'rules' })).toBe('RULE')
|
||||
expect(outboundLabel({ kind: 'direct' })).toBe('DIRECT')
|
||||
@@ -179,3 +165,20 @@ describe('出站预设表是读写显示三者的唯一约定', () => {
|
||||
expect(isUnsetOutbound({ kind: 'named', name: 'AUTO' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('经由入站发请求时连哪个端口', () => {
|
||||
it('单个端口直接用', () => {
|
||||
expect(firstPort('7890')).toBe(7890)
|
||||
})
|
||||
|
||||
it('多端口和区间取第一个数', () => {
|
||||
expect(firstPort('200,302')).toBe(200)
|
||||
expect(firstPort('401-429')).toBe(401)
|
||||
})
|
||||
|
||||
it('解析不出或越界返回 null', () => {
|
||||
expect(firstPort('')).toBeNull()
|
||||
expect(firstPort('abc')).toBeNull()
|
||||
expect(firstPort('70000')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
+28
-10
@@ -60,11 +60,10 @@ export interface Inbound {
|
||||
/**
|
||||
* 不用指名道姓的那几种出站,以及它们各自对应配置里的什么值。
|
||||
*
|
||||
* 这张表是「读配置 → 界面 → 写回配置」三者的唯一约定来源。之前它被拆成四份散在
|
||||
* 各处(读一份、显示一份、写回一份、表单里又抄了一份),加一种出站模式就要同时改
|
||||
* 四个地方,漏一个就是「界面显示的」和「实际写进文件的」对不上。
|
||||
* 这张表是「读配置 → 界面显示」的唯一约定来源。Patchbay 自己不会写出这些值
|
||||
* (出站模式的写方向已随设计废弃),但手写配置里它们都可能出现,读和显示必须认得。
|
||||
*
|
||||
* `field` 为 undefined 表示配置里**不写** proxy 这个字段——「没有这个键」和
|
||||
* `field` 为 undefined 表示配置里**没写** proxy 这个字段——「没有这个键」和
|
||||
* 「这个键是空字符串」在 YAML 里是两回事。
|
||||
*/
|
||||
export const OUTBOUND_PRESETS: Array<{
|
||||
@@ -83,12 +82,6 @@ function outboundOf(proxy: unknown): OutboundMode {
|
||||
return preset ? preset.mode : { kind: 'named', name: proxy }
|
||||
}
|
||||
|
||||
/** 把出站去向写回成配置里 proxy 字段该有的值。返回 undefined 表示不写这个字段。 */
|
||||
export function outboundToProxyField(mode: OutboundMode): string | undefined {
|
||||
if (mode.kind === 'named') return mode.name
|
||||
return OUTBOUND_PRESETS.find((p) => p.mode.kind === mode.kind)?.field
|
||||
}
|
||||
|
||||
/** 出站在界面上的名字。指名道姓的就是它自己的名字,其余用配置里的原词。 */
|
||||
export function outboundLabel(mode: OutboundMode): string {
|
||||
if (mode.kind === 'named') return mode.name
|
||||
@@ -172,12 +165,37 @@ export function collectInbounds(data: ConfigObject): Inbound[] {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一路入站在内核连接快照里的 `inboundName`。
|
||||
*
|
||||
* 命名入站带的是配置里的 name 原词(内核源码 `adapter/inbound/addition.go:19`,
|
||||
* 名字来自 listener 配置的 name 字段)。老式顶层端口没有名字,内核给它们造了
|
||||
* `DEFAULT-协议` 这样的内部名(如 `listener/mixed/mixed.go:57` 的 DEFAULT-MIXED、
|
||||
* `listener/http/server.go:61` 的 DEFAULT-HTTP)。按这个名字才能把连接、吞吐这些
|
||||
* 运行时数据对回界面上的某一路。
|
||||
*/
|
||||
export function inboundStatKey(inbound: Inbound): string {
|
||||
if (!inbound.isDefault) return inbound.name
|
||||
return `DEFAULT-${inbound.type.toUpperCase()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 走规则表的流量最后交给谁。
|
||||
*
|
||||
* `MATCH` 匹配一切,所以第一条 MATCH 之后的规则都到不了,它就是兜底出站。
|
||||
* 没有 MATCH 时内核会直连,返回 null。
|
||||
*/
|
||||
/**
|
||||
* 服务端要经由这一路入站发请求时,连本机的哪个端口。
|
||||
* 命名入站的端口可以写成 "200,302" 或 "401-429",取第一个数字;解析不出返回 null。
|
||||
*/
|
||||
export function firstPort(port: string): number | null {
|
||||
const match = /\d+/.exec(port)
|
||||
if (!match) return null
|
||||
const value = Number(match[0])
|
||||
return value >= 1 && value <= 65535 ? value : null
|
||||
}
|
||||
|
||||
export function fallbackTarget(data: ConfigObject): string | null {
|
||||
const rules = Array.isArray(data.rules) ? data.rules : []
|
||||
for (const rule of rules) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { isProviderFilePath } from './provider-files.ts'
|
||||
|
||||
describe('isProviderFilePath', () => {
|
||||
test('订阅缓存和规则清单的标准形状放行', () => {
|
||||
expect(isProviderFilePath('./providers/subs.yaml')).toBe(true)
|
||||
expect(isProviderFilePath('./providers/rules/ads.txt')).toBe(true)
|
||||
expect(isProviderFilePath('./providers/rules/直连清单.yaml')).toBe(true)
|
||||
})
|
||||
|
||||
test('目录逃逸和绝对路径全拦', () => {
|
||||
expect(isProviderFilePath('./providers/../config.yaml')).toBe(false)
|
||||
expect(isProviderFilePath('providers/subs.yaml')).toBe(false)
|
||||
expect(isProviderFilePath('/etc/mihomo/config.yaml')).toBe(false)
|
||||
expect(isProviderFilePath('./providers/rules/../../x.yaml')).toBe(false)
|
||||
expect(isProviderFilePath('./providers/a/b.yaml')).toBe(false)
|
||||
})
|
||||
|
||||
test('mrs 是二进制格式,不走文本上传', () => {
|
||||
expect(isProviderFilePath('./providers/rules/geo.mrs')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 上传的线路/规则清单文件允许写到哪。
|
||||
*
|
||||
* 服务端的写文件端点只认这两种形状——它们正是 providerCachePath()(订阅缓存)
|
||||
* 和 ruleProviderPath()(规则组清单)生成的路径。白名单式校验:文件名只许
|
||||
* 字母数字下划线连字符,杜绝 ../、绝对路径和多余的目录层级。
|
||||
* mrs 不在扩展名里:那是二进制格式,文本上传通道传不了。
|
||||
*/
|
||||
export function isProviderFilePath(path: string): boolean {
|
||||
return /^\.\/providers\/(rules\/)?[\p{L}\p{N}_-]+\.(yaml|txt)$/u.test(path)
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
DEFAULT_HEALTH_CHECK,
|
||||
DEFAULT_INTERVAL,
|
||||
addFileSubscription,
|
||||
addSubscription,
|
||||
listSubscriptions,
|
||||
moveSubscription,
|
||||
providerNames,
|
||||
removeSubscription,
|
||||
renameSubscription,
|
||||
setSubscriptionHealthCheck,
|
||||
subscriptionPathConflict,
|
||||
subscriptionReferences,
|
||||
updateSubscription,
|
||||
} from './providers.ts'
|
||||
import {
|
||||
applySetup,
|
||||
chainProviderName,
|
||||
providerCachePath,
|
||||
pruneGenerated,
|
||||
syncGenerated,
|
||||
} from './inbound-groups.ts'
|
||||
import type { OutboundSetup } from './inbound-groups.ts'
|
||||
import { collectInbounds } from './inbounds.ts'
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
|
||||
const URL = 'http://127.0.0.1:3001/token/download/V2House'
|
||||
|
||||
/** 一份带一个订阅的配置,形状仿照目标机器上那份。 */
|
||||
function base(): ConfigObject {
|
||||
return {
|
||||
'proxy-providers': {
|
||||
subs: {
|
||||
type: 'http',
|
||||
url: URL,
|
||||
path: './providers/subs.yaml',
|
||||
interval: 86400,
|
||||
'health-check': { enable: true, url: 'https://www.gstatic.com/generate_204', interval: 300 },
|
||||
},
|
||||
},
|
||||
'proxy-groups': [{ name: 'PROXY', type: 'select', proxies: ['DIRECT'], use: ['subs'] }],
|
||||
}
|
||||
}
|
||||
|
||||
const chainedSetup: OutboundSetup = {
|
||||
proxy: { sources: ['subs'], filter: null },
|
||||
chain: { sources: ['subs'], filter: null },
|
||||
}
|
||||
|
||||
describe('读出订阅列表', () => {
|
||||
it('读出地址、间隔和健康检查', () => {
|
||||
expect(listSubscriptions(base())).toEqual([
|
||||
{
|
||||
name: 'subs',
|
||||
type: 'http',
|
||||
path: './providers/subs.yaml',
|
||||
url: URL,
|
||||
interval: 86400,
|
||||
healthCheck: { url: 'https://www.gstatic.com/generate_204', interval: 300 },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('健康检查没写或没开都算未开启', () => {
|
||||
const data = base()
|
||||
const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject
|
||||
delete subs['health-check']
|
||||
expect(listSubscriptions(data)[0]?.healthCheck).toBeNull()
|
||||
subs['health-check'] = { enable: false, url: 'https://example.com', interval: 60 }
|
||||
expect(listSubscriptions(data)[0]?.healthCheck).toBeNull()
|
||||
})
|
||||
|
||||
it('不含 Patchbay 生成的订阅副本', () => {
|
||||
const data = applySetup(
|
||||
{ ...base(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
|
||||
'test1',
|
||||
chainedSetup,
|
||||
)
|
||||
expect(listSubscriptions(data).map((s) => s.name)).toEqual(['subs'])
|
||||
// 副本还在,只是不列出来
|
||||
expect(providerNames(data)).toContain(chainProviderName('test1', 'subs'))
|
||||
})
|
||||
|
||||
it('没有 proxy-providers 段时是空列表', () => {
|
||||
expect(listSubscriptions({ mode: 'rule' })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('新增订阅', () => {
|
||||
it('自动生成缓存路径、默认间隔和默认开启的健康检查', () => {
|
||||
const next = addSubscription({}, 'subs2', URL)
|
||||
expect(next['proxy-providers']).toEqual({
|
||||
subs2: {
|
||||
type: 'http',
|
||||
url: URL,
|
||||
path: './providers/subs2.yaml',
|
||||
interval: DEFAULT_INTERVAL,
|
||||
'health-check': DEFAULT_HEALTH_CHECK,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('不动已有的订阅', () => {
|
||||
const data = base()
|
||||
const next = addSubscription(data, 'subs2', URL)
|
||||
expect((next['proxy-providers'] as ConfigObject).subs).toEqual(
|
||||
(data['proxy-providers'] as ConfigObject).subs,
|
||||
)
|
||||
})
|
||||
|
||||
it('关掉自动更新时不写 interval 键', () => {
|
||||
const next = addSubscription({}, 'subs2', URL, null)
|
||||
const entry = (next['proxy-providers'] as ConfigObject).subs2 as ConfigObject
|
||||
expect('interval' in entry).toBe(false)
|
||||
})
|
||||
|
||||
it('从文件导入的订阅是 file 类型,没有地址和间隔', () => {
|
||||
const next = addFileSubscription({}, 'subs2')
|
||||
expect(next['proxy-providers']).toEqual({
|
||||
subs2: {
|
||||
type: 'file',
|
||||
path: './providers/subs2.yaml',
|
||||
'health-check': DEFAULT_HEALTH_CHECK,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('健康检查开关', () => {
|
||||
it('开启时缺的字段用默认值补齐', () => {
|
||||
const data = base()
|
||||
const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject
|
||||
delete subs['health-check']
|
||||
const next = setSubscriptionHealthCheck(data, 'subs', true)
|
||||
const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject
|
||||
expect(entry['health-check']).toEqual(DEFAULT_HEALTH_CHECK)
|
||||
})
|
||||
|
||||
it('关闭只翻 enable,手写的 url 和 interval 留着', () => {
|
||||
const data = base()
|
||||
const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject
|
||||
subs['health-check'] = { enable: true, url: 'https://example.com/ping', interval: 60 }
|
||||
const next = setSubscriptionHealthCheck(data, 'subs', false)
|
||||
const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject
|
||||
expect(entry['health-check']).toEqual({
|
||||
enable: false,
|
||||
url: 'https://example.com/ping',
|
||||
interval: 60,
|
||||
})
|
||||
// 再开启时它们原样回来
|
||||
const back = setSubscriptionHealthCheck(next, 'subs', true)
|
||||
expect(listSubscriptions(back)[0]?.healthCheck).toEqual({
|
||||
url: 'https://example.com/ping',
|
||||
interval: 60,
|
||||
})
|
||||
})
|
||||
|
||||
it('本来就没写又要关闭时不做改动', () => {
|
||||
const data = base()
|
||||
const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject
|
||||
delete subs['health-check']
|
||||
expect(setSubscriptionHealthCheck(data, 'subs', false)).toBe(data)
|
||||
})
|
||||
|
||||
it('订阅不存在时不做改动', () => {
|
||||
const data = base()
|
||||
expect(setSubscriptionHealthCheck(data, 'ghost', true)).toBe(data)
|
||||
})
|
||||
})
|
||||
|
||||
describe('订阅改名', () => {
|
||||
it('改映射键并同步策略组的 use 引用,path 不动', () => {
|
||||
const next = renameSubscription(base(), 'subs', 'subs-new')
|
||||
const providers = next['proxy-providers'] as ConfigObject
|
||||
expect(Object.keys(providers)).toEqual(['subs-new'])
|
||||
expect((providers['subs-new'] as ConfigObject).path).toBe('./providers/subs.yaml')
|
||||
const group = (next['proxy-groups'] as ConfigObject[])[0]!
|
||||
expect(group.use).toEqual(['subs-new'])
|
||||
})
|
||||
|
||||
it('保持条目在映射里的位置', () => {
|
||||
const data = addSubscription(addSubscription(base(), 'a', URL), 'b', URL)
|
||||
const next = renameSubscription(data, 'a', 'z')
|
||||
expect(Object.keys(next['proxy-providers'] as ConfigObject)).toEqual(['subs', 'z', 'b'])
|
||||
})
|
||||
|
||||
it('改名后保存链路按新名字重建链式副本', () => {
|
||||
const data = applySetup(
|
||||
{ ...base(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
|
||||
'test1',
|
||||
chainedSetup,
|
||||
)
|
||||
const renamed = renameSubscription(data, 'subs', 'subs2')
|
||||
// 页面 commit() 每次落盘前都跑这两步
|
||||
const synced = pruneGenerated(syncGenerated(renamed, collectInbounds(renamed)))
|
||||
expect(providerNames(synced)).toContain(chainProviderName('test1', 'subs2'))
|
||||
expect(providerNames(synced)).not.toContain(chainProviderName('test1', 'subs'))
|
||||
// 引用检查也要认得出新名字,删除保护不能因为改名失效
|
||||
expect(subscriptionReferences(synced, 'subs2')).not.toEqual([])
|
||||
})
|
||||
|
||||
it('原名不存在、新名已占用或没改时都不做改动', () => {
|
||||
const data = base()
|
||||
expect(renameSubscription(data, 'ghost', 'x')).toBe(data)
|
||||
const withX = addSubscription(data, 'x', URL)
|
||||
expect(renameSubscription(withX, 'subs', 'x')).toBe(withX)
|
||||
expect(renameSubscription(data, 'subs', 'subs')).toBe(data)
|
||||
})
|
||||
})
|
||||
|
||||
describe('编辑订阅', () => {
|
||||
it('只改地址和间隔,其余字段原样保留', () => {
|
||||
const next = updateSubscription(base(), 'subs', 'http://example.com/new', 3600)
|
||||
const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject
|
||||
expect(entry.url).toBe('http://example.com/new')
|
||||
expect(entry.interval).toBe(3600)
|
||||
expect(entry.path).toBe('./providers/subs.yaml')
|
||||
expect(entry['health-check']).toEqual({
|
||||
enable: true,
|
||||
url: 'https://www.gstatic.com/generate_204',
|
||||
interval: 300,
|
||||
})
|
||||
})
|
||||
|
||||
it('订阅不存在时不做改动', () => {
|
||||
const data = base()
|
||||
expect(updateSubscription(data, 'ghost', URL, 1)).toBe(data)
|
||||
})
|
||||
|
||||
it('关掉自动更新时把已有的 interval 键去掉', () => {
|
||||
const next = updateSubscription(base(), 'subs', URL, null)
|
||||
const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject
|
||||
expect('interval' in entry).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('删除订阅', () => {
|
||||
it('从映射里去掉', () => {
|
||||
const data = addSubscription(base(), 'subs2', URL)
|
||||
const next = removeSubscription(data, 'subs2')
|
||||
expect(providerNames(next)).toEqual(['subs'])
|
||||
})
|
||||
|
||||
it('删到最后一个时整个键去掉,不留空映射', () => {
|
||||
const next = removeSubscription(base(), 'subs')
|
||||
expect('proxy-providers' in next).toBe(false)
|
||||
})
|
||||
|
||||
it('订阅不存在时不做改动', () => {
|
||||
const data = base()
|
||||
expect(removeSubscription(data, 'ghost')).toBe(data)
|
||||
})
|
||||
})
|
||||
|
||||
describe('删除前的引用检查', () => {
|
||||
it('找出 use 它的策略组', () => {
|
||||
expect(subscriptionReferences(base(), 'subs')).toEqual(['PROXY'])
|
||||
})
|
||||
|
||||
it('没人在用时是空列表', () => {
|
||||
const data = base()
|
||||
;(data['proxy-groups'] as ConfigObject[])[0] = { name: 'PROXY', type: 'select', proxies: ['DIRECT'] }
|
||||
expect(subscriptionReferences(data, 'subs')).toEqual([])
|
||||
})
|
||||
|
||||
it('链式代理的专属组 use 的是副本,也算引用原订阅', () => {
|
||||
const data = applySetup(
|
||||
{ ...base(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
|
||||
'test1',
|
||||
chainedSetup,
|
||||
)
|
||||
const refs = subscriptionReferences(data, 'subs')
|
||||
// 入口组直接 use 原订阅,三个专属组 use 副本,两边都要认出来
|
||||
expect(refs).toContain('patchbay:test1 链式')
|
||||
expect(refs).toContain('patchbay:test1')
|
||||
})
|
||||
|
||||
it('订阅不存在时是空列表', () => {
|
||||
expect(subscriptionReferences(base(), 'ghost')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('调整订阅顺序', () => {
|
||||
/** 三个用户订阅,外加一份接在中间的订阅副本。 */
|
||||
function three(): ConfigObject {
|
||||
let data: ConfigObject = {}
|
||||
for (const name of ['A', 'B', 'C']) data = addSubscription(data, name, `https://${name}.example`)
|
||||
return data
|
||||
}
|
||||
|
||||
it('上移和下移交换相邻两个订阅', () => {
|
||||
expect(listSubscriptions(moveSubscription(three(), 'B', -1)).map((s) => s.name)).toEqual([
|
||||
'B',
|
||||
'A',
|
||||
'C',
|
||||
])
|
||||
expect(listSubscriptions(moveSubscription(three(), 'B', 1)).map((s) => s.name)).toEqual([
|
||||
'A',
|
||||
'C',
|
||||
'B',
|
||||
])
|
||||
})
|
||||
|
||||
it('到头了原样返回,配置对象不变', () => {
|
||||
const data = three()
|
||||
expect(moveSubscription(data, 'A', -1)).toBe(data)
|
||||
expect(moveSubscription(data, 'C', 1)).toBe(data)
|
||||
expect(moveSubscription(data, '不存在', 1)).toBe(data)
|
||||
})
|
||||
|
||||
it('夹在中间的订阅副本留在原地', () => {
|
||||
const withCopy = applySetup(
|
||||
{ ...three(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
|
||||
'test1',
|
||||
{ proxy: { sources: ['B'], filter: null }, chain: { sources: ['B'], filter: null } },
|
||||
)
|
||||
const copy = chainProviderName('test1', 'B')
|
||||
const before = Object.keys(withCopy['proxy-providers'] as ConfigObject).indexOf(copy)
|
||||
const after = moveSubscription(withCopy, 'A', 1)
|
||||
expect(listSubscriptions(after).map((s) => s.name)).toEqual(['B', 'A', 'C'])
|
||||
expect(Object.keys(after['proxy-providers'] as ConfigObject).indexOf(copy)).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('缓存路径撞名', () => {
|
||||
it('名字只差符号时净化成同一个路径,能认出来', () => {
|
||||
const data = addSubscription({}, '订阅 A', 'https://a.example/sub')
|
||||
const path = providerCachePath('订阅/A')
|
||||
expect(path).toBe(providerCachePath('订阅 A'))
|
||||
expect(subscriptionPathConflict(data, '订阅/A', path)).toBe('订阅 A')
|
||||
})
|
||||
|
||||
it('不同路径、或者就是自己,都不算撞', () => {
|
||||
const data = addSubscription({}, '订阅 A', 'https://a.example/sub')
|
||||
expect(subscriptionPathConflict(data, 'B', providerCachePath('B'))).toBeNull()
|
||||
expect(subscriptionPathConflict(data, '订阅 A', providerCachePath('订阅 A'))).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* 订阅的读写。
|
||||
*
|
||||
* 配置文件里的 `proxy-providers` 段:一个名字对一份声明,内核定时从地址拉回一批线路。
|
||||
* 这段里除了用户自己加的订阅,还有 Patchbay 为链式代理生成的副本(带 `patchbay:` 前缀)。
|
||||
* 这个模块只操作用户订阅;副本的增删由 `inbound-groups.ts` 的 applySetup/pruneGenerated 负责,
|
||||
* 写配置的链路(页面里的 commit)每次都会跑它们,所以这里改完不用补同步。
|
||||
*
|
||||
* 改名必须走 renameSubscription:名字被策略组的 `use:` 和生成的副本引用,改名和
|
||||
* 同步引用要在同一次变换里完成,散开做漏一处就是断引用。
|
||||
*/
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
import { isGenerated, providerCachePath } from './inbound-groups.ts'
|
||||
|
||||
/** 新增订阅时的默认更新间隔,秒。一天一次。 */
|
||||
export const DEFAULT_INTERVAL = 86400
|
||||
|
||||
/**
|
||||
* 新增订阅或开启健康检查时写入的默认值。
|
||||
*
|
||||
* `url` 必须写:内核没有默认检查地址,为空时 interval 被强制归零、检查不会运行
|
||||
* (内核源码 adapter/provider/healthcheck.go:196-199)。`interval` 与内核在
|
||||
* enable 时的默认一致(adapter/provider/parser.go:66-68),写死一个更稳。
|
||||
*/
|
||||
export const DEFAULT_HEALTH_CHECK = {
|
||||
enable: true,
|
||||
url: 'https://www.gstatic.com/generate_204',
|
||||
interval: 300,
|
||||
}
|
||||
|
||||
/** 一个用户订阅在界面上的样子。只读配置文件的 `proxy-providers` 段(决策记录 0004)。 */
|
||||
export interface Subscription {
|
||||
name: string
|
||||
/** http 定时从地址拉取;file 读本机文件,内核靠文件监视跟进变化 */
|
||||
type: 'http' | 'file'
|
||||
url: string
|
||||
/** 线路文件的路径(http 类型是缓存,file 类型是数据本体) */
|
||||
path: string | null
|
||||
/**
|
||||
* 更新间隔,秒。null 表示配置里没写——内核只在 interval > 0 时才起定时
|
||||
* 拉取循环(内核源码 component/resource/fetcher.go:226-228),所以
|
||||
* 「没写」就是「不自动更新」,界面上它是个开关而不是必填数字。
|
||||
*/
|
||||
interval: number | null
|
||||
/** 健康检查。没写或 enable 不是 true 都算未开启 */
|
||||
healthCheck: { url: string | null; interval: number | null } | null
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is ConfigObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function providersOf(data: ConfigObject): ConfigObject {
|
||||
return isPlainObject(data['proxy-providers']) ? data['proxy-providers'] : {}
|
||||
}
|
||||
|
||||
function groupsOf(data: ConfigObject): ConfigObject[] {
|
||||
return (Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : []).filter(isPlainObject)
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
||||
}
|
||||
|
||||
/** 所有用户订阅,按配置里的顺序。不含生成的副本。 */
|
||||
export function listSubscriptions(data: ConfigObject): Subscription[] {
|
||||
const result: Subscription[] = []
|
||||
for (const [name, entry] of Object.entries(providersOf(data))) {
|
||||
if (isGenerated(name) || !isPlainObject(entry)) continue
|
||||
const hc = isPlainObject(entry['health-check']) ? entry['health-check'] : null
|
||||
result.push({
|
||||
name,
|
||||
type: entry.type === 'file' ? 'file' : 'http',
|
||||
path: typeof entry.path === 'string' ? entry.path : null,
|
||||
url: typeof entry.url === 'string' ? entry.url : '',
|
||||
interval: typeof entry.interval === 'number' ? entry.interval : null,
|
||||
healthCheck:
|
||||
hc !== null && hc.enable === true
|
||||
? {
|
||||
url: typeof hc.url === 'string' ? hc.url : null,
|
||||
interval: typeof hc.interval === 'number' ? hc.interval : null,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* `proxy-providers` 里的所有名字,含生成的副本。
|
||||
* 新增订阅挡重名要用全部键:它是映射,和用户看不见的副本撞名也会被静默丢掉。
|
||||
*/
|
||||
export function providerNames(data: ConfigObject): string[] {
|
||||
return Object.keys(providersOf(data))
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增一个订阅。
|
||||
*
|
||||
* `path` 自动给出确定的缓存文件路径,不让用户填:不写的话内核按 url 哈希自己生成一个,
|
||||
* 那个路径 Patchbay 算不出来,链式代理的副本就没法靠「读同一个缓存文件」认来源
|
||||
* (见 inbound-groups.ts 的 applySetup)。
|
||||
*
|
||||
* `interval` 为 null 时不写这个键,内核就不自动更新(见 Subscription.interval 的注)。
|
||||
*/
|
||||
export function addSubscription(
|
||||
data: ConfigObject,
|
||||
name: string,
|
||||
url: string,
|
||||
interval: number | null = DEFAULT_INTERVAL,
|
||||
): ConfigObject {
|
||||
const next = { ...data }
|
||||
next['proxy-providers'] = {
|
||||
...providersOf(data),
|
||||
[name]: {
|
||||
type: 'http',
|
||||
url,
|
||||
path: providerCachePath(name),
|
||||
...(interval !== null ? { interval } : {}),
|
||||
'health-check': { ...DEFAULT_HEALTH_CHECK },
|
||||
},
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 新订阅的缓存路径是否和现有订阅的撞车。
|
||||
*
|
||||
* 名字查重挡不住这种:路径由名字净化而来,「订阅 A」和「订阅/A」是两个名字、
|
||||
* 同一个 `providers/订阅-A.yaml`,两个订阅会互相覆盖对方的缓存文件。
|
||||
* 返回撞上的那个订阅名,没撞返回 null。
|
||||
*/
|
||||
export function subscriptionPathConflict(
|
||||
data: ConfigObject,
|
||||
selfName: string,
|
||||
path: string,
|
||||
): string | null {
|
||||
for (const [name, decl] of Object.entries(providersOf(data))) {
|
||||
if (name === selfName) continue
|
||||
// 没写 path 的声明由内核按 url 哈希生成路径,和这里的净化路径撞不上
|
||||
if ((decl as ConfigObject).path === path) return name
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增一个来自本机文件的订阅。数据就在 path 指的文件里(内容由上传流程先写好),
|
||||
* 内核靠文件监视跟进变化,没有地址也没有更新间隔。
|
||||
*/
|
||||
export function addFileSubscription(data: ConfigObject, name: string): ConfigObject {
|
||||
const next = { ...data }
|
||||
next['proxy-providers'] = {
|
||||
...providersOf(data),
|
||||
[name]: {
|
||||
type: 'file',
|
||||
path: providerCachePath(name),
|
||||
'health-check': { ...DEFAULT_HEALTH_CHECK },
|
||||
},
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** 改订阅的地址和更新间隔。其余字段(缓存路径、健康检查、筛选)原样保留。 */
|
||||
export function updateSubscription(
|
||||
data: ConfigObject,
|
||||
name: string,
|
||||
url: string,
|
||||
interval: number | null,
|
||||
): ConfigObject {
|
||||
const entry = providersOf(data)[name]
|
||||
if (!isPlainObject(entry)) return data
|
||||
const updated: ConfigObject = { ...entry, url }
|
||||
if (interval === null) delete updated.interval
|
||||
else updated.interval = interval
|
||||
const next = { ...data }
|
||||
next['proxy-providers'] = {
|
||||
...providersOf(data),
|
||||
[name]: updated,
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 开关一个订阅的健康检查。
|
||||
*
|
||||
* 关闭写 `enable: false` 而不是删掉整个键:用户手写的 url/interval 要留着,
|
||||
* 下次开启时还用它们。开启时缺的字段用默认值补齐——url 不能缺,内核没有默认
|
||||
* 检查地址(见 DEFAULT_HEALTH_CHECK 的注)。
|
||||
*/
|
||||
export function setSubscriptionHealthCheck(
|
||||
data: ConfigObject,
|
||||
name: string,
|
||||
enable: boolean,
|
||||
): ConfigObject {
|
||||
const entry = providersOf(data)[name]
|
||||
if (!isPlainObject(entry)) return data
|
||||
const existing = isPlainObject(entry['health-check']) ? entry['health-check'] : null
|
||||
// 本来就没写又要关闭,不必写一个 enable: false 的完整声明进去
|
||||
if (!enable && existing === null) return data
|
||||
const next = { ...data }
|
||||
next['proxy-providers'] = {
|
||||
...providersOf(data),
|
||||
[name]: { ...entry, 'health-check': { ...DEFAULT_HEALTH_CHECK, ...existing, enable } },
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 改订阅的名字,并同步所有策略组 `use:` 里的引用。
|
||||
*
|
||||
* 缓存路径 `path` 保持不变:file 类型的数据本体就在旧路径上,链式代理的副本靠
|
||||
* 「和原订阅同一个 path」认来源(见 inbound-groups.ts),路径一动这两件事都得另做迁移。
|
||||
* 生成的副本这里不碰:写配置的链路每次都跑 syncGenerated/pruneGenerated,
|
||||
* 会按新名字重建副本、清掉旧的。
|
||||
*/
|
||||
export function renameSubscription(
|
||||
data: ConfigObject,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): ConfigObject {
|
||||
const providers = providersOf(data)
|
||||
if (oldName === newName || !(oldName in providers) || newName in providers) return data
|
||||
|
||||
const next = { ...data }
|
||||
// 逐项重建以保住条目在映射里的位置,diff 才不会把整段重排
|
||||
next['proxy-providers'] = Object.fromEntries(
|
||||
Object.entries(providers).map(([name, entry]) => [name === oldName ? newName : name, entry]),
|
||||
)
|
||||
|
||||
if (Array.isArray(data['proxy-groups'])) {
|
||||
next['proxy-groups'] = data['proxy-groups'].map((group) => {
|
||||
if (!isPlainObject(group) || !Array.isArray(group.use)) return group
|
||||
if (!group.use.includes(oldName)) return group
|
||||
return { ...group, use: group.use.map((item) => (item === oldName ? newName : item)) }
|
||||
})
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一个订阅在列表里上移或下移一位。
|
||||
*
|
||||
* 顺序只是呈现顺序,内核不看 `proxy-providers` 里条目的先后。交换的是两个用户订阅在
|
||||
* 映射里的位置,中间夹着的副本留在原地——它们不在列表上,跟着挪只会把 diff 撑大。
|
||||
* 已经在头(尾)还要上(下)移时原样返回,调用方据此不发起保存。
|
||||
*/
|
||||
export function moveSubscription(
|
||||
data: ConfigObject,
|
||||
name: string,
|
||||
delta: -1 | 1,
|
||||
): ConfigObject {
|
||||
const entries = Object.entries(providersOf(data))
|
||||
const keys = entries.map(([key]) => key)
|
||||
const mine = keys.filter((key) => !isGenerated(key))
|
||||
const at = mine.indexOf(name)
|
||||
const target = mine[at + delta]
|
||||
if (at < 0 || target === undefined) return data
|
||||
|
||||
const from = keys.indexOf(name)
|
||||
const onto = keys.indexOf(target)
|
||||
const order = [...entries]
|
||||
order[from] = entries[onto]!
|
||||
order[onto] = entries[from]!
|
||||
|
||||
const next = { ...data }
|
||||
next['proxy-providers'] = Object.fromEntries(order)
|
||||
return next
|
||||
}
|
||||
|
||||
/** 删除一个订阅。删到最后一个时把整个键去掉,不在文件里留空映射。 */
|
||||
export function removeSubscription(data: ConfigObject, name: string): ConfigObject {
|
||||
const providers = { ...providersOf(data) }
|
||||
if (!(name in providers)) return data
|
||||
delete providers[name]
|
||||
const next = { ...data }
|
||||
if (Object.keys(providers).length === 0) delete next['proxy-providers']
|
||||
else next['proxy-providers'] = providers
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 哪些策略组在引用这个订阅。删除前必须先查:被引用的订阅删了,那些组的 `use:` 就空了,
|
||||
* 而空组会让指向它的入站无路可走。
|
||||
*
|
||||
* 引用有两种,第二种容易漏:
|
||||
* 直接 组的 `use:` 里有这个订阅。链式代理的入口组就是这么引原订阅的
|
||||
* 间接 开了链式代理的入站,它的专属组 `use:` 的是订阅副本;副本靠和原订阅
|
||||
* 共用缓存文件(`path` 相同)认来源,名字不可靠解析(见 inbound-groups.ts)
|
||||
*/
|
||||
export function subscriptionReferences(data: ConfigObject, name: string): string[] {
|
||||
const target = providersOf(data)[name]
|
||||
if (!isPlainObject(target)) return []
|
||||
|
||||
const copies = new Set<string>()
|
||||
if (typeof target.path === 'string') {
|
||||
for (const [copyName, entry] of Object.entries(providersOf(data))) {
|
||||
if (isGenerated(copyName) && isPlainObject(entry) && entry.path === target.path) {
|
||||
copies.add(copyName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groupsOf(data)
|
||||
.filter((group) => {
|
||||
const use = stringList(group.use)
|
||||
return use.includes(name) || use.some((u) => copies.has(u))
|
||||
})
|
||||
.map((group) => String(group.name))
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
describeProbeFailure,
|
||||
findTarget,
|
||||
isChallenge,
|
||||
judge,
|
||||
rayId,
|
||||
REACH_TARGETS,
|
||||
} from './reachability.ts'
|
||||
|
||||
const openai = REACH_TARGETS[0]!
|
||||
const gemini = REACH_TARGETS[2]!
|
||||
|
||||
const headers = (init: Record<string, string> = {}) => new Headers(init)
|
||||
|
||||
describe('REACH_TARGETS', () => {
|
||||
test('标识不重名,否则接口按 id 取目标会取错', () => {
|
||||
const ids = REACH_TARGETS.map((t) => t.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
})
|
||||
|
||||
test('findTarget 认不出来的标识返回 null', () => {
|
||||
expect(findTarget('openai')?.label).toBe('OpenAI')
|
||||
expect(findTarget('nonesuch')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('judge', () => {
|
||||
test('无鉴权探测拿到 401 算可达,并说明这是预期结果', () => {
|
||||
const { verdict, message } = judge(openai, 401, headers(), '')
|
||||
expect(verdict).toBe('reachable')
|
||||
expect(message).toBe('HTTP 401,目标可达')
|
||||
})
|
||||
|
||||
test('2xx 不需要解释,只报状态码', () => {
|
||||
expect(judge(gemini, 200, headers(), '{}')).toEqual({
|
||||
verdict: 'reachable',
|
||||
message: 'HTTP 200',
|
||||
})
|
||||
})
|
||||
|
||||
test('白名单之外的状态码算不通', () => {
|
||||
const { verdict, message } = judge(gemini, 403, headers(), '')
|
||||
expect(verdict).toBe('unreachable')
|
||||
expect(message).toBe('HTTP 403,不是预期的状态码')
|
||||
})
|
||||
|
||||
test('429 单独说是限流,不和连不上混在一起', () => {
|
||||
expect(judge(openai, 429, headers(), '').verdict).toBe('throttled')
|
||||
})
|
||||
|
||||
test('Cloudflare 拦截优先于状态码白名单', () => {
|
||||
// 429 在 openai 的白名单外,但结论要是 challenge 而不是 throttled:
|
||||
// 前者要换出口 IP,后者只要等
|
||||
const { verdict, message } = judge(
|
||||
openai,
|
||||
429,
|
||||
headers({ 'cf-mitigated': 'challenge', 'cf-ray': '8f0a1b2c3d4e5f60-LAX' }),
|
||||
'',
|
||||
)
|
||||
expect(verdict).toBe('challenge')
|
||||
expect(message).toContain('8f0a1b2c3d4e5f60-LAX')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isChallenge', () => {
|
||||
test('只有 403 和 429 才可能是拦截页', () => {
|
||||
const body = '<html>window._cf_chl_opt</html>'
|
||||
expect(isChallenge(403, headers(), body)).toBe(true)
|
||||
expect(isChallenge(429, headers(), body)).toBe(true)
|
||||
expect(isChallenge(200, headers(), body)).toBe(false)
|
||||
})
|
||||
|
||||
test('没有 cf-mitigated 头时认正文里的标记', () => {
|
||||
expect(isChallenge(403, headers(), '<title>Just a Moment...</title>')).toBe(true)
|
||||
})
|
||||
|
||||
test('普通的 403 不是拦截页', () => {
|
||||
expect(isChallenge(403, headers(), '{"error":"forbidden"}')).toBe(false)
|
||||
})
|
||||
|
||||
test('正文只看前 4 KB,标记在那之后不算', () => {
|
||||
expect(isChallenge(403, headers(), 'x'.repeat(5000) + 'just a moment')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rayId', () => {
|
||||
test('优先取响应头', () => {
|
||||
expect(rayId(headers({ 'cf-ray': ' abc123-LAX ' }), 'cf-ray: zzz')).toBe('abc123-LAX')
|
||||
})
|
||||
|
||||
test('没有响应头时从正文里找', () => {
|
||||
expect(rayId(headers(), 'Cloudflare Ray ID: <code>cf-ray: abc123</code>')).toBe('abc123')
|
||||
})
|
||||
|
||||
test('都没有就是空串', () => {
|
||||
expect(rayId(headers(), 'nothing here')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeProbeFailure', () => {
|
||||
test('超时说等了多久', () => {
|
||||
const error = new Error('timed out')
|
||||
error.name = 'TimeoutError'
|
||||
expect(describeProbeFailure(7890, 10_000, error)).toBe('10 秒内没有响应')
|
||||
})
|
||||
|
||||
test('本机端口没人监听时指向内核,不照抄 Bun 那句指向目标网址的话', () => {
|
||||
const error = Object.assign(new Error('Unable to connect. Is the computer able to access the url?'), {
|
||||
code: 'ConnectionRefused',
|
||||
})
|
||||
expect(describeProbeFailure(7890, 10_000, error)).toBe('本机 7890 端口没有响应,内核可能没在运行')
|
||||
})
|
||||
|
||||
test('其余原因照原文给出,不猜', () => {
|
||||
expect(describeProbeFailure(7890, 10_000, new Error('TLS handshake failed'))).toBe(
|
||||
'连接失败:TLS handshake failed',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 可访问性测试:一路入站到几个常用 AI 服务通不通。
|
||||
*
|
||||
* 判定方式来自 sub2api 的代理质量检查(backend/internal/service/admin_proxy.go:337):
|
||||
* 发一个**不带鉴权**的 GET,按状态码白名单判可达。这些接口没有 key 一定不返回 200,
|
||||
* 但只要能返回 401/405 这类业务错误,就说明 TLS 握手、路由、目标服务全都正常——
|
||||
* 这正是「这一路能不能用」要问的。用 200 当唯一标准反而永远测不通。
|
||||
*
|
||||
* 判定逻辑放在 shared/ 而不是 server/,是因为「什么状态码算通」是这个功能的全部内容,
|
||||
* 它得能被 bun test 覆盖;server/ 那边只剩下发请求。
|
||||
*/
|
||||
|
||||
/** 一个探测目标。 */
|
||||
export interface ReachTarget {
|
||||
/** 接口参数里用的标识 */
|
||||
id: string
|
||||
/** 界面上显示的服务名 */
|
||||
label: string
|
||||
url: string
|
||||
/** 这些状态码都算目标可达 */
|
||||
reachableStatuses: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设目标清单,照抄 sub2api 的四家(admin_service.go:600)。
|
||||
*
|
||||
* Anthropic 的白名单最宽,是因为 /v1/messages 只接受 POST,用 GET 探它时不同的
|
||||
* 边缘节点会回 400/404/405 中的任意一个,这几种都只说明请求到达了对面。
|
||||
*/
|
||||
export const REACH_TARGETS: ReachTarget[] = [
|
||||
{
|
||||
id: 'openai',
|
||||
label: 'OpenAI',
|
||||
url: 'https://api.openai.com/v1/models',
|
||||
reachableStatuses: [200, 401],
|
||||
},
|
||||
{
|
||||
id: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
url: 'https://api.anthropic.com/v1/messages',
|
||||
reachableStatuses: [200, 400, 401, 404, 405],
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
label: 'Gemini',
|
||||
url: 'https://generativelanguage.googleapis.com/$discovery/rest?version=v1beta',
|
||||
reachableStatuses: [200],
|
||||
},
|
||||
{
|
||||
id: 'grok',
|
||||
label: 'Grok',
|
||||
url: 'https://api.x.ai/v1/models',
|
||||
reachableStatuses: [200, 401],
|
||||
},
|
||||
]
|
||||
|
||||
export function findTarget(id: string): ReachTarget | null {
|
||||
return REACH_TARGETS.find((t) => t.id === id) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次探测的结论。
|
||||
* reachable 通了
|
||||
* challenge 网络通,但出口 IP 被 Cloudflare 拦下要人机验证——这一路发得出请求,用不了服务
|
||||
* throttled 对面回了 429,通但有频控
|
||||
* unreachable 没通,或者回了预期之外的状态码
|
||||
*/
|
||||
export type ReachVerdict = 'reachable' | 'challenge' | 'throttled' | 'unreachable'
|
||||
|
||||
export interface ReachResult {
|
||||
target: string
|
||||
verdict: ReachVerdict
|
||||
/** 没拿到响应时(连接失败、超时)为 null */
|
||||
status: number | null
|
||||
/** 整个来回的毫秒数 */
|
||||
ms: number
|
||||
/** 说明这个结论怎么来的,直接显示给用户 */
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Cloudflare 拦截页面里必然出现的标记之一。摘自 sub2api util/httputil/httputil.go:15。 */
|
||||
const CHALLENGE_MARKERS = [
|
||||
'window._cf_chl_opt',
|
||||
'just a moment',
|
||||
'enable javascript and cookies to continue',
|
||||
'__cf_chl_',
|
||||
'challenge-platform',
|
||||
]
|
||||
|
||||
/**
|
||||
* 这个响应是不是 Cloudflare 的人机验证。
|
||||
*
|
||||
* 只有 403 和 429 才可能是。cf-mitigated 头是 Cloudflare 自己给的明示,没有这个头时
|
||||
* 退回去认拦截页面的正文标记——正文只看前 4 KB,验证页的标记都在最前面。
|
||||
*/
|
||||
export function isChallenge(status: number, headers: Headers, body: string): boolean {
|
||||
if (status !== 403 && status !== 429) return false
|
||||
if (headers.get('cf-mitigated')?.trim().toLowerCase() === 'challenge') return true
|
||||
|
||||
const preview = body.slice(0, 4096).toLowerCase()
|
||||
return CHALLENGE_MARKERS.some((marker) => preview.includes(marker))
|
||||
}
|
||||
|
||||
/** Cloudflare 的请求编号。报障时对面要这个,所以命中拦截要把它带出来。 */
|
||||
export function rayId(headers: Headers, body: string): string {
|
||||
const header = headers.get('cf-ray')?.trim()
|
||||
if (header) return header
|
||||
return /cf-ray[:\s=]+([a-z0-9-]+)/i.exec(body.slice(0, 8192))?.[1] ?? ''
|
||||
}
|
||||
|
||||
/** 把一次拿到了响应的探测判成结论。连接根本没建立时不走这里,见 server/host.ts。 */
|
||||
export function judge(
|
||||
target: ReachTarget,
|
||||
status: number,
|
||||
headers: Headers,
|
||||
body: string,
|
||||
): { verdict: ReachVerdict; message: string } {
|
||||
if (isChallenge(status, headers, body)) {
|
||||
const ray = rayId(headers, body)
|
||||
return {
|
||||
verdict: 'challenge',
|
||||
message: `出口 IP 被 Cloudflare 拦下要人机验证${ray ? `(cf-ray ${ray})` : ''}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (target.reachableStatuses.includes(status)) {
|
||||
return {
|
||||
verdict: 'reachable',
|
||||
// 白名单里的非 2xx 是无鉴权探测的预期结果,不解释一句会被当成失败
|
||||
message: status >= 200 && status < 300 ? `HTTP ${status}` : `HTTP ${status},目标可达`,
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 429) return { verdict: 'throttled', message: 'HTTP 429,目标在限流' }
|
||||
|
||||
return { verdict: 'unreachable', message: `HTTP ${status},不是预期的状态码` }
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「连响应都没拿到」翻译成一句说明原因的话。
|
||||
*
|
||||
* 探测走的是本机的入站端口,所以连接失败有两种完全不同的来源:端口上根本没人监听
|
||||
* (内核没运行),和端口通了但代理那一跳走不出去。Bun 对前者给的是
|
||||
* 「Unable to connect. Is the computer able to access the url?」——这句话把矛头指向了
|
||||
* 目标网址,而真正的原因在本机,照抄出来只会让人去查错方向。
|
||||
*/
|
||||
export function describeProbeFailure(port: number, timeoutMs: number, error: unknown): string {
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
return `${String(timeoutMs / 1000)} 秒内没有响应`
|
||||
}
|
||||
|
||||
const code = error instanceof Error ? (error as { code?: unknown }).code : undefined
|
||||
if (code === 'ConnectionRefused') {
|
||||
return `本机 ${String(port)} 端口没有响应,内核可能没在运行`
|
||||
}
|
||||
|
||||
return `连接失败:${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
buildRule,
|
||||
findRuleIssues,
|
||||
firstMatchIndex,
|
||||
inferRuleFormat,
|
||||
insertBeforeMatch,
|
||||
isRawOnlyRule,
|
||||
isStructuredEditable,
|
||||
missingLanRules,
|
||||
moveRule,
|
||||
parseIniRulesets,
|
||||
parseRule,
|
||||
planIniImport,
|
||||
ruleProviderPath,
|
||||
ruleProviderPathConflict,
|
||||
ruleSetReferences,
|
||||
setRuleProviders,
|
||||
setRules,
|
||||
subRuleSetReferences,
|
||||
} from './rules.ts'
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
|
||||
describe('规则字符串的拆分与拼装', () => {
|
||||
it('常见的三段规则', () => {
|
||||
expect(parseRule('DOMAIN-SUFFIX,youtube.com,PROXY')).toEqual({
|
||||
type: 'DOMAIN-SUFFIX',
|
||||
payload: 'youtube.com',
|
||||
target: 'PROXY',
|
||||
extras: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('MATCH 没有载荷', () => {
|
||||
expect(parseRule('MATCH,PROXY')).toEqual({
|
||||
type: 'MATCH',
|
||||
payload: null,
|
||||
target: 'PROXY',
|
||||
extras: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('选项尾部原样保留', () => {
|
||||
const parsed = parseRule('IP-CIDR,127.0.0.0/8,DIRECT,no-resolve')
|
||||
expect(parsed.extras).toEqual(['no-resolve'])
|
||||
// 改目标不能丢掉选项
|
||||
expect(buildRule({ ...parsed, target: 'REJECT' })).toBe('IP-CIDR,127.0.0.0/8,REJECT,no-resolve')
|
||||
})
|
||||
|
||||
it('拆开再拼回原样', () => {
|
||||
for (const rule of [
|
||||
'DOMAIN-SUFFIX,youtube.com,PROXY',
|
||||
'MATCH,PROXY',
|
||||
'IP-CIDR,127.0.0.0/8,DIRECT,no-resolve',
|
||||
'RULE-SET,ads,REJECT',
|
||||
'AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY',
|
||||
]) {
|
||||
expect(buildRule(parseRule(rule))).toBe(rule)
|
||||
}
|
||||
})
|
||||
|
||||
it('逻辑规则的载荷里有逗号也不被拆开', () => {
|
||||
const parsed = parseRule('AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY')
|
||||
expect(parsed.payload).toBe('((DOMAIN-SUFFIX,a.com),(DST-PORT,443))')
|
||||
expect(parsed.target).toBe('PROXY')
|
||||
})
|
||||
|
||||
it('正则类规则的顶层逗号不拆坏:最后一段是目标,其余全归载荷', () => {
|
||||
// 内核对 DOMAIN-REGEX 这几类按「载荷可含逗号、不许带选项」读(rules/common/base.go:47-79)
|
||||
expect(parseRule('DOMAIN-REGEX,^(a|b),c$,PROXY')).toEqual({
|
||||
type: 'DOMAIN-REGEX',
|
||||
payload: '^(a|b),c$',
|
||||
target: 'PROXY',
|
||||
extras: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('结构化编辑只给常见形状,逻辑规则、子规则和正则类按原文编辑', () => {
|
||||
expect(isStructuredEditable('DOMAIN-SUFFIX,youtube.com,PROXY')).toBe(true)
|
||||
expect(isStructuredEditable('MATCH,PROXY')).toBe(true)
|
||||
expect(isStructuredEditable('AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY')).toBe(false)
|
||||
expect(isStructuredEditable('SUB-RULE,(DOMAIN-SUFFIX,a.com),sub1')).toBe(false)
|
||||
expect(isStructuredEditable('DOMAIN-REGEX,^ads?,b$,REJECT')).toBe(false)
|
||||
expect(isStructuredEditable('孤零零一段')).toBe(false)
|
||||
})
|
||||
|
||||
it('只认整行编辑的类型;空行和没写完的行不算,表单要能接回去', () => {
|
||||
expect(isRawOnlyRule('AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY')).toBe(true)
|
||||
expect(isRawOnlyRule('DOMAIN-REGEX,^ads?,b$,REJECT')).toBe(true)
|
||||
expect(isRawOnlyRule('')).toBe(false)
|
||||
expect(isRawOnlyRule('DOMAIN,youtube.com')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('规则表的顺序', () => {
|
||||
const rules = ['DOMAIN-SUFFIX,a.com,DIRECT', 'MATCH,PROXY', 'DOMAIN-SUFFIX,b.com,REJECT']
|
||||
|
||||
it('第一条 MATCH 之后的规则不可达', () => {
|
||||
expect(firstMatchIndex(rules)).toBe(1)
|
||||
expect(firstMatchIndex(['DOMAIN-SUFFIX,a.com,DIRECT'])).toBe(-1)
|
||||
})
|
||||
|
||||
it('新规则插在第一条 MATCH 之前', () => {
|
||||
expect(insertBeforeMatch(rules, ['GEOIP,CN,DIRECT'])).toEqual([
|
||||
'DOMAIN-SUFFIX,a.com,DIRECT',
|
||||
'GEOIP,CN,DIRECT',
|
||||
'MATCH,PROXY',
|
||||
'DOMAIN-SUFFIX,b.com,REJECT',
|
||||
])
|
||||
})
|
||||
|
||||
it('没有 MATCH 时加在末尾', () => {
|
||||
expect(insertBeforeMatch(['GEOIP,CN,DIRECT'], ['MATCH,PROXY'])).toEqual([
|
||||
'GEOIP,CN,DIRECT',
|
||||
'MATCH,PROXY',
|
||||
])
|
||||
})
|
||||
|
||||
it('上移下移只交换相邻两条,其余不动', () => {
|
||||
expect(moveRule(rules, 0, 1)).toEqual([
|
||||
'MATCH,PROXY',
|
||||
'DOMAIN-SUFFIX,a.com,DIRECT',
|
||||
'DOMAIN-SUFFIX,b.com,REJECT',
|
||||
])
|
||||
expect(moveRule(rules, 0, -1)).toBe(rules)
|
||||
expect(moveRule(rules, 2, 1)).toBe(rules)
|
||||
})
|
||||
})
|
||||
|
||||
describe('断引用', () => {
|
||||
it('目标出站不存在和规则组不存在都标出来', () => {
|
||||
const rules = [
|
||||
'DOMAIN-SUFFIX,a.com,NOPE',
|
||||
'RULE-SET,ads,REJECT',
|
||||
'RULE-SET,gone,DIRECT',
|
||||
'SUB-RULE,(DOMAIN-SUFFIX,a.com),sub1',
|
||||
'MATCH,PROXY',
|
||||
]
|
||||
const issues = findRuleIssues(rules, new Set(['PROXY', 'REJECT', 'DIRECT']), new Set(['ads']))
|
||||
expect(issues).toEqual([
|
||||
{ index: 0, kind: 'target', name: 'NOPE' },
|
||||
{ index: 2, kind: 'rule-set', name: 'gone' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('内网直连预设', () => {
|
||||
it('空表全缺', () => {
|
||||
expect(missingLanRules([])).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('已管住的网段不重复加,哪怕写法不完全一样', () => {
|
||||
const existing = ['IP-CIDR,192.168.0.0/16,DIRECT', 'MATCH,PROXY']
|
||||
const missing = missingLanRules(existing)
|
||||
expect(missing).toHaveLength(7)
|
||||
expect(missing.some((r) => r.includes('192.168.0.0/16'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('删除规则组前找引用', () => {
|
||||
it('列出 rules 里引用它的规则下标', () => {
|
||||
const rules = ['RULE-SET,ads,REJECT', 'DOMAIN-SUFFIX,a.com,DIRECT', 'RULE-SET,ads,PROXY']
|
||||
expect(ruleSetReferences(rules, 'ads')).toEqual([0, 2])
|
||||
expect(ruleSetReferences(rules, 'other')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('配置形状', () => {
|
||||
it('rules 空了删掉整个键', () => {
|
||||
const data: ConfigObject = { rules: ['MATCH,PROXY'] }
|
||||
setRules(data, ['A,B,C'])
|
||||
expect(data.rules).toEqual(['A,B,C'])
|
||||
setRules(data, [])
|
||||
expect('rules' in data).toBe(false)
|
||||
})
|
||||
|
||||
it('rule-providers 空了删掉整个键', () => {
|
||||
const data: ConfigObject = {}
|
||||
setRuleProviders(data, { ads: { type: 'http' } })
|
||||
expect(Object.keys(data['rule-providers'] as ConfigObject)).toEqual(['ads'])
|
||||
setRuleProviders(data, {})
|
||||
expect('rule-providers' in data).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ini 的 ruleset= 行解析', () => {
|
||||
const ini = `
|
||||
[custom]
|
||||
; 注释行不算
|
||||
;ruleset=🎯 全球直连,[]GEOIP,LAN
|
||||
ruleset=🎯 全球直连,https://example.com/LocalAreaNetwork.list
|
||||
ruleset=🛑 全球拦截,https://example.com/BanAD.list?token=1
|
||||
ruleset=🎯 全球直连,https://example.com/GoogleCN.list
|
||||
ruleset=🍎 苹果服务,Apple.list
|
||||
ruleset=🎯 全球直连,[]GEOIP,CN
|
||||
ruleset=🐟 漏网之鱼,[]FINAL
|
||||
custom_proxy_group=🚀 节点选择\`select\`[]AUTO
|
||||
`
|
||||
|
||||
it('URL、本地路径、内联 GEOIP 和 FINAL 都认出来', () => {
|
||||
expect(parseIniRulesets(ini)).toEqual([
|
||||
{ target: '🎯 全球直连', kind: 'url', source: 'https://example.com/LocalAreaNetwork.list' },
|
||||
{ target: '🛑 全球拦截', kind: 'url', source: 'https://example.com/BanAD.list?token=1' },
|
||||
{ target: '🎯 全球直连', kind: 'url', source: 'https://example.com/GoogleCN.list' },
|
||||
{ target: '🍎 苹果服务', kind: 'file', source: 'Apple.list' },
|
||||
{ target: '🎯 全球直连', kind: 'geoip', source: 'CN' },
|
||||
{ target: '🐟 漏网之鱼', kind: 'final', source: '' },
|
||||
])
|
||||
})
|
||||
|
||||
it('custom_proxy_group 和残缺行不产生条目', () => {
|
||||
expect(parseIniRulesets('ruleset=\nruleset=只有目标,\ncustom_proxy_group=x`select`y')).toEqual([])
|
||||
})
|
||||
|
||||
it('GEOIP 类别后的附加参数不并进类别名,生成规则时放在目标之后', () => {
|
||||
const entries = parseIniRulesets('ruleset=🎯 全球直连,[]GEOIP,CN,no-resolve')
|
||||
expect(entries).toEqual([
|
||||
{ target: '🎯 全球直连', kind: 'geoip', source: 'CN', flags: ['no-resolve'] },
|
||||
])
|
||||
const plan = planIniImport(entries, { '🎯 全球直连': 'DIRECT' }, new Set())
|
||||
expect(plan.rules).toEqual(['GEOIP,CN,DIRECT,no-resolve'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ini 导入计划', () => {
|
||||
const entries = parseIniRulesets(`
|
||||
ruleset=🎯 全球直连,https://example.com/LocalAreaNetwork.list
|
||||
ruleset=🎯 全球直连,https://example.com/GoogleCN.list
|
||||
ruleset=🎯 全球直连,[]GEOIP,CN
|
||||
ruleset=🐟 漏网之鱼,[]FINAL
|
||||
`)
|
||||
|
||||
it('每行一个规则组加一条规则骨架,FINAL 落在表末尾', () => {
|
||||
const plan = planIniImport(
|
||||
entries,
|
||||
{ '🎯 全球直连': 'DIRECT', '🐟 漏网之鱼': 'PROXY' },
|
||||
new Set(),
|
||||
)
|
||||
expect(Object.keys(plan.providers)).toEqual(['LocalAreaNetwork', 'GoogleCN'])
|
||||
expect(plan.providers.LocalAreaNetwork).toEqual({
|
||||
type: 'http',
|
||||
behavior: 'classical',
|
||||
url: 'https://example.com/LocalAreaNetwork.list',
|
||||
path: './providers/rules/LocalAreaNetwork.txt',
|
||||
format: 'text',
|
||||
interval: 86400,
|
||||
})
|
||||
expect(plan.rules).toEqual([
|
||||
'RULE-SET,LocalAreaNetwork,DIRECT',
|
||||
'RULE-SET,GoogleCN,DIRECT',
|
||||
'GEOIP,CN,DIRECT',
|
||||
])
|
||||
expect(plan.finals).toEqual(['MATCH,PROXY'])
|
||||
})
|
||||
|
||||
it('同一个来源出现多次只声明一个规则组', () => {
|
||||
const plan = planIniImport(
|
||||
[
|
||||
{ target: '甲', kind: 'url', source: 'https://example.com/a.list' },
|
||||
{ target: '乙', kind: 'url', source: 'https://example.com/a.list' },
|
||||
],
|
||||
{ 甲: 'DIRECT', 乙: 'REJECT' },
|
||||
new Set(),
|
||||
)
|
||||
expect(Object.keys(plan.providers)).toEqual(['a'])
|
||||
expect(plan.rules).toEqual(['RULE-SET,a,DIRECT', 'RULE-SET,a,REJECT'])
|
||||
})
|
||||
|
||||
it('和已有规则组撞名时加序号', () => {
|
||||
const plan = planIniImport(
|
||||
[{ target: '甲', kind: 'url', source: 'https://example.com/ads.list' }],
|
||||
{ 甲: 'REJECT' },
|
||||
new Set(['ads']),
|
||||
)
|
||||
expect(Object.keys(plan.providers)).toEqual(['ads-2'])
|
||||
expect(plan.rules).toEqual(['RULE-SET,ads-2,REJECT'])
|
||||
})
|
||||
|
||||
it('本地路径生成 file 类型,path 就是来源本身', () => {
|
||||
const plan = planIniImport(
|
||||
[{ target: '甲', kind: 'file', source: 'D:\\lists\\ads.list' }],
|
||||
{ 甲: 'REJECT' },
|
||||
new Set(),
|
||||
)
|
||||
expect(plan.providers['ads']).toEqual({
|
||||
type: 'file',
|
||||
behavior: 'classical',
|
||||
path: 'D:\\lists\\ads.list',
|
||||
format: 'text',
|
||||
})
|
||||
})
|
||||
|
||||
it('没映射目标组的行跳过', () => {
|
||||
const plan = planIniImport(entries, { '🎯 全球直连': 'DIRECT' }, new Set())
|
||||
expect(plan.finals).toEqual([])
|
||||
expect(plan.rules).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('清单格式与缓存路径', () => {
|
||||
it('格式按扩展名推断,拿不准回 yaml', () => {
|
||||
expect(inferRuleFormat('https://example.com/a.list?token=1')).toBe('text')
|
||||
expect(inferRuleFormat('https://example.com/a.txt')).toBe('text')
|
||||
expect(inferRuleFormat('https://example.com/a.mrs')).toBe('mrs')
|
||||
expect(inferRuleFormat('https://example.com/a.yaml')).toBe('yaml')
|
||||
expect(inferRuleFormat('https://example.com/no-extension')).toBe('yaml')
|
||||
})
|
||||
|
||||
it('缓存路径的扩展名跟着格式走', () => {
|
||||
expect(ruleProviderPath('ads', 'text')).toBe('./providers/rules/ads.txt')
|
||||
expect(ruleProviderPath('ads', 'mrs')).toBe('./providers/rules/ads.mrs')
|
||||
expect(ruleProviderPath('ads', 'yaml')).toBe('./providers/rules/ads.yaml')
|
||||
expect(ruleProviderPath('🛑 广告', 'yaml')).toBe('./providers/rules/广告.yaml')
|
||||
})
|
||||
|
||||
it('名字只差符号时清单路径撞车,能认出来;自己不算', () => {
|
||||
const data = {
|
||||
'rule-providers': {
|
||||
'🛑 广告': { behavior: 'domain', path: './providers/rules/广告.yaml' },
|
||||
},
|
||||
}
|
||||
const path = ruleProviderPath('广告', 'yaml')
|
||||
expect(ruleProviderPathConflict(data, '广告', path)).toBe('🛑 广告')
|
||||
expect(ruleProviderPathConflict(data, '🛑 广告', path)).toBeNull()
|
||||
expect(ruleProviderPathConflict(data, 'x', ruleProviderPath('x', 'yaml'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('附加规则里的规则组引用', () => {
|
||||
it('sub-rules 各表里的 RULE-SET 引用都认出来,别的表不误报', () => {
|
||||
const data = {
|
||||
'sub-rules': {
|
||||
'patchbay:t1 附加': ['RULE-SET,ads,REJECT', 'MATCH,patchbay:t1'],
|
||||
mine: ['RULE-SET,other,DIRECT'],
|
||||
},
|
||||
}
|
||||
expect(subRuleSetReferences(data, 'ads')).toEqual(['RULE-SET,ads,REJECT'])
|
||||
expect(subRuleSetReferences(data, 'ghost')).toEqual([])
|
||||
expect(subRuleSetReferences({}, 'ads')).toEqual([])
|
||||
})
|
||||
})
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* 分流规则与规则组(rule-providers)的纯逻辑。
|
||||
*
|
||||
* 拆分规则字符串只为显示和编辑常见字段,不为每种规则类型建模——内核认识的类型
|
||||
* 比界面需要的多得多,拆不明白的(逻辑规则、子规则)由界面按原文整行编辑。
|
||||
* 拆分用 vendor 的 splitRuleFields:它认得引号和嵌套括号,`AND`/`OR` 这类载荷里
|
||||
* 的逗号不会被当成字段分隔。
|
||||
*/
|
||||
import { splitRuleFields } from '../vendor/config-editor/index.ts'
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
|
||||
/** 一条分流规则拆开的样子。extras 是目标之后的选项段(如 no-resolve),原样保留。 */
|
||||
export interface ParsedRule {
|
||||
type: string
|
||||
/** MATCH 没有载荷,为 null */
|
||||
payload: string | null
|
||||
/** 目标出站。拆不出来(字段不够)时为 null */
|
||||
target: string | null
|
||||
extras: string[]
|
||||
}
|
||||
|
||||
export function parseRule(rule: string): ParsedRule {
|
||||
const fields = splitRuleFields(rule)
|
||||
const type = fields[0] ?? ''
|
||||
const upper = type.toUpperCase()
|
||||
if (upper === 'MATCH') {
|
||||
return { type, payload: null, target: fields[1] ?? null, extras: fields.slice(2) }
|
||||
}
|
||||
// 载荷可含顶层逗号的类型,内核把最后一段读成目标、其余全归载荷、不许带选项
|
||||
// (rules/common/base.go:47-79)。按普通形状拆会把正则截一半当目标。
|
||||
if (RAW_ONLY_TYPES.has(upper) && fields.length >= 3) {
|
||||
return { type, payload: fields.slice(1, -1).join(','), target: fields.at(-1) ?? null, extras: [] }
|
||||
}
|
||||
return { type, payload: fields[1] ?? null, target: fields[2] ?? null, extras: fields.slice(3) }
|
||||
}
|
||||
|
||||
/** 拼回一行规则。空字段不写,所以 MATCH 不会多出一个空载荷段。 */
|
||||
export function buildRule(parsed: ParsedRule): string {
|
||||
return [parsed.type, parsed.payload, parsed.target, ...parsed.extras]
|
||||
.filter((part): part is string => part !== null && part !== '')
|
||||
.join(',')
|
||||
}
|
||||
|
||||
/**
|
||||
* 界面能按「类型,载荷,目标」结构化编辑的规则。
|
||||
*
|
||||
* 这份清单必须和内核 `rules/common/base.go:56` 的 `payloadCanContainComma` 一致:
|
||||
* 那些类型内核按「载荷可含逗号、最后一段是目标、不许带选项」读,按普通形状拆的话,
|
||||
* 正则里的顶层逗号会被当成字段分隔,显示错的目标、编辑后悄悄改变语义。
|
||||
*/
|
||||
const RAW_ONLY_TYPES = new Set([
|
||||
'AND',
|
||||
'OR',
|
||||
'NOT',
|
||||
'SUB-RULE',
|
||||
'DOMAIN-REGEX',
|
||||
'PROCESS-NAME-REGEX',
|
||||
'PROCESS-PATH-REGEX',
|
||||
])
|
||||
|
||||
/** 这条规则是不是只能按原文整行编辑的类型(逻辑规则、载荷可含逗号的正则类)。 */
|
||||
export function isRawOnlyRule(rule: string): boolean {
|
||||
return RAW_ONLY_TYPES.has(parseRule(rule).type.toUpperCase())
|
||||
}
|
||||
|
||||
export function isStructuredEditable(rule: string): boolean {
|
||||
if (isRawOnlyRule(rule)) return false
|
||||
return parseRule(rule).target !== null
|
||||
}
|
||||
|
||||
/** 第一条 MATCH 的下标。它之后的规则永远到不了。没有 MATCH 返回 -1。 */
|
||||
export function firstMatchIndex(rules: string[]): number {
|
||||
return rules.findIndex((rule) => parseRule(rule).type.toUpperCase() === 'MATCH')
|
||||
}
|
||||
|
||||
/** 把新规则插到第一条 MATCH 之前;没有 MATCH 就加在末尾。 */
|
||||
export function insertBeforeMatch(rules: string[], additions: string[]): string[] {
|
||||
const at = firstMatchIndex(rules)
|
||||
const index = at < 0 ? rules.length : at
|
||||
return [...rules.slice(0, index), ...additions, ...rules.slice(index)]
|
||||
}
|
||||
|
||||
/** 上移或下移一条规则。越界时原样返回。 */
|
||||
export function moveRule(rules: string[], index: number, delta: -1 | 1): string[] {
|
||||
const to = index + delta
|
||||
if (index < 0 || index >= rules.length || to < 0 || to >= rules.length) return rules
|
||||
const next = [...rules]
|
||||
next[index] = rules[to]!
|
||||
next[to] = rules[index]!
|
||||
return next
|
||||
}
|
||||
|
||||
/** 一条规则的断引用:目标出站不存在,或者 RULE-SET 引用的规则组不存在。 */
|
||||
export interface RuleIssue {
|
||||
index: number
|
||||
kind: 'target' | 'rule-set'
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 找出引用了不存在的出站或规则组的规则。
|
||||
*
|
||||
* 内核在配置解析时就会拒绝这类引用(config/config.go:1102-1118),坏配置最终会被
|
||||
* 校验重载链拦下并还原;这里的标注是为了让人在保存前就看见、改得快,而 vendor 自检
|
||||
* 对规则目标只给 warning 不拦保存。SUB-RULE 的目标段是子规则集合名而不是出站,
|
||||
* 跳过——那是路由页的段落,这里不认。
|
||||
*/
|
||||
export function findRuleIssues(
|
||||
rules: string[],
|
||||
outboundNames: ReadonlySet<string>,
|
||||
providerNames: ReadonlySet<string>,
|
||||
): RuleIssue[] {
|
||||
const issues: RuleIssue[] = []
|
||||
rules.forEach((rule, index) => {
|
||||
const parsed = parseRule(rule)
|
||||
const type = parsed.type.toUpperCase()
|
||||
if (type === 'RULE-SET' && parsed.payload !== null && !providerNames.has(parsed.payload)) {
|
||||
issues.push({ index, kind: 'rule-set', name: parsed.payload })
|
||||
}
|
||||
if (type !== 'SUB-RULE' && parsed.target !== null && !outboundNames.has(parsed.target)) {
|
||||
issues.push({ index, kind: 'target', name: parsed.target })
|
||||
}
|
||||
})
|
||||
return issues
|
||||
}
|
||||
|
||||
/**
|
||||
* 「内网直连」预设:回环、私网和链路本地地址一律 DIRECT。
|
||||
*
|
||||
* no-resolve 是这类规则的固定尾巴:它们按目标 IP 匹配,让内核先做一次 DNS 解析
|
||||
* 才能匹配是白忙一场,还会拖慢纯 IP 的连接。
|
||||
*/
|
||||
export const LAN_DIRECT_RULES = [
|
||||
'IP-CIDR,127.0.0.0/8,DIRECT,no-resolve',
|
||||
'IP-CIDR,10.0.0.0/8,DIRECT,no-resolve',
|
||||
'IP-CIDR,172.16.0.0/12,DIRECT,no-resolve',
|
||||
'IP-CIDR,192.168.0.0/16,DIRECT,no-resolve',
|
||||
'IP-CIDR,169.254.0.0/16,DIRECT,no-resolve',
|
||||
'IP-CIDR6,::1/128,DIRECT,no-resolve',
|
||||
'IP-CIDR6,fc00::/7,DIRECT,no-resolve',
|
||||
'IP-CIDR6,fe80::/10,DIRECT,no-resolve',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 预设里还没出现在规则表里的那几条。
|
||||
*
|
||||
* 按「类型,载荷」判重而不是整行比对:用户手写的 `IP-CIDR,192.168.0.0/16,DIRECT`
|
||||
* 没有 no-resolve 尾巴,语义上已经管住了这个网段,再塞一条带尾巴的是重复。
|
||||
*/
|
||||
export function missingLanRules(rules: string[]): string[] {
|
||||
const covered = new Set(
|
||||
rules.map((rule) => {
|
||||
const parsed = parseRule(rule)
|
||||
return `${parsed.type.toUpperCase()},${parsed.payload ?? ''}`
|
||||
}),
|
||||
)
|
||||
return LAN_DIRECT_RULES.filter((preset) => {
|
||||
const parsed = parseRule(preset)
|
||||
return !covered.has(`${parsed.type},${parsed.payload ?? ''}`)
|
||||
})
|
||||
}
|
||||
|
||||
/** rules: 里引用了这个规则组的规则下标。删除规则组前拿它拦人。 */
|
||||
export function ruleSetReferences(rules: string[], providerName: string): number[] {
|
||||
const result: number[] = []
|
||||
rules.forEach((rule, index) => {
|
||||
const parsed = parseRule(rule)
|
||||
if (parsed.type.toUpperCase() === 'RULE-SET' && parsed.payload === providerName) {
|
||||
result.push(index)
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* sub-rules 各表(含每路入站的附加规则)里引用这个规则组的规则原文。
|
||||
* 删除规则组前和顶层的一起查——漏了的话删除会被内核重载拒掉,用户看到的
|
||||
* 是一段原始解析错误而不是「谁在引用它」。
|
||||
*/
|
||||
export function subRuleSetReferences(data: ConfigObject, providerName: string): string[] {
|
||||
const tables = data['sub-rules']
|
||||
if (typeof tables !== 'object' || tables === null || Array.isArray(tables)) return []
|
||||
const hits: string[] = []
|
||||
for (const rules of Object.values(tables)) {
|
||||
if (!Array.isArray(rules)) continue
|
||||
for (const rule of rules) {
|
||||
if (typeof rule !== 'string') continue
|
||||
const parsed = parseRule(rule)
|
||||
if (parsed.type.toUpperCase() === 'RULE-SET' && parsed.payload === providerName) {
|
||||
hits.push(rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
/** 配置里的 rule-providers 段。不是映射就当没有。 */
|
||||
export function ruleProvidersOf(data: ConfigObject): Record<string, ConfigObject> {
|
||||
const value = data['rule-providers']
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}
|
||||
return value as Record<string, ConfigObject>
|
||||
}
|
||||
|
||||
/** 直接改传入的对象——调用方先 clone。providers 空了就把整个键去掉,不留空映射。 */
|
||||
export function setRuleProviders(data: ConfigObject, providers: Record<string, ConfigObject>): void {
|
||||
if (Object.keys(providers).length === 0) delete data['rule-providers']
|
||||
else data['rule-providers'] = providers
|
||||
}
|
||||
|
||||
/** 配置里的分流规则表。不是数组就当没有;非字符串的条目自检会报,这里不呈现。 */
|
||||
export function rulesOf(data: ConfigObject): string[] {
|
||||
if (!Array.isArray(data.rules)) return []
|
||||
return data.rules.filter((rule): rule is string => typeof rule === 'string')
|
||||
}
|
||||
|
||||
/** 同上。规则表空了就删掉 rules 键,不留空数组。 */
|
||||
export function setRules(data: ConfigObject, rules: string[]): void {
|
||||
if (rules.length === 0) delete data.rules
|
||||
else data.rules = rules
|
||||
}
|
||||
|
||||
/** 给文件名和规则组名去重用。 */
|
||||
function safeName(text: string): string {
|
||||
return text.replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
/** FNV-1a。只用来给名字去重,不是安全用途。 */
|
||||
function shortHash(text: string): string {
|
||||
let h = 0x811c9dc5
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
h ^= text.charCodeAt(i)
|
||||
h = Math.imul(h, 0x01000193) >>> 0
|
||||
}
|
||||
return h.toString(16).padStart(8, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个 http 规则组的缓存文件落在哪。
|
||||
*
|
||||
* 不写 path 时内核按 url 哈希自己生成一个(rules/provider/parse.go:51),那个路径
|
||||
* 这里算不出来,所以补一个确定的。扩展名跟着格式走,翻目录时看得出里面是什么。
|
||||
*/
|
||||
export function ruleProviderPath(name: string, format: string): string {
|
||||
const ext = format === 'mrs' ? 'mrs' : format === 'text' ? 'txt' : 'yaml'
|
||||
return `./providers/rules/${safeName(name) || shortHash(name)}.${ext}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 新规则组的清单路径是否和现有规则组的撞车。
|
||||
*
|
||||
* 名字查重挡不住这种:路径由名字净化而来,「广告 A」和「广告/A」是两个名字、
|
||||
* 同一个清单文件,互相覆盖。返回撞上的那个规则组名,没撞返回 null。
|
||||
*/
|
||||
export function ruleProviderPathConflict(
|
||||
data: ConfigObject,
|
||||
selfName: string,
|
||||
path: string,
|
||||
): string | null {
|
||||
for (const [name, decl] of Object.entries(ruleProvidersOf(data))) {
|
||||
if (name === selfName) continue
|
||||
// 没写 path 的声明由内核按 url 哈希生成路径,和这里的净化路径撞不上
|
||||
if (decl.path === path) return name
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 清单格式按来源的扩展名推断。拿不准时回到 yaml——那是内核没写 format 时的默认。 */
|
||||
export function inferRuleFormat(source: string): 'yaml' | 'text' | 'mrs' {
|
||||
const clean = (source.split(/[?#]/)[0] ?? '').toLowerCase()
|
||||
if (clean.endsWith('.mrs')) return 'mrs'
|
||||
if (clean.endsWith('.list') || clean.endsWith('.txt')) return 'text'
|
||||
return 'yaml'
|
||||
}
|
||||
|
||||
/** ini 里一行 ruleset= 解析出来的东西。 */
|
||||
export interface IniRuleset {
|
||||
/** ini 里的目标组名(「🎯 全球直连」这类),导入时由用户映射到实际出站 */
|
||||
target: string
|
||||
kind: 'url' | 'file' | 'geoip' | 'geosite' | 'final'
|
||||
/** kind 为 url/file 时是来源,为 geoip/geosite 时是类别名,final 时为空 */
|
||||
source: string
|
||||
/** geoip/geosite 类别后面的附加参数(no-resolve 这类)。生成规则时放在目标之后 */
|
||||
flags?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 subconverter ini 里的 ruleset= 行。
|
||||
*
|
||||
* 以 ; 和 # 开头的是 ini 的注释,跳过。custom_proxy_group= 是 subconverter 自己的
|
||||
* 建组指令,和本项目的专属组体系冲突,不在解析范围内。
|
||||
* 来源以 [] 开头的是内联规则而不是清单:[]GEOIP,CN 这类直接生成规则,不建规则组;
|
||||
* []FINAL(以及 []MATCH)是兜底规则。
|
||||
*/
|
||||
export function parseIniRulesets(text: string): IniRuleset[] {
|
||||
const result: IniRuleset[] = []
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed === '' || trimmed.startsWith(';') || trimmed.startsWith('#')) continue
|
||||
const match = /^ruleset\s*=\s*(.+)$/i.exec(trimmed)
|
||||
if (!match) continue
|
||||
const body = match[1]!
|
||||
const comma = body.indexOf(',')
|
||||
if (comma < 0) continue
|
||||
const target = body.slice(0, comma).trim()
|
||||
const source = body.slice(comma + 1).trim()
|
||||
if (target === '' || source === '') continue
|
||||
|
||||
if (source.startsWith('[]')) {
|
||||
const inline = source.slice(2).trim()
|
||||
const geo = /^(GEOIP|GEOSITE)\s*,\s*(.+)$/i.exec(inline)
|
||||
if (geo) {
|
||||
// []GEOIP,CN,no-resolve:第二段才是类别,再往后是 no-resolve 这类附加参数。
|
||||
// 附加参数在 mihomo 规则里的位置是目标之后,混进类别名会生成字段错位的规则
|
||||
const fields = geo[2]!
|
||||
.split(',')
|
||||
.map((field) => field.trim())
|
||||
.filter(Boolean)
|
||||
const [category, ...flags] = fields
|
||||
if (!category) continue
|
||||
result.push({
|
||||
target,
|
||||
kind: geo[1]!.toUpperCase() === 'GEOIP' ? 'geoip' : 'geosite',
|
||||
source: category,
|
||||
...(flags.length > 0 ? { flags } : {}),
|
||||
})
|
||||
} else if (inline.toUpperCase() === 'FINAL' || inline.toUpperCase() === 'MATCH') {
|
||||
result.push({ target, kind: 'final', source: '' })
|
||||
}
|
||||
// 其它 [] 写法([]REJECT 这类)认不出来,跳过比生成一条错规则好
|
||||
continue
|
||||
}
|
||||
result.push({ target, kind: /^https?:\/\//i.test(source) ? 'url' : 'file', source })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** ini 导入的产物:一批规则组声明,加一批新规则。 */
|
||||
export interface IniImportPlan {
|
||||
providers: Record<string, ConfigObject>
|
||||
/** 普通规则,插在第一条 MATCH 之前 */
|
||||
rules: string[]
|
||||
/** ini 里 []FINAL 生成的兜底规则,加在整张表末尾 */
|
||||
finals: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 ini 的 ruleset 行变成规则组声明和规则。
|
||||
*
|
||||
* 每个来源生成一个规则组,名字取来源的文件名(去扩展名),和已有的、以及本次
|
||||
* 已生成的撞名时加序号。ACL4SSR 的清单是 classical 行为的 text 格式,行为按
|
||||
* classical 声明,格式按扩展名推断。
|
||||
* 目标组名由 mapping 逐个映射到实际出站,没映射的行跳过。
|
||||
*/
|
||||
export function planIniImport(
|
||||
entries: IniRuleset[],
|
||||
mapping: Readonly<Record<string, string>>,
|
||||
existingProviders: ReadonlySet<string>,
|
||||
): IniImportPlan {
|
||||
const providers: Record<string, ConfigObject> = {}
|
||||
const rules: string[] = []
|
||||
const finals: string[] = []
|
||||
const taken = new Set(existingProviders)
|
||||
|
||||
/** 同一行 ruleset 重复出现时共用一个名字,不重复声明。 */
|
||||
const nameBySource = new Map<string, string>()
|
||||
const nameFor = (source: string): string => {
|
||||
const known = nameBySource.get(source)
|
||||
if (known) return known
|
||||
const base = (source.split(/[?#]/)[0] ?? '').split(/[\\/]/).pop() ?? ''
|
||||
const stem = safeName(base.replace(/\.(list|txt|yaml|yml|mrs)$/i, '')) || 'rules'
|
||||
let name = stem
|
||||
for (let i = 2; taken.has(name); i += 1) name = `${stem}-${String(i)}`
|
||||
taken.add(name)
|
||||
nameBySource.set(source, name)
|
||||
return name
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const target = mapping[entry.target]
|
||||
if (!target) continue
|
||||
|
||||
if (entry.kind === 'geoip' || entry.kind === 'geosite') {
|
||||
const type = entry.kind === 'geoip' ? 'GEOIP' : 'GEOSITE'
|
||||
rules.push([type, entry.source, target, ...(entry.flags ?? [])].join(','))
|
||||
continue
|
||||
}
|
||||
if (entry.kind === 'final') {
|
||||
finals.push(`MATCH,${target}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const format = inferRuleFormat(entry.source)
|
||||
const name = nameFor(`${entry.kind}:${entry.source}`)
|
||||
if (!providers[name]) {
|
||||
providers[name] =
|
||||
entry.kind === 'url'
|
||||
? {
|
||||
type: 'http',
|
||||
behavior: 'classical',
|
||||
url: entry.source,
|
||||
path: ruleProviderPath(name, format),
|
||||
format,
|
||||
interval: 86400,
|
||||
}
|
||||
: { type: 'file', behavior: 'classical', path: entry.source, format }
|
||||
}
|
||||
rules.push(`RULE-SET,${name},${target}`)
|
||||
}
|
||||
return { providers, rules, finals }
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
bucketRates,
|
||||
lastRate,
|
||||
pushWindow,
|
||||
routeDeltas,
|
||||
windowBytes,
|
||||
type ConnTotals,
|
||||
type RouteSample,
|
||||
} from './telemetry.ts'
|
||||
import type { ConnectionItem } from './connections.ts'
|
||||
|
||||
function conn(id: string, inbound: string, upload: number, download: number): ConnectionItem {
|
||||
return {
|
||||
id,
|
||||
upload,
|
||||
download,
|
||||
start: '2026-08-11T00:00:00+08:00',
|
||||
chains: ['DIRECT'],
|
||||
rule: 'Match',
|
||||
rulePayload: '',
|
||||
metadata: {
|
||||
host: 'example.com',
|
||||
destinationIP: '1.2.3.4',
|
||||
destinationPort: '443',
|
||||
inboundName: inbound,
|
||||
inboundPort: '7890',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('pushWindow', () => {
|
||||
test('把窗口外的老点挤掉,保留窗口内的', () => {
|
||||
const list = [{ t: 0 }, { t: 5_000 }, { t: 9_000 }]
|
||||
const next = pushWindow(list, { t: 12_000 }, 5_000)
|
||||
expect(next.map((p) => p.t)).toEqual([9_000, 12_000])
|
||||
})
|
||||
|
||||
test('全部过期时只剩新点', () => {
|
||||
const next = pushWindow([{ t: 0 }], { t: 60_000 }, 5_000)
|
||||
expect(next.map((p) => p.t)).toEqual([60_000])
|
||||
})
|
||||
})
|
||||
|
||||
describe('routeDeltas', () => {
|
||||
test('已有连接算差值,并按入站聚合', () => {
|
||||
const prev: ConnTotals = new Map([
|
||||
['a', { up: 100, down: 200 }],
|
||||
['b', { up: 10, down: 20 }],
|
||||
])
|
||||
const { deltas } = routeDeltas(prev, [
|
||||
conn('a', 'lan-a', 150, 260),
|
||||
conn('b', 'lan-a', 30, 21),
|
||||
])
|
||||
expect(deltas['lan-a']).toEqual({ up: 70, down: 61 })
|
||||
})
|
||||
|
||||
test('新连接把累计全部记入本次间隔', () => {
|
||||
const { deltas } = routeDeltas(new Map(), [conn('a', 'lan-a', 500, 700)])
|
||||
expect(deltas['lan-a']).toEqual({ up: 500, down: 700 })
|
||||
})
|
||||
|
||||
test('累计变小(内核重启)按全新连接处理,不出负数', () => {
|
||||
const prev: ConnTotals = new Map([['a', { up: 900, down: 900 }]])
|
||||
const { deltas } = routeDeltas(prev, [conn('a', 'lan-a', 50, 60)])
|
||||
expect(deltas['lan-a']).toEqual({ up: 50, down: 60 })
|
||||
})
|
||||
|
||||
test('消失的连接不再出现在 next 里', () => {
|
||||
const prev: ConnTotals = new Map([['gone', { up: 1, down: 1 }]])
|
||||
const { next } = routeDeltas(prev, [conn('a', 'lan-a', 5, 5)])
|
||||
expect(next.has('gone')).toBe(false)
|
||||
expect(next.get('a')).toEqual({ up: 5, down: 5 })
|
||||
})
|
||||
|
||||
test('没有名字的入站退回端口做键', () => {
|
||||
const item = conn('a', '', 5, 5)
|
||||
const { deltas } = routeDeltas(new Map(), [item])
|
||||
expect(Object.keys(deltas)).toEqual(['7890'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('bucketRates', () => {
|
||||
test('样本按桶平均,桶的时间戳在桶中央', () => {
|
||||
const samples: RouteSample[] = [
|
||||
{ t: 1_000, ms: 1_000, up: 100, down: 0 },
|
||||
{ t: 2_000, ms: 1_000, up: 300, down: 0 },
|
||||
{ t: 6_000, ms: 1_000, up: 50, down: 50 },
|
||||
]
|
||||
// 0-10s 分两桶:前桶 400 字节 / 2 秒 = 200 B/s,后桶 100 字节 / 1 秒 = 100 B/s
|
||||
expect(bucketRates(samples, 0, 10_000, 2)).toEqual([
|
||||
[2_500, 200],
|
||||
[7_500, 100],
|
||||
])
|
||||
})
|
||||
|
||||
test('开头没数据的桶被裁掉,中间的留成 null', () => {
|
||||
const samples: RouteSample[] = [
|
||||
{ t: 3_000, ms: 1_000, up: 100, down: 0 },
|
||||
{ t: 9_000, ms: 1_000, up: 100, down: 0 },
|
||||
]
|
||||
const points = bucketRates(samples, 0, 10_000, 5)
|
||||
expect(points).toHaveLength(4)
|
||||
expect(points[0]).toEqual([3_000, 100])
|
||||
expect(points[1]![1]).toBeNull()
|
||||
})
|
||||
|
||||
test('没有任何样本返回空数组', () => {
|
||||
expect(bucketRates([], 0, 10_000, 5)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('windowBytes / lastRate', () => {
|
||||
const samples: RouteSample[] = [
|
||||
{ t: 0, ms: 3_000, up: 100, down: 100 },
|
||||
{ t: 3_000, ms: 3_000, up: 300, down: 300 },
|
||||
{ t: 6_000, ms: 2_000, up: 400, down: 600 },
|
||||
]
|
||||
|
||||
test('windowBytes 只算窗口内的样本', () => {
|
||||
expect(windowBytes(samples, 3_000)).toBe(300 + 300 + 400 + 600)
|
||||
})
|
||||
|
||||
test('lastRate 按实际间隔换算成每秒', () => {
|
||||
expect(lastRate(samples)).toEqual({ up: 200, down: 300 })
|
||||
})
|
||||
|
||||
test('lastRate 没有样本时是 null', () => {
|
||||
expect(lastRate([])).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 仪表盘遥测的纯逻辑:滑动窗口和按入站聚合的吞吐差值。
|
||||
*
|
||||
* 采集本身(开流、轮询)在 web/telemetry.ts 的单例里做;这里只回答
|
||||
* 「两次连接快照之间每一路走了多少字节」这类纯问题,让 bun test 够得着。
|
||||
*/
|
||||
import type { ConnectionItem } from './connections.ts'
|
||||
|
||||
/** 流量大图保留多久的历史。切走页面数据还在,是这个窗口在兜底。 */
|
||||
export const TRAFFIC_WINDOW_MS = 30 * 60_000
|
||||
|
||||
/** 每路吞吐走势保留多久。活跃判定也用同一个窗口。 */
|
||||
export const ROUTE_WINDOW_MS = 5 * 60_000
|
||||
|
||||
/** 连接快照的轮询间隔。 */
|
||||
export const ROUTE_POLL_MS = 3_000
|
||||
|
||||
/**
|
||||
* 「活跃」的门槛:最近 5 分钟走过的字节数。
|
||||
* 取 256 KB 是为了滤掉心跳、DNS 这类背景流量——它们在 5 分钟里通常只有几十 KB,
|
||||
* 而任何真实使用(网页、更新检查)轻松超过这个数。
|
||||
*/
|
||||
export const ACTIVE_BYTES = 256 * 1024
|
||||
|
||||
/** 一个采样点。up/down 是这个采样间隔里走过的字节数(不是速率),ms 是实际间隔。 */
|
||||
export interface RouteSample {
|
||||
t: number
|
||||
ms: number
|
||||
up: number
|
||||
down: number
|
||||
}
|
||||
|
||||
/** 流量曲线的一个点。up/down 是内核报的每秒字节数。 */
|
||||
export interface TrafficPoint {
|
||||
t: number
|
||||
up: number
|
||||
down: number
|
||||
}
|
||||
|
||||
/** 追加一个点并把窗口外的老点挤掉。窗口按时间算,不按条数——采样间隔不保证均匀。 */
|
||||
export function pushWindow<T extends { t: number }>(list: T[], point: T, windowMs: number): T[] {
|
||||
const cutoff = point.t - windowMs
|
||||
const start = list.findIndex((p) => p.t >= cutoff)
|
||||
return [...(start === -1 ? [] : list.slice(start)), point]
|
||||
}
|
||||
|
||||
/** 上次快照里每条连接的累计字节,按连接 id 记。 */
|
||||
export type ConnTotals = Map<string, { up: number; down: number }>
|
||||
|
||||
/**
|
||||
* 两次连接快照之间,每一路入站走了多少字节。
|
||||
*
|
||||
* 按连接逐条求差再按入站聚合,而不是先按入站求和再求差:连接关闭后会从快照里
|
||||
* 消失,直接对总和求差会算出负数。代价是连接关闭前最后一段流量丢失——对走势图
|
||||
* 来说可以接受。新出现的连接把它的累计全部记入本次间隔(它就是这段时间里产生的)。
|
||||
* 同一条 id 的累计变小说明内核重启过计数,按全新连接处理。
|
||||
*/
|
||||
export function routeDeltas(
|
||||
prev: ConnTotals,
|
||||
conns: ConnectionItem[],
|
||||
): { deltas: Record<string, { up: number; down: number }>; next: ConnTotals } {
|
||||
const deltas: Record<string, { up: number; down: number }> = {}
|
||||
const next: ConnTotals = new Map()
|
||||
for (const conn of conns) {
|
||||
const key = conn.metadata.inboundName || conn.metadata.inboundPort
|
||||
const last = prev.get(conn.id)
|
||||
const fresh = !last || last.up > conn.upload || last.down > conn.download
|
||||
const up = fresh ? conn.upload : conn.upload - last.up
|
||||
const down = fresh ? conn.download : conn.download - last.down
|
||||
const entry = (deltas[key] ??= { up: 0, down: 0 })
|
||||
entry.up += up
|
||||
entry.down += down
|
||||
next.set(conn.id, { up: conn.upload, down: conn.download })
|
||||
}
|
||||
return { deltas, next }
|
||||
}
|
||||
|
||||
/** 窗口内一共走了多少字节(上下行合计)。活跃判定用。 */
|
||||
export function windowBytes(samples: RouteSample[], since: number): number {
|
||||
let total = 0
|
||||
for (const s of samples) {
|
||||
if (s.t >= since) total += s.up + s.down
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/** 最近一个采样的速率(字节每秒)。没有采样时是 null——「没数据」和 0 不是一回事。 */
|
||||
export function lastRate(samples: RouteSample[]): { up: number; down: number } | null {
|
||||
const last = samples.at(-1)
|
||||
if (!last || last.ms <= 0) return null
|
||||
return { up: (last.up / last.ms) * 1000, down: (last.down / last.ms) * 1000 }
|
||||
}
|
||||
|
||||
/**
|
||||
* 把窗口内的样本合并成固定数量的桶,每桶是平均速率(字节每秒)。
|
||||
*
|
||||
* 走势线要的是形状,不是每一次采样:3 秒一个点、5 分钟窗口是一百个点,
|
||||
* 全画在一条几十像素宽的线上只剩噪声。没有采样的桶是 null(「没在采集」,
|
||||
* 不是「速率为 0」——零样本是采集层补出来的真零),开头的 null 直接裁掉,
|
||||
* 采集是从中途开始的,不该画出一段假的静止期。
|
||||
*/
|
||||
export function bucketRates(
|
||||
samples: RouteSample[],
|
||||
start: number,
|
||||
end: number,
|
||||
buckets: number,
|
||||
): Array<[number, number | null]> {
|
||||
if (end <= start || buckets <= 0) return []
|
||||
const width = (end - start) / buckets
|
||||
const sums = Array.from({ length: buckets }, () => ({ bytes: 0, ms: 0 }))
|
||||
for (const s of samples) {
|
||||
if (s.t < start || s.t > end) continue
|
||||
const i = Math.min(buckets - 1, Math.floor((s.t - start) / width))
|
||||
sums[i]!.bytes += s.up + s.down
|
||||
sums[i]!.ms += s.ms
|
||||
}
|
||||
const points = sums.map(
|
||||
(b, i): [number, number | null] => [
|
||||
Math.round(start + (i + 0.5) * width),
|
||||
b.ms > 0 ? (b.bytes / b.ms) * 1000 : null,
|
||||
],
|
||||
)
|
||||
const first = points.findIndex(([, v]) => v !== null)
|
||||
return first === -1 ? [] : points.slice(first)
|
||||
}
|
||||
Vendored
+1
@@ -12,6 +12,7 @@
|
||||
|---|---|---|
|
||||
| `index.test.ts` | 测试框架的导入从 `vitest` 换成 `bun:test` | 本项目用 Bun 自带的测试运行器,不引入 vitest。两者的 `describe` / `it` / `expect` 接口一致,用例本身一行没动 |
|
||||
| `index.ts` | `stringifyTouched` 结尾的 `String(doc)` 换成 `doc.toString({ flowCollectionPadding: false, lineWidth: 0 })` | 上游用默认输出参数,会把 `[a, b]` 改写成 `[ a, b ]`,并且在超过 80 列时折行——目标机器的订阅地址就够长。这两样都会让没被改动的段落出现在 diff 里 |
|
||||
| `index.ts` | `validateDocument` 末尾新增:listener 的 `rule:` 指向不存在的 sub-rules 表时报 `missing-reference` | 内核对这种引用静默退回全局规则(tunnel.go getRules 的 else 分支),`mihomo -t` 和重载都不会拦,只有这层自检能拦住 |
|
||||
|
||||
## 配套的本项目代码
|
||||
|
||||
|
||||
Vendored
+19
@@ -1056,6 +1056,25 @@ export function validateDocument(data: ConfigObject): ConfigDiagnostic[] {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Patchbay local addition (see ORIGIN.md): a listener whose `rule:` names a
|
||||
// missing sub-rules table silently falls back to the global rules (mihomo
|
||||
// tunnel.go getRules), so neither `mihomo -t` nor a reload will ever flag it.
|
||||
if (Array.isArray(data.listeners)) {
|
||||
data.listeners.forEach((entry, index) => {
|
||||
if (!isObject(entry)) return
|
||||
const rule = entry.rule
|
||||
if (typeof rule !== 'string' || !rule) return
|
||||
if (!isObject(subRules) || !(rule in subRules)) {
|
||||
diagnostics.push({
|
||||
path: ['listeners', index, 'rule'],
|
||||
code: 'missing-reference',
|
||||
message: `Unknown sub-rule: ${rule}`,
|
||||
severity: 'error',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
|
||||
@@ -35,5 +35,14 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: '../web-dist',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// ECharts 单独一个 chunk。应用一进来就是仪表盘,懒加载省不了首屏,
|
||||
// 拆开图的是缓存粒度:业务代码天天变,这 400KB 的库不跟着换 hash 重下
|
||||
manualChunks: {
|
||||
echarts: ['echarts/core', 'echarts/charts', 'echarts/components', 'echarts/renderers'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
+459
-43
@@ -1,14 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ApiError, api, clearToken, getToken, setToken } from './api'
|
||||
import type { ConfigSnapshot } from './api'
|
||||
import { NAV } from './nav'
|
||||
import { density, toggleDensity } from './prefs'
|
||||
import { kernel, reloadRuntime } from './runtime'
|
||||
import { kernel, refreshKernel, reloadRuntime } from './runtime'
|
||||
import { startTelemetry } from './telemetry'
|
||||
import { startLogStream } from './log-store'
|
||||
import ConfigPage from './views/ConfigPage.vue'
|
||||
import ConnectionsPage from './views/ConnectionsPage.vue'
|
||||
import DashboardPage from './views/DashboardPage.vue'
|
||||
import RequestsPage from './views/RequestsPage.vue'
|
||||
import RouterPage from './views/RouterPage.vue'
|
||||
import RulesPage from './views/RulesPage.vue'
|
||||
import SettingsPage from './views/SettingsPage.vue'
|
||||
import TokenGate from './views/TokenGate.vue'
|
||||
import ToastHost from './components/ToastHost.vue'
|
||||
import HistoryDialog from './components/HistoryDialog.vue'
|
||||
import ConfirmDialog from './components/ConfirmDialog.vue'
|
||||
import { toast, toastError } from './toast'
|
||||
import IconFold from '~icons/ri/sidebar-fold-line'
|
||||
import IconUnfold from '~icons/ri/sidebar-unfold-line'
|
||||
import IconLogout from '~icons/ri/logout-box-r-line'
|
||||
import IconUndo from '~icons/ri/arrow-go-back-line'
|
||||
import IconRedo from '~icons/ri/arrow-go-forward-line'
|
||||
import IconHistory from '~icons/ri/history-line'
|
||||
import IconReload from '~icons/ri/refresh-line'
|
||||
import IconRestart from '~icons/ri/restart-line'
|
||||
|
||||
const snapshot = ref<ConfigSnapshot | null>(null)
|
||||
const needsToken = ref(!getToken())
|
||||
@@ -17,6 +35,17 @@ const booted = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const active = ref('dashboard')
|
||||
|
||||
/**
|
||||
* 仪表盘的路由行点过去时要选中的那一路。放外壳是因为跳转跨页面:
|
||||
* 仪表盘发起、路由页消费,消费完清掉,下次再点同一行还能再跳。
|
||||
*/
|
||||
const routerFocus = ref<string | null>(null)
|
||||
|
||||
function openInbound(id: string) {
|
||||
routerFocus.value = id
|
||||
active.value = 'router'
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新读一遍状态。
|
||||
*
|
||||
@@ -24,35 +53,178 @@ const active = ref('dashboard')
|
||||
* 选中的那一路入站丢失,改完一个设置还要重新找回原来的位置。所以只有首次加载显示占位,
|
||||
* 之后都是原地更新。
|
||||
*/
|
||||
async function load() {
|
||||
async function load(): Promise<'ok' | 'unauthorized' | 'error'> {
|
||||
refreshing.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
// 先刷运行时再换配置数据。反过来的话,保存后界面已经按新配置渲染,
|
||||
// 而新建的组在运行时里还查不到,成员卡片会先闪空一下再出现。
|
||||
await reloadRuntime()
|
||||
snapshot.value = await api.readConfig()
|
||||
needsToken.value = false
|
||||
await reloadRuntime()
|
||||
// 撤回/重做按钮的可用性跟着每次重读走
|
||||
void refreshHistory()
|
||||
return 'ok'
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.unauthorized) {
|
||||
clearToken()
|
||||
needsToken.value = true
|
||||
} else {
|
||||
loadError.value = error instanceof Error ? error.message : String(error)
|
||||
return 'unauthorized'
|
||||
}
|
||||
loadError.value = error instanceof Error ? error.message : String(error)
|
||||
return 'error'
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
booted.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function submitToken(value: string) {
|
||||
/**
|
||||
* 提交登录页填的令牌。
|
||||
*
|
||||
* 401 必须当场说一声:登录页在提交前后都是它自己,组件不重建、输入框也不清空,
|
||||
* 不提示的话点了「进入」界面一个字都不变,看着像没反应。
|
||||
* 只在这里提示,不在 load() 里——存着的令牌失效时弹回登录页,页面本身就是说明。
|
||||
*/
|
||||
async function submitToken(value: string) {
|
||||
setToken(value)
|
||||
void load()
|
||||
// 令牌在哪儿取,登录页上就写着,不必在提示里再说一遍
|
||||
if ((await load()) === 'unauthorized') toast('令牌不正确', { kind: 'error' })
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearToken()
|
||||
location.reload()
|
||||
}
|
||||
|
||||
/* ── 侧栏左下角的配置动作 ─────────────────────── */
|
||||
|
||||
/** 撤回栈(备份链)和重做栈的深度,决定按钮亮不亮。每次 load() 时刷新。 */
|
||||
const history = ref<{ backups: string[]; redo: number }>({ backups: [], redo: 0 })
|
||||
|
||||
async function refreshHistory() {
|
||||
history.value = await api.listBackups().catch(() => ({ backups: [], redo: 0 }))
|
||||
}
|
||||
|
||||
const busyAct = ref(false)
|
||||
const historyOpen = ref(false)
|
||||
const askRestart = ref(false)
|
||||
|
||||
/**
|
||||
* 撤回/重做各自的冷却:窗内的连点算一次。手抖不该多退一步;顺带避开
|
||||
* 重做的备份名同秒覆盖(备份名精确到秒,窗比一秒宽就撞不上)。
|
||||
* 两个方向分开冷却,撤回错了马上重做这条路不受影响。
|
||||
*/
|
||||
const HISTORY_COOLDOWN_MS = 2_500
|
||||
const cooling = ref({ undo: false, redo: false })
|
||||
|
||||
function coolDown(kind: 'undo' | 'redo') {
|
||||
cooling.value = { ...cooling.value, [kind]: true }
|
||||
window.setTimeout(() => {
|
||||
cooling.value = { ...cooling.value, [kind]: false }
|
||||
}, HISTORY_COOLDOWN_MS)
|
||||
}
|
||||
|
||||
async function undo() {
|
||||
if (busyAct.value || cooling.value.undo) return
|
||||
coolDown('undo')
|
||||
busyAct.value = true
|
||||
try {
|
||||
const result = await api.undoConfig()
|
||||
toast(result.reloaded ? '已撤回上一次改动' : `已撤回上一次改动(${result.reloadMessage})`)
|
||||
await load()
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
busyAct.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function redo() {
|
||||
if (busyAct.value || cooling.value.redo) return
|
||||
coolDown('redo')
|
||||
busyAct.value = true
|
||||
try {
|
||||
const result = await api.redoConfig()
|
||||
toast(result.reloaded ? '已重做' : `已重做(${result.reloadMessage})`)
|
||||
await load()
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
busyAct.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 让内核重读磁盘上的配置文件,界面跟着重读。给 SSH 手改过配置的场景用。 */
|
||||
async function reloadFromDisk() {
|
||||
if (busyAct.value) return
|
||||
busyAct.value = true
|
||||
try {
|
||||
await api.reloadKernelConfig()
|
||||
toast('内核已重新加载配置文件')
|
||||
await load()
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
busyAct.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 重启后每 2 秒探一次、最多 30 秒;心跳兜底是 10 秒一次,这里只是快一点。 */
|
||||
let restartTimer = 0
|
||||
onBeforeUnmount(() => window.clearInterval(restartTimer))
|
||||
|
||||
async function restartConfirmed() {
|
||||
askRestart.value = false
|
||||
try {
|
||||
await api.restartKernel()
|
||||
kernel.value = { online: false }
|
||||
toast('已发出重启指令,等待内核回来')
|
||||
const started = Date.now()
|
||||
window.clearInterval(restartTimer)
|
||||
restartTimer = window.setInterval(() => {
|
||||
void refreshKernel().then(() => {
|
||||
if (kernel.value.online) {
|
||||
window.clearInterval(restartTimer)
|
||||
void reloadRuntime()
|
||||
toast('内核已恢复在线')
|
||||
} else if (Date.now() - started > 30_000) {
|
||||
window.clearInterval(restartTimer)
|
||||
toast('内核 30 秒内没有回来', { kind: 'error', hint: '去日志页看看它在说什么。' })
|
||||
}
|
||||
})
|
||||
}, 2_000)
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!needsToken.value) void load()
|
||||
// 流量、每路吞吐和日志的历史跨页面留存,所以收集挂在外壳上而不是各页里。
|
||||
// 单例自己跟着 kernel.online 起落,这里只负责点火
|
||||
startTelemetry()
|
||||
startLogStream()
|
||||
})
|
||||
|
||||
/**
|
||||
* 内核状态的心跳。kernel.online 原本只在 load() 里更新,用户停在任何页面期间它都是
|
||||
* 静态快照:内核重启一次,请求页和仪表盘会锁死在「内核未连接」,各页等它回来的 watch
|
||||
* 永远不会触发。10 秒探一次(/api/kernel 是个 5 秒超时的轻量探测),恢复在线的那一次
|
||||
* 顺带把运行时数据拉新,路由页的组和线路也跟着回来。
|
||||
*/
|
||||
let kernelTimer = 0
|
||||
onMounted(() => {
|
||||
kernelTimer = window.setInterval(() => {
|
||||
if (needsToken.value || !snapshot.value) return
|
||||
const wasOnline = kernel.value.online
|
||||
void refreshKernel().then(() => {
|
||||
if (!wasOnline && kernel.value.online) void reloadRuntime()
|
||||
})
|
||||
}, 10_000)
|
||||
})
|
||||
onBeforeUnmount(() => window.clearInterval(kernelTimer))
|
||||
|
||||
const problems = computed(
|
||||
() => snapshot.value?.diagnostics.filter((d) => d.severity === 'error') ?? [],
|
||||
)
|
||||
@@ -83,11 +255,66 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button type="button" class="nav-item off" title="退出登录" @click="logout">
|
||||
<IconLogout class="nav-icon" />
|
||||
<span class="nav-label">退出登录</span>
|
||||
</button>
|
||||
|
||||
<!-- 配置动作一排。收拢时容器变窄,图标自动折行成一列 -->
|
||||
<div class="acts">
|
||||
<button
|
||||
type="button"
|
||||
class="act"
|
||||
title="撤回"
|
||||
:disabled="busyAct || cooling.undo || !snapshot || history.backups.length === 0"
|
||||
@click="undo"
|
||||
>
|
||||
<IconUndo />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="act"
|
||||
title="重做"
|
||||
:disabled="busyAct || cooling.redo || !snapshot || history.redo === 0"
|
||||
@click="redo"
|
||||
>
|
||||
<IconRedo />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="act"
|
||||
title="历史记录"
|
||||
:disabled="!snapshot"
|
||||
@click="historyOpen = true"
|
||||
>
|
||||
<IconHistory />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="act"
|
||||
:title="kernel.online ? '更新配置(让内核重读配置文件)' : '内核未连接'"
|
||||
:disabled="busyAct || !snapshot || !kernel.online"
|
||||
@click="reloadFromDisk"
|
||||
>
|
||||
<IconReload />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="side-foot">
|
||||
<div class="kernel" :title="kernel.online ? kernel.version : '内核未连接'">
|
||||
<span :class="['dot', kernel.online ? 'dot-ok' : 'dot-off']" aria-hidden="true"></span>
|
||||
<span class="kernel-text mono">{{ kernel.online ? kernel.version : 'offline' }}</span>
|
||||
</div>
|
||||
<!-- 平时是内核状态,悬停变成重启入口。内核不在线时只当状态看 -->
|
||||
<button
|
||||
type="button"
|
||||
class="kernel-btn"
|
||||
:title="kernel.online ? '重启内核' : '内核未连接'"
|
||||
:disabled="!kernel.online"
|
||||
@click="askRestart = true"
|
||||
>
|
||||
<span class="k-idle">
|
||||
<span :class="['dot', kernel.online ? 'dot-ok' : 'dot-off']" aria-hidden="true"></span>
|
||||
<span class="kernel-text mono">{{ kernel.online ? kernel.version : 'offline' }}</span>
|
||||
</span>
|
||||
<span class="k-hot" aria-hidden="true"><IconRestart class="k-ico" /> 重启内核</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="densify"
|
||||
@@ -118,49 +345,125 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<DashboardPage
|
||||
v-if="active === 'dashboard'"
|
||||
:data="snapshot.data"
|
||||
@open-inbound="openInbound"
|
||||
@open-router="active = 'router'"
|
||||
/>
|
||||
|
||||
<RouterPage
|
||||
v-if="active === 'router'"
|
||||
v-else-if="active === 'router'"
|
||||
:data="snapshot.data"
|
||||
:revision="snapshot.revision"
|
||||
:focus="routerFocus"
|
||||
@focus-done="routerFocus = null"
|
||||
@saved="load"
|
||||
/>
|
||||
|
||||
<ConfigPage
|
||||
v-else-if="active === 'config'"
|
||||
:data="snapshot.data"
|
||||
:revision="snapshot.revision"
|
||||
@saved="load"
|
||||
/>
|
||||
|
||||
<div v-else class="note">
|
||||
<RulesPage
|
||||
v-else-if="active === 'rules'"
|
||||
:data="snapshot.data"
|
||||
:revision="snapshot.revision"
|
||||
@saved="load"
|
||||
/>
|
||||
|
||||
<SettingsPage
|
||||
v-else-if="active === 'settings'"
|
||||
:data="snapshot.data"
|
||||
:revision="snapshot.revision"
|
||||
@saved="load"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="active !== 'requests' && active !== 'connections'"
|
||||
class="note"
|
||||
>
|
||||
<component :is="current?.icon" class="note-icon" />
|
||||
<p>{{ current?.label }} 尚未实现</p>
|
||||
</div>
|
||||
|
||||
<!-- 请求页和连接页保活:切走不销毁,筛选词、排序、滚动位置都留住。
|
||||
KeepAlive 必须常驻、让子组件自己 v-if 进出——把 v-if 放在 KeepAlive
|
||||
上的话它随条件卸载,缓存跟着没了。轮询的起停走 activated 钩子 -->
|
||||
<KeepAlive>
|
||||
<RequestsPage v-if="active === 'requests'" />
|
||||
</KeepAlive>
|
||||
<KeepAlive>
|
||||
<ConnectionsPage v-if="active === 'connections'" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<HistoryDialog :open="historyOpen" @close="historyOpen = false" @restored="load" />
|
||||
|
||||
<!-- 文案与仪表盘的重启确认一致,同一件事不说两种话 -->
|
||||
<ConfirmDialog
|
||||
:open="askRestart"
|
||||
message="重启内核会断开它当前的所有连接,几秒后自动恢复。"
|
||||
action="重启"
|
||||
@close="askRestart = false"
|
||||
@confirm="restartConfirmed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ToastHost />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 侧栏折叠的做法:只过渡外层宽度,内层内容保持展开时的固定宽度、被 overflow 裁切,
|
||||
* 文字用透明度淡出。不用 display: none 藏文字——那会让内容在动画中途重排、图标跳位。
|
||||
* 60px 这个折叠宽度是算出来的:图标中心在 12+1+8+9=30px,正好是 60 的一半,
|
||||
* 收拢后图标视觉居中,全程不需要挪动任何东西。
|
||||
*/
|
||||
.shell {
|
||||
--side-w: 184px;
|
||||
display: grid;
|
||||
grid-template-columns: 184px 1fr;
|
||||
grid-template-columns: var(--side-w) 1fr;
|
||||
min-height: 100%;
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
}
|
||||
|
||||
.shell.is-compact {
|
||||
grid-template-columns: 56px 1fr;
|
||||
--side-w: 60px;
|
||||
}
|
||||
|
||||
/* ── 侧栏 ───────────────────────────────────── */
|
||||
/*
|
||||
* 钉在视口里。不加 sticky 的话它会被网格行撑到和主区一样高,页面一长,
|
||||
* 导航就跟着滚出屏幕外,而它是全局导航,任何时候都该够得着。
|
||||
* 钉在视口里。用 fixed 而不是 sticky:sticky 元素的位置跟着 scrollTop 走,
|
||||
* 而滚动到底时 scrollTop = scrollHeight - clientHeight,在 Windows 显示缩放下
|
||||
* 是小数——侧栏落在小数像素上,图标和文字取整方向不同,部分图标会跳 1px。
|
||||
* fixed 按视口摆放,不参与滚动偏移的运算。网格的第一列只当占位。
|
||||
*/
|
||||
.side {
|
||||
position: sticky;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
left: 0;
|
||||
width: var(--side-w);
|
||||
height: 100vh;
|
||||
transition: width 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-5);
|
||||
padding: var(--s-4) var(--s-3);
|
||||
border-right: 1px solid var(--c-border);
|
||||
background: var(--c-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 内容按展开宽度定死(184 - 24 内边距 - 1 边框),收拢时被裁掉而不是挤压换行 */
|
||||
.brand,
|
||||
.nav,
|
||||
.side-foot {
|
||||
min-width: 159px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -202,18 +505,32 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 收拢时淡出的文字。比外层宽度的 0.3s 快,字先消失、再收边,不露半截字 */
|
||||
.brand-name,
|
||||
.nav-label,
|
||||
.kernel-btn {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.is-compact .brand-name,
|
||||
.is-compact .nav-label,
|
||||
.is-compact .kernel-btn {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nav {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: var(--s-1);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
/* 写死而不是 100%:百分比到像素之间没法插值,宽度动画会直接跳变 */
|
||||
width: 159px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
@@ -225,6 +542,18 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
/* width 跟侧栏同节奏,颜色沿用全局的 0.15s;transition 不叠加,这里要写全 */
|
||||
transition:
|
||||
width 0.3s ease,
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* 收拢后按钮缩成正方形贴着图标,高亮块不再被裁切:8 + 18 + 8 加两侧边框 = 36 */
|
||||
.is-compact .nav-item {
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
@@ -244,21 +573,117 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* 退出登录:形态同导航项,但不参与「当前页」的高亮,收拢时同样缩成方块 */
|
||||
.off {
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
/* 配置动作排:展开时一行五个,收拢时容器随 --side-w 变窄、flex 自动折成一列 */
|
||||
.acts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-1);
|
||||
padding: 0 var(--s-2);
|
||||
width: calc(var(--side-w) - 2 * var(--s-3));
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.act {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.act:hover:not(:disabled) {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.act:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.act svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.side-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-2);
|
||||
padding: 0 var(--s-2);
|
||||
/* 右边给绝对定位的折叠按钮留位置 */
|
||||
padding: 0 44px 0 var(--s-2);
|
||||
}
|
||||
|
||||
.kernel {
|
||||
/* 内核状态兼重启按钮:两层叠在同一格里,悬停换层 */
|
||||
.kernel-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 26px;
|
||||
padding: 0 var(--s-2);
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.kernel-btn:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.kernel-btn:hover:not(:disabled) {
|
||||
background: var(--c-surface-sunken);
|
||||
}
|
||||
|
||||
.k-idle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
min-width: 0;
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-xs);
|
||||
/* 等宽字体的行框上下不对称,压成 1 让点和文字真正居中 */
|
||||
line-height: 1;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.k-hot {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding: 0 var(--s-2);
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-xs);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.kernel-btn:hover:not(:disabled) .k-idle,
|
||||
.kernel-btn:focus-visible:not(:disabled) .k-idle {
|
||||
opacity: 0;
|
||||
}
|
||||
.kernel-btn:hover:not(:disabled) .k-hot,
|
||||
.kernel-btn:focus-visible:not(:disabled) .k-hot {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.k-ico {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
@@ -279,10 +704,14 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 钉在侧栏右下角:宽度过渡时自动跟着边缘走。right 取 17 是为了收拢到 60px 时正好居中 */
|
||||
.densify {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: absolute;
|
||||
right: 17px;
|
||||
bottom: var(--s-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid transparent;
|
||||
@@ -296,24 +725,11 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
/* 紧凑模式:文字收起来,只留图标 */
|
||||
.is-compact .brand-name,
|
||||
.is-compact .nav-label,
|
||||
.is-compact .kernel-text {
|
||||
display: none;
|
||||
}
|
||||
.is-compact .brand,
|
||||
.is-compact .nav-item {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
.is-compact .side-foot {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── 主区 ───────────────────────────────────── */
|
||||
/* 侧栏是 fixed、不占网格位,主区要自己站到第二列,否则会顶进第一列 */
|
||||
.main {
|
||||
grid-column: 2;
|
||||
padding: var(--s-6) var(--s-6) var(--s-7);
|
||||
max-width: 1600px;
|
||||
}
|
||||
|
||||
+196
-9
@@ -5,12 +5,14 @@
|
||||
* (或者持有 SSH 隧道)的人才打得开,存在浏览器里不额外扩大暴露面。
|
||||
*/
|
||||
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
import type { ConnectionSnapshot } from '@shared/connections.ts'
|
||||
import type { ExitProbe, HostInfo } from '@shared/host-info.ts'
|
||||
import type { GeoFileStatus } from '@shared/geo.ts'
|
||||
import type { ReachResult } from '@shared/reachability.ts'
|
||||
import { DEFAULT_TEST_URL, testUrl } from './prefs'
|
||||
|
||||
const TOKEN_KEY = 'patchbay.token'
|
||||
|
||||
/** 测延迟用的目标。204 空响应,体积最小,全球都有部署。 */
|
||||
const TEST_URL = 'https://www.gstatic.com/generate_204'
|
||||
|
||||
export function getToken(): string {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? ''
|
||||
}
|
||||
@@ -99,6 +101,13 @@ export function latestDelay(info: ProxyInfo | undefined): number | null {
|
||||
return last === undefined || last === 0 ? null : last
|
||||
}
|
||||
|
||||
/** 一个订阅的运行时状态。updatedAt 是上次拉取成功的时刻,没拉过是 Go 的零值时间。 */
|
||||
export interface ProviderInfo {
|
||||
vehicleType: string
|
||||
updatedAt?: string
|
||||
proxies: ProxyInfo[]
|
||||
}
|
||||
|
||||
export interface FieldSpec {
|
||||
key: string
|
||||
type: 'string' | 'number' | 'boolean' | 'string[]' | 'number[]' | 'object' | 'object[]'
|
||||
@@ -113,19 +122,141 @@ export interface ListenerFields {
|
||||
types: Record<string, { note: string; fields: FieldSpec[] }>
|
||||
}
|
||||
|
||||
export interface RuleProviderInfo {
|
||||
/** domain / ipcidr / classical */
|
||||
behavior: string
|
||||
/** yaml / text / mrs。内核始终序列化它(provider.go:37 无 omitempty),inline 类型给的是空串 */
|
||||
format?: string
|
||||
name: string
|
||||
/** 当前装了多少条规则 */
|
||||
ruleCount: number
|
||||
/** HTTP / File / Inline */
|
||||
vehicleType: string
|
||||
/** RFC3339。还没拉取过时是零值(0001-01-01) */
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export const api = {
|
||||
readConfig: () => request<ConfigSnapshot>('GET', '/api/config'),
|
||||
|
||||
applyConfig: (data: ConfigObject, baseRevision: string) =>
|
||||
request<ApplySuccess>('PUT', '/api/config', { data, baseRevision }),
|
||||
|
||||
listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'),
|
||||
/** 备份链(撤回栈)和重做栈深度。侧栏用它决定撤回/重做按钮亮不亮。 */
|
||||
listBackups: () => request<{ backups: string[]; redo: number }>('GET', '/api/config/backups'),
|
||||
|
||||
/** 撤回最近一次改动:应用最新备份并消费掉它,撤回前的状态进重做栈。 */
|
||||
undoConfig: () => request<ApplySuccess>('POST', '/api/config/undo'),
|
||||
|
||||
/** 重做最近一次被撤回的改动。新的正常保存会清空重做栈。 */
|
||||
redoConfig: () => request<ApplySuccess>('POST', '/api/config/redo'),
|
||||
|
||||
/** 配置文件的原文,给「下载当前配置」用。 */
|
||||
configRaw: async (): Promise<string> => {
|
||||
const res = await fetch('/api/config/raw', {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
const payload = text ? (JSON.parse(text) as Record<string, unknown>) : {}
|
||||
throw new ApiError(res.status, payload)
|
||||
}
|
||||
return res.text()
|
||||
},
|
||||
|
||||
/**
|
||||
* 用上传的 YAML 替换当前配置:override 整份替换,compatible 只搬
|
||||
* Patchbay 接管的段落。走服务端完整的保护链路(校验、备份、重载、失败回退)。
|
||||
*/
|
||||
importConfig: (content: string, mode: 'override' | 'compatible') =>
|
||||
request<ApplySuccess>('POST', '/api/config/import', { content, mode }),
|
||||
|
||||
restoreBackup: (name: string) =>
|
||||
request<ApplySuccess>('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`),
|
||||
|
||||
/** 一条历史记录的对比原文:这份备份(改动前)和它之后的状态(改动后)。 */
|
||||
backupDiff: (name: string) =>
|
||||
request<{ before: string; after: string }>(
|
||||
'GET',
|
||||
`/api/config/backups/${encodeURIComponent(name)}/diff`,
|
||||
),
|
||||
|
||||
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
|
||||
|
||||
/**
|
||||
* 服务端所在主机的指标:CPU、内存、负载、内网地址、网关。
|
||||
* 内核的管理接口只报自己的内存,这些只有跑在同一台机器上的 Patchbay 服务端答得出。
|
||||
*/
|
||||
host: () => request<HostInfo>('GET', '/api/host'),
|
||||
|
||||
/**
|
||||
* 经由某个入站端口探测公网 IP(服务端拿本机对应端口当代理去访问 ip.sb)。
|
||||
* 会发起真实的外网请求,只在用户点了检测时调。
|
||||
*/
|
||||
probeExit: (port: number) => request<ExitProbe>('GET', `/api/host/exit?port=${String(port)}`),
|
||||
|
||||
/**
|
||||
* 经由某个入站端口探测一个目标能不能访问。一次一个目标,调用方并发发起。
|
||||
* 同样会发起真实的外网请求,只在用户点了测试时调。
|
||||
*/
|
||||
probeReach: (port: number, target: string) =>
|
||||
request<ReachResult>('GET', `/api/host/reach?port=${String(port)}&target=${target}`),
|
||||
|
||||
/** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */
|
||||
kernelConfigs: () => request<Record<string, unknown>>('GET', '/api/kernel/configs'),
|
||||
|
||||
/**
|
||||
* 重启内核进程。所有连接会断开,几秒后自动恢复;
|
||||
* 策略组的选中靠配置里的 profile.store-selected 找回来。
|
||||
*/
|
||||
restartKernel: () => request<{ ok: true }>('POST', '/api/kernel/restart'),
|
||||
|
||||
/** 让内核重新加载磁盘上的配置文件。给 SSH 手改过配置的场景用。 */
|
||||
reloadKernelConfig: () => request<{ ok: true }>('POST', '/api/kernel/reload'),
|
||||
|
||||
/**
|
||||
* 让内核更新 GEO 数据库。同步接口,下载完才回话,可能要等上几十秒。
|
||||
* 内核只有全量更新这一个口子,没变化的文件靠内容哈希跳过重写。
|
||||
*/
|
||||
updateGeo: () => request<{ ok: true }>('POST', '/api/kernel/geo'),
|
||||
|
||||
/** 四类 GEO 文件的本地状态:大小、更新时间、生效中的下载地址。 */
|
||||
geoStatus: () => request<{ files: GeoFileStatus[] }>('GET', '/api/geo'),
|
||||
|
||||
/**
|
||||
* 让服务端下载单个 GEO 文件(内核的更新接口是全量的,单文件只能自己下)。
|
||||
* 内核缓存着旧数据,重启后才换用新文件。
|
||||
*/
|
||||
syncGeoFile: (configKey: string) =>
|
||||
request<{ ok: true }>('POST', '/api/geo/sync', { key: configKey }),
|
||||
|
||||
/**
|
||||
* 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。
|
||||
*
|
||||
* 不能用上面的 request():它会把整个响应读完,而对永不结束的流来说那等于挂死。
|
||||
* 调用方拿到响应体后用 web/stream.ts 的 readLines() 逐行读;中止 signal 即断流。
|
||||
*/
|
||||
openStream: async (path: string, signal: AbortSignal): Promise<ReadableStream<Uint8Array>> => {
|
||||
const res = await fetch(`/api/kernel${path}`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
const payload = text ? (JSON.parse(text) as Record<string, unknown>) : {}
|
||||
throw new ApiError(res.status, payload)
|
||||
}
|
||||
if (!res.body) throw new Error('响应没有内容')
|
||||
return res.body
|
||||
},
|
||||
|
||||
/**
|
||||
* 把上传的线路/规则清单写进内核工作目录的 providers/ 下。
|
||||
* path 必须是 providerCachePath / ruleProviderPath 生成的形状,服务端有白名单校验。
|
||||
*/
|
||||
uploadProviderFile: (path: string, content: string) =>
|
||||
request<{ ok: true }>('POST', '/api/provider-files', { path, content }),
|
||||
|
||||
/** 入站类型的字段表,从内核源码提取,用来驱动表单。 */
|
||||
listenerFields: () => request<ListenerFields>('GET', '/api/meta/listener-fields'),
|
||||
|
||||
@@ -151,13 +282,72 @@ export const api = {
|
||||
* 订阅线路不在表里(那正是它们无法被入站直接引用的原因),只能从所属订阅进去测。
|
||||
*/
|
||||
testLine: (name: string, provider?: string, timeout = 5000) => {
|
||||
const q = `url=${encodeURIComponent(TEST_URL)}&timeout=${String(timeout)}`
|
||||
// 测速地址是设置页里的偏好,空值退回默认
|
||||
const target = testUrl.value.trim() || DEFAULT_TEST_URL
|
||||
const q = `url=${encodeURIComponent(target)}&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)
|
||||
},
|
||||
|
||||
/**
|
||||
* 当前所有连接的快照。
|
||||
*
|
||||
* 这个接口没有流式版本(流式的是 /traffic、/logs 那几个),只能全量轮询。
|
||||
* 没有连接时 connections 字段是 null 而不是空数组。
|
||||
*/
|
||||
connections: () => request<ConnectionSnapshot>('GET', '/api/kernel/connections'),
|
||||
|
||||
/** 断开某一条连接。运行时动作,不写配置文件。 */
|
||||
closeConnection: (id: string) =>
|
||||
request<{ message?: string }>(
|
||||
'DELETE',
|
||||
`/api/kernel/connections/${encodeURIComponent(id)}`,
|
||||
),
|
||||
|
||||
/** 断开所有连接。 */
|
||||
closeAllConnections: () => request<{ message?: string }>('DELETE', '/api/kernel/connections'),
|
||||
|
||||
/**
|
||||
* 让内核立刻重新拉取某个订阅。
|
||||
*
|
||||
* 运行时动作,不写配置文件,所以不走 applyConfig 那条校验重载链路。成功时内核回
|
||||
* 204 空响应,拉取失败回 503 带错误消息(内核源码 hub/route/provider.go:52-60)。
|
||||
*/
|
||||
refreshProvider: (name: string) =>
|
||||
request<unknown>('PUT', `/api/kernel/providers/proxies/${encodeURIComponent(name)}`),
|
||||
|
||||
/**
|
||||
* 让内核立刻对一个订阅的全部线路做一次健康检查:逐条 URLTest,测通与否和延迟一起
|
||||
* 记进历史(内核源码 adapter/provider/healthcheck.go:123-189)。
|
||||
*
|
||||
* 用的是这个订阅 `health-check.url` 里的地址而不是设置页的测速地址——内核这个接口
|
||||
* 不收 url 参数。它是同步的,测完全部线路才回话,所以可能要等上几十秒。
|
||||
*/
|
||||
checkProviderLines: (name: string) =>
|
||||
request<unknown>(
|
||||
'GET',
|
||||
`/api/kernel/providers/proxies/${encodeURIComponent(name)}/healthcheck`,
|
||||
),
|
||||
|
||||
/**
|
||||
* 每个规则组的运行时状态:装了多少条规则、上次什么时候更新。
|
||||
* 配置文件里只有声明,这两个数字只有运行中的内核知道。
|
||||
*/
|
||||
ruleProviders: () =>
|
||||
request<{ providers: Record<string, RuleProviderInfo> }>('GET', '/api/kernel/providers/rules'),
|
||||
|
||||
/**
|
||||
* 让内核立刻重新拉取某个规则组。
|
||||
* 这是运行时动作,不写配置文件,所以不走落盘那条保护链路。
|
||||
*/
|
||||
updateRuleProvider: (name: string) =>
|
||||
request<Record<string, never>>(
|
||||
'PUT',
|
||||
`/api/kernel/providers/rules/${encodeURIComponent(name)}`,
|
||||
),
|
||||
|
||||
/**
|
||||
* 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。
|
||||
*
|
||||
@@ -165,8 +355,5 @@ export const api = {
|
||||
* 全局名字表里,这是拿到它们类型和延迟的唯一途径。
|
||||
*/
|
||||
providers: () =>
|
||||
request<{ providers: Record<string, { vehicleType: string; proxies: ProxyInfo[] }> }>(
|
||||
'GET',
|
||||
'/api/kernel/providers/proxies',
|
||||
),
|
||||
request<{ providers: Record<string, ProviderInfo> }>('GET', '/api/kernel/providers/proxies'),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 可自由填写的组合框,替代原生 input + datalist。
|
||||
*
|
||||
* 和 SelectBox 的分工:SelectBox 的值只能从选项里挑;这里选项只是建议,
|
||||
* 手填任何值都合法(规则的目标可以是候选之外的出站名)。换掉 datalist
|
||||
* 的理由和 SelectBox 一样:原生建议列表的样式、动画、开合状态都管不到。
|
||||
*
|
||||
* 过滤只在本次展开后输入过才生效:带着已有值展开时按值过滤的话,
|
||||
* 列表只剩当前值自己,其它选项等于不存在。
|
||||
*
|
||||
* Enter 的归属:弹层开着且有高亮项时选中它、不提交表单;弹层收起时不拦,
|
||||
* 交给对话框的表单提交——填完直接回车保存是原生 input 的习惯,不能破坏。
|
||||
*/
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import IconChevron from '~icons/ri/arrow-down-s-line'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 建议列表。过滤是 includes,不区分大小写 */
|
||||
options: string[]
|
||||
placeholder?: string
|
||||
/** 等宽字体,规则原文这类内容用 */
|
||||
mono?: boolean
|
||||
ariaLabel?: string
|
||||
}>()
|
||||
|
||||
const text = defineModel<string>({ default: '' })
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
const menuEl = ref<HTMLElement | null>(null)
|
||||
const open = ref(false)
|
||||
const openUp = ref(false)
|
||||
const active = ref(0)
|
||||
const menuStyle = ref<Record<string, string>>({})
|
||||
/** 本次展开后是否输入过。没输入过就给全量列表 */
|
||||
const typed = ref(false)
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = text.value.trim().toLowerCase()
|
||||
if (!typed.value || !q) return props.options
|
||||
return props.options.filter((name) => name.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
const MENU_ROOM = 220
|
||||
|
||||
/**
|
||||
* 弹层是 position: fixed,按视口摆放,理由同 SelectBox:absolute 会计入
|
||||
* 祖先滚动容器的可滚动区域,在对话框里打开会把对话框撑出滚动条。
|
||||
* 位置不跟随滚动,所以滚动和缩放时直接收起。
|
||||
*/
|
||||
function place() {
|
||||
const rect = root.value?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const up = window.innerHeight - rect.bottom < MENU_ROOM && rect.top > MENU_ROOM
|
||||
openUp.value = up
|
||||
menuStyle.value = {
|
||||
left: `${String(rect.left)}px`,
|
||||
minWidth: `${String(rect.width)}px`,
|
||||
...(up
|
||||
? { bottom: `${String(window.innerHeight - rect.top + 4)}px` }
|
||||
: { top: `${String(rect.bottom + 4)}px` }),
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面或对话框一滚动,锚点就不准了,直接收起。弹层内部的滚动不算 */
|
||||
function closeOnMove(event?: Event) {
|
||||
if (event?.target instanceof Node && menuEl.value?.contains(event.target)) return
|
||||
open.value = false
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
window.addEventListener('scroll', closeOnMove, true)
|
||||
window.addEventListener('resize', closeOnMove)
|
||||
} else {
|
||||
window.removeEventListener('scroll', closeOnMove, true)
|
||||
window.removeEventListener('resize', closeOnMove)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', closeOnMove, true)
|
||||
window.removeEventListener('resize', closeOnMove)
|
||||
})
|
||||
|
||||
function show() {
|
||||
typed.value = false
|
||||
active.value = 0
|
||||
place()
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
typed.value = true
|
||||
active.value = 0
|
||||
place()
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function toggleFromChevron() {
|
||||
if (open.value) open.value = false
|
||||
else show()
|
||||
input.value?.focus()
|
||||
}
|
||||
|
||||
function pick(name: string) {
|
||||
text.value = name
|
||||
open.value = false
|
||||
input.value?.focus()
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
if (!open.value) {
|
||||
show()
|
||||
return
|
||||
}
|
||||
const count = filtered.value.length
|
||||
if (count === 0) return
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1
|
||||
active.value = (active.value + delta + count) % count
|
||||
} else if (event.key === 'Enter') {
|
||||
if (!open.value) return // 交给表单提交
|
||||
const name = filtered.value[active.value]
|
||||
event.preventDefault()
|
||||
if (name) pick(name)
|
||||
else open.value = false
|
||||
} else if (event.key === 'Escape' && open.value) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFocusOut(event: FocusEvent) {
|
||||
const next = event.relatedTarget
|
||||
if (next instanceof Node && root.value?.contains(next)) return
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="combo" @focusout="onFocusOut">
|
||||
<input
|
||||
ref="input"
|
||||
v-model="text"
|
||||
:class="['field', mono && 'mono']"
|
||||
type="text"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
:aria-label="ariaLabel"
|
||||
:placeholder="placeholder"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
@focus="show"
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle"
|
||||
:aria-label="open ? '收起建议' : '展开建议'"
|
||||
tabindex="-1"
|
||||
@click="toggleFromChevron"
|
||||
>
|
||||
<IconChevron :class="['chev', open && 'is-open']" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Transition name="menu">
|
||||
<ul
|
||||
v-if="open && filtered.length > 0"
|
||||
ref="menuEl"
|
||||
:class="['menu', openUp && 'is-up']"
|
||||
:style="menuStyle"
|
||||
role="listbox"
|
||||
>
|
||||
<li v-for="(name, i) in filtered" :key="name">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="name === text"
|
||||
:class="['opt', i === active && 'is-active']"
|
||||
@mousemove="active = i"
|
||||
@click="pick(name)"
|
||||
>
|
||||
{{ name }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.combo {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
/* 给右侧箭头留位置 */
|
||||
padding-right: var(--s-6);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--select-bg, var(--c-bg));
|
||||
}
|
||||
|
||||
.toggle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--s-2);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chev {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chev.is-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 位置(left/top/bottom/minWidth)由 place() 以内联样式给出 */
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
max-width: min(360px, calc(100vw - 2 * var(--s-4)));
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
/* 细杆加透明轨道,别让弹层里出现一根系统灰滚动条 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-strong) transparent;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--s-1);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.opt {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
text-align: left;
|
||||
font-size: var(--t-sm);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opt.is-active {
|
||||
background: var(--c-surface-sunken);
|
||||
}
|
||||
|
||||
.menu-enter-active,
|
||||
.menu-leave-active {
|
||||
transition:
|
||||
opacity 0.12s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
|
||||
.menu-enter-from,
|
||||
.menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.menu.is-up.menu-enter-from,
|
||||
.menu.is-up.menu-leave-to {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 危险动作的二次确认。
|
||||
*
|
||||
* 原生 <dialog>,焦点捕获、Esc 关闭、背景遮罩都是浏览器自带的(同 InboundForm)。
|
||||
* 不用 window.confirm 是因为它的样式游离在页面之外,按钮文案也固定是「确定」,
|
||||
* 对不上具体要做的动作。打开时焦点落在「取消」上,回车不会误删。
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
message: string
|
||||
/** 确认按钮上的动词,比如「删除」 */
|
||||
action: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: []; confirm: [] }>()
|
||||
|
||||
const dialog = ref<HTMLDialogElement | null>(null)
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) dialog.value?.showModal()
|
||||
else dialog.value?.close()
|
||||
},
|
||||
)
|
||||
|
||||
/** 点在遮罩上就关闭。内容区的点击 target 是 .body 里的元素,到不了 dialog 本身。 */
|
||||
function onBackdrop(event: MouseEvent) {
|
||||
if (event.target === dialog.value) emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialog"
|
||||
class="dlg"
|
||||
@click="onBackdrop"
|
||||
@close="emit('close')"
|
||||
@cancel.prevent="emit('close')"
|
||||
>
|
||||
<div class="body">
|
||||
<p class="msg">{{ message }}</p>
|
||||
<footer class="acts">
|
||||
<button type="button" class="btn" @click="emit('close')">取消</button>
|
||||
<button type="button" class="btn go" @click="emit('confirm')">{{ action }}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dlg {
|
||||
width: min(400px, calc(100vw - 2 * var(--s-5)));
|
||||
/* 内边距放在 .body 上:dialog 自己的内边距区域点了会被当成遮罩点击 */
|
||||
padding: 0;
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: var(--s-5);
|
||||
}
|
||||
|
||||
.dlg::backdrop {
|
||||
background: rgb(0 0 0 / 0.45);
|
||||
}
|
||||
|
||||
.msg {
|
||||
line-height: var(--line);
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--s-2);
|
||||
margin-top: var(--s-5);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 确认键用危险色实底:这个对话框只出现在删除这类不可逆动作前 */
|
||||
.go {
|
||||
border-color: transparent;
|
||||
background: var(--c-danger);
|
||||
color: var(--c-bg);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 拖拽或点选一个文件,读出文本内容交给调用方。
|
||||
*
|
||||
* 无状态:只发 load 事件,选没选、选了什么由调用方自己记——三个用它的表单
|
||||
* 对「选中之后长什么样」要求各不相同(订阅显示文件名待提交,ini 导入直接
|
||||
* 灌进文本框),状态放这里反而要为每种用法开口子。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import IconUpload from '~icons/ri/upload-cloud-2-line'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 原生 accept 串,如 ".yaml,.yml,.txt" */
|
||||
accept?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ load: [name: string, content: string] }>()
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
const dragging = ref(false)
|
||||
|
||||
async function take(file: File | undefined) {
|
||||
if (!file) return
|
||||
emit('load', file.name, await file.text())
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragging.value = false
|
||||
void take(event.dataTransfer?.files[0])
|
||||
}
|
||||
|
||||
function onPick(event: Event) {
|
||||
const el = event.target as HTMLInputElement
|
||||
void take(el.files?.[0])
|
||||
// 同一个文件选第二次也要触发 change
|
||||
el.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:class="['zone', dragging && 'is-over']"
|
||||
@click="input?.click()"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<IconUpload class="ico" aria-hidden="true" />
|
||||
<span>拖拽文件到这里,或点击选择</span>
|
||||
<input ref="input" type="file" :accept="props.accept" hidden @change="onPick" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.zone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-2);
|
||||
width: 100%;
|
||||
padding: var(--s-4);
|
||||
border: 1px dashed var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.zone:hover,
|
||||
.zone.is-over {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-text);
|
||||
background: var(--c-accent-soft);
|
||||
}
|
||||
|
||||
.ico {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ECharts 的最小封装:初始化、跟随 option 更新、跟随容器尺寸缩放、卸载时销毁。
|
||||
*
|
||||
* 按需注册而不是整包引入:仪表盘只用折线和环形图。调用方传新的 option 对象
|
||||
* 触发更新(computed 每次重算都是新引用),这里不做深比较。
|
||||
*/
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { LineChart, PieChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import type { EChartsCoreOption } from 'echarts/core'
|
||||
|
||||
echarts.use([LineChart, PieChart, GridComponent, TooltipComponent, CanvasRenderer])
|
||||
|
||||
const props = defineProps<{ option: EChartsCoreOption }>()
|
||||
|
||||
const el = ref<HTMLElement | null>(null)
|
||||
let chart: echarts.EChartsType | null = null
|
||||
let observer: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!el.value) return
|
||||
chart = echarts.init(el.value)
|
||||
chart.setOption(props.option)
|
||||
observer = new ResizeObserver(() => chart?.resize())
|
||||
observer.observe(el.value)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.option,
|
||||
(option) => chart?.setOption(option),
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect()
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el" class="echart"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.echart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,282 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 备份链(历史记录)的弹窗:列出每次改动前留下的备份,点还原直接跳回去。
|
||||
* 还原走服务端同一条保护链路,当前状态会先自动备份,所以不需要再套一层确认。
|
||||
* 点一行展开这次改动的 diff:改动前是备份本身,改动后是更新一份的状态。
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { backupTime } from '@shared/backups.ts'
|
||||
import { diffHunks, diffLines } from '@shared/diff.ts'
|
||||
import type { DiffLine } from '@shared/diff.ts'
|
||||
import { api } from '../api'
|
||||
import { toast, toastError } from '../toast'
|
||||
import IconClose from '~icons/ri/close-line'
|
||||
import IconRight from '~icons/ri/arrow-right-s-line'
|
||||
|
||||
const props = defineProps<{ open: boolean }>()
|
||||
|
||||
/** restored:还原成功,外壳该重读配置了。 */
|
||||
const emit = defineEmits<{ close: []; restored: [] }>()
|
||||
|
||||
const dialog = ref<HTMLDialogElement | null>(null)
|
||||
const backups = ref<string[] | null>(null)
|
||||
const busy = ref(false)
|
||||
|
||||
/** 展开着的那条。同时只开一条,diff 是逐条看的东西,摊开一串没法读 */
|
||||
const expanded = ref<string | null>(null)
|
||||
/** 已取回并算好的 diff 片段。null 表示取失败了 */
|
||||
const diffs = ref<Record<string, DiffLine[][] | null>>({})
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
dialog.value?.showModal()
|
||||
backups.value = null
|
||||
expanded.value = null
|
||||
diffs.value = {}
|
||||
void api
|
||||
.listBackups()
|
||||
.then((r) => (backups.value = r.backups))
|
||||
.catch(() => (backups.value = []))
|
||||
} else {
|
||||
dialog.value?.close()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function toggleDiff(name: string) {
|
||||
expanded.value = expanded.value === name ? null : name
|
||||
if (expanded.value !== name || name in diffs.value) return
|
||||
try {
|
||||
const { before, after } = await api.backupDiff(name)
|
||||
diffs.value = { ...diffs.value, [name]: diffHunks(diffLines(before, after)) }
|
||||
} catch {
|
||||
diffs.value = { ...diffs.value, [name]: null }
|
||||
}
|
||||
}
|
||||
|
||||
const sign = (kind: DiffLine['kind']) => (kind === 'add' ? '+' : kind === 'del' ? '−' : ' ')
|
||||
|
||||
async function restore(name: string) {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await api.restoreBackup(name)
|
||||
const time = backupTime(name) ?? name
|
||||
toast(result.reloaded ? `已还原到 ${time}` : `已还原到 ${time}(${result.reloadMessage})`)
|
||||
emit('restored')
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 点在遮罩上就关闭。内容区的点击 target 是里面的元素,到不了 dialog 本身。 */
|
||||
function onBackdrop(event: MouseEvent) {
|
||||
if (event.target === dialog.value) emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialog"
|
||||
class="dlg"
|
||||
@click="onBackdrop"
|
||||
@close="emit('close')"
|
||||
@cancel.prevent="emit('close')"
|
||||
>
|
||||
<div class="body">
|
||||
<header class="head">
|
||||
<h2>历史记录</h2>
|
||||
<button type="button" class="x" title="关闭" @click="emit('close')"><IconClose /></button>
|
||||
</header>
|
||||
|
||||
<p v-if="backups === null" class="faint">载入中</p>
|
||||
<p v-else-if="backups.length === 0" class="faint">
|
||||
没有备份。每次保存都会把改动前的状态留在这里,最多 10 份。
|
||||
</p>
|
||||
<ul v-else class="list">
|
||||
<li v-for="name in backups" :key="name" class="item">
|
||||
<div
|
||||
class="row"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="name"
|
||||
@click="toggleDiff(name)"
|
||||
@keydown.enter="toggleDiff(name)"
|
||||
>
|
||||
<IconRight :class="['chev', expanded === name && 'is-open']" aria-hidden="true" />
|
||||
<span class="mono time">{{ backupTime(name) ?? name }}</span>
|
||||
<button type="button" class="btn" :disabled="busy" @click.stop="restore(name)">
|
||||
还原
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="expanded === name" class="diff mono">
|
||||
<p v-if="!(name in diffs)" class="faint">载入中</p>
|
||||
<p v-else-if="diffs[name] === null" class="faint">对比内容读取失败</p>
|
||||
<p v-else-if="diffs[name]!.length === 0" class="faint">和之后的状态没有差异</p>
|
||||
<template v-else>
|
||||
<template v-for="(hunk, hi) in diffs[name]" :key="hi">
|
||||
<div v-if="hi > 0" class="gap" aria-hidden="true">⋯</div>
|
||||
<div v-for="(line, li) in hunk" :key="li" :class="['dl', line.kind]">
|
||||
<span class="dsign">{{ sign(line.kind) }}</span>{{ line.text }}
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dlg {
|
||||
width: min(640px, 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);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: var(--s-5);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
|
||||
.head h2 {
|
||||
font-size: var(--t-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.x {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: 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);
|
||||
}
|
||||
|
||||
.faint {
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-strong) transparent;
|
||||
}
|
||||
|
||||
.item + .item {
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-2) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chev {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--c-text-faint);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.chev.is-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.time {
|
||||
flex: 1;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.btn {
|
||||
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;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
.btn:disabled {
|
||||
color: var(--c-text-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.diff {
|
||||
margin: 0 0 var(--s-3);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-bg);
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-strong) transparent;
|
||||
font-size: var(--t-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.dl {
|
||||
white-space: pre;
|
||||
}
|
||||
.dl.add {
|
||||
color: var(--c-ok);
|
||||
}
|
||||
.dl.del {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
.dl.same {
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
.dsign {
|
||||
display: inline-block;
|
||||
width: 1.2em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.gap {
|
||||
padding: var(--s-1) 0;
|
||||
color: var(--c-text-faint);
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -6,8 +6,10 @@
|
||||
*/
|
||||
import { inboundTypeLabel, isUnsetOutbound, outboundLabel } from '@shared/inbounds.ts'
|
||||
import { displayName, isGenerated } from '@shared/inbound-groups.ts'
|
||||
import { resolveChain } from '@shared/outbound-chain.ts'
|
||||
import type { Inbound } from '@shared/inbounds.ts'
|
||||
import { runtime } from '../runtime'
|
||||
import { isChainedLine, runtime } from '../runtime'
|
||||
import IconLink from '~icons/ri/links-line'
|
||||
|
||||
defineProps<{ inbounds: Inbound[] }>()
|
||||
const selected = defineModel<string | null>('selected', { required: true })
|
||||
@@ -15,13 +17,19 @@ const selected = defineModel<string | null>('selected', { required: true })
|
||||
/**
|
||||
* 这一行右侧显示什么。
|
||||
*
|
||||
* 指向 Patchbay 自动创建的代理组时显示那个组当前选中的线路,而不是组名——组名是
|
||||
* 「patchbay:」加入站名,和左边的名字列完全重复,等于一列没有信息。
|
||||
* 指向 Patchbay 自动创建的代理组时,顺着「当前选中」解析到最终出口——组名和左边的
|
||||
* 名字列完全重复,而组选中 AUTO 时「AUTO」也不是答案,用户要的是流量走哪条线。
|
||||
* 出口经过链式代理入口时带一个标记,和详情里的流量去向对得上。
|
||||
*/
|
||||
function outboundOf(inbound: Inbound): string {
|
||||
function outboundOf(inbound: Inbound): { text: string; chained: boolean } {
|
||||
const mode = inbound.outbound
|
||||
if (mode.kind !== 'named' || !isGenerated(mode.name)) return outboundLabel(mode)
|
||||
return displayName(runtime.value[mode.name]?.now ?? '') || '未选择'
|
||||
if (mode.kind !== 'named' || !isGenerated(mode.name))
|
||||
return { text: outboundLabel(mode), chained: false }
|
||||
const exit = resolveChain(runtime.value, runtime.value[mode.name]?.now ?? null).at(-1)
|
||||
return {
|
||||
text: displayName(exit ?? '') || '未选择',
|
||||
chained: exit !== undefined && isChainedLine(exit),
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -37,10 +45,13 @@ function outboundOf(inbound: Inbound): string {
|
||||
<span class="type mono">{{ inboundTypeLabel(inbound.type) }}</span>
|
||||
<span class="name">
|
||||
{{ inbound.name }}
|
||||
<span v-if="inbound.isDefault" class="tag">默认</span>
|
||||
<span v-if="inbound.isDefault" class="tag">legacy</span>
|
||||
</span>
|
||||
<span :class="['out mono', isUnsetOutbound(inbound.outbound) && 'is-muted']">
|
||||
{{ outboundOf(inbound) }}
|
||||
<span v-if="outboundOf(inbound).chained" class="chain" title="经过链式代理">
|
||||
<IconLink />
|
||||
</span>
|
||||
{{ outboundOf(inbound).text }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -114,8 +125,16 @@ function outboundOf(inbound: Inbound): string {
|
||||
}
|
||||
|
||||
.out {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--s-1);
|
||||
font-size: var(--t-sm);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.chain {
|
||||
display: flex;
|
||||
color: var(--c-chain-text);
|
||||
}
|
||||
|
||||
.out.is-muted {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 「纯文本 + 编辑铅笔」的行内编辑:平时只显示值,点铅笔换成输入框,
|
||||
* Enter 或失焦提交,Esc 放弃。设置页里一行一个值的场合都用它,
|
||||
* 免得整页铺满常驻的输入框。
|
||||
*
|
||||
* 校验交给 validate:Enter 提交没过就地标红不退出;失焦没过直接放弃改动
|
||||
* ——失焦是「我走了」,不该把人拽回来。
|
||||
*/
|
||||
import { nextTick, ref } from 'vue'
|
||||
import IconEdit from '~icons/ri/pencil-line'
|
||||
import IconReset from '~icons/ri/arrow-go-back-line'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 当前值的原始文本,空串表示未设置 */
|
||||
value: string
|
||||
/** 空值时显示的说明(「未启用」「内核默认」) */
|
||||
placeholder?: string
|
||||
/** 值后面的单位 */
|
||||
unit?: string
|
||||
mono?: boolean
|
||||
/** 显示「恢复默认」按钮 */
|
||||
canReset?: boolean
|
||||
/** 提交进行中,禁用编辑入口 */
|
||||
busy?: boolean
|
||||
validate?: (value: string) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ submit: [value: string]; reset: [] }>()
|
||||
|
||||
const editing = ref(false)
|
||||
const draft = ref('')
|
||||
const bad = ref(false)
|
||||
const inputEl = ref<HTMLInputElement | null>(null)
|
||||
|
||||
async function open() {
|
||||
draft.value = props.value
|
||||
bad.value = false
|
||||
editing.value = true
|
||||
await nextTick()
|
||||
inputEl.value?.focus()
|
||||
inputEl.value?.select()
|
||||
}
|
||||
|
||||
/** lenient 是失焦路径:无效就放弃,不纠缠。 */
|
||||
function finish(lenient: boolean) {
|
||||
if (!editing.value) return
|
||||
const next = draft.value.trim()
|
||||
if (props.validate && !props.validate(next)) {
|
||||
if (lenient) editing.value = false
|
||||
else bad.value = true
|
||||
return
|
||||
}
|
||||
editing.value = false
|
||||
if (next !== props.value) emit('submit', next)
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
editing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="ie">
|
||||
<template v-if="!editing">
|
||||
<span :class="['val', mono && 'mono', !value && 'faint']" :title="value || undefined">
|
||||
{{ value || placeholder }}
|
||||
</span>
|
||||
<span v-if="unit && value" class="unit">{{ unit }}</span>
|
||||
<button
|
||||
v-if="canReset"
|
||||
type="button"
|
||||
class="ico"
|
||||
title="恢复默认"
|
||||
:disabled="busy"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
<IconReset />
|
||||
</button>
|
||||
<button type="button" class="ico" title="编辑" :disabled="busy" @click="open">
|
||||
<IconEdit />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<input
|
||||
ref="inputEl"
|
||||
v-model="draft"
|
||||
:class="['input', mono && 'mono', bad && 'is-bad']"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
@keydown.enter.prevent="finish(false)"
|
||||
@keydown.esc.prevent="cancel"
|
||||
@blur="finish(true)"
|
||||
/>
|
||||
<span v-if="unit" class="unit">{{ unit }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ie {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.val {
|
||||
font-size: var(--t-sm);
|
||||
max-width: var(--ie-max, 320px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.faint {
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
.unit {
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.ico {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ico:hover:not(:disabled) {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.ico:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ico svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: var(--ie-width, 220px);
|
||||
max-width: 100%;
|
||||
padding: var(--s-1) var(--s-2);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.input.is-bad {
|
||||
border-color: var(--c-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -8,7 +8,7 @@
|
||||
import IconFlash from '~icons/ri/flashlight-line'
|
||||
import IconLoad from '~icons/ri/loader-4-line'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
/** 第二行。策略组显示「类型(当前选中)」,线路显示协议 */
|
||||
sub: string
|
||||
@@ -19,6 +19,10 @@ defineProps<{
|
||||
disabledReason?: string
|
||||
/** 这张卡的改动正在提交 */
|
||||
pending?: boolean
|
||||
/** 草稿预览里保存后才加入的成员 */
|
||||
draft?: boolean
|
||||
/** 草稿预览里保存后将移除的成员 */
|
||||
removed?: boolean
|
||||
/** 这条线路正在测延迟 */
|
||||
testing?: boolean
|
||||
/** 能不能测延迟。内置出站没得测 */
|
||||
@@ -32,6 +36,14 @@ defineProps<{
|
||||
|
||||
const emit = defineEmits<{ pick: []; test: [] }>()
|
||||
|
||||
/**
|
||||
* foot 的空白处也算选择。它属于卡片,点了没反应会让人以为卡片坏了;
|
||||
* 延迟按钮自己 stop 掉冒泡,测延迟不会连带换线。
|
||||
*/
|
||||
function pickFromFoot() {
|
||||
if (!props.disabledReason) emit('pick')
|
||||
}
|
||||
|
||||
function delayClass(ms: number | null): string {
|
||||
if (ms === null) return ''
|
||||
if (ms < 200) return 'd-ok'
|
||||
@@ -48,6 +60,9 @@ function delayClass(ms: number | null): string {
|
||||
selected && 'is-on',
|
||||
disabledReason && 'is-off',
|
||||
pending && 'is-pending',
|
||||
draft && 'is-draft',
|
||||
removed && 'is-removed',
|
||||
!testable && !pending && 'no-foot',
|
||||
]"
|
||||
>
|
||||
<button
|
||||
@@ -61,14 +76,15 @@ function delayClass(ms: number | null): string {
|
||||
<span class="sub">{{ sub || '—' }}</span>
|
||||
</button>
|
||||
|
||||
<div class="foot">
|
||||
<!-- 没内容就不渲染:空的 foot 会盖在卡片下缘,挡住主按钮的点击 -->
|
||||
<div v-if="testable || pending" class="foot" @click="pickFromFoot">
|
||||
<button
|
||||
v-if="testable"
|
||||
type="button"
|
||||
:class="['delay', delayClass(delay)]"
|
||||
:disabled="testing"
|
||||
:title="delay === null ? '测试延迟' : '重新测试'"
|
||||
@click="emit('test')"
|
||||
@click.stop="emit('test')"
|
||||
>
|
||||
<IconLoad v-if="testing" class="spin" />
|
||||
<template v-else-if="delay !== null">{{ delay }} ms</template>
|
||||
@@ -92,6 +108,9 @@ function delayClass(ms: number | null): string {
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface-sunken);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.card.tone-chain {
|
||||
@@ -117,8 +136,30 @@ function delayClass(ms: number | null): string {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* 草稿态:虚线区分于实线的现实。将移除的再加删除线,和「只是禁用」分开 */
|
||||
.card.is-draft,
|
||||
.card.is-removed {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.card.is-removed .name {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* 当前选中的线路即将被移除:危险色警示,这是草稿里最该被看见的一张卡 */
|
||||
.card.is-removed.is-on {
|
||||
border-color: var(--c-danger);
|
||||
background: var(--c-danger-soft);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card.is-removed.is-on .name {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.hit {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
min-width: 0;
|
||||
@@ -129,6 +170,12 @@ function delayClass(ms: number | null): string {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 没有 foot 时主按钮补回它的全部高度(延迟行 20px + 它的下边距),
|
||||
草稿卡和真实卡等高,网格不跳动,而且整张卡都是点击区 */
|
||||
.card.no-foot .hit {
|
||||
padding-bottom: calc(var(--s-1) + 20px + var(--s-2));
|
||||
}
|
||||
|
||||
.hit:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -161,6 +208,11 @@ function delayClass(ms: number | null): string {
|
||||
padding: 0 var(--s-3) var(--s-2);
|
||||
}
|
||||
|
||||
/* 空白处的点击是选择,光标也照选择的样子给 */
|
||||
.card:not(.is-off) .foot {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.delay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 订阅列表。每行一个订阅:名字、当前线路数、上次更新时间,末尾是调顺序的两个箭头。
|
||||
*
|
||||
* 只管显示、选中和「往哪边挪一位」,不碰任何数据。
|
||||
*/
|
||||
import IconUp from '~icons/ri/arrow-up-line'
|
||||
import IconDown from '~icons/ri/arrow-down-line'
|
||||
|
||||
defineProps<{
|
||||
/** 一行要显示的东西,由页面按配置和运行时数据拼好 */
|
||||
rows: Array<{
|
||||
name: string
|
||||
/** 当前有多少条线路。null 表示内核没给数据(没连接,或这个订阅还没拉取过) */
|
||||
lines: number | null
|
||||
/** 上次拉取成功的时刻。null 表示不知道或从没拉取过 */
|
||||
updated: Date | null
|
||||
}>
|
||||
/** 配置正在保存。调顺序要写配置文件,保存期间不能再点 */
|
||||
saving: boolean
|
||||
}>()
|
||||
const selected = defineModel<string | null>('selected', { required: true })
|
||||
|
||||
/** 把这个订阅在列表里挪一位。写配置文件,由页面走保存链路。 */
|
||||
const emit = defineEmits<{ move: [name: string, delta: -1 | 1] }>()
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
function formatUpdated(at: Date): string {
|
||||
return `${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ol class="rows">
|
||||
<li
|
||||
v-for="(row, i) in rows"
|
||||
:key="row.name"
|
||||
:class="['row', selected === row.name && 'is-on']"
|
||||
>
|
||||
<button type="button" class="line" @click="selected = row.name">
|
||||
<span class="name">{{ row.name }}</span>
|
||||
<span class="lines mono">
|
||||
<template v-if="row.lines !== null">{{ row.lines }} 条</template>
|
||||
<template v-else>—</template>
|
||||
</span>
|
||||
<span class="updated mono">{{ row.updated ? formatUpdated(row.updated) : '—' }}</span>
|
||||
</button>
|
||||
<span class="acts">
|
||||
<button
|
||||
type="button"
|
||||
title="上移"
|
||||
:disabled="saving || i === 0"
|
||||
@click="emit('move', row.name, -1)"
|
||||
>
|
||||
<IconUp />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="下移"
|
||||
:disabled="saving || i === rows.length - 1"
|
||||
@click="emit('move', row.name, 1)"
|
||||
>
|
||||
<IconDown />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rows li + li {
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: var(--s-3);
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--c-surface-sunken);
|
||||
}
|
||||
|
||||
.row.is-on {
|
||||
background: var(--c-accent-soft);
|
||||
}
|
||||
|
||||
.line {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 4rem 6.5rem;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3) var(--s-2) var(--s-3) var(--s-4);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lines,
|
||||
.updated {
|
||||
text-align: right;
|
||||
font-size: var(--t-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
/* 平时不显示:调顺序不是常用动作,每行常驻两个箭头只是噪声。
|
||||
隐藏用 opacity 而不是 display,位置一直占着,鼠标划过时右边一列才不会跳 */
|
||||
.acts {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
gap: 1px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.row:hover .acts,
|
||||
.acts:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.acts button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.acts button:hover:not(:disabled) {
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.acts button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 可访问性测试:这一路入站到几个常用 AI 服务通不通。
|
||||
*
|
||||
* 每个目标一个请求、并发发起,所以每一行各自落定,不用等最慢的那个才整表出现。
|
||||
* 判定不在这里——「什么状态码算通」在 shared/reachability.ts,这里只管显示。
|
||||
*
|
||||
* 这是纯读的运行时动作:不写配置文件、不改内核状态,所以打开就直接开测,不设确认。
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { REACH_TARGETS } from '@shared/reachability.ts'
|
||||
import type { ReachResult, ReachVerdict } from '@shared/reachability.ts'
|
||||
import { api } from '../api'
|
||||
import IconClose from '~icons/ri/close-line'
|
||||
import IconRefresh from '~icons/ri/refresh-line'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
/** 经由哪一路入站测。名字只用来显示,端口才是真正走的那个口 */
|
||||
name: string
|
||||
port: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
type Row = { state: 'testing' } | { state: 'done'; result: ReachResult }
|
||||
|
||||
const rows = ref<Record<string, Row>>({})
|
||||
const running = ref(false)
|
||||
|
||||
/**
|
||||
* 第几轮测试。重测和关掉重开都会让它加一,上一轮迟到的响应据此丢弃——
|
||||
* 不这么做的话,换了一路入站再打开,上一路的结果会落到新的表里。
|
||||
*/
|
||||
let round = 0
|
||||
|
||||
async function runAll() {
|
||||
if (props.port <= 0) return
|
||||
round += 1
|
||||
const mine = round
|
||||
const port = props.port
|
||||
running.value = true
|
||||
rows.value = Object.fromEntries(REACH_TARGETS.map((t) => [t.id, { state: 'testing' } as Row]))
|
||||
|
||||
await Promise.all(
|
||||
REACH_TARGETS.map(async (target) => {
|
||||
const result = await api.probeReach(port, target.id).catch(
|
||||
(error: unknown): ReachResult => ({
|
||||
target: target.id,
|
||||
verdict: 'unreachable',
|
||||
status: null,
|
||||
ms: 0,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
)
|
||||
if (round !== mine) return
|
||||
rows.value = { ...rows.value, [target.id]: { state: 'done', result } }
|
||||
}),
|
||||
)
|
||||
|
||||
if (round === mine) running.value = false
|
||||
}
|
||||
|
||||
const dialog = ref<HTMLDialogElement | null>(null)
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
dialog.value?.showModal()
|
||||
void runAll()
|
||||
} else {
|
||||
dialog.value?.close()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** 点在遮罩上就关闭。内容区的点击 target 是 .body 里的元素,到不了 dialog 本身。 */
|
||||
function onBackdrop(event: MouseEvent) {
|
||||
if (event.target === dialog.value) emit('close')
|
||||
}
|
||||
|
||||
const VERDICT_LABEL: Record<ReachVerdict, string> = {
|
||||
reachable: '通过',
|
||||
throttled: '限流',
|
||||
challenge: '人机验证',
|
||||
unreachable: '失败',
|
||||
}
|
||||
|
||||
/** 某个目标测完了的结果,还在测就是 null。模板里的分支只认这一个函数。 */
|
||||
function resultOf(id: string): ReachResult | null {
|
||||
const row = rows.value[id]
|
||||
return row?.state === 'done' ? row.result : null
|
||||
}
|
||||
|
||||
/** 目标地址显示成主机名加路径,完整 URL 太长会把这一行撑开。 */
|
||||
function shortUrl(url: string): string {
|
||||
const parsed = new URL(url)
|
||||
return parsed.host + parsed.pathname
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialog"
|
||||
class="dlg"
|
||||
@click="onBackdrop"
|
||||
@close="emit('close')"
|
||||
@cancel.prevent="emit('close')"
|
||||
>
|
||||
<div class="body">
|
||||
<header class="head">
|
||||
<h2>
|
||||
可访问性测试
|
||||
<span class="via mono">{{ name }} : {{ port }}</span>
|
||||
</h2>
|
||||
<button type="button" class="icon" title="关闭" @click="emit('close')">
|
||||
<IconClose />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<ul class="rows">
|
||||
<li v-for="target in REACH_TARGETS" :key="target.id" class="row">
|
||||
<div class="who">
|
||||
<span class="label">{{ target.label }}</span>
|
||||
<span class="url mono">{{ shortUrl(target.url) }}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="resultOf(target.id)">
|
||||
<span :class="['verdict', resultOf(target.id)!.verdict]">
|
||||
{{ VERDICT_LABEL[resultOf(target.id)!.verdict] }}
|
||||
</span>
|
||||
<span class="ms mono">{{ resultOf(target.id)!.ms }} ms</span>
|
||||
<!-- 排在毫秒之后:它跨整行,写在前面会把毫秒挤到下一行去 -->
|
||||
<span class="msg">{{ resultOf(target.id)!.message }}</span>
|
||||
</template>
|
||||
<span v-else class="waiting">测试中</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<footer class="acts">
|
||||
<button type="button" class="btn" :disabled="running" @click="void runAll()">
|
||||
<IconRefresh /> 重新测试
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dlg {
|
||||
width: min(560px, calc(100vw - 2 * var(--s-5)));
|
||||
/* 内边距放在 .body 上:dialog 自己的内边距区域点了会被当成遮罩点击 */
|
||||
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);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: var(--s-5);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
margin-bottom: var(--s-4);
|
||||
}
|
||||
|
||||
.head h2 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-3);
|
||||
font-size: var(--t-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.via {
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
padding: var(--s-1);
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon:hover {
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
/* 服务名那列吃掉剩余宽度,结论和毫秒各自贴右:四行对齐才看得出哪个不一样 */
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: var(--s-1) var(--s-3);
|
||||
padding: var(--s-3) 0;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.url {
|
||||
overflow: hidden;
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 每一行是各自的 grid,列宽不共享,所以结论和毫秒都定死一个下限,
|
||||
否则「通过」和「人机验证」不一样长,四行的徽章就对不齐 */
|
||||
.verdict {
|
||||
min-width: 4.5em;
|
||||
padding: 2px var(--s-2);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--t-xs);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.verdict.reachable {
|
||||
background: var(--c-ok-soft);
|
||||
color: var(--c-ok);
|
||||
}
|
||||
.verdict.throttled,
|
||||
.verdict.challenge {
|
||||
background: var(--c-warn-soft);
|
||||
color: var(--c-warn);
|
||||
}
|
||||
.verdict.unreachable {
|
||||
background: var(--c-danger-soft);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.ms {
|
||||
min-width: 4.5em;
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 结论那一句独占第二行,长了也不会把状态和毫秒挤出去 */
|
||||
.msg {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-xs);
|
||||
}
|
||||
|
||||
.waiting {
|
||||
grid-column: 2 / -1;
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--s-5);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
.btn:disabled {
|
||||
color: var(--c-text-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,317 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 单选下拉,替代原生 <select>。
|
||||
*
|
||||
* 换成自绘只为一件事:原生控件没有「展开中」这个可读状态,右侧箭头做不了
|
||||
* 开合动画,弹层样式也管不到。交互仿 SourcePicker(同一族控件):点开列出
|
||||
* 全部选项、勾标当前选中;上下键移动、Enter 选中、Esc 只收弹层不关对话框。
|
||||
*
|
||||
* 背景色用 --select-bg 变量留了口子:对话框里的控件要比对话框底色沉一档
|
||||
* (--c-bg),页面上的控件要比页面底色浮一档(--c-surface),一个值满足不了。
|
||||
*/
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import IconCheck from '~icons/ri/check-line'
|
||||
import IconChevron from '~icons/ri/arrow-down-s-line'
|
||||
|
||||
export interface SelectItem {
|
||||
value: string
|
||||
/** 界面上显示的字,缺省用 value 原文 */
|
||||
label?: string
|
||||
/** 分组标题。相邻同组的选项归在一个标题下 */
|
||||
group?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
options: SelectItem[]
|
||||
/** 当前值不在选项里(含空串)时显示的占位文案 */
|
||||
placeholder?: string
|
||||
ariaLabel?: string
|
||||
}>()
|
||||
|
||||
const chosen = defineModel<string>({ default: '' })
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const menuEl = ref<HTMLElement | null>(null)
|
||||
const open = ref(false)
|
||||
/** 往上弹。下方视口不够放弹层、上方够时翻转,不把弹层怼出屏幕 */
|
||||
const openUp = ref(false)
|
||||
const active = ref(0)
|
||||
const menuStyle = ref<Record<string, string>>({})
|
||||
|
||||
const current = computed(() => props.options.find((o) => o.value === chosen.value) ?? null)
|
||||
|
||||
const MENU_ROOM = 220
|
||||
|
||||
/**
|
||||
* 弹层是 position: fixed,按视口摆放。absolute 的弹层会计入祖先滚动容器的
|
||||
* 可滚动区域——在对话框里打开就把对话框撑出滚动条。fixed 逃出所有 overflow
|
||||
* 裁剪,又因为还在 dialog 的 DOM 子树里,照样画在 top layer 之上。
|
||||
* 代价是位置不跟随滚动,所以下面在滚动和缩放时直接收起。
|
||||
*/
|
||||
function place() {
|
||||
const rect = root.value?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const up = window.innerHeight - rect.bottom < MENU_ROOM && rect.top > MENU_ROOM
|
||||
openUp.value = up
|
||||
menuStyle.value = {
|
||||
left: `${String(rect.left)}px`,
|
||||
minWidth: `${String(rect.width)}px`,
|
||||
...(up
|
||||
? { bottom: `${String(window.innerHeight - rect.top + 4)}px` }
|
||||
: { top: `${String(rect.bottom + 4)}px` }),
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
if (open.value) {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
active.value = Math.max(
|
||||
0,
|
||||
props.options.findIndex((o) => o.value === chosen.value),
|
||||
)
|
||||
place()
|
||||
open.value = true
|
||||
}
|
||||
|
||||
/** 页面或对话框一滚动,锚点就不准了,直接收起。弹层内部的滚动不算 */
|
||||
function closeOnMove(event?: Event) {
|
||||
if (event?.target instanceof Node && menuEl.value?.contains(event.target)) return
|
||||
open.value = false
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
window.addEventListener('scroll', closeOnMove, true)
|
||||
window.addEventListener('resize', closeOnMove)
|
||||
} else {
|
||||
window.removeEventListener('scroll', closeOnMove, true)
|
||||
window.removeEventListener('resize', closeOnMove)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', closeOnMove, true)
|
||||
window.removeEventListener('resize', closeOnMove)
|
||||
})
|
||||
|
||||
function pick(value: string) {
|
||||
chosen.value = value
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
if (!open.value) {
|
||||
toggleOpen()
|
||||
return
|
||||
}
|
||||
const count = props.options.length
|
||||
if (count === 0) return
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1
|
||||
active.value = (active.value + delta + count) % count
|
||||
} else if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
if (!open.value) {
|
||||
toggleOpen()
|
||||
return
|
||||
}
|
||||
const item = props.options[active.value]
|
||||
if (item) pick(item.value)
|
||||
} else if (event.key === 'Escape' && open.value) {
|
||||
// 对话框里按 Esc 只收弹层,不能顺手把整个对话框关了
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 焦点离开整块时收起。新焦点还在里面就不收,否则点选项会先把自己关掉。 */
|
||||
function onFocusOut(event: FocusEvent) {
|
||||
const next = event.relatedTarget
|
||||
if (next instanceof Node && root.value?.contains(next)) return
|
||||
open.value = false
|
||||
}
|
||||
|
||||
/** 这个下标前面要不要画组标题:是组里第一项才画。 */
|
||||
function groupAt(index: number): string | null {
|
||||
const group = props.options[index]?.group
|
||||
if (!group) return null
|
||||
return props.options[index - 1]?.group === group ? null : group
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="select" @focusout="onFocusOut">
|
||||
<button
|
||||
type="button"
|
||||
class="face"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
:aria-label="ariaLabel"
|
||||
@click="toggleOpen"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<span :class="['face-text', !current && 'is-empty']">
|
||||
{{ current ? (current.label ?? current.value) : (placeholder ?? '请选择') }}
|
||||
</span>
|
||||
<IconChevron :class="['chev', open && 'is-open']" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Transition name="menu">
|
||||
<ul v-if="open" ref="menuEl" :class="['menu', openUp && 'is-up']" :style="menuStyle" role="listbox">
|
||||
<li v-if="options.length === 0" class="none">没有可选项</li>
|
||||
<template v-for="(item, i) in options" :key="item.value">
|
||||
<li v-if="groupAt(i)" class="group" role="presentation">{{ groupAt(i) }}</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="item.value === chosen"
|
||||
:class="['opt', item.value === chosen && 'is-on', i === active && 'is-active']"
|
||||
@mousemove="active = i"
|
||||
@click="pick(item.value)"
|
||||
>
|
||||
<span class="mark"><IconCheck v-if="item.value === chosen" /></span>
|
||||
{{ item.label ?? item.value }}
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 宽度不在这里定:块级元素在表单列里自然撑满,行内布局由调用方给定宽度。
|
||||
写死 width: 100% 会和调用方的宽度规则打架,两条同权重规则谁赢看加载顺序 */
|
||||
.select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.face {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
width: 100%;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--select-bg, var(--c-bg));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 和文本输入一致:焦点态是描边变色,不叠外圈 */
|
||||
.face:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--c-accent);
|
||||
}
|
||||
|
||||
.face-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.face-text.is-empty {
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
.chev {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--c-text-faint);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chev.is-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 位置(left/top/bottom/minWidth)由 place() 以内联样式给出 */
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
/* 弹层可以比控件宽(按最长选项撑开),但不能宽出屏幕 */
|
||||
max-width: min(360px, calc(100vw - 2 * var(--s-4)));
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
/* 细杆加透明轨道,别让弹层里出现一根系统灰滚动条 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-strong) transparent;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--s-1);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.group {
|
||||
padding: var(--s-2) var(--s-3) var(--s-1);
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-xs);
|
||||
}
|
||||
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
width: 100%;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
text-align: left;
|
||||
font-size: var(--t-sm);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opt.is-active {
|
||||
background: var(--c-surface-sunken);
|
||||
}
|
||||
|
||||
.opt.is-on {
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.mark {
|
||||
display: flex;
|
||||
flex: none;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.none {
|
||||
padding: var(--s-2) var(--s-3);
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.menu-enter-active,
|
||||
.menu-leave-active {
|
||||
transition:
|
||||
opacity 0.12s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
|
||||
.menu-enter-from,
|
||||
.menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.menu.is-up.menu-enter-from,
|
||||
.menu.is-up.menu-leave-to {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
</style>
|
||||
+213
-64
@@ -1,80 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 挑几个订阅。已选的是一排 tag,末尾一个加号,点开是还没选的那些。
|
||||
* 订阅多选。已选的是输入框里的一排标签,输入文字过滤下拉列表,点选项在
|
||||
* 选与不选之间切换,列表保持打开,方便一口气挑好几个。
|
||||
*
|
||||
* 不用原生 select:它在已选项旁边显示的是「当前值」,而这里要表达的是「已经选了这几个」,
|
||||
* 两种语义。之前用 select 拼出来的那版,加号和 tag 的对齐、宽度、选中后的复位都得单独调,
|
||||
* 全是在跟控件本身的默认行为较劲。
|
||||
* 交互是多选组合框的通行形态(Ant Design、Element Plus 的多选 Select 都是这一套):
|
||||
* 标签收在框内、下拉列出全部选项并给已选的打勾、点击不收起。订阅只有一两个时
|
||||
* 平铺标签更直接,但数量一多平铺就是一面墙,这个形态两头都撑得住。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import IconAdd from '~icons/ri/add-line'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import IconCheck from '~icons/ri/check-line'
|
||||
import IconRemove from '~icons/ri/close-line'
|
||||
|
||||
defineProps<{ options: string[] }>()
|
||||
const props = defineProps<{ options: string[] }>()
|
||||
const chosen = defineModel<string[]>({ required: true })
|
||||
|
||||
const open = ref(false)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
const menuEl = ref<HTMLElement | null>(null)
|
||||
const open = ref(false)
|
||||
const openUp = ref(false)
|
||||
const query = ref('')
|
||||
/** 键盘上下键在过滤结果里走到第几项 */
|
||||
const active = ref(0)
|
||||
const menuStyle = ref<Record<string, string>>({})
|
||||
|
||||
function add(name: string) {
|
||||
chosen.value = [...chosen.value, name]
|
||||
const filtered = computed(() =>
|
||||
props.options.filter((name) => name.toLowerCase().includes(query.value.trim().toLowerCase())),
|
||||
)
|
||||
|
||||
watch(query, () => {
|
||||
active.value = 0
|
||||
open.value = true
|
||||
})
|
||||
|
||||
const MENU_ROOM = 220
|
||||
|
||||
/**
|
||||
* 弹层是 position: fixed,按视口摆放,理由同 SelectBox:absolute 会计入
|
||||
* 祖先滚动容器的可滚动区域,把页面或对话框撑出滚动条。
|
||||
* 位置不跟随滚动,所以滚动和缩放时直接收起。
|
||||
*/
|
||||
function place() {
|
||||
const rect = root.value?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const up = window.innerHeight - rect.bottom < MENU_ROOM && rect.top > MENU_ROOM
|
||||
openUp.value = up
|
||||
menuStyle.value = {
|
||||
left: `${String(rect.left)}px`,
|
||||
minWidth: `${String(rect.width)}px`,
|
||||
...(up
|
||||
? { bottom: `${String(window.innerHeight - rect.top + 4)}px` }
|
||||
: { top: `${String(rect.bottom + 4)}px` }),
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面一滚动锚点就不准了,直接收起。弹层内部的滚动不算 */
|
||||
function closeOnMove(event?: Event) {
|
||||
if (event?.target instanceof Node && menuEl.value?.contains(event.target)) return
|
||||
open.value = false
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
place()
|
||||
window.addEventListener('scroll', closeOnMove, true)
|
||||
window.addEventListener('resize', closeOnMove)
|
||||
} else {
|
||||
window.removeEventListener('scroll', closeOnMove, true)
|
||||
window.removeEventListener('resize', closeOnMove)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', closeOnMove, true)
|
||||
window.removeEventListener('resize', closeOnMove)
|
||||
})
|
||||
|
||||
function toggle(name: string) {
|
||||
chosen.value = chosen.value.includes(name)
|
||||
? chosen.value.filter((item) => item !== name)
|
||||
: [...chosen.value, name]
|
||||
// 点完一项接着挑下一项,不用再点回输入框
|
||||
input.value?.focus()
|
||||
// 标签增减会改变输入框高度,锚点变了要重新摆
|
||||
void nextTick(place)
|
||||
}
|
||||
|
||||
function remove(name: string) {
|
||||
chosen.value = chosen.value.filter((item) => item !== name)
|
||||
if (open.value) void nextTick(place)
|
||||
}
|
||||
|
||||
/** 焦点离开整块时收起。判断新焦点还在不在里面,否则点列表里的按钮会先把自己关掉。 */
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
open.value = true
|
||||
const count = filtered.value.length
|
||||
if (count === 0) return
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1
|
||||
active.value = (active.value + delta + count) % count
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const name = filtered.value[active.value]
|
||||
if (open.value && name) toggle(name)
|
||||
} else if (event.key === 'Escape') {
|
||||
open.value = false
|
||||
} else if (event.key === 'Backspace' && query.value === '') {
|
||||
const last = chosen.value.at(-1)
|
||||
if (last) remove(last)
|
||||
}
|
||||
}
|
||||
|
||||
/** 焦点离开整块时收起。判断新焦点还在不在里面,否则点列表里的选项会先把自己关掉。 */
|
||||
function onFocusOut(event: FocusEvent) {
|
||||
const next = event.relatedTarget
|
||||
if (next instanceof Node && root.value?.contains(next)) return
|
||||
open.value = false
|
||||
query.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="picker" @focusout="onFocusOut">
|
||||
<span v-for="name in chosen" :key="name" class="tag">
|
||||
{{ name }}
|
||||
<button type="button" :title="`移除 ${name}`" @click="remove(name)">
|
||||
<IconRemove />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<div v-if="options.some((o) => !chosen.includes(o))" class="add">
|
||||
<button type="button" class="plus" title="添加订阅" @click="open = !open">
|
||||
<IconAdd />
|
||||
</button>
|
||||
|
||||
<ul v-if="open" class="menu">
|
||||
<template v-for="name in options" :key="name">
|
||||
<li v-if="!chosen.includes(name)">
|
||||
<button type="button" @click="add(name)">{{ name }}</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="box" @click="input?.focus()">
|
||||
<span v-for="name in chosen" :key="name" class="tag">
|
||||
{{ name }}
|
||||
<button type="button" :title="`移除 ${name}`" @click.stop="remove(name)">
|
||||
<IconRemove />
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="query"
|
||||
:placeholder="chosen.length === 0 ? '选择订阅' : ''"
|
||||
:aria-expanded="open"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
@focus="open = true"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span v-else-if="chosen.length === 0" class="empty">没有可选的订阅</span>
|
||||
<Transition name="menu">
|
||||
<ul
|
||||
v-if="open"
|
||||
ref="menuEl"
|
||||
:class="['menu', openUp && 'is-up']"
|
||||
:style="menuStyle"
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
>
|
||||
<li v-if="options.length === 0" class="none">没有可选的订阅</li>
|
||||
<li v-else-if="filtered.length === 0" class="none">没有匹配的订阅</li>
|
||||
<li v-for="(name, i) in filtered" :key="name">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="chosen.includes(name)"
|
||||
:class="['opt', chosen.includes(name) && 'is-on', i === active && 'is-active']"
|
||||
@mousemove="active = i"
|
||||
@click="toggle(name)"
|
||||
>
|
||||
<span class="mark"><IconCheck v-if="chosen.includes(name)" /></span>
|
||||
{{ name }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
gap: var(--s-1);
|
||||
min-height: 34px;
|
||||
padding: 3px var(--s-2);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
cursor: text;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.box:focus-within {
|
||||
border-color: var(--c-accent);
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
height: 34px;
|
||||
padding: 0 var(--s-1) 0 var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
height: 26px;
|
||||
padding: 0 var(--s-1) 0 var(--s-2);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface-sunken);
|
||||
font-size: var(--t-sm);
|
||||
@@ -83,8 +207,8 @@ function onFocusOut(event: FocusEvent) {
|
||||
.tag button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
@@ -97,44 +221,39 @@ function onFocusOut(event: FocusEvent) {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.add {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.plus {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px dashed var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
.box input {
|
||||
flex: 1;
|
||||
min-width: 6em;
|
||||
height: 26px;
|
||||
padding: 0 var(--s-1);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.plus:hover {
|
||||
border-style: solid;
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
/* 浮在上面,不能把下面几行顶下去 */
|
||||
/* 浮在上面,不能把下面几行顶下去。位置由 place() 以内联样式给出 */
|
||||
.menu {
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
top: calc(34px + var(--s-1));
|
||||
left: 0;
|
||||
min-width: 10em;
|
||||
max-width: min(360px, calc(100vw - 2 * var(--s-4)));
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
/* 细杆加透明轨道,别让弹层里出现一根系统灰滚动条 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-strong) transparent;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--s-1);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.menu button {
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
width: 100%;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: none;
|
||||
@@ -146,12 +265,42 @@ function onFocusOut(event: FocusEvent) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu button:hover {
|
||||
.opt.is-active {
|
||||
background: var(--c-surface-sunken);
|
||||
}
|
||||
|
||||
.empty {
|
||||
.opt.is-on {
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
/* 勾的位置固定占着,选没选名字都对齐 */
|
||||
.mark {
|
||||
display: flex;
|
||||
flex: none;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.none {
|
||||
padding: var(--s-2) var(--s-3);
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.menu-enter-active,
|
||||
.menu-leave-active {
|
||||
transition:
|
||||
opacity 0.12s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
|
||||
.menu-enter-from,
|
||||
.menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.menu.is-up.menu-enter-from,
|
||||
.menu.is-up.menu-leave-to {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 轻提示的渲染宿主,挂在外壳根部。进场从右滑入,退场上移淡出。
|
||||
* 点一下:普通提示立刻消掉;错误提示是要拿去粘贴求助的,点击复制而不是关闭,
|
||||
* 消失交给 toast.ts 的定时器。内容来自 web/toast.ts 的单例队列。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { dismiss, toasts } from '../toast'
|
||||
import type { Toast } from '../toast'
|
||||
import IconOk from '~icons/ri/checkbox-circle-line'
|
||||
import IconErr from '~icons/ri/error-warning-line'
|
||||
import IconInfo from '~icons/ri/information-line'
|
||||
|
||||
/** 已复制的错误提示:hint 原地换成「已复制到剪贴板」作为反馈。 */
|
||||
const copied = ref(new Set<number>())
|
||||
|
||||
async function onClick(t: Toast) {
|
||||
if (t.kind !== 'error') {
|
||||
dismiss(t.id)
|
||||
return
|
||||
}
|
||||
const ok = await copyText([t.message, t.hint].filter(Boolean).join('\n'))
|
||||
if (ok) copied.value = new Set(copied.value).add(t.id)
|
||||
}
|
||||
|
||||
/** 经 http 从局域网访问时没有 navigator.clipboard(非安全上下文),退回 execCommand。 */
|
||||
async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
const area = document.createElement('textarea')
|
||||
area.value = text
|
||||
area.style.position = 'fixed'
|
||||
area.style.opacity = '0'
|
||||
document.body.appendChild(area)
|
||||
area.select()
|
||||
const ok = document.execCommand('copy')
|
||||
area.remove()
|
||||
return ok
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="host" aria-live="polite">
|
||||
<TransitionGroup name="tst">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
:class="['tst', t.kind === 'error' && 'is-error']"
|
||||
:title="t.kind === 'error' ? '点击复制错误信息' : undefined"
|
||||
@click="onClick(t)"
|
||||
>
|
||||
<IconErr v-if="t.kind === 'error'" class="tico" />
|
||||
<IconInfo v-else-if="t.kind === 'info'" class="tico is-info" />
|
||||
<IconOk v-else class="tico" />
|
||||
<div class="tbody">
|
||||
<strong class="tmsg">{{ t.message }}</strong>
|
||||
<span v-if="copied.has(t.id)" class="thint">已复制到剪贴板</span>
|
||||
<span v-else-if="t.hint" class="thint">{{ t.hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.host {
|
||||
position: fixed;
|
||||
top: var(--s-4);
|
||||
right: var(--s-4);
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--s-2);
|
||||
/* 宿主自己不挡点击,字条才挡 */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tst {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s-3);
|
||||
min-width: 240px;
|
||||
max-width: 380px;
|
||||
padding: var(--s-3) var(--s-4);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
box-shadow: 0 6px 24px rgb(0 0 0 / 0.16);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tst.is-error {
|
||||
border-color: color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
|
||||
}
|
||||
|
||||
.tico {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 1px;
|
||||
color: var(--c-ok);
|
||||
}
|
||||
|
||||
.is-error .tico {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.tico.is-info {
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
.tbody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tmsg {
|
||||
font-size: var(--t-sm);
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.thint {
|
||||
font-size: var(--t-xs);
|
||||
color: var(--c-text-soft);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 进场:从右滑入;退场:上移淡出;队列压缩时其余字条平滑归位 */
|
||||
.tst-enter-active {
|
||||
transition:
|
||||
transform 0.28s cubic-bezier(0.21, 0.8, 0.35, 1),
|
||||
opacity 0.28s ease;
|
||||
}
|
||||
.tst-enter-from {
|
||||
transform: translateX(120%);
|
||||
opacity: 0;
|
||||
}
|
||||
.tst-leave-active {
|
||||
transition:
|
||||
transform 0.4s ease,
|
||||
opacity 0.4s ease;
|
||||
/* 退场中脱离布局,后面的字条立刻开始归位 */
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
.tst-leave-to {
|
||||
transform: translateY(-14px);
|
||||
opacity: 0;
|
||||
}
|
||||
.tst-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { kernelPorts, modeLabel } from './dashboard.ts'
|
||||
|
||||
describe('运行模式', () => {
|
||||
it('三个已知模式有中文名', () => {
|
||||
expect(modeLabel('rule')).toBe('规则')
|
||||
expect(modeLabel('global')).toBe('全局')
|
||||
expect(modeLabel('direct')).toBe('直连')
|
||||
})
|
||||
|
||||
it('没见过的值原样显示,缺省是破折号', () => {
|
||||
expect(modeLabel('script')).toBe('script')
|
||||
expect(modeLabel(undefined)).toBe('—')
|
||||
expect(modeLabel('')).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('代理端口', () => {
|
||||
it('挑出大于 0 的端口,按固定顺序', () => {
|
||||
expect(kernelPorts({ 'mixed-port': 7890, port: 7891, 'socks-port': 0 })).toEqual([
|
||||
{ name: 'port', port: 7891 },
|
||||
{ name: 'mixed-port', port: 7890 },
|
||||
])
|
||||
})
|
||||
|
||||
it('0、缺省和非数字都不算启用', () => {
|
||||
expect(kernelPorts({ port: 0, 'socks-port': 'abc' })).toEqual([])
|
||||
})
|
||||
|
||||
it('字符串端口号也认得', () => {
|
||||
expect(kernelPorts({ port: '7890' })).toEqual([{ name: 'port', port: 7890 }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 仪表盘页的数据整理。
|
||||
*
|
||||
* 「拿到内核数据之后怎么摆」的纯逻辑,放在这里而不是 DashboardPage.vue 的
|
||||
* computed 里,才能被 bun test 直接测到。流量窗口和每路吞吐的聚合在
|
||||
* shared/telemetry.ts——那些是采集层的事,跨页面共用。
|
||||
*/
|
||||
|
||||
/** 内核运行模式在界面上怎么念。不在表里的原样显示,不猜它的意思。 */
|
||||
export function modeLabel(mode: unknown): string {
|
||||
switch (mode) {
|
||||
case 'rule':
|
||||
return '规则'
|
||||
case 'global':
|
||||
return '全局'
|
||||
case 'direct':
|
||||
return '直连'
|
||||
default:
|
||||
return typeof mode === 'string' && mode ? mode : '—'
|
||||
}
|
||||
}
|
||||
|
||||
/** 一个监听中的代理端口。name 用配置键原词,和路由页legacy 入站的命名一致。 */
|
||||
export interface KernelPort {
|
||||
name: string
|
||||
port: number
|
||||
}
|
||||
|
||||
const PORT_KEYS = ['port', 'socks-port', 'mixed-port', 'redir-port', 'tproxy-port'] as const
|
||||
|
||||
/** 从内核 /configs 的运行时设置里挑出正在监听的代理端口。0 和缺省都表示没启用。 */
|
||||
export function kernelPorts(configs: Record<string, unknown>): KernelPort[] {
|
||||
const result: KernelPort[] = []
|
||||
for (const key of PORT_KEYS) {
|
||||
const raw = configs[key]
|
||||
const port = typeof raw === 'number' ? raw : Number(raw)
|
||||
if (Number.isFinite(port) && port > 0) result.push({ name: key, port })
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -4,6 +4,15 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<!-- 标签页图标只声明 .ico:它里面有 16/32/48/64 四个尺寸,是照着小尺寸调过的。
|
||||
再声明一个大 PNG 的话,浏览器会按显示密度在两者之间挑,而这套素材的 .ico 是
|
||||
深底浅图、PNG 是白底黑图,挑到哪个不一定,同一个标签页会换样子。 -->
|
||||
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48 64x64" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<!-- 移动端浏览器地址栏的底色,跟着系统主题走,和 tokens.css 里的 --c-bg 一致 -->
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fbfbfc" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#16181c" />
|
||||
<title>Patchbay</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 免责声明的文案。设置页的子页和登录页的弹层共用同一份,不出现两个版本。
|
||||
*/
|
||||
export const DISCLAIMER_TITLE = '免责声明'
|
||||
|
||||
export const DISCLAIMER = [
|
||||
'Patchbay 是编辑 mihomo 配置文件的本地网页工具,按现状提供,不提供任何形式的担保。因配置错误、软件缺陷或网络中断造成的服务不可用、数据丢失,作者不承担责任;重要改动前请自行留存配置文件副本。',
|
||||
'代理技术本身是中性的。使用者应遵守所在国家和地区的法律法规,因使用本工具产生的一切后果由使用者自行承担。',
|
||||
'配置文件与备份中可能包含密钥、订阅地址等敏感信息,访问令牌等同于密码,请妥善保管,不要在公开场合分享。',
|
||||
'继续使用 Patchbay 即视为已阅读并同意以上条款。',
|
||||
]
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 内核日志的常驻收集。
|
||||
*
|
||||
* 住在模块级单例里而不是请求页组件里,是因为日志要跨页面留存——收集跟着组件
|
||||
* 走的话,切走一次列表就清空一次。外壳启动时点火,之后跟着 kernel.online 起落。
|
||||
*
|
||||
* 节奏和缓冲:收到的行先进 pending,每 200ms 一批刷进 rows——日志密集时逐条
|
||||
* 触发响应式更新会把界面拖死。暂停就是不刷,缓冲照收(也设上限),恢复时补上。
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { kernel } from './runtime'
|
||||
import { LOG_CAP, pushCapped, streamLogs } from './logs'
|
||||
import type { LogLevel, LogLine } from './logs'
|
||||
|
||||
export interface LogRow extends LogLine {
|
||||
id: number
|
||||
/** 收到那一刻的时间。日志行本身不带时间戳 */
|
||||
time: string
|
||||
}
|
||||
|
||||
/** 日志级别。改它会断开重连:内核的级别是建立连接时定的,中途改不了。 */
|
||||
export const level = ref<LogLevel>('info')
|
||||
export const paused = ref(false)
|
||||
export const rows = ref<LogRow[]>([])
|
||||
export const failure = ref('')
|
||||
/** 内核那边把流断了(比如重启)。和主动中止区分开,这个要告诉用户 */
|
||||
export const ended = ref(false)
|
||||
/** 每刷进一批加一。rows 是原地增改,组件要跟随滚动只能看这个信号 */
|
||||
export const revision = ref(0)
|
||||
|
||||
let pending: LogRow[] = []
|
||||
let nextId = 0
|
||||
let aborter: AbortController | null = null
|
||||
let retryTimer = 0
|
||||
let started = false
|
||||
|
||||
const clock = (d: Date): string =>
|
||||
[d.getHours(), d.getMinutes(), d.getSeconds()]
|
||||
.map((n) => String(n).padStart(2, '0'))
|
||||
.join(':')
|
||||
|
||||
function flush() {
|
||||
if (paused.value) {
|
||||
// 暂停时继续接收但不渲染。缓冲也设上限,暂停再久内存也不涨
|
||||
if (pending.length > LOG_CAP) pending.splice(0, pending.length - LOG_CAP)
|
||||
return
|
||||
}
|
||||
if (pending.length === 0) return
|
||||
pushCapped(rows.value, pending, LOG_CAP)
|
||||
pending = []
|
||||
revision.value += 1
|
||||
}
|
||||
|
||||
/** 只清界面上攒下的,内核那边的日志不受影响。 */
|
||||
export function clearLogs() {
|
||||
rows.value = []
|
||||
pending = []
|
||||
revision.value += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 断流不等于内核没了:配置重载会掐断流,而内核几秒内就回来、online 从未翻转。
|
||||
* 断了 5 秒后自动重连,失败会再次走到这里形成重试节奏;真离线时交给下面等
|
||||
* online 翻转的 watch。
|
||||
*/
|
||||
function scheduleRetry() {
|
||||
window.clearTimeout(retryTimer)
|
||||
retryTimer = window.setTimeout(() => {
|
||||
if (kernel.value.online) void pump()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
async function pump() {
|
||||
// 切换级别或内核恢复在线都从这里重连。旧流先停,不然两条流一起往缓冲里倒
|
||||
aborter?.abort()
|
||||
window.clearTimeout(retryTimer)
|
||||
const controller = new AbortController()
|
||||
aborter = controller
|
||||
failure.value = ''
|
||||
ended.value = false
|
||||
try {
|
||||
for await (const line of streamLogs(level.value, controller.signal)) {
|
||||
pending.push({ ...line, id: nextId++, time: clock(new Date()) })
|
||||
}
|
||||
if (!controller.signal.aborted) {
|
||||
ended.value = true
|
||||
scheduleRetry()
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
failure.value = error instanceof Error ? error.message : String(error)
|
||||
scheduleRetry()
|
||||
}
|
||||
}
|
||||
|
||||
/** 外壳启动时调一次。之后跟着内核在线状态和级别自己重连,应用关掉前不停。 */
|
||||
export function startLogStream(): void {
|
||||
if (started) return
|
||||
started = true
|
||||
// 刷新节拍常驻,缓冲空转一次是零开销;应用关掉前不需要清
|
||||
window.setInterval(flush, 200)
|
||||
if (kernel.value.online) void pump()
|
||||
|
||||
// 挂载时内核不在线就不连;等它上线再开始。反方向(流到一半内核掉了)
|
||||
// 由流自己报错或结束来呈现
|
||||
watch(
|
||||
() => kernel.value.online,
|
||||
(online) => {
|
||||
if (online) void pump()
|
||||
},
|
||||
)
|
||||
|
||||
watch(level, () => {
|
||||
if (kernel.value.online) void pump()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterAll, afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { LOG_CAP, parseLogLine, pushCapped, streamLogs } from './logs.ts'
|
||||
import { LogStreamError } from './logs.ts'
|
||||
|
||||
describe('解析日志行', () => {
|
||||
it('形状对的行解出级别和内容', () => {
|
||||
expect(parseLogLine('{"type":"info","payload":"[TCP] dial example.com"}')).toEqual({
|
||||
type: 'info',
|
||||
payload: '[TCP] dial example.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('不是 JSON 的脏行返回 null 而不是抛出去', () => {
|
||||
expect(parseLogLine('not json at all')).toBeNull()
|
||||
expect(parseLogLine('{"type":"info","payl')).toBeNull()
|
||||
})
|
||||
|
||||
it('是 JSON 但形状不对的也跳过', () => {
|
||||
expect(parseLogLine('null')).toBeNull()
|
||||
expect(parseLogLine('[1,2]')).toBeNull()
|
||||
expect(parseLogLine('{"type":"info"}')).toBeNull()
|
||||
expect(parseLogLine('{"type":"info","payload":42}')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('成批追加上限', () => {
|
||||
it('超出上限丢最旧的,留下最新的', () => {
|
||||
const list = [1, 2, 3]
|
||||
pushCapped(list, [4, 5, 6], 4)
|
||||
expect(list).toEqual([3, 4, 5, 6])
|
||||
})
|
||||
|
||||
it('一批比上限还大时只留下限条', () => {
|
||||
const list: number[] = []
|
||||
pushCapped(list, [1, 2, 3, 4, 5], 3)
|
||||
expect(list).toEqual([3, 4, 5])
|
||||
})
|
||||
})
|
||||
|
||||
function streamOf(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const realFetch = globalThis.fetch
|
||||
|
||||
// bun 的测试环境没有 localStorage,而 streamLogs 要从里面取令牌。
|
||||
// 桩只在本文件生效:bun test 是单进程,全局残留会影响之后跑的其它测试文件
|
||||
const hadStorage = typeof globalThis.localStorage !== 'undefined'
|
||||
if (!hadStorage) {
|
||||
;(globalThis as Record<string, unknown>).localStorage = {
|
||||
getItem: () => '',
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
if (!hadStorage) delete (globalThis as Record<string, unknown>).localStorage
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch
|
||||
})
|
||||
|
||||
describe('读日志流', () => {
|
||||
it('脏行被跳过,不会中断后面的行', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
streamOf([
|
||||
'{"type":"info","payload":"第一条"}\n',
|
||||
'这不是 JSON\n',
|
||||
'{"type":"warning","payload":"第',
|
||||
'二条"}\n',
|
||||
]),
|
||||
),
|
||||
),
|
||||
) as unknown as typeof fetch
|
||||
|
||||
const lines: string[] = []
|
||||
for await (const line of streamLogs('info', new AbortController().signal)) {
|
||||
lines.push(`${line.type}: ${line.payload}`)
|
||||
}
|
||||
expect(lines).toEqual(['info: 第一条', 'warning: 第二条'])
|
||||
})
|
||||
|
||||
it('接口报错时用服务端给的 message 抛出', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ message: '内核未连接' }), { status: 502 }),
|
||||
),
|
||||
) as unknown as typeof fetch
|
||||
|
||||
const run = async () => {
|
||||
for await (const _ of streamLogs('info', new AbortController().signal)) {
|
||||
// 不该有产出
|
||||
}
|
||||
}
|
||||
await expect(run()).rejects.toThrow(new LogStreamError('内核未连接'))
|
||||
})
|
||||
|
||||
it('LOG_CAP 是个有限值,无限流不会把内存吃光', () => {
|
||||
expect(LOG_CAP).toBeGreaterThan(0)
|
||||
expect(LOG_CAP).toBeLessThanOrEqual(10000)
|
||||
})
|
||||
})
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 内核日志流。
|
||||
*
|
||||
* 接口是 `GET /api/kernel/logs?level=...`,一条永不结束的流,一行一个 JSON,
|
||||
* 形如 `{ "type": "info", "payload": "..." }`。所以不能走 api.ts 的 request()——
|
||||
* 那个会把整个响应读完,对流等于挂死。这里自己 fetch,按行读用 stream.ts。
|
||||
*
|
||||
* 解析和成批追加放在组件外面,是因为「脏行跳过」「超上限丢最旧的」这两条
|
||||
* 是验收里点名要测的行为,埋在 .vue 里测不到。
|
||||
*/
|
||||
import { getToken } from './api'
|
||||
import { readLines } from './stream'
|
||||
|
||||
/** 内核接受的日志级别。连接建立时定死,中途改不了,所以切换等于重连。 */
|
||||
export const LEVELS = ['debug', 'info', 'warning', 'error', 'silent'] as const
|
||||
export type LogLevel = (typeof LEVELS)[number]
|
||||
|
||||
/**
|
||||
* 页面上保留的最近条数。日志是无限流,不设上限内存会一直涨,
|
||||
* 而目标机器只有 1-2G。超出就丢最旧的。
|
||||
*/
|
||||
export const LOG_CAP = 1000
|
||||
|
||||
export interface LogLine {
|
||||
type: string
|
||||
payload: string
|
||||
}
|
||||
|
||||
/** 解析一行日志。脏行(不是 JSON、形状不对)返回 null,由调用方跳过,不能抛。 */
|
||||
export function parseLogLine(line: string): LogLine | null {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(line)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
|
||||
const { type, payload } = value as Record<string, unknown>
|
||||
if (typeof type !== 'string' || typeof payload !== 'string') return null
|
||||
return { type, payload }
|
||||
}
|
||||
|
||||
/** 把一批新条目追加进列表,超出上限从头部丢最旧的。 */
|
||||
export function pushCapped<T>(list: T[], items: T[], cap: number): void {
|
||||
list.push(...items)
|
||||
if (list.length > cap) list.splice(0, list.length - cap)
|
||||
}
|
||||
|
||||
export class LogStreamError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'LogStreamError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连上内核的日志流,逐条产出。停止的方式是中止 signal。
|
||||
*
|
||||
* 流正常结束(for await 走完)意味着内核那边断了连接,比如内核重启——
|
||||
* 调用方要把它和「主动中止」区分开,前者该告诉用户。
|
||||
*/
|
||||
export async function* streamLogs(
|
||||
level: LogLevel,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<LogLine> {
|
||||
const res = await fetch(`/api/kernel/logs?level=${level}`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
// 失败时后端回的是一次性的 { message, hint? },不是流
|
||||
let message = `日志接口请求失败(HTTP ${String(res.status)})`
|
||||
try {
|
||||
const payload = JSON.parse(await res.text()) as Record<string, unknown>
|
||||
if (typeof payload.message === 'string') message = payload.message
|
||||
} catch {
|
||||
// 保留默认消息
|
||||
}
|
||||
throw new LogStreamError(message)
|
||||
}
|
||||
if (!res.body) throw new LogStreamError('日志接口没有返回数据流')
|
||||
|
||||
for await (const line of readLines(res.body)) {
|
||||
const entry = parseLogLine(line)
|
||||
if (entry) yield entry
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { mergeMemberPreview } from './member-preview.ts'
|
||||
import type { ProxyInfo } from './api.ts'
|
||||
|
||||
const info = (name: string) => ({ name, type: 'Vmess' }) as ProxyInfo
|
||||
const noLookup = () => undefined
|
||||
|
||||
describe('成员预览', () => {
|
||||
it('草稿和现状一致时谁都不标', () => {
|
||||
const cards = mergeMemberPreview([{ name: 'A', info: info('A') }], ['A'], noLookup)
|
||||
expect(cards).toEqual([{ name: 'A', info: info('A'), removed: false, added: false }])
|
||||
})
|
||||
|
||||
it('不在保存后名单里的现有成员标为将移除', () => {
|
||||
const cards = mergeMemberPreview(
|
||||
[
|
||||
{ name: 'A', info: info('A') },
|
||||
{ name: 'B', info: info('B') },
|
||||
],
|
||||
['B'],
|
||||
noLookup,
|
||||
)
|
||||
expect(cards.map((c) => [c.name, c.removed])).toEqual([
|
||||
['A', true],
|
||||
['B', false],
|
||||
])
|
||||
})
|
||||
|
||||
it('新增的追加在末尾,线路信息从 lookup 取', () => {
|
||||
const cards = mergeMemberPreview([{ name: 'A', info: info('A') }], ['A', 'C'], info)
|
||||
expect(cards[1]).toEqual({ name: 'C', info: info('C'), removed: false, added: true })
|
||||
})
|
||||
|
||||
it('现有成员保持原序在前,新增按保存后名单的顺序在后', () => {
|
||||
// 顺序稳定才不会出现「改一个筛选,卡片全部跳位」
|
||||
const cards = mergeMemberPreview(
|
||||
[
|
||||
{ name: 'B', info: undefined },
|
||||
{ name: 'A', info: undefined },
|
||||
],
|
||||
['C', 'A', 'D'],
|
||||
noLookup,
|
||||
)
|
||||
expect(cards.map((c) => c.name)).toEqual(['B', 'A', 'C', 'D'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 成员卡片的「保存后会怎样」预览。
|
||||
*
|
||||
* 配置草稿和已保存内容不一致时,卡片区不再显示内核现状,而是显示按草稿保存后组里
|
||||
* 会有谁:留下的照常、将移除的标出来、新增的追加在后面。放在组件外面是为了让
|
||||
* bun test 够得着——这是纯集合运算,写进 computed 就没人测了。
|
||||
*/
|
||||
import type { ProxyInfo } from './api'
|
||||
|
||||
export interface MemberPreview {
|
||||
name: string
|
||||
info: ProxyInfo | undefined
|
||||
/** 现在是成员,保存后不再是 */
|
||||
removed: boolean
|
||||
/** 现在不是成员,保存后才加入 */
|
||||
added: boolean
|
||||
}
|
||||
|
||||
export function mergeMemberPreview(
|
||||
current: Array<{ name: string; info: ProxyInfo | undefined }>,
|
||||
future: string[],
|
||||
lookup: (name: string) => ProxyInfo | undefined,
|
||||
): MemberPreview[] {
|
||||
const futureSet = new Set(future)
|
||||
const currentNames = new Set(current.map((item) => item.name))
|
||||
return [
|
||||
...current.map((item) => ({
|
||||
name: item.name,
|
||||
info: item.info,
|
||||
removed: !futureSet.has(item.name),
|
||||
added: false,
|
||||
})),
|
||||
...future
|
||||
.filter((name) => !currentNames.has(name))
|
||||
.map((name) => ({ name, info: lookup(name), removed: false, added: true })),
|
||||
]
|
||||
}
|
||||
+4
-2
@@ -6,7 +6,8 @@
|
||||
import IconRoute from '~icons/ri/route-line'
|
||||
import IconDashboard from '~icons/ri/dashboard-line'
|
||||
import IconConfig from '~icons/ri/file-list-3-line'
|
||||
import IconRequests from '~icons/ri/pulse-line'
|
||||
import IconRules from '~icons/ri/filter-3-line'
|
||||
import IconLogs from '~icons/ri/terminal-line'
|
||||
import IconConnections from '~icons/ri/links-line'
|
||||
import IconSettings from '~icons/ri/settings-3-line'
|
||||
import type { Component } from 'vue'
|
||||
@@ -21,7 +22,8 @@ export const NAV: NavItem[] = [
|
||||
{ key: 'dashboard', label: '仪表盘', icon: IconDashboard },
|
||||
{ key: 'router', label: '路由', icon: IconRoute },
|
||||
{ key: 'config', label: '配置', icon: IconConfig },
|
||||
{ key: 'requests', label: '请求', icon: IconRequests },
|
||||
{ key: 'rules', label: '规则', icon: IconRules },
|
||||
{ key: 'requests', label: '日志', icon: IconLogs },
|
||||
{ key: 'connections', label: '连接', icon: IconConnections },
|
||||
{ key: 'settings', label: '设置', icon: IconSettings },
|
||||
]
|
||||
|
||||
+47
-4
@@ -1,23 +1,66 @@
|
||||
/**
|
||||
* 界面偏好,存在 localStorage 里。
|
||||
*
|
||||
* 目前只有一项:工具栏和导航是只显示图标,还是图标加文字。
|
||||
* 密度:工具栏和导航是只显示图标,还是图标加文字。
|
||||
* 默认是「完整」——这个工具是隔一阵子才动一次的,纯图标对偶尔来一次的人不友好;
|
||||
* 想要紧凑的人自己去设置里切。
|
||||
* 想收拢的人用侧栏底部的按钮切。
|
||||
*
|
||||
* 主题:跟随系统、浅色、深色。默认跟随系统。
|
||||
*
|
||||
* localStorage 和 document 都加了存在性守卫:api.ts 引用这里(测速地址),
|
||||
* 而 api.ts 被 bun test 环境里跑的模块间接引用——那里没有浏览器全局,
|
||||
* 模块顶层直接碰它们会把整个测试文件炸掉。
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const storage = typeof localStorage === 'undefined' ? null : localStorage
|
||||
|
||||
export type Density = 'full' | 'compact'
|
||||
|
||||
const KEY = 'patchbay.density'
|
||||
|
||||
const stored = localStorage.getItem(KEY)
|
||||
const stored = storage?.getItem(KEY)
|
||||
export const density = ref<Density>(stored === 'compact' ? 'compact' : 'full')
|
||||
|
||||
watch(density, (value) => {
|
||||
localStorage.setItem(KEY, value)
|
||||
storage?.setItem(KEY, value)
|
||||
})
|
||||
|
||||
export function toggleDensity(): void {
|
||||
density.value = density.value === 'full' ? 'compact' : 'full'
|
||||
}
|
||||
|
||||
/** 测延迟用的目标地址。204 空响应的端点最合适,默认值全球都有部署。 */
|
||||
export const DEFAULT_TEST_URL = 'https://www.gstatic.com/generate_204'
|
||||
|
||||
const TEST_URL_KEY = 'patchbay.test-url'
|
||||
|
||||
export const testUrl = ref(storage?.getItem(TEST_URL_KEY) || DEFAULT_TEST_URL)
|
||||
|
||||
watch(testUrl, (value) => {
|
||||
if (value.trim() === '' || value === DEFAULT_TEST_URL) storage?.removeItem(TEST_URL_KEY)
|
||||
else storage?.setItem(TEST_URL_KEY, value.trim())
|
||||
})
|
||||
|
||||
export type Theme = 'system' | 'light' | 'dark'
|
||||
|
||||
const THEME_KEY = 'patchbay.theme'
|
||||
|
||||
const storedTheme = storage?.getItem(THEME_KEY)
|
||||
export const theme = ref<Theme>(
|
||||
storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : 'system',
|
||||
)
|
||||
|
||||
// 显式选了浅色或深色就在 <html> 上标出来:tokens.css 里 :root[data-theme=…] 的权重
|
||||
// 高于 :root 和媒体查询里的 :root,压过系统偏好。跟随系统则不加标记,交回给媒体查询,
|
||||
// 这样系统主题切换时界面仍然跟着变。
|
||||
watch(
|
||||
theme,
|
||||
(value) => {
|
||||
storage?.setItem(THEME_KEY, value)
|
||||
if (typeof document === 'undefined') return
|
||||
if (value === 'system') document.documentElement.removeAttribute('data-theme')
|
||||
else document.documentElement.setAttribute('data-theme', value)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "Patchbay",
|
||||
"short_name": "Patchbay",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#fbfbfc",
|
||||
"theme_color": "#fbfbfc",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/maskable-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
+7
-2
@@ -32,6 +32,11 @@ export const lineProvider = ref<Record<string, string>>({})
|
||||
|
||||
export const kernel = ref<{ online: boolean; version?: string }>({ online: false })
|
||||
|
||||
/** 只探测内核在不在线,不拉运行时数据。给外壳的定时心跳用,各页等内核回来的 watch 靠它可达。 */
|
||||
export async function refreshKernel(): Promise<void> {
|
||||
kernel.value = await api.kernel().catch(() => ({ online: false }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新读一遍运行时状态。
|
||||
*
|
||||
@@ -42,7 +47,7 @@ export const kernel = ref<{ online: boolean; version?: string }>({ online: false
|
||||
* 最需要这个界面的时候。
|
||||
*/
|
||||
export async function reloadRuntime(): Promise<void> {
|
||||
kernel.value = await api.kernel().catch(() => ({ online: false }))
|
||||
await refreshKernel()
|
||||
if (!kernel.value.online) {
|
||||
runtime.value = {}
|
||||
providers.value = {}
|
||||
@@ -118,7 +123,7 @@ export function isChainedLine(name: string): boolean {
|
||||
/**
|
||||
* 这几个订阅里一共有哪些线路。
|
||||
*
|
||||
* 只给高级配置里那个「匹配到几条」的预览用:那时用户选的来源还没保存,内核也还不知道
|
||||
* 只给配置表单里那个「匹配到几条」的预览用:那时用户选的来源还没保存,内核也还不知道
|
||||
* 有这么个组,只能自己按来源算一遍。已经存在的组要用下面的 `membersOf`。
|
||||
*/
|
||||
export function linesFrom(uses: string[]): string[] {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { readLines } from './stream.ts'
|
||||
|
||||
function streamOf(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function collect(chunks: string[]): Promise<string[]> {
|
||||
const lines: string[] = []
|
||||
for await (const line of readLines(streamOf(chunks))) lines.push(line)
|
||||
return lines
|
||||
}
|
||||
|
||||
describe('按行读流', () => {
|
||||
it('一块里有好几行就拆开', async () => {
|
||||
expect(await collect(['a\nb\nc\n'])).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('半行留在缓冲里,等下一块补齐', async () => {
|
||||
// 分块边界和行边界无关,这是这个模块存在的全部理由
|
||||
expect(await collect(['{"up":1,"do', 'wn":2}\n{"up":3}\n'])).toEqual([
|
||||
'{"up":1,"down":2}',
|
||||
'{"up":3}',
|
||||
])
|
||||
})
|
||||
|
||||
it('流结束时缓冲里剩的半行也交出去', async () => {
|
||||
expect(await collect(['a\nb'])).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('空行丢弃,\\r\\n 换行的 \\r 去掉', async () => {
|
||||
expect(await collect(['a\r\n\n\nb\n'])).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('多字节字符跨块也不会读坏', async () => {
|
||||
const bytes = new TextEncoder().encode('香港01\n')
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
// 故意从「港」字中间切开
|
||||
controller.enqueue(bytes.slice(0, 4))
|
||||
controller.enqueue(bytes.slice(4))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
const lines: string[] = []
|
||||
for await (const line of readLines(stream)) lines.push(line)
|
||||
expect(lines).toEqual(['香港01'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 把持续吐数据的 HTTP 响应体读成一行一行。
|
||||
*
|
||||
* 内核的 /traffic、/memory、/logs 在没有 WebSocket 升级时退化成分块流,一行一个
|
||||
* JSON。分块的边界和行的边界无关:一次 read 可能拿到半行,也可能拿到好几行。
|
||||
* 这里把它们缝成完整的行再交出去,空行丢弃。
|
||||
*
|
||||
* 停止读取的方式是中止 fetch(AbortController)或 break 出循环,两种都会走到
|
||||
* finally 里把流取消掉,不留悬着的连接。
|
||||
*/
|
||||
export async function* readLines(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const parts = buffer.split('\n')
|
||||
// 最后一段可能是半行,留在缓冲里等下一块补齐
|
||||
buffer = parts.pop() ?? ''
|
||||
for (const part of parts) {
|
||||
const line = part.endsWith('\r') ? part.slice(0, -1) : part
|
||||
if (line) yield line
|
||||
}
|
||||
}
|
||||
const rest = buffer.trim()
|
||||
if (rest) yield rest
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,71 @@
|
||||
--line: 1.7;
|
||||
}
|
||||
|
||||
/*
|
||||
* 显式选择的主题(web/prefs.ts 在 <html> 上写 data-theme)。两个方向都要写:
|
||||
* 深色系统上选浅色也要生效。选择器权重高于 :root 和上面媒体查询里的 :root,
|
||||
* 显式选择压过系统偏好;不标 data-theme 时这两块不命中,媒体查询说了算。
|
||||
* 只覆盖颜色变量,间距、字号与主题无关。
|
||||
*/
|
||||
:root[data-theme='light'] {
|
||||
/* 变量之外还要告诉浏览器本身:滚动条、select 弹层这些 UA 部件不看变量,只看 color-scheme */
|
||||
color-scheme: light;
|
||||
|
||||
--c-bg: #fbfbfc;
|
||||
--c-surface: #ffffff;
|
||||
--c-surface-sunken: #f4f5f7;
|
||||
--c-border: #e4e6ea;
|
||||
--c-border-strong: #d0d3d9;
|
||||
|
||||
--c-text: #1c1e21;
|
||||
--c-text-soft: #5c6169;
|
||||
--c-text-faint: #8b9098;
|
||||
|
||||
--c-accent: #2b7a78;
|
||||
--c-accent-soft: #e6f2f1;
|
||||
--c-accent-text: #1f5c5a;
|
||||
|
||||
--c-chain: #5145cd;
|
||||
--c-chain-soft: #eeedfb;
|
||||
--c-chain-text: #4038a8;
|
||||
|
||||
--c-danger: #b3261e;
|
||||
--c-danger-soft: #fdedec;
|
||||
--c-warn: #8a6100;
|
||||
--c-warn-soft: #fdf5e3;
|
||||
--c-ok: #1f6b3a;
|
||||
--c-ok-soft: #e9f5ed;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--c-bg: #16181c;
|
||||
--c-surface: #1d2025;
|
||||
--c-surface-sunken: #121417;
|
||||
--c-border: #2b2f36;
|
||||
--c-border-strong: #3a3f48;
|
||||
|
||||
--c-text: #e6e8eb;
|
||||
--c-text-soft: #9ba1a9;
|
||||
--c-text-faint: #6c727a;
|
||||
|
||||
--c-accent: #4db6ac;
|
||||
--c-accent-soft: #1b3330;
|
||||
--c-accent-text: #7fd4cb;
|
||||
|
||||
--c-chain: #8b83f0;
|
||||
--c-chain-soft: #241f4a;
|
||||
--c-chain-text: #a9a2f7;
|
||||
|
||||
--c-danger: #f2b8b5;
|
||||
--c-danger-soft: #3a1f1e;
|
||||
--c-warn: #e8c37a;
|
||||
--c-warn-soft: #33290f;
|
||||
--c-ok: #86d6a0;
|
||||
--c-ok-soft: #16301f;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--c-bg: #16181c;
|
||||
@@ -112,6 +177,17 @@ body,
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 页面高度跨过视口阈值时滚动条出现消失,内容会横向抖一下;把滚动条的位置常驻留出来 */
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* 模态框开着时锁住背后的页面滚动。:modal 只匹配 showModal() 打开的 dialog;
|
||||
滚动条的位置由上面的 scrollbar-gutter 常驻留出,锁定解锁都不会横向跳动 */
|
||||
html:has(:modal) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--c-bg);
|
||||
@@ -150,6 +226,61 @@ kbd,
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 文本输入的焦点态统一为描边变色,不叠外圈(订阅选择器的形态)。按钮保留外圈,那是键盘可达性 */
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--c-accent);
|
||||
}
|
||||
|
||||
/* 交互控件的颜色过渡。只过渡颜色类属性,不碰布局;时长以「感知得到但不等待」为准 */
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* 对话框入场:快速淡入加一点上移 */
|
||||
dialog[open] {
|
||||
animation: dialog-in 0.15s ease;
|
||||
}
|
||||
|
||||
dialog[open]::backdrop {
|
||||
animation: backdrop-in 0.15s ease;
|
||||
}
|
||||
|
||||
/* 对话框退场:单纯的快速淡出。display/overlay 的 allow-discrete 让元素在
|
||||
过渡结束前留在 top layer;不支持的浏览器退化为立即消失,无害 */
|
||||
dialog,
|
||||
dialog::backdrop {
|
||||
transition:
|
||||
opacity 0.12s ease,
|
||||
display 0.12s allow-discrete,
|
||||
overlay 0.12s allow-discrete;
|
||||
}
|
||||
|
||||
dialog:not([open]),
|
||||
dialog:not([open])::backdrop {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes dialog-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes backdrop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--c-accent-soft);
|
||||
color: var(--c-accent-text);
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 常驻的遥测采集:流量曲线、内核内存、每路入站的吞吐。
|
||||
*
|
||||
* 住在模块级单例里而不是仪表盘组件里,是因为历史要跨页面留存——采集跟着组件
|
||||
* 走的话,切走一次曲线就清零一次。应用启动后由外壳调一次 startTelemetry(),
|
||||
* 之后跟着 kernel.online 自己起落:上线接上,掉线断开但**不清历史**,断档在
|
||||
* 时间轴上自然呈现为一段空白。
|
||||
*
|
||||
* 数据来源两路:
|
||||
* - /traffic、/memory 流,每秒一行(内核源码 hub/route/server.go:386-414)
|
||||
* - /connections 快照 3 秒一轮询,按连接逐条求差聚合出每路的吞吐
|
||||
* (快照本身没有流式 HTTP 版本,见 hub/route/connections.go:24-28)
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
ROUTE_POLL_MS,
|
||||
ROUTE_WINDOW_MS,
|
||||
TRAFFIC_WINDOW_MS,
|
||||
pushWindow,
|
||||
routeDeltas,
|
||||
} from '@shared/telemetry.ts'
|
||||
import type { ConnTotals, RouteSample, TrafficPoint } from '@shared/telemetry.ts'
|
||||
import type { ExitProbe } from '@shared/host-info.ts'
|
||||
import { api } from './api'
|
||||
import { kernel } from './runtime'
|
||||
import { readLines } from './stream'
|
||||
|
||||
/** 流量曲线,最近 30 分钟。up/down 是内核报的每秒字节数。 */
|
||||
export const traffic = ref<TrafficPoint[]>([])
|
||||
|
||||
/** 内核报的累计上下行字节数。没收到过是 null。 */
|
||||
export const totals = ref<{ up: number; down: number } | null>(null)
|
||||
|
||||
/** 内核自己的内存占用。 */
|
||||
export const kernelMemory = ref<number | null>(null)
|
||||
|
||||
/** 每路入站的吞吐样本,键是内核连接快照里的 inboundName。 */
|
||||
export const routeSamples = ref<Record<string, RouteSample[]>>({})
|
||||
|
||||
/** 当前连接数。 */
|
||||
export const connCount = ref<number | null>(null)
|
||||
|
||||
/** 流当前接着没有。断流期间仪表盘的「当前值」要显示成没数据,而不是冻在最后一秒。 */
|
||||
export const live = ref(false)
|
||||
|
||||
/** 每个端口的公网 IP 检测结果,会话内缓存。键是端口号。 */
|
||||
export const exitProbes = ref<Record<number, { state: 'loading' | 'done'; result?: ExitProbe }>>({})
|
||||
|
||||
/** 采集的时钟,每次轮询走一格。活跃判定这类「相对现在」的计算依赖它保持新鲜。 */
|
||||
export const tick = ref(Date.now())
|
||||
|
||||
const num = (value: unknown): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
|
||||
let abort: AbortController | null = null
|
||||
let pollTimer = 0
|
||||
let retryTimer = 0
|
||||
let prevTotals: ConnTotals = new Map()
|
||||
let prevAt = 0
|
||||
|
||||
async function consume(
|
||||
path: string,
|
||||
ctrl: AbortController,
|
||||
onLine: (payload: Record<string, unknown>) => void,
|
||||
) {
|
||||
const body = await api.openStream(path, ctrl.signal)
|
||||
for await (const line of readLines(body)) {
|
||||
// 偶发的坏行跳过就行,抛出去会杀掉整个流、进 5 秒重试循环
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = JSON.parse(line) as Record<string, unknown>
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
onLine(payload)
|
||||
}
|
||||
}
|
||||
|
||||
function startStreams() {
|
||||
const ctrl = new AbortController()
|
||||
abort = ctrl
|
||||
live.value = true
|
||||
const run = (path: string, onLine: (payload: Record<string, unknown>) => void) =>
|
||||
void consume(path, ctrl, onLine)
|
||||
.catch(() => {})
|
||||
// 这两个流本身永不结束,正常返回也意味着断了
|
||||
.finally(() => {
|
||||
if (ctrl.signal.aborted) return
|
||||
// 一条断了把另一条也收掉,然后走统一的重连节奏
|
||||
ctrl.abort()
|
||||
live.value = false
|
||||
scheduleRetry()
|
||||
})
|
||||
|
||||
run('/traffic', (p) => {
|
||||
traffic.value = pushWindow(
|
||||
traffic.value,
|
||||
{ t: Date.now(), up: num(p.up), down: num(p.down) },
|
||||
TRAFFIC_WINDOW_MS,
|
||||
)
|
||||
totals.value = { up: num(p.upTotal), down: num(p.downTotal) }
|
||||
})
|
||||
run('/memory', (p) => {
|
||||
kernelMemory.value = num(p.inuse)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 断流不等于内核没了:保存配置触发的重载会掐断流,内核几秒内就回来、online 从未
|
||||
* 翻转。5 秒后重试,失败会再次走到这里形成节奏;真掉线交给下面 online 的 watch。
|
||||
*/
|
||||
function scheduleRetry() {
|
||||
window.clearTimeout(retryTimer)
|
||||
retryTimer = window.setTimeout(() => {
|
||||
if (kernel.value.online && !live.value) startStreams()
|
||||
}, 5_000)
|
||||
}
|
||||
|
||||
async function pollConnections() {
|
||||
const now = Date.now()
|
||||
try {
|
||||
const snapshot = await api.connections()
|
||||
const conns = snapshot.connections ?? []
|
||||
connCount.value = conns.length
|
||||
const { deltas, next } = routeDeltas(prevTotals, conns)
|
||||
const ms = prevAt ? now - prevAt : ROUTE_POLL_MS
|
||||
prevTotals = next
|
||||
prevAt = now
|
||||
|
||||
const merged = { ...routeSamples.value }
|
||||
for (const [key, delta] of Object.entries(deltas)) {
|
||||
merged[key] = pushWindow(
|
||||
merged[key] ?? [],
|
||||
{ t: now, ms, up: delta.up, down: delta.down },
|
||||
ROUTE_WINDOW_MS,
|
||||
)
|
||||
}
|
||||
// 有历史但这一轮没有连接的路,补零样本让走势落回去而不是冻住;
|
||||
// 整个窗口都归零之后把这路忘掉,改名、删除的入站才不会在内存里越攒越多
|
||||
for (const key of Object.keys(merged)) {
|
||||
if (key in deltas) continue
|
||||
const samples = pushWindow(merged[key]!, { t: now, ms, up: 0, down: 0 }, ROUTE_WINDOW_MS)
|
||||
if (samples.every((s) => s.up === 0 && s.down === 0)) delete merged[key]
|
||||
else merged[key] = samples
|
||||
}
|
||||
routeSamples.value = merged
|
||||
} catch {
|
||||
// 单轮失败就跳过。持续失败说明内核掉了,online 的 watch 会把轮询停掉
|
||||
} finally {
|
||||
tick.value = now
|
||||
}
|
||||
}
|
||||
|
||||
function attach() {
|
||||
if (!abort) startStreams()
|
||||
if (!pollTimer) {
|
||||
void pollConnections()
|
||||
pollTimer = window.setInterval(() => void pollConnections(), ROUTE_POLL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
function detach() {
|
||||
abort?.abort()
|
||||
abort = null
|
||||
live.value = false
|
||||
window.clearInterval(pollTimer)
|
||||
window.clearTimeout(retryTimer)
|
||||
pollTimer = 0
|
||||
prevTotals = new Map()
|
||||
prevAt = 0
|
||||
}
|
||||
|
||||
let started = false
|
||||
|
||||
/** 外壳登录成功后调一次。之后采集跟着内核在线状态自己起落。 */
|
||||
export function startTelemetry(): void {
|
||||
if (started) return
|
||||
started = true
|
||||
watch(
|
||||
() => kernel.value.online,
|
||||
(online) => {
|
||||
if (online) attach()
|
||||
else detach()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
}
|
||||
|
||||
/** 检测某个端口的公网 IP。结果缓存到会话结束,force 重测。 */
|
||||
export async function probeExit(port: number, force = false): Promise<void> {
|
||||
const cached = exitProbes.value[port]
|
||||
if (!force && cached && (cached.state === 'loading' || cached.result?.ok)) return
|
||||
exitProbes.value = { ...exitProbes.value, [port]: { state: 'loading' } }
|
||||
const result = await api
|
||||
.probeExit(port)
|
||||
.catch((error: unknown) => ({ ok: false, message: String(error) }) as ExitProbe)
|
||||
exitProbes.value = { ...exitProbes.value, [port]: { state: 'done', result } }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 图表取色。
|
||||
*
|
||||
* Canvas 里画的东西不认 CSS 变量,颜色要在 JS 里解析出来传给 ECharts。
|
||||
* themeTick 在主题变化(设置页显式切换写 data-theme,或系统偏好翻转)时走一格,
|
||||
* 图表的 option 把它算进依赖,主题一变整个重新取色。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const themeTick = ref(0)
|
||||
|
||||
const bump = () => {
|
||||
themeTick.value += 1
|
||||
}
|
||||
|
||||
new MutationObserver(bump).observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme'],
|
||||
})
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', bump)
|
||||
|
||||
/** 当前生效的某个 CSS 变量的值,如 cssColor('--c-accent')。 */
|
||||
export function cssColor(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 右上角的轻提示(成就弹窗式):从右滑入一行字,几秒后上移淡出。
|
||||
*
|
||||
* 模块级单例,谁都能发;渲染在外壳的 ToastHost 里。适合「动作已完成/失败」
|
||||
* 这类看一眼就行的反馈;需要用户处理的内容(表单校验、带重试的错误)
|
||||
* 仍然留在页面里,不进这里。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { ApiError } from './api'
|
||||
|
||||
export type ToastKind = 'ok' | 'error' | 'info'
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
kind: ToastKind
|
||||
message: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
export const toasts = ref<Toast[]>([])
|
||||
|
||||
let nextId = 1
|
||||
|
||||
export function toast(message: string, opts?: { kind?: ToastKind; hint?: string }): void {
|
||||
const item: Toast = {
|
||||
id: nextId++,
|
||||
kind: opts?.kind ?? 'ok',
|
||||
message,
|
||||
hint: opts?.hint ?? '',
|
||||
}
|
||||
toasts.value = [...toasts.value, item]
|
||||
// 错误多留一会儿,看清 hint 里的下一步
|
||||
const ttl = item.kind === 'error' ? 8000 : 4000
|
||||
setTimeout(() => dismiss(item.id), ttl)
|
||||
}
|
||||
|
||||
export function dismiss(id: number): void {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||
}
|
||||
|
||||
/** 接口报错的统一出口:message 说发生了什么,hint 说下一步。 */
|
||||
export function toastError(error: unknown): void {
|
||||
if (error instanceof ApiError) toast(error.message, { kind: 'error', hint: error.hint })
|
||||
else toast(String(error), { kind: 'error' })
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 配置页:订阅的导入和管理。左列表右详情,同路由页的主从布局。
|
||||
*
|
||||
* 编排守的分工和路由页一样:增删改订阅都走 commit(),带校验和重载,失败按
|
||||
* message/hint 说;「立刻更新」是让内核重新拉取的运行时动作,不写配置文件,
|
||||
* 不走 commit(),也没有保存那种等待感。
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { collectInbounds } from '@shared/inbounds.ts'
|
||||
import { pruneGenerated, syncGenerated } from '@shared/inbound-groups.ts'
|
||||
import {
|
||||
addFileSubscription,
|
||||
addSubscription,
|
||||
listSubscriptions,
|
||||
moveSubscription,
|
||||
providerNames,
|
||||
removeSubscription,
|
||||
renameSubscription,
|
||||
setSubscriptionHealthCheck,
|
||||
subscriptionPathConflict,
|
||||
subscriptionReferences,
|
||||
updateSubscription,
|
||||
} from '@shared/providers.ts'
|
||||
import type { Subscription } from '@shared/providers.ts'
|
||||
import { providerCachePath } from '@shared/inbound-groups.ts'
|
||||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||
import { ApiError, api } from '../api'
|
||||
import { toast, toastError } from '../toast'
|
||||
import type { ProviderInfo } from '../api'
|
||||
import { kernel, providerLines, reloadRuntime } from '../runtime'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import ProviderList from '../components/ProviderList.vue'
|
||||
import SubscriptionDetail from './SubscriptionDetail.vue'
|
||||
import SubscriptionForm from './SubscriptionForm.vue'
|
||||
import IconAdd from '~icons/ri/add-line'
|
||||
import IconRefresh from '~icons/ri/refresh-line'
|
||||
|
||||
const props = defineProps<{ data: ConfigObject; revision: string }>()
|
||||
|
||||
/** 配置文件变了,需要重读整份配置。运行时的变化不走这里。 */
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
|
||||
const subscriptions = computed(() => listSubscriptions(props.data))
|
||||
|
||||
const selectedName = ref<string | null>(null)
|
||||
const saving = ref(false)
|
||||
const formOpen = ref(false)
|
||||
const formEditing = ref<Subscription | null>(null)
|
||||
/** 待确认删除的那个订阅。非 null 时确认对话框打开着 */
|
||||
const removing = ref<Subscription | null>(null)
|
||||
/** 正在等内核重新拉取的订阅名 */
|
||||
const refreshing = ref<string | null>(null)
|
||||
/** 正在等内核测完线路的订阅名 */
|
||||
const checking = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* 每个订阅的运行时数据:当前线路、上次更新时间。
|
||||
*
|
||||
* 配置文件里只有地址和间隔,这些只有运行中的内核知道。runtime.ts 那份合并视图
|
||||
* 没有 updatedAt,而它归路由页共用、不归这个页面改,所以这里自己留一份。
|
||||
* 内核没连接时就是空的,页面照常显示配置里有的东西(决策记录 0004)。
|
||||
*/
|
||||
const providerInfo = ref<Record<string, ProviderInfo>>({})
|
||||
|
||||
async function reloadProviders() {
|
||||
if (!kernel.value.online) {
|
||||
providerInfo.value = {}
|
||||
return
|
||||
}
|
||||
providerInfo.value = await api
|
||||
.providers()
|
||||
.then((result) => result.providers)
|
||||
.catch(() => ({}))
|
||||
}
|
||||
|
||||
onMounted(() => void reloadProviders())
|
||||
// 保存成功后父组件重读配置,data 换了就顺带把运行时数据也换新的
|
||||
watch(() => props.data, () => void reloadProviders())
|
||||
|
||||
// 默认选中第一行。空面板没有信息量,不如直接展示一个订阅。
|
||||
watch(
|
||||
subscriptions,
|
||||
(list) => {
|
||||
if (!list.some((s) => s.name === selectedName.value)) selectedName.value = list[0]?.name ?? null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const selected = computed(
|
||||
() => subscriptions.value.find((s) => s.name === selectedName.value) ?? null,
|
||||
)
|
||||
|
||||
/** 新增表单挡重名要用全部键:和用户看不见的订阅副本撞名也会被静默丢掉 */
|
||||
const takenNames = computed(() => providerNames(props.data))
|
||||
|
||||
/** Go 零值时间(0001-01-01)表示从没拉取过,按没有处理 */
|
||||
function parseUpdated(at: string | undefined): Date | null {
|
||||
if (!at) return null
|
||||
const d = new Date(at)
|
||||
return Number.isNaN(d.getTime()) || d.getFullYear() < 2000 ? null : d
|
||||
}
|
||||
|
||||
const rows = computed(() =>
|
||||
subscriptions.value.map((s) => {
|
||||
const info = providerInfo.value[s.name]
|
||||
return {
|
||||
name: s.name,
|
||||
lines: info ? info.proxies.length : null,
|
||||
updated: parseUpdated(info?.updatedAt),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
|
||||
|
||||
/** 所有改配置文件的动作都从这里走。反馈走右上角轻提示,返回值告知成败。 */
|
||||
async function commit(raw: ConfigObject, success: string): Promise<boolean> {
|
||||
// 先把生成的东西按当前规则重建一遍,再清掉没人再引用的。订阅副本的来源被改地址时
|
||||
// 靠这一步保持和原订阅一致,删订阅前已由 subscriptionReferences 挡住有引用的情形。
|
||||
const next = pruneGenerated(syncGenerated(raw, collectInbounds(raw)))
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await api.applyConfig(next, props.revision)
|
||||
// 没重载有两种情况(内容没有变化、内核不在线只写了文件),都要如实说,不能只报成功
|
||||
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
|
||||
emit('saved')
|
||||
return true
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
formEditing.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit() {
|
||||
if (!selected.value || saving.value) return
|
||||
formEditing.value = selected.value
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
async function submitForm(draft: {
|
||||
name: string
|
||||
url: string
|
||||
interval: number | null
|
||||
file: { name: string; content: string } | null
|
||||
}) {
|
||||
const editing = formEditing.value
|
||||
|
||||
// 名字查重放行了、但净化后的缓存路径和别人相同的,在这里拦
|
||||
// (只查新增:改名不动缓存路径,不会制造新的撞车)
|
||||
if (!editing) {
|
||||
const path = providerCachePath(draft.name)
|
||||
const clash = subscriptionPathConflict(props.data, draft.name, path)
|
||||
if (clash) {
|
||||
toast(`「${draft.name}」和「${clash}」的缓存文件会是同一个`, {
|
||||
kind: 'error',
|
||||
hint: '名字只在符号或空格上有差异时会净化成同一个文件名,换一个名字。',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 从文件导入:先把内容写到订阅的缓存路径上,成了再写声明。
|
||||
// 顺序不能反——声明先落盘的话,内核重载会去读一个还不存在的文件
|
||||
if (draft.file) {
|
||||
try {
|
||||
await api.uploadProviderFile(providerCachePath(draft.name), draft.file.content)
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑时先改名再改字段。名字没动时 renameSubscription 原样返回;
|
||||
// file 类型只有名称可改,不能走 updateSubscription——它会写进 url
|
||||
const next = editing
|
||||
? editing.type === 'file'
|
||||
? renameSubscription(clone(), editing.name, draft.name)
|
||||
: updateSubscription(
|
||||
renameSubscription(clone(), editing.name, draft.name),
|
||||
draft.name,
|
||||
draft.url,
|
||||
draft.interval,
|
||||
)
|
||||
: draft.file
|
||||
? addFileSubscription(clone(), draft.name)
|
||||
: addSubscription(clone(), draft.name, draft.url, draft.interval)
|
||||
const ok = await commit(next, editing ? '已保存' : `已新增 ${draft.name}`)
|
||||
// 失败时不能关对话框——关了再打开就是空表单,填的名称、地址、间隔全没了
|
||||
if (!ok) return
|
||||
formOpen.value = false
|
||||
// 新增和改名都要把选中跟过去,否则选中名失效会被重置到第一行
|
||||
selectedName.value = draft.name
|
||||
}
|
||||
|
||||
function askRemove() {
|
||||
const target = selected.value
|
||||
if (!target || saving.value) return
|
||||
// 被引用的订阅不能删:那些组的 use: 一空,指向它们的入站就无路可走
|
||||
const refs = subscriptionReferences(props.data, target.name)
|
||||
if (refs.length > 0) {
|
||||
toast(`无法删除 ${target.name}:${refs.join('、')} 在引用它`, { kind: 'error' })
|
||||
return
|
||||
}
|
||||
removing.value = target
|
||||
}
|
||||
|
||||
async function removeConfirmed() {
|
||||
const target = removing.value
|
||||
removing.value = null
|
||||
if (!target || saving.value) return
|
||||
selectedName.value = null
|
||||
await commit(removeSubscription(clone(), target.name), `已删除 ${target.name}`)
|
||||
}
|
||||
|
||||
/** 开关选中订阅的健康检查。写配置文件,走 commit()。 */
|
||||
async function toggleHealthCheck() {
|
||||
const target = selected.value
|
||||
if (!target || saving.value) return
|
||||
const enable = target.healthCheck === null
|
||||
await commit(
|
||||
setSubscriptionHealthCheck(clone(), target.name, enable),
|
||||
enable ? `已开启 ${target.name} 的健康检查` : `已关闭 ${target.name} 的健康检查`,
|
||||
)
|
||||
}
|
||||
|
||||
/** 让内核立刻重新拉取。运行时动作,不写配置文件,所以不走 commit()。 */
|
||||
async function refresh() {
|
||||
const target = selected.value
|
||||
if (!target || refreshing.value !== null) return
|
||||
refreshing.value = target.name
|
||||
try {
|
||||
await api.refreshProvider(target.name)
|
||||
await Promise.all([reloadRuntime(), reloadProviders()])
|
||||
toast(`已更新 ${target.name}`)
|
||||
} catch (error) {
|
||||
toast(error instanceof ApiError ? error.message : String(error), {
|
||||
kind: 'error',
|
||||
hint: '该操作仅修改内核运行状态,配置文件未被修改。',
|
||||
})
|
||||
} finally {
|
||||
refreshing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 让内核立刻测一遍选中订阅的线路。运行时动作,不写配置文件,所以不走 commit()。
|
||||
*
|
||||
* 内核是测完全部线路才回话的,线路多又都测不通时要等上几十秒,期间按钮转着。
|
||||
*/
|
||||
async function check() {
|
||||
const target = selected.value
|
||||
if (!target || checking.value !== null) return
|
||||
checking.value = target.name
|
||||
try {
|
||||
await api.checkProviderLines(target.name)
|
||||
await reloadProviders()
|
||||
toast(`已检查 ${target.name} 的线路`)
|
||||
} catch (error) {
|
||||
toast(error instanceof ApiError ? error.message : String(error), {
|
||||
kind: 'error',
|
||||
hint: '该操作仅修改内核运行状态,配置文件未被修改。',
|
||||
})
|
||||
} finally {
|
||||
checking.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 把一个订阅在列表里挪一位。顺序在配置文件里,所以要走 commit()。 */
|
||||
async function move(name: string, delta: -1 | 1) {
|
||||
if (saving.value) return
|
||||
const before = clone()
|
||||
const next = moveSubscription(before, name, delta)
|
||||
// 到头了 moveSubscription 原样返回,这时不该发起一次什么都没改的保存
|
||||
if (next === before) return
|
||||
await commit(next, '顺序已保存')
|
||||
}
|
||||
|
||||
const refreshingAll = ref(false)
|
||||
|
||||
/** 挨个让内核重新拉取所有订阅。失败的点名,成功的不逐个报。 */
|
||||
async function refreshAll() {
|
||||
if (refreshingAll.value || !kernel.value.online || subscriptions.value.length === 0) return
|
||||
refreshingAll.value = true
|
||||
const names = subscriptions.value.map((s) => s.name)
|
||||
const results = await Promise.allSettled(names.map((n) => api.refreshProvider(n)))
|
||||
await Promise.all([reloadRuntime(), reloadProviders()])
|
||||
const failed = names.filter((_, i) => results[i]?.status === 'rejected')
|
||||
if (failed.length > 0) {
|
||||
toast(`${failed.join('、')} 更新失败`, {
|
||||
kind: 'error',
|
||||
hint: '其余订阅已更新。该操作仅修改内核运行状态,配置文件未被修改。',
|
||||
})
|
||||
} else {
|
||||
toast(`已更新全部 ${names.length} 个订阅`)
|
||||
}
|
||||
refreshingAll.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="head">
|
||||
<div class="sums">
|
||||
<div class="sum">
|
||||
<span class="sum-n">{{ subscriptions.length }}</span>
|
||||
<span class="sum-k">订阅</span>
|
||||
</div>
|
||||
<div class="sum">
|
||||
<span class="sum-n">{{ providerLines.length }}</span>
|
||||
<span class="sum-k">订阅线路</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="head-acts">
|
||||
<button
|
||||
class="add"
|
||||
type="button"
|
||||
:disabled="refreshingAll || !kernel.online || subscriptions.length === 0"
|
||||
@click="refreshAll"
|
||||
>
|
||||
<IconRefresh />
|
||||
<span>{{ refreshingAll ? '更新中' : '更新所有订阅' }}</span>
|
||||
</button>
|
||||
<button class="add" type="button" :disabled="saving" @click="openAdd">
|
||||
<IconAdd />
|
||||
<span>新增</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="split">
|
||||
<ProviderList
|
||||
v-model:selected="selectedName"
|
||||
:rows="rows"
|
||||
:saving="saving"
|
||||
@move="move"
|
||||
/>
|
||||
|
||||
<SubscriptionDetail
|
||||
v-if="selected"
|
||||
:subscription="selected"
|
||||
:info="providerInfo[selected.name]"
|
||||
:online="kernel.online"
|
||||
:refreshing="refreshing === selected.name"
|
||||
:checking="checking === selected.name"
|
||||
:saving="saving"
|
||||
@edit="openEdit"
|
||||
@remove="askRemove"
|
||||
@refresh="refresh"
|
||||
@check="check"
|
||||
@toggle-health-check="toggleHealthCheck"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="subscriptions.length === 0" class="empty">没有订阅。</p>
|
||||
|
||||
<SubscriptionForm
|
||||
:open="formOpen"
|
||||
:editing="formEditing"
|
||||
:taken-names="takenNames"
|
||||
:saving="saving"
|
||||
@close="formOpen = false"
|
||||
@submit="submitForm"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="removing !== null"
|
||||
:message="removing ? `删除订阅「${removing.name}」?` : ''"
|
||||
action="删除"
|
||||
@close="removing = null"
|
||||
@confirm="removeConfirmed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-4);
|
||||
margin-bottom: var(--s-5);
|
||||
}
|
||||
|
||||
.sums {
|
||||
display: flex;
|
||||
gap: var(--s-6);
|
||||
}
|
||||
|
||||
.sum {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.sum-n {
|
||||
font-size: var(--t-2xl);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sum-k {
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.add:hover:not(:disabled) {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
.add:disabled {
|
||||
color: var(--c-text-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.head-acts {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
/* 左列表右详情。窄时改成上下,详情不再吸顶 */
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 1fr);
|
||||
gap: var(--s-5);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* 按内容区宽度切换而不是按视口:侧栏能收拢,同一个视口宽度下内容区可宽可窄 */
|
||||
.page {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
@container (max-width: 900px) {
|
||||
.split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
margin-top: var(--s-4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,483 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 连接页:内核当前有哪些连接,从哪一路入站进来,能断开它们。
|
||||
*
|
||||
* /connections 没有流式版本,只能每秒全量轮询一次。表格数据来自内核快照,
|
||||
* 显示层的问题(主机名怎么摆、出站链按什么方向、持续时间多长)都在 shared/connections.ts
|
||||
* 里回答,这里只管取数、状态和断开动作。
|
||||
*/
|
||||
import { computed, onActivated, onDeactivated, onUnmounted, ref } from 'vue'
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||
import {
|
||||
chainLabel,
|
||||
filterConnections,
|
||||
formatDuration,
|
||||
groupByInbound,
|
||||
hostLabel,
|
||||
ruleLabel,
|
||||
sortConnections,
|
||||
} from '@shared/connections.ts'
|
||||
import type { ConnectionSnapshot, SortKey } from '@shared/connections.ts'
|
||||
import { formatBytes } from '@shared/format.ts'
|
||||
import { ApiError, api } from '../api'
|
||||
import { toast, toastError } from '../toast'
|
||||
import { kernel } from '../runtime'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import IconClose from '~icons/ri/close-line'
|
||||
|
||||
const snapshot = ref<ConnectionSnapshot | null>(null)
|
||||
/**
|
||||
* 轮询取数的失败,成功时自己清掉。这是持续状态所以留在页内;
|
||||
* 断开动作的失败是一次性事件,走右上角轻提示。
|
||||
*/
|
||||
const pollFailure = ref('')
|
||||
/** 表格里的持续时间按这一刻算。跟着轮询节拍走,不单独开秒表 */
|
||||
const now = ref(Date.now())
|
||||
const query = ref('')
|
||||
const sort = ref<SortKey>('download')
|
||||
/** 正在断开中的连接 id,防止连点 */
|
||||
const closing = ref<string[]>([])
|
||||
const closingAll = ref(false)
|
||||
const askAll = ref(false)
|
||||
/** 有请求在飞就不再发,内核卡住时每秒叠一个请求只会让局面更糟 */
|
||||
let fetching = false
|
||||
let timer: number | undefined
|
||||
|
||||
/**
|
||||
* 轮询的取数失败和断开动作的失败分开放。共用一条通道的话,断开失败的提示
|
||||
* 会被下一秒轮询成功时的清空抹掉,用户只看到那条连接还在、不知道为什么。
|
||||
*/
|
||||
async function refresh() {
|
||||
// 内核不在线时不打这个接口:得不到数据,界面直接说原因
|
||||
if (!kernel.value.online || fetching) return
|
||||
fetching = true
|
||||
try {
|
||||
snapshot.value = await api.connections()
|
||||
now.value = Date.now()
|
||||
pollFailure.value = ''
|
||||
} catch (error) {
|
||||
pollFailure.value = error instanceof ApiError ? error.message : String(error)
|
||||
} finally {
|
||||
fetching = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件包在 KeepAlive 里:首次挂载和每次切回都走 activated(挂载时它也会触发,
|
||||
// 不能再用 onMounted 起轮询,否则会叠两个定时器),切走时停掉轮询不白打后端
|
||||
onActivated(() => {
|
||||
void refresh()
|
||||
timer = window.setInterval(() => void refresh(), 1000)
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
window.clearInterval(timer)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearInterval(timer)
|
||||
})
|
||||
|
||||
const all = computed(() => snapshot.value?.connections ?? [])
|
||||
const rows = computed(() =>
|
||||
sortConnections(filterConnections(all.value, query.value), sort.value, now.value),
|
||||
)
|
||||
const uses = computed(() => groupByInbound(all.value))
|
||||
|
||||
/* 行高写死,estimateSize 必须和它一致,否则滚动位置算不准 */
|
||||
const ROW_HEIGHT = 48
|
||||
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
const virtualizer = useVirtualizer(
|
||||
computed(() => ({
|
||||
count: rows.value.length,
|
||||
getScrollElement: () => scrollEl.value,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 8,
|
||||
})),
|
||||
)
|
||||
const virtualRows = computed(() => virtualizer.value.getVirtualItems())
|
||||
const totalSize = computed(() => virtualizer.value.getTotalSize())
|
||||
|
||||
async function closeOne(id: string) {
|
||||
if (closing.value.includes(id) || closingAll.value) return
|
||||
closing.value = [...closing.value, id]
|
||||
try {
|
||||
await api.closeConnection(id)
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
closing.value = closing.value.filter((item) => item !== id)
|
||||
}
|
||||
}
|
||||
|
||||
async function closeAllConfirmed() {
|
||||
askAll.value = false
|
||||
if (closingAll.value) return
|
||||
closingAll.value = true
|
||||
try {
|
||||
await api.closeAllConnections()
|
||||
await refresh()
|
||||
toast('已断开所有连接')
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
closingAll.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="head">
|
||||
<div class="sums">
|
||||
<div class="sum">
|
||||
<span class="sum-n">{{ all.length }}</span>
|
||||
<span class="sum-k">连接</span>
|
||||
</div>
|
||||
<div class="sum">
|
||||
<span class="sum-n">{{ formatBytes(snapshot?.uploadTotal ?? 0) }}</span>
|
||||
<span class="sum-k">上传总量</span>
|
||||
</div>
|
||||
<div class="sum">
|
||||
<span class="sum-n">{{ formatBytes(snapshot?.downloadTotal ?? 0) }}</span>
|
||||
<span class="sum-k">下载总量</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tools">
|
||||
<input v-model="query" type="search" class="search" placeholder="按主机筛选" />
|
||||
<button
|
||||
type="button"
|
||||
class="close-all"
|
||||
:disabled="all.length === 0 || closingAll"
|
||||
@click="askAll = true"
|
||||
>
|
||||
全部断开
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="pollFailure" class="failure">
|
||||
<strong class="failure-text">{{ pollFailure }}</strong>
|
||||
<button type="button" class="failure-x" title="关闭提示" @click="pollFailure = ''">
|
||||
<IconClose />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!kernel.online" class="note">内核未连接,看不到当前有哪些连接。</p>
|
||||
<p v-else-if="!snapshot" class="note">读取中</p>
|
||||
<p v-else-if="all.length === 0" class="note">当前没有连接。</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- 这个项目特有的视角:每一路入站(每个容器)现在有多少条连接 -->
|
||||
<div class="uses">
|
||||
<span v-for="use in uses" :key="use.label" class="use">
|
||||
{{ use.label }}
|
||||
<strong>{{ use.count }}</strong> 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="rows.length === 0" class="note">没有主机匹配「{{ query.trim() }}」。</p>
|
||||
|
||||
<div v-else class="table">
|
||||
<!-- 表头在滚动容器里而不是外面:.scroller 声明 overflow-y 后按规范会连带
|
||||
拿到自己的横向滚动上下文,表头留在外面的话窄屏横向滚动时两者会脱节。
|
||||
竖直方向靠 sticky 钉住。 -->
|
||||
<div ref="scrollEl" class="scroller">
|
||||
<div class="rowgrid thead">
|
||||
<span>主机</span>
|
||||
<span>规则</span>
|
||||
<span>出站链</span>
|
||||
<span class="num">上传</span>
|
||||
<button
|
||||
type="button"
|
||||
:class="['th num', sort === 'download' && 'is-on']"
|
||||
@click="sort = 'download'"
|
||||
>
|
||||
下载{{ sort === 'download' ? ' ↓' : '' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['th num', sort === 'duration' && 'is-on']"
|
||||
@click="sort = 'duration'"
|
||||
>
|
||||
持续时间{{ sort === 'duration' ? ' ↓' : '' }}
|
||||
</button>
|
||||
<span>入站</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="spacer" :style="{ height: `${totalSize}px` }">
|
||||
<div
|
||||
v-for="vRow in virtualRows"
|
||||
:key="rows[vRow.index]?.id ?? vRow.index"
|
||||
class="rowgrid row"
|
||||
:style="{ transform: `translateY(${vRow.start}px)` }"
|
||||
>
|
||||
<template v-if="rows[vRow.index]">
|
||||
<span class="cell mono" :title="hostLabel(rows[vRow.index]!.metadata)">
|
||||
{{ hostLabel(rows[vRow.index]!.metadata) }}
|
||||
</span>
|
||||
<span class="cell mono">{{ ruleLabel(rows[vRow.index]!.rule, rows[vRow.index]!.rulePayload) }}</span>
|
||||
<span class="cell mono" :title="chainLabel(rows[vRow.index]!.chains)">
|
||||
{{ chainLabel(rows[vRow.index]!.chains) }}
|
||||
</span>
|
||||
<span class="cell num mono">{{ formatBytes(rows[vRow.index]!.upload) }}</span>
|
||||
<span class="cell num mono">{{ formatBytes(rows[vRow.index]!.download) }}</span>
|
||||
<span class="cell num mono">{{ formatDuration(rows[vRow.index]!.start, now) }}</span>
|
||||
<span class="cell mono">{{ rows[vRow.index]!.metadata.inboundName }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="drop"
|
||||
title="断开这条连接"
|
||||
:disabled="closing.includes(rows[vRow.index]!.id) || closingAll"
|
||||
@click="closeOne(rows[vRow.index]!.id)"
|
||||
>
|
||||
<IconClose />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="askAll"
|
||||
:message="`断开全部 ${String(all.length)} 条连接?正在进行的传输会中断。`"
|
||||
action="全部断开"
|
||||
@close="askAll = false"
|
||||
@confirm="closeAllConfirmed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-4);
|
||||
margin-bottom: var(--s-5);
|
||||
}
|
||||
|
||||
.sums {
|
||||
display: flex;
|
||||
gap: var(--s-6);
|
||||
}
|
||||
|
||||
.sum {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.sum-n {
|
||||
font-size: var(--t-xl);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sum-k {
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 200px;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
}
|
||||
|
||||
.close-all {
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.close-all:hover:not(:disabled) {
|
||||
border-color: var(--c-danger);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
.close-all:disabled {
|
||||
color: var(--c-text-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.failure {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s-3);
|
||||
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-danger-soft);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
margin-bottom: var(--s-4);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.failure-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.failure-x {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-danger);
|
||||
cursor: pointer;
|
||||
}
|
||||
.failure-x:hover {
|
||||
background: color-mix(in srgb, var(--c-danger) 12%, transparent);
|
||||
}
|
||||
|
||||
.note {
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.uses {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-2);
|
||||
margin-bottom: var(--s-4);
|
||||
}
|
||||
|
||||
.use {
|
||||
padding: var(--s-1) var(--s-3);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 999px;
|
||||
background: var(--c-surface);
|
||||
font-size: var(--t-sm);
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
.use strong {
|
||||
color: var(--c-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.table {
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
/* 只负责圆角裁切;横竖两个方向的滚动都归 .scroller 一个容器管 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rowgrid {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(160px, 1.5fr) minmax(120px, 1fr) minmax(160px, 1.5fr)
|
||||
5rem 5rem 6.5rem minmax(90px, 0.8fr) 2rem;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
min-width: 880px;
|
||||
padding: 0 var(--s-4);
|
||||
}
|
||||
|
||||
/* 表头随内容横向滚动(同一个容器),竖直方向 sticky 钉在顶上。
|
||||
行是绝对定位的,要 z-index 才能压住;背景不透明才不会透出滚过的行 */
|
||||
.thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
height: 40px;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-xs);
|
||||
}
|
||||
|
||||
/* 可排序的列表头。用按钮但视觉上仍是表头文字,排序中的那列才显色 */
|
||||
.th {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
}
|
||||
.th:hover {
|
||||
color: var(--c-text);
|
||||
}
|
||||
.th.is-on {
|
||||
color: var(--c-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
overflow: auto;
|
||||
/* 表格最长不顶满一屏:留出头部和汇总条的位置,连接少时随内容缩短 */
|
||||
max-height: calc(100vh - 20rem);
|
||||
/* 表头占 40px,虚拟滚动的可见窗口按纯行高算会偏这 40px——overscan 8 行
|
||||
(384px)足以盖住,不必给虚拟器配 scrollMargin */
|
||||
}
|
||||
|
||||
.spacer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.cell {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.drop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.drop:hover:not(:disabled) {
|
||||
background: var(--c-danger-soft);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
.drop:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 追加 Hosts 的编辑弹窗:配置顶层的 `hosts:` 映射(config.go:454,值可以是
|
||||
* 单个 IP/域名或数组,parseHosts config.go:1131)。
|
||||
*
|
||||
* 在弹窗里随便改,点保存才一次性提交——写配置要走校验和重载,每行失焦
|
||||
* 就提交会把几秒的等待塞进每一次输入。
|
||||
*
|
||||
* 值的往返规则:没动过的行原样保留(数组仍是数组);动过的行按显示文本
|
||||
* 重建,含逗号拆成数组,否则存字符串。
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||
import IconAdd from '~icons/ri/add-line'
|
||||
import IconClose from '~icons/ri/close-line'
|
||||
|
||||
const props = defineProps<{ open: boolean; hosts: ConfigObject }>()
|
||||
|
||||
const emit = defineEmits<{ close: []; save: [hosts: ConfigObject] }>()
|
||||
|
||||
interface Row {
|
||||
key: string
|
||||
value: string
|
||||
/** 值被编辑过。没动过的保存时用 original 原样写回 */
|
||||
dirty: boolean
|
||||
original: unknown
|
||||
}
|
||||
|
||||
const dialog = ref<HTMLDialogElement | null>(null)
|
||||
const rows = ref<Row[]>([])
|
||||
/** 保存被拦下的行号(键空着或和别的行重复) */
|
||||
const badRows = ref<Set<number>>(new Set())
|
||||
|
||||
function display(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (Array.isArray(value)) return value.map(String).join(', ')
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
rows.value = Object.entries(props.hosts).map(([key, value]) => ({
|
||||
key,
|
||||
value: display(value),
|
||||
dirty: false,
|
||||
original: value,
|
||||
}))
|
||||
badRows.value = new Set()
|
||||
dialog.value?.showModal()
|
||||
} else {
|
||||
dialog.value?.close()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function addRow() {
|
||||
rows.value = [...rows.value, { key: '', value: '', dirty: true, original: undefined }]
|
||||
}
|
||||
|
||||
function removeRow(index: number) {
|
||||
rows.value = rows.value.filter((_, i) => i !== index)
|
||||
badRows.value = new Set()
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const bad = new Set<number>()
|
||||
const seen = new Set<string>()
|
||||
const next: ConfigObject = {}
|
||||
for (const [index, row] of rows.value.entries()) {
|
||||
const key = row.key.trim()
|
||||
const raw = row.value.trim()
|
||||
if (key === '' && raw === '') continue
|
||||
if (key === '' || seen.has(key)) {
|
||||
bad.add(index)
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
if (!row.dirty && row.original !== undefined) {
|
||||
next[key] = row.original as ConfigObject[string]
|
||||
} else {
|
||||
next[key] = raw.includes(',')
|
||||
? raw.split(',').map((part) => part.trim()).filter(Boolean)
|
||||
: raw
|
||||
}
|
||||
}
|
||||
if (bad.size > 0) {
|
||||
badRows.value = bad
|
||||
return
|
||||
}
|
||||
emit('save', next)
|
||||
}
|
||||
|
||||
/** 点在遮罩上就关闭。内容区的点击 target 是里面的元素,到不了 dialog 本身。 */
|
||||
function onBackdrop(event: MouseEvent) {
|
||||
if (event.target === dialog.value) emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialog"
|
||||
class="dlg"
|
||||
@click="onBackdrop"
|
||||
@close="emit('close')"
|
||||
@cancel.prevent="emit('close')"
|
||||
>
|
||||
<div class="body">
|
||||
<header class="head">
|
||||
<h2>追加 Hosts</h2>
|
||||
<button type="button" class="add" @click="addRow"><IconAdd /> 添加</button>
|
||||
</header>
|
||||
|
||||
<p v-if="rows.length === 0" class="faint">
|
||||
没有条目。hosts 把域名固定解析到指定的 IP 或别名,值里用逗号分隔可以写多个。
|
||||
</p>
|
||||
<div v-else class="list">
|
||||
<div v-for="(row, i) in rows" :key="i" :class="['hrow', badRows.has(i) && 'is-bad']">
|
||||
<input
|
||||
v-model="row.key"
|
||||
class="input mono"
|
||||
type="text"
|
||||
placeholder="域名,如 router.lan"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<input
|
||||
v-model="row.value"
|
||||
class="input mono grow"
|
||||
type="text"
|
||||
placeholder="IP 或别名,逗号分隔多个"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
@input="row.dirty = true"
|
||||
/>
|
||||
<button type="button" class="x" title="删除这一行" @click="removeRow(i)">
|
||||
<IconClose />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="foot">
|
||||
<button type="button" class="btn" @click="emit('close')">取消</button>
|
||||
<button type="button" class="btn go" @click="submit">保存</button>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dlg {
|
||||
width: min(560px, 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);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: var(--s-5);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--s-4);
|
||||
}
|
||||
|
||||
.head h2 {
|
||||
font-size: var(--t-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-1) var(--s-2);
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.add:hover {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.add svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.faint {
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
line-height: var(--line);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-strong) transparent;
|
||||
}
|
||||
|
||||
.hrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 180px;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-bg);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.is-bad .input {
|
||||
border-color: var(--c-danger);
|
||||
}
|
||||
|
||||
.x {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
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);
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--s-3);
|
||||
margin-top: var(--s-5);
|
||||
}
|
||||
|
||||
.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:hover {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.go {
|
||||
border-color: transparent;
|
||||
background: var(--c-accent);
|
||||
color: var(--c-bg);
|
||||
}
|
||||
.go:hover {
|
||||
border-color: transparent;
|
||||
color: var(--c-bg);
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
+738
-92
File diff suppressed because it is too large
Load Diff
+47
-30
@@ -12,6 +12,8 @@ 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 SelectBox from '../components/SelectBox.vue'
|
||||
import type { SelectItem } from '../components/SelectBox.vue'
|
||||
import IconClose from '~icons/ri/close-line'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -42,6 +44,11 @@ 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 typeOptions = computed<SelectItem[]>(() => [
|
||||
...COMMON_TYPES.map((t) => ({ value: t, group: '客户端入站' })),
|
||||
...serverTypes.value.map((t) => ({ value: t, group: '服务端入站' })),
|
||||
])
|
||||
|
||||
/** 当前类型的必填字段,公共字段之外的那些。 */
|
||||
const requiredExtras = computed<FieldSpec[]>(
|
||||
() => props.fields?.types[type.value]?.fields.filter((f) => f.required && !f.key.includes('.')) ?? [],
|
||||
@@ -91,6 +98,11 @@ watch(
|
||||
)
|
||||
|
||||
/** 把表单值转成配置里该有的形状。空字段不写,避免生成一堆没意义的键。 */
|
||||
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
|
||||
function onBackdrop(event: MouseEvent) {
|
||||
if (event.target === dialog.value) emit('close')
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!canSubmit.value) return
|
||||
const entry: ConfigObject = {
|
||||
@@ -120,10 +132,16 @@ function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')">
|
||||
<dialog
|
||||
ref="dialog"
|
||||
class="dlg"
|
||||
@click="onBackdrop"
|
||||
@close="emit('close')"
|
||||
@cancel.prevent="emit('close')"
|
||||
>
|
||||
<form method="dialog" @submit.prevent="submit">
|
||||
<header class="dlg-head">
|
||||
<h2>{{ editing ? '编辑入站' : '新增入站' }}</h2>
|
||||
<h2>{{ editing ? '编辑路由' : '新增路由' }}</h2>
|
||||
<button type="button" class="x" @click="emit('close')"><IconClose /></button>
|
||||
</header>
|
||||
|
||||
@@ -136,20 +154,15 @@ function submit() {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="row">
|
||||
<!-- SelectBox 的行不用 label 包:没有 for 的 label 绑定第一个可关联控件,
|
||||
正好是它的开关按钮,悬停行内任何位置都会点亮它、点空白处会展开面板 -->
|
||||
<div 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>
|
||||
<SelectBox v-model="type" :options="typeOptions" aria-label="类型" />
|
||||
<span v-if="typeNote" class="note">{{ typeNote }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="row">
|
||||
<span class="k">端口</span>
|
||||
@@ -167,17 +180,27 @@ function submit() {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label v-for="f in requiredExtras" :key="f.key" class="row">
|
||||
<div 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" />
|
||||
<SelectBox
|
||||
v-if="f.enum"
|
||||
v-model="extras[f.key]"
|
||||
:options="f.enum.map((opt) => ({ value: String(opt) }))"
|
||||
placeholder="请选择"
|
||||
:aria-label="f.key"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
v-model="extras[f.key]"
|
||||
class="mono"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:aria-label="f.key"
|
||||
/>
|
||||
<span v-if="f.note" class="note">{{ f.note }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="dlg-foot">
|
||||
@@ -218,8 +241,9 @@ function submit() {
|
||||
}
|
||||
|
||||
.x {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
@@ -261,8 +285,7 @@ function submit() {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
input {
|
||||
width: 100%;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
@@ -270,12 +293,6 @@ select {
|
||||
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);
|
||||
@@ -306,7 +323,7 @@ select:focus {
|
||||
.btn-go {
|
||||
background: var(--c-accent);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
color: var(--c-bg);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 从 subconverter 的 ini 导入规则组。
|
||||
*
|
||||
* 粘贴文本或选本地文件(浏览器 File API 读内容)。只解析 ruleset= 行:
|
||||
* 清单来源(URL 或路径)每行生成一个规则组声明加一条 RULE-SET 规则骨架,
|
||||
* []GEOIP,CN 这类内联写法直接生成规则,[]FINAL 生成兜底规则。
|
||||
* custom_proxy_group= 是 subconverter 自己的建组指令,和本项目的专属组体系
|
||||
* 冲突,不导入。
|
||||
*
|
||||
* ini 里的目标组名(「🎯 全球直连」这类)在这份配置里不一定存在,逐个让用户
|
||||
* 映射到实际出站;没映射的行不导入。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { parseIniRulesets, planIniImport } from '@shared/rules.ts'
|
||||
import type { IniImportPlan } from '@shared/rules.ts'
|
||||
import SelectBox from '../components/SelectBox.vue'
|
||||
import DropFile from '../components/DropFile.vue'
|
||||
import IconClose from '~icons/ri/close-line'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
/** 映射候选:内置出站、策略组、自有线路 */
|
||||
outboundNames: string[]
|
||||
/** 已声明的规则组名,生成新名字时避开 */
|
||||
existingProviderNames: string[]
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: []; submit: [plan: IniImportPlan] }>()
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
url: '规则组',
|
||||
file: '规则组',
|
||||
geoip: 'GEOIP 规则',
|
||||
geosite: 'GEOSITE 规则',
|
||||
final: '兜底规则',
|
||||
}
|
||||
|
||||
const dialog = ref<HTMLDialogElement | null>(null)
|
||||
const text = ref('')
|
||||
const mapping = ref<Record<string, string>>({})
|
||||
|
||||
const entries = computed(() => parseIniRulesets(text.value))
|
||||
|
||||
const outboundOptions = computed(() => props.outboundNames.map((name) => ({ value: name })))
|
||||
|
||||
/** ini 里出现过的目标组名,按出现顺序去重。每个都要映射到一个实际出站。 */
|
||||
const targets = computed(() => [...new Set(entries.value.map((e) => e.target))])
|
||||
|
||||
const plan = computed(() =>
|
||||
planIniImport(entries.value, mapping.value, new Set(props.existingProviderNames)),
|
||||
)
|
||||
|
||||
const unmapped = computed(() => targets.value.filter((t) => !mapping.value[t]))
|
||||
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
entries.value.length > 0 &&
|
||||
unmapped.value.length === 0 &&
|
||||
Object.keys(plan.value.providers).length +
|
||||
plan.value.rules.length +
|
||||
plan.value.finals.length >
|
||||
0 &&
|
||||
!props.saving,
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
text.value = ''
|
||||
mapping.value = {}
|
||||
dialog.value?.showModal()
|
||||
} else {
|
||||
dialog.value?.close()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 新解析出来的目标组名补进映射表,消失的清掉
|
||||
watch(targets, (list) => {
|
||||
const next: Record<string, string> = {}
|
||||
for (const target of list) next[target] = mapping.value[target] ?? ''
|
||||
mapping.value = next
|
||||
})
|
||||
|
||||
function submit() {
|
||||
if (!canSubmit.value) return
|
||||
emit('submit', plan.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Esc 关弹窗走这里。文件选择框被取消时 input 的 cancel 事件会冒泡到 dialog,
|
||||
* 不是发给 dialog 自己的一律不理——否则取消选文件会把整个弹窗带关。
|
||||
*/
|
||||
function onCancel(event: Event) {
|
||||
if (event.target !== dialog.value) return
|
||||
event.preventDefault()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
|
||||
function onBackdrop(event: MouseEvent) {
|
||||
if (event.target === dialog.value) emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialog"
|
||||
class="dlg"
|
||||
@click="onBackdrop"
|
||||
@close="emit('close')"
|
||||
@cancel="onCancel"
|
||||
>
|
||||
<form method="dialog" @submit.prevent="submit">
|
||||
<header class="dlg-head">
|
||||
<h2>导入 ini</h2>
|
||||
<button type="button" class="x" @click="emit('close')"><IconClose /></button>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<!-- 不能用 label 包:没有 for 的 label 绑定内部第一个可关联控件,
|
||||
正好是 DropFile 的按钮——悬停 textarea 会点亮投放区,点到行内
|
||||
空白处还会弹出文件选择框 -->
|
||||
<div class="row">
|
||||
<span class="k">内容</span>
|
||||
<span class="v">
|
||||
<DropFile accept=".ini,.conf,.txt" @load="(_, content) => (text = content)" />
|
||||
<textarea
|
||||
v-model="text"
|
||||
class="mono"
|
||||
rows="8"
|
||||
placeholder="或把 ini 文本粘贴到这里"
|
||||
spellcheck="false"
|
||||
aria-label="ini 内容"
|
||||
></textarea>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="entries.length > 0">
|
||||
<div class="row">
|
||||
<span class="k">条目</span>
|
||||
<ol class="found">
|
||||
<li v-for="(entry, i) in entries" :key="i">
|
||||
<span class="mono src" :title="entry.source || entry.target">
|
||||
{{ entry.kind === 'final' ? '[]FINAL' : entry.source }}
|
||||
</span>
|
||||
<span class="kind">{{ KIND_LABELS[entry.kind] }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- 同上:SelectBox 的面板开关也是 button,label 会把悬停和点击都转给它 -->
|
||||
<div v-for="target in targets" :key="target" class="row">
|
||||
<span class="k map-name" :title="target">{{ target }}</span>
|
||||
<span class="v">
|
||||
<SelectBox
|
||||
v-model="mapping[target]"
|
||||
:options="outboundOptions"
|
||||
placeholder="映射到…"
|
||||
:aria-label="`${target} 映射到`"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</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(560px, 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: flex;
|
||||
align-items: center;
|
||||
justify-content: 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(64vh, 560px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 7rem 1fr;
|
||||
gap: var(--s-4);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.k {
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
textarea {
|
||||
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);
|
||||
resize: vertical;
|
||||
font-size: var(--t-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.found {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
max-height: 10rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.found li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-3);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.src {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kind {
|
||||
flex: none;
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-xs);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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-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>
|
||||
@@ -0,0 +1,265 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 请求页:内核的日志流,一条一条「这次请求匹配了哪条规则、走了哪个出站」。
|
||||
*
|
||||
* 收集在 web/log-store.ts 的常驻单例里——日志跨页面留存,切走再回来不清空。
|
||||
* 这个组件只管呈现:筛选、暂停、清空、跟随滚动。
|
||||
*/
|
||||
import { computed, nextTick, onActivated, onMounted, ref, watch } from 'vue'
|
||||
import { kernel } from '../runtime'
|
||||
import { LEVELS } from '../logs'
|
||||
import { clearLogs, ended, failure, level, paused, revision, rows } from '../log-store'
|
||||
import SelectBox from '../components/SelectBox.vue'
|
||||
import IconPause from '~icons/ri/pause-line'
|
||||
import IconPlay from '~icons/ri/play-line'
|
||||
import IconClear from '~icons/ri/delete-bin-line'
|
||||
import IconClose from '~icons/ri/close-line'
|
||||
|
||||
const query = ref('')
|
||||
|
||||
const levelOptions = LEVELS.map((l) => ({ value: l }))
|
||||
|
||||
/** 级别标签的着色。只染级别那一小块,整行染色在低密度的设计里很吵 */
|
||||
const LEVEL_CLASS: Record<string, string> = {
|
||||
debug: 'lv-debug',
|
||||
info: 'lv-info',
|
||||
warning: 'lv-warn',
|
||||
error: 'lv-error',
|
||||
}
|
||||
|
||||
const listEl = ref<HTMLElement | null>(null)
|
||||
/** 是否跟随最新条目。用户手动上滚时关掉,滚回底部再开 */
|
||||
let follow = true
|
||||
|
||||
function onScroll() {
|
||||
const el = listEl.value
|
||||
if (!el) return
|
||||
// 阈值必须小于一行的高度(约 25px),否则向上滚不足一行仍算「在底部」,
|
||||
// 新日志会把人拽回去。8px 只容浏览器的取整误差
|
||||
follow = el.scrollTop + el.clientHeight >= el.scrollHeight - 8
|
||||
}
|
||||
|
||||
async function scrollToBottom(force = false) {
|
||||
if (!follow && !force) return
|
||||
await nextTick()
|
||||
const el = listEl.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
||||
// 收集在后台持续进行,新一批刷进来时跟着滚
|
||||
watch(revision, () => void scrollToBottom())
|
||||
|
||||
// 切回这一页时接上离开时的进度,直接落到最新一条
|
||||
onMounted(() => void scrollToBottom(true))
|
||||
// 组件包在 KeepAlive 里,切走期间元素脱离文档、scrollHeight 不可靠,
|
||||
// 跟随模式的自动滚动可能把位置写歪;切回来重新贴底(手动上滚过的不动)
|
||||
onActivated(() => void scrollToBottom())
|
||||
|
||||
const visible = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
if (!q) return rows.value
|
||||
return rows.value.filter((row) => row.payload.toLowerCase().includes(q))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="head">
|
||||
<SelectBox v-model="level" class="lvl" :options="levelOptions" aria-label="日志级别" />
|
||||
|
||||
<input v-model="query" class="ctl search" type="search" placeholder="筛选" />
|
||||
|
||||
<button class="ctl act" type="button" @click="paused = !paused">
|
||||
<IconPlay v-if="paused" />
|
||||
<IconPause v-else />
|
||||
<span>{{ paused ? '继续' : '暂停' }}</span>
|
||||
</button>
|
||||
|
||||
<button class="ctl act" type="button" @click="clearLogs">
|
||||
<IconClear />
|
||||
<span>清空日志</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="failure" class="failure">
|
||||
<strong class="failure-text">{{ failure }}</strong>
|
||||
<button type="button" class="failure-x" title="关闭提示" @click="failure = ''">
|
||||
<IconClose />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref="listEl" class="log" @scroll="onScroll">
|
||||
<div v-for="row in visible" :key="row.id" class="row">
|
||||
<span class="time mono">{{ row.time }}</span>
|
||||
<span :class="['lv', LEVEL_CLASS[row.type] ?? 'lv-info']">{{ row.type }}</span>
|
||||
<span class="msg mono">{{ row.payload }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="!kernel.online" class="state">
|
||||
{{ rows.length > 0 ? '内核未连接,日志不再更新。' : '内核未连接,无法读取日志。' }}
|
||||
</p>
|
||||
<p v-else-if="rows.length === 0 && !failure && !ended" class="state">
|
||||
{{
|
||||
level === 'silent'
|
||||
? '级别为 silent 时内核不输出日志。'
|
||||
: '暂无日志。没有流量经过内核时不会产生请求记录。'
|
||||
}}
|
||||
</p>
|
||||
<p v-else-if="visible.length === 0 && rows.length > 0" class="state">
|
||||
没有包含「{{ query.trim() }}」的日志。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="ended && !failure" class="notice">日志流已中断,将自动重连。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 日志区自己滚动、撑满视口剩余高度:自动跟随需要它有自己的滚动容器 */
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--s-6) - var(--s-7));
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
margin-bottom: var(--s-4);
|
||||
}
|
||||
|
||||
/* 页面底色上的控件要比底色浮一档 */
|
||||
.lvl {
|
||||
width: 130px;
|
||||
flex: none;
|
||||
--select-bg: var(--c-surface);
|
||||
}
|
||||
|
||||
.ctl {
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.act {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.act:hover {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
/* 两个动作靠右,和左边的筛选拉开 */
|
||||
.act:first-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.failure {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s-3);
|
||||
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-danger-soft);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
margin-bottom: var(--s-4);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.failure-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.failure-x {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-danger);
|
||||
cursor: pointer;
|
||||
}
|
||||
.failure-x:hover {
|
||||
background: color-mix(in srgb, var(--c-danger) 12%, transparent);
|
||||
}
|
||||
|
||||
.log {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
gap: var(--s-3);
|
||||
align-items: baseline;
|
||||
padding: 2px 0;
|
||||
font-size: var(--t-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
.lv {
|
||||
padding: 0 var(--s-2);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--t-xs);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lv-debug {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
.lv-info {
|
||||
background: var(--c-accent-soft);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
.lv-warn {
|
||||
background: var(--c-warn-soft);
|
||||
color: var(--c-warn);
|
||||
}
|
||||
.lv-error {
|
||||
background: var(--c-danger-soft);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.msg {
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.state {
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
padding: var(--s-3) 0;
|
||||
}
|
||||
|
||||
.notice {
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
margin-top: var(--s-3);
|
||||
}
|
||||
</style>
|
||||
+244
-74
@@ -7,61 +7,79 @@
|
||||
* 运行时动作(切换选中、测延迟)不经过它,因为它们不写文件也不需要校验。
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { collectInbounds } from '@shared/inbounds.ts'
|
||||
import { collectInbounds, collectOutboundNames } from '@shared/inbounds.ts'
|
||||
import type { Inbound } from '@shared/inbounds.ts'
|
||||
import {
|
||||
applySetup,
|
||||
applySubRules,
|
||||
chainGroupName,
|
||||
groupUses,
|
||||
isGenerated,
|
||||
panelGroups,
|
||||
proxyGroupName,
|
||||
pruneGenerated,
|
||||
readSubRules,
|
||||
sharedPeers,
|
||||
syncGenerated,
|
||||
readSetup,
|
||||
upgradeLegacyInbound,
|
||||
userProviders,
|
||||
} from '@shared/inbound-groups.ts'
|
||||
import { missingLanRules, moveRule, ruleProvidersOf } from '@shared/rules.ts'
|
||||
import type { OutboundSetup } from '@shared/inbound-groups.ts'
|
||||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||
import { ApiError, api } from '../api'
|
||||
import { toast, toastError } from '../toast'
|
||||
import type { ListenerFields } from '../api'
|
||||
import type { GroupView } from '../runtime'
|
||||
import { optimisticSelect, providerLines, reloadRuntime, runtime } from '../runtime'
|
||||
import { useDelayTest } from '../delay-test'
|
||||
import { density } from '../prefs'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import InboundList from '../components/InboundList.vue'
|
||||
import InboundDetail from './InboundDetail.vue'
|
||||
import InboundForm from './InboundForm.vue'
|
||||
import RuleForm from './RuleForm.vue'
|
||||
import IconAdd from '~icons/ri/add-line'
|
||||
|
||||
const props = defineProps<{ data: ConfigObject; revision: string }>()
|
||||
const props = defineProps<{
|
||||
data: ConfigObject
|
||||
revision: string
|
||||
/** 外部(仪表盘的路由行)指名要选中的一路。消费后通过 focus-done 请外壳清掉 */
|
||||
focus?: string | null
|
||||
}>()
|
||||
|
||||
/** 配置文件变了,需要重读整份配置。运行时的变化不走这里。 */
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const emit = defineEmits<{ saved: []; 'focus-done': [] }>()
|
||||
|
||||
const inbounds = computed(() => collectInbounds(props.data))
|
||||
const subscriptions = computed(() => userProviders(props.data))
|
||||
|
||||
/** 刚接管一路入站时给它什么:订阅全给上,不筛。三个组由 applySetup 一并建出来。 */
|
||||
/**
|
||||
* 接管一路已有入站时给它什么:订阅全给上,不筛。三个组由 applySetup 一并建出来。
|
||||
* 来源不留空,是因为这一路本来就在把流量送到某个出站上,接管不该顺手把它改成直连。
|
||||
*/
|
||||
const defaultSetup = (): OutboundSetup => ({
|
||||
proxy: { sources: [...subscriptions.value], filter: null },
|
||||
chain: null,
|
||||
})
|
||||
|
||||
/**
|
||||
* 新增入站时给它什么:一个订阅都不预设。全新的端口还没有人在用,替用户挑一批订阅
|
||||
* 只会让他保存完再回来取消勾选。来源为空的专属组只有 DIRECT(见 applySetup),
|
||||
* 用户在详情页的配置里选好订阅再保存。
|
||||
*/
|
||||
const emptySetup = (): OutboundSetup => ({ proxy: { sources: [], filter: null }, chain: null })
|
||||
|
||||
const selectedId = ref<string | null>(null)
|
||||
const saving = ref(false)
|
||||
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 { testing, test } = useDelayTest(
|
||||
() => void reloadRuntime(),
|
||||
(message) => {
|
||||
notice.value = message
|
||||
},
|
||||
// 「某条线路测不通」是关于线路的结论,不是操作失败,走中性的轻提示
|
||||
(message) => toast(message, { kind: 'info' }),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -81,6 +99,18 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 带着指名跳进来时选中那一路。immediate 是因为组件随页签切换重建,
|
||||
// watch 挂上时 focus 早就是最终值了,不 immediate 就永远等不到变化
|
||||
watch(
|
||||
() => props.focus,
|
||||
(id) => {
|
||||
if (!id) return
|
||||
if (inbounds.value.some((i) => i.id === id)) selectedId.value = id
|
||||
emit('focus-done')
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const selected = computed(() => inbounds.value.find((i) => i.id === selectedId.value) ?? null)
|
||||
const takenNames = computed(() => inbounds.value.map((i) => i.name))
|
||||
|
||||
@@ -91,7 +121,12 @@ const panel = computed(() =>
|
||||
: { groups: [], shared: false },
|
||||
)
|
||||
|
||||
/** 共用的组是别人的,改不了它的来源,所以高级配置也不给。 */
|
||||
/** 选中这一路落在共用组上时,还有哪些入站也走同一个组。决定「共用」标记显不显示。 */
|
||||
const sharedWith = computed(() =>
|
||||
selected.value ? sharedPeers(props.data, runtime.value, inbounds.value, selected.value) : [],
|
||||
)
|
||||
|
||||
/** 共用的组是别人的,改不了它的来源,所以配置表单也不给。 */
|
||||
const setup = computed<OutboundSetup | null>(() =>
|
||||
selected.value && !panel.value.shared ? readSetup(props.data, selected.value.name) : null,
|
||||
)
|
||||
@@ -105,34 +140,45 @@ const chainView = computed<GroupView | null>(() => {
|
||||
|
||||
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
|
||||
|
||||
/** 所有改配置文件的动作都从这里走。 */
|
||||
async function commit(raw: ConfigObject, success: string) {
|
||||
/** 所有改配置文件的动作都从这里走。反馈走右上角轻提示,返回值告知成败。 */
|
||||
async function commit(raw: ConfigObject, success: string): Promise<boolean> {
|
||||
// 先把生成的东西按当前规则重建一遍,再清掉没人再引用的。不清理的话,改几次文件里就
|
||||
// 攒下一堆用户从没主动建过的东西,而没人用的订阅副本还会按 interval 继续联网。
|
||||
const next = pruneGenerated(syncGenerated(raw, collectInbounds(raw)))
|
||||
saving.value = true
|
||||
failure.value = null
|
||||
done.value = ''
|
||||
notice.value = ''
|
||||
try {
|
||||
await api.applyConfig(next, props.revision)
|
||||
done.value = success
|
||||
const result = await api.applyConfig(next, props.revision)
|
||||
// 没重载有两种情况(内容没有变化、内核不在线只写了文件),都要如实说,不能只报成功
|
||||
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
|
||||
emit('saved')
|
||||
return true
|
||||
} catch (error) {
|
||||
failure.value =
|
||||
error instanceof ApiError
|
||||
? { message: error.message, hint: error.hint }
|
||||
: { message: String(error), hint: '' }
|
||||
toastError(error)
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存高级配置:改的是这一路的组从哪些订阅取成员、筛掉哪些。 */
|
||||
async function saveSetup(next: OutboundSetup) {
|
||||
/** 保存这一路的配置:组从哪些订阅取成员、筛掉哪些、开不开链式代理。 */
|
||||
async function saveSetup(next: OutboundSetup, picks: { proxy: string | null; chain: string | null }) {
|
||||
const target = selected.value
|
||||
if (!target || saving.value) return
|
||||
await commit(applySetup(clone(), target.name, next), `${target.name} 的代理配置已保存`)
|
||||
const ok = await commit(applySetup(clone(), target.name, next), `${target.name} 的代理配置已保存`)
|
||||
if (!ok) return
|
||||
// 草稿态里预选的线路在保存成功、组按新配置就位之后逐个选上。这是运行时动作,
|
||||
// 失败不影响已保存的配置:组停在内核的默认选中上,刷新后显示的就是真实状态。
|
||||
const picked: Array<[string, string | null]> = [
|
||||
[proxyGroupName(target.name), picks.proxy],
|
||||
[chainGroupName(target.name), picks.chain],
|
||||
]
|
||||
let selectedAny = false
|
||||
for (const [group, member] of picked) {
|
||||
if (!member) continue
|
||||
await api.selectProxy(group, member).catch(() => {})
|
||||
selectedAny = true
|
||||
}
|
||||
if (selectedAny) await reloadRuntime()
|
||||
}
|
||||
|
||||
/** 把一路还没接管的入站改成专属代理组模式。 */
|
||||
@@ -142,6 +188,30 @@ async function adopt() {
|
||||
await commit(applySetup(clone(), target.name, defaultSetup()), `${target.name} 已改为逐条选线路`)
|
||||
}
|
||||
|
||||
/** 待确认升级的那一路 legacy 入站。非 null 时确认对话框打开着。 */
|
||||
const upgrading = ref<Inbound | null>(null)
|
||||
|
||||
function askUpgrade() {
|
||||
const target = selected.value
|
||||
if (!target || target.legacyKey === null || saving.value) return
|
||||
upgrading.value = target
|
||||
}
|
||||
|
||||
async function upgradeConfirmed() {
|
||||
const target = upgrading.value
|
||||
upgrading.value = null
|
||||
if (!target || saving.value) return
|
||||
const next = upgradeLegacyInbound(clone(), target)
|
||||
if (!next) {
|
||||
toast(`已有名为 ${target.name} 的入站,无法升级`, { kind: 'error' })
|
||||
return
|
||||
}
|
||||
// 新入站追加在 listeners 末尾,升级成功后把选中跟过去,否则选中会被重置到第一行
|
||||
const index = Array.isArray(props.data.listeners) ? props.data.listeners.length : 0
|
||||
const ok = await commit(next, `${target.name} 已改为命名入站`)
|
||||
if (ok) selectedId.value = `listener:${String(index)}`
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
formEditing.value = null
|
||||
formOpen.value = true
|
||||
@@ -165,11 +235,11 @@ async function submitForm(entry: ConfigObject) {
|
||||
// 新增的入站直接进专属组模式。否则新建完看到的是一块「这一路不由 Patchbay 管理」
|
||||
// 的提示,还得再点一次才能用。
|
||||
const name = String(entry.name)
|
||||
const withGroup = editingIndex === null ? applySetup(next, name, defaultSetup()) : next
|
||||
const withGroup = editingIndex === null ? applySetup(next, name, emptySetup()) : next
|
||||
|
||||
await commit(withGroup, editingIndex === null ? `已新增 ${name}` : '已保存')
|
||||
const ok = await commit(withGroup, editingIndex === null ? `已新增 ${name}` : '已保存')
|
||||
// 失败时不能关对话框——关了再打开就是空表单,用户填的名称、端口、类型全没了
|
||||
if (failure.value) return
|
||||
if (!ok) return
|
||||
formOpen.value = false
|
||||
if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
|
||||
}
|
||||
@@ -178,24 +248,108 @@ async function submitForm(entry: ConfigObject) {
|
||||
async function selectMember(group: string, member: string) {
|
||||
if (saving.value) return
|
||||
optimisticSelect(group, member)
|
||||
failure.value = null
|
||||
done.value = ''
|
||||
notice.value = ''
|
||||
try {
|
||||
await api.selectProxy(group, member)
|
||||
await reloadRuntime()
|
||||
} catch (error) {
|
||||
failure.value = {
|
||||
message: error instanceof ApiError ? error.message : String(error),
|
||||
toast(error instanceof ApiError ? error.message : String(error), {
|
||||
kind: 'error',
|
||||
hint: '该操作仅修改内核运行状态,配置文件未被修改。',
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSelected() {
|
||||
/* ── 附加规则 ─────────────────────────────────── */
|
||||
|
||||
/** 选中这一路的附加规则(不含表底的兜底 MATCH)。 */
|
||||
const subRules = computed(() =>
|
||||
selected.value && setup.value !== null ? readSubRules(props.data, selected.value.name) : [],
|
||||
)
|
||||
|
||||
/** RULE-SET 载荷的候选,给规则表单用 */
|
||||
const ruleProviderNames = computed(() => Object.keys(ruleProvidersOf(props.data)))
|
||||
|
||||
/**
|
||||
* 附加规则目标的建议:日常用的两个内置出站、这一路自己的专属组(「这个载荷走
|
||||
* 本路代理」写的就是它),再加用户自己建的策略组和自有线路。别的入站的专属组不列。
|
||||
*/
|
||||
const subTargetOptions = computed(() => {
|
||||
const outs = collectOutboundNames(props.data)
|
||||
return [
|
||||
'DIRECT',
|
||||
'REJECT',
|
||||
...(selected.value ? [proxyGroupName(selected.value.name)] : []),
|
||||
...outs.groups.filter((name) => !isGenerated(name)),
|
||||
...outs.ownProxies,
|
||||
]
|
||||
})
|
||||
|
||||
const subFormOpen = ref(false)
|
||||
/** 编辑时是下标,新增时是 null */
|
||||
const subEditing = ref<number | null>(null)
|
||||
|
||||
function openSubAdd() {
|
||||
subEditing.value = null
|
||||
subFormOpen.value = true
|
||||
}
|
||||
|
||||
function openSubEdit(index: number) {
|
||||
subEditing.value = index
|
||||
subFormOpen.value = true
|
||||
}
|
||||
|
||||
/** 所有附加规则的改动都收敛到这里:算出新表,整表写回。 */
|
||||
async function commitSubRules(next: string[], success: string): Promise<boolean> {
|
||||
const target = selected.value
|
||||
if (!target || saving.value) return false
|
||||
return commit(applySubRules(clone(), target.name, next), success)
|
||||
}
|
||||
|
||||
async function submitSubRule(ruleText: string) {
|
||||
const editing = subEditing.value
|
||||
const next =
|
||||
editing === null
|
||||
? [...subRules.value, ruleText]
|
||||
: subRules.value.map((rule, i) => (i === editing ? ruleText : rule))
|
||||
const ok = await commitSubRules(next, editing === null ? '已新增附加规则' : '已保存')
|
||||
// 失败时不能关对话框——关了再打开就是空表单
|
||||
if (ok) subFormOpen.value = false
|
||||
}
|
||||
|
||||
async function moveSubRule(index: number, delta: -1 | 1) {
|
||||
if (saving.value) return
|
||||
await commitSubRules(moveRule(subRules.value, index, delta), '顺序已保存')
|
||||
}
|
||||
|
||||
// 删除不设二次确认:删错了有撤回,规则原文放进提示里让人看清删掉的是哪条
|
||||
async function removeSubRule(index: number) {
|
||||
const rule = subRules.value[index]
|
||||
if (rule === undefined) return
|
||||
await commitSubRules(
|
||||
subRules.value.filter((_, i) => i !== index),
|
||||
`已删除附加规则 [ ${rule} ]`,
|
||||
)
|
||||
}
|
||||
|
||||
async function addLanSubRules() {
|
||||
const lan = missingLanRules(subRules.value)
|
||||
if (lan.length === 0) return
|
||||
await commitSubRules([...subRules.value, ...lan], `已加入 ${lan.length} 条内网直连规则`)
|
||||
}
|
||||
|
||||
/** 待确认删除的那一路。非 null 时确认对话框打开着。 */
|
||||
const removing = ref<Inbound | null>(null)
|
||||
|
||||
function askRemove() {
|
||||
const target = selected.value
|
||||
if (!target || target.listenerIndex === null || saving.value) return
|
||||
if (!confirm(`删除入站「${target.name}」?端口 ${target.port} 将停止监听。`)) return
|
||||
removing.value = target
|
||||
}
|
||||
|
||||
async function removeConfirmed() {
|
||||
const target = removing.value
|
||||
removing.value = null
|
||||
if (!target || target.listenerIndex === null || saving.value) return
|
||||
|
||||
const next = clone()
|
||||
const listeners = (next.listeners as ConfigObject[]).filter((_, i) => i !== target.listenerIndex)
|
||||
@@ -226,19 +380,13 @@ async function removeSelected() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 侧栏收拢是侧栏自己的事,页面内容不跟着变形 -->
|
||||
<button class="add" type="button" :disabled="saving" @click="openAdd">
|
||||
<IconAdd />
|
||||
<span v-if="density === 'full'">新增</span>
|
||||
<span>新增</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="failure" class="failure">
|
||||
<strong>{{ failure.message }}</strong>
|
||||
<span v-if="failure.hint">{{ failure.hint }}</span>
|
||||
</div>
|
||||
<p v-else-if="done" class="done">{{ done }}</p>
|
||||
<p v-else-if="notice" class="notice">{{ notice }}</p>
|
||||
|
||||
<div class="split">
|
||||
<InboundList v-model:selected="selectedId" :inbounds="inbounds" />
|
||||
|
||||
@@ -246,17 +394,25 @@ async function removeSelected() {
|
||||
v-if="selected"
|
||||
:inbound="selected"
|
||||
:panel="panel"
|
||||
:shared-with="sharedWith"
|
||||
:chain="chainView"
|
||||
:subscriptions="subscriptions"
|
||||
:setup="setup"
|
||||
:sub-rules="subRules"
|
||||
:testing="testing"
|
||||
:saving="saving"
|
||||
@edit="openEdit"
|
||||
@remove="removeSelected"
|
||||
@remove="askRemove"
|
||||
@upgrade="askUpgrade"
|
||||
@select="selectMember"
|
||||
@test="test"
|
||||
@save="saveSetup"
|
||||
@adopt="adopt"
|
||||
@sub-add="openSubAdd"
|
||||
@sub-edit="openSubEdit"
|
||||
@sub-remove="removeSubRule"
|
||||
@sub-move="moveSubRule"
|
||||
@sub-lan="addLanSubRules"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -271,6 +427,38 @@ async function removeSelected() {
|
||||
@close="formOpen = false"
|
||||
@submit="submitForm"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="removing !== null"
|
||||
:message="removing ? `删除入站「${removing.name}」?端口 ${removing.port} 将停止监听。` : ''"
|
||||
action="删除"
|
||||
@close="removing = null"
|
||||
@confirm="removeConfirmed"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="upgrading !== null"
|
||||
:message="
|
||||
upgrading
|
||||
? `将「${upgrading.name}」改为命名入站?端口不变,此后它的流量改查自己的附加规则` +
|
||||
`(全局规则中 MATCH 之前的部分会复制过来),可以单独选订阅和线路。`
|
||||
: ''
|
||||
"
|
||||
action="升级"
|
||||
@close="upgrading = null"
|
||||
@confirm="upgradeConfirmed"
|
||||
/>
|
||||
|
||||
<RuleForm
|
||||
:open="subFormOpen"
|
||||
:editing="subEditing === null ? null : { index: subEditing, raw: subRules[subEditing]! }"
|
||||
:provider-names="ruleProviderNames"
|
||||
:target-options="subTargetOptions"
|
||||
:saving="saving"
|
||||
@close="subFormOpen = false"
|
||||
@submit="submitSubRule"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -325,31 +513,7 @@ async function removeSelected() {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.failure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-danger-soft);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
margin-bottom: var(--s-4);
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 1fr);
|
||||
@@ -357,7 +521,13 @@ async function removeSelected() {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
/* 按内容区宽度切换而不是按视口:侧栏能收拢,同一个视口宽度下内容区可宽可窄。
|
||||
900px 时右列还有约 380px,再窄详情就挤得难用了 */
|
||||
.page {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
@container (max-width: 900px) {
|
||||
.split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 新增或编辑一条分流规则。
|
||||
*
|
||||
* 常见规则按「类型,载荷,目标」三段编辑,目标之后的选项段(no-resolve 这类)单独
|
||||
* 一个输入框、原样拼回,改一条规则不会丢掉它的选项。逻辑规则、子规则这类结构
|
||||
* 不是三段的按整行原文编辑——为每种规则类型建模不值得,内核认识的类型比这里
|
||||
* 列出来的多得多。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { buildRule, isRawOnlyRule, isStructuredEditable, parseRule } from '@shared/rules.ts'
|
||||
import ComboBox from '../components/ComboBox.vue'
|
||||
import IconClose from '~icons/ri/close-line'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
/** 传了就是编辑:下标加原文;没传就是新增 */
|
||||
editing: { index: number; raw: string } | null
|
||||
/** RULE-SET 载荷的候选:已声明的规则组 */
|
||||
providerNames: string[]
|
||||
/** 目标的建议。只是建议,手填任何出站名都合法 */
|
||||
targetOptions: string[]
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: []; submit: [rule: string] }>()
|
||||
|
||||
/**
|
||||
* 内核认识的全部规则类型,照抄解析器的 switch(rules/parser.go:17-98),
|
||||
* 按域名、地理、IP、端口、进程、入站、逻辑的次序排。
|
||||
*/
|
||||
const RULE_TYPES = [
|
||||
'DOMAIN',
|
||||
'DOMAIN-SUFFIX',
|
||||
'DOMAIN-KEYWORD',
|
||||
'DOMAIN-REGEX',
|
||||
'DOMAIN-WILDCARD',
|
||||
'GEOSITE',
|
||||
'GEOIP',
|
||||
'SRC-GEOIP',
|
||||
'IP-CIDR',
|
||||
'IP-CIDR6',
|
||||
'SRC-IP-CIDR',
|
||||
'IP-SUFFIX',
|
||||
'SRC-IP-SUFFIX',
|
||||
'IP-ASN',
|
||||
'SRC-IP-ASN',
|
||||
'DST-PORT',
|
||||
'SRC-PORT',
|
||||
'IN-PORT',
|
||||
'PROCESS-NAME',
|
||||
'PROCESS-PATH',
|
||||
'PROCESS-NAME-REGEX',
|
||||
'PROCESS-PATH-REGEX',
|
||||
'PROCESS-NAME-WILDCARD',
|
||||
'PROCESS-PATH-WILDCARD',
|
||||
'NETWORK',
|
||||
'UID',
|
||||
'DSCP',
|
||||
'IN-TYPE',
|
||||
'IN-USER',
|
||||
'IN-NAME',
|
||||
'REMATCH-NAME',
|
||||
'AND',
|
||||
'OR',
|
||||
'NOT',
|
||||
'SUB-RULE',
|
||||
'RULE-SET',
|
||||
'MATCH',
|
||||
]
|
||||
|
||||
const dialog = ref<HTMLDialogElement | null>(null)
|
||||
const rawMode = ref(false)
|
||||
const raw = ref('')
|
||||
const type = ref('')
|
||||
const payload = ref('')
|
||||
const target = ref('')
|
||||
const extras = ref('')
|
||||
|
||||
const isMatch = computed(() => type.value.trim().toUpperCase() === 'MATCH')
|
||||
const isRuleSet = computed(() => type.value.trim().toUpperCase() === 'RULE-SET')
|
||||
|
||||
/** 结构化表单当前的内容拼成一行。 */
|
||||
function structured(): string {
|
||||
return buildRule({
|
||||
type: type.value.trim(),
|
||||
payload: isMatch.value ? null : payload.value.trim() || null,
|
||||
target: target.value.trim(),
|
||||
extras: extras.value
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (props.saving) return false
|
||||
if (rawMode.value) return raw.value.trim().includes(',')
|
||||
if (type.value.trim() === '' || target.value.trim() === '') return false
|
||||
return isMatch.value || payload.value.trim() !== ''
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
const source = props.editing?.raw ?? ''
|
||||
raw.value = source
|
||||
if (source !== '' && !isStructuredEditable(source)) {
|
||||
rawMode.value = true
|
||||
} else {
|
||||
rawMode.value = false
|
||||
const parsed = parseRule(source)
|
||||
// 新增时留空:预填的类型会把下拉的建议过滤到只剩它自己
|
||||
type.value = parsed.type
|
||||
payload.value = parsed.payload ?? ''
|
||||
target.value = parsed.target ?? ''
|
||||
extras.value = parsed.extras.join(',')
|
||||
}
|
||||
dialog.value?.showModal()
|
||||
} else {
|
||||
dialog.value?.close()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** 切换编辑方式时带上当前内容:切成原文就把表单拼成一行,切回表单就重新拆。 */
|
||||
function toggleMode() {
|
||||
if (rawMode.value) {
|
||||
const line = raw.value.trim()
|
||||
// 只有逻辑规则这类整行编辑的类型留在原文模式;空行、没写完的行都带回表单
|
||||
// 接着填——按 isStructuredEditable 拦的话,新增时的空原文会让人永远切不回去
|
||||
if (isRawOnlyRule(line)) return
|
||||
const parsed = parseRule(line)
|
||||
type.value = parsed.type
|
||||
payload.value = parsed.payload ?? ''
|
||||
target.value = parsed.target ?? ''
|
||||
extras.value = parsed.extras.join(',')
|
||||
rawMode.value = false
|
||||
} else {
|
||||
raw.value = structured()
|
||||
rawMode.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!canSubmit.value) return
|
||||
emit('submit', rawMode.value ? raw.value.trim() : structured())
|
||||
}
|
||||
|
||||
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
|
||||
function onBackdrop(event: MouseEvent) {
|
||||
if (event.target === dialog.value) emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialog"
|
||||
class="dlg"
|
||||
@click="onBackdrop"
|
||||
@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 v-if="rawMode" class="body">
|
||||
<label class="row">
|
||||
<span class="k">规则</span>
|
||||
<span class="v">
|
||||
<input
|
||||
v-model="raw"
|
||||
class="mono"
|
||||
type="text"
|
||||
placeholder="AND,((DOMAIN-SUFFIX,baidu.com),(NETWORK,UDP)),DIRECT"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-else class="body">
|
||||
<label class="row">
|
||||
<span class="k">类型</span>
|
||||
<span class="v">
|
||||
<ComboBox v-model="type" mono :options="RULE_TYPES" placeholder="DOMAIN-SUFFIX" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label v-if="!isMatch" class="row">
|
||||
<span class="k">载荷</span>
|
||||
<span class="v">
|
||||
<ComboBox
|
||||
v-if="isRuleSet"
|
||||
v-model="payload"
|
||||
mono
|
||||
:options="providerNames"
|
||||
placeholder="规则组名"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
v-model="payload"
|
||||
class="mono"
|
||||
type="text"
|
||||
placeholder="youtube.com"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="row">
|
||||
<span class="k">目标</span>
|
||||
<span class="v">
|
||||
<ComboBox v-model="target" mono :options="targetOptions" placeholder="DIRECT" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label v-if="!isMatch" class="row">
|
||||
<span class="k">选项</span>
|
||||
<span class="v">
|
||||
<input
|
||||
v-model="extras"
|
||||
class="mono"
|
||||
type="text"
|
||||
placeholder="no-resolve"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<footer class="dlg-foot">
|
||||
<button type="button" class="btn mode" @click="toggleMode">
|
||||
{{ rawMode ? '按字段编辑' : '按原文编辑' }}
|
||||
</button>
|
||||
<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: flex;
|
||||
align-items: center;
|
||||
justify-content: 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);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 4rem 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 {
|
||||
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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 编辑方式的切换放左边,和右边的确认/取消拉开 */
|
||||
.mode {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -0,0 +1,378 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 新增一个规则组(rule-providers 里的一条声明)。
|
||||
*
|
||||
* 内容的拉取、缓存、定时更新全是内核的活,这里只写声明:URL 生成 http 类型,
|
||||
* 上传的清单生成 file 类型。来源不收本地路径——浏览器里填服务器上的路径既没法
|
||||
* 验证也容易写错,真有本地清单走文件上传,落盘位置由 Patchbay 统一管。
|
||||
* 缓存路径自动补一个确定的——不写 path 时内核按 url 哈希自己生成,那个路径
|
||||
* 这里算不出来。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { inferRuleFormat, ruleProviderPath } from '@shared/rules.ts'
|
||||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||
import SelectBox from '../components/SelectBox.vue'
|
||||
import DropFile from '../components/DropFile.vue'
|
||||
import IconClose from '~icons/ri/close-line'
|
||||
import IconFile from '~icons/ri/file-text-line'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
/** 已被占用的规则组名 */
|
||||
takenNames: string[]
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
/** file 非 null 表示清单内容来自上传,要先落盘再写声明 */
|
||||
submit: [entry: { name: string; declaration: ConfigObject; file: { content: string } | null }]
|
||||
}>()
|
||||
|
||||
const dialog = ref<HTMLDialogElement | null>(null)
|
||||
const name = ref('')
|
||||
const source = ref('')
|
||||
const behavior = ref('classical')
|
||||
// 值域就是选项里那三个,SelectBox 的 v-model 是宽的 string,这里不再收窄
|
||||
const format = ref<string>('yaml')
|
||||
/** 用户手选过格式就不再跟着来源推断 */
|
||||
const formatTouched = ref(false)
|
||||
|
||||
const BEHAVIOR_OPTIONS = [{ value: 'classical' }, { value: 'domain' }, { value: 'ipcidr' }]
|
||||
const FORMAT_OPTIONS = [{ value: 'yaml' }, { value: 'text' }, { value: 'mrs' }]
|
||||
|
||||
/** 上传的清单。选了文件就不再需要来源:内容就是这份文件 */
|
||||
const file = ref<{ name: string; content: string } | null>(null)
|
||||
|
||||
const isUrl = computed(() => /^https?:\/\//i.test(source.value.trim()))
|
||||
|
||||
watch(source, (value) => {
|
||||
if (!formatTouched.value) format.value = inferRuleFormat(value.trim())
|
||||
})
|
||||
|
||||
function onFile(fileName: string, content: string) {
|
||||
file.value = { name: fileName, content }
|
||||
if (!formatTouched.value) format.value = inferRuleFormat(fileName)
|
||||
}
|
||||
|
||||
const nameTaken = computed(() => props.takenNames.includes(name.value.trim()))
|
||||
|
||||
/** 内核没有实现 classical 行为的 mrs 格式(rules/provider/ 下只有 domain 和 ipcidr 两种策略) */
|
||||
const mrsConflict = computed(() => format.value === 'mrs' && behavior.value === 'classical')
|
||||
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
name.value.trim() !== '' &&
|
||||
(file.value !== null || isUrl.value) &&
|
||||
!nameTaken.value &&
|
||||
!mrsConflict.value &&
|
||||
!props.saving,
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
name.value = ''
|
||||
source.value = ''
|
||||
behavior.value = 'classical'
|
||||
format.value = 'yaml'
|
||||
formatTouched.value = false
|
||||
file.value = null
|
||||
dialog.value?.showModal()
|
||||
} else {
|
||||
dialog.value?.close()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function submit() {
|
||||
if (!canSubmit.value) return
|
||||
const trimmedName = name.value.trim()
|
||||
const trimmedSource = source.value.trim()
|
||||
const declaration: ConfigObject = file.value
|
||||
? {
|
||||
type: 'file',
|
||||
behavior: behavior.value,
|
||||
path: ruleProviderPath(trimmedName, format.value),
|
||||
format: format.value,
|
||||
}
|
||||
: {
|
||||
type: 'http',
|
||||
behavior: behavior.value,
|
||||
url: trimmedSource,
|
||||
path: ruleProviderPath(trimmedName, format.value),
|
||||
format: format.value,
|
||||
interval: 86400,
|
||||
}
|
||||
emit('submit', {
|
||||
name: trimmedName,
|
||||
declaration,
|
||||
file: file.value ? { content: file.value.content } : null,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Esc 关弹窗走这里。文件选择框被取消时 input 的 cancel 事件会冒泡到 dialog,
|
||||
* 不是发给 dialog 自己的一律不理——否则取消选文件会把整个弹窗带关。
|
||||
*/
|
||||
function onCancel(event: Event) {
|
||||
if (event.target !== dialog.value) return
|
||||
event.preventDefault()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
|
||||
function onBackdrop(event: MouseEvent) {
|
||||
if (event.target === dialog.value) emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialog"
|
||||
class="dlg"
|
||||
@click="onBackdrop"
|
||||
@close="emit('close')"
|
||||
@cancel="onCancel"
|
||||
>
|
||||
<form method="dialog" @submit.prevent="submit">
|
||||
<header class="dlg-head">
|
||||
<h2>新增规则组</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="ads" autocomplete="off" />
|
||||
<span v-if="nameTaken" class="bad">名称已存在</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label v-if="file === null" class="row">
|
||||
<span class="k">来源</span>
|
||||
<span class="v">
|
||||
<input
|
||||
v-model="source"
|
||||
class="mono"
|
||||
type="text"
|
||||
placeholder="https://example.com/rules.list"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span v-if="source.trim() !== '' && !isUrl" class="bad">
|
||||
来源要是 http(s) 地址,本地清单走下面的文件导入
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- SelectBox 的行不用 label 包:没有 for 的 label 绑定第一个可关联控件,
|
||||
正好是它的开关按钮,悬停行内任何位置都会点亮它、点空白处会展开面板 -->
|
||||
<div class="row">
|
||||
<span class="k">行为</span>
|
||||
<span class="v">
|
||||
<SelectBox v-model="behavior" :options="BEHAVIOR_OPTIONS" aria-label="行为" />
|
||||
<span class="note">
|
||||
清单每行带 DOMAIN,、IP-CIDR, 这类前缀的是 classical;纯域名列表是 domain;
|
||||
纯 IP 网段列表是 ipcidr
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="k">格式</span>
|
||||
<span class="v">
|
||||
<SelectBox
|
||||
v-model="format"
|
||||
:options="FORMAT_OPTIONS"
|
||||
aria-label="格式"
|
||||
@update:model-value="formatTouched = true"
|
||||
/>
|
||||
<span v-if="mrsConflict" class="bad">mrs 格式只支持 domain 和 ipcidr 两种行为</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="divide" role="presentation"><span>或从文件导入</span></div>
|
||||
|
||||
<DropFile v-if="file === null" accept=".yaml,.yml,.txt,.list" @load="onFile" />
|
||||
<div v-else class="picked">
|
||||
<IconFile class="picked-ico" aria-hidden="true" />
|
||||
<span class="picked-name">{{ file.name }}</span>
|
||||
<button type="button" class="x" title="移除文件" @click="file = null"><IconClose /></button>
|
||||
</div>
|
||||
</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: flex;
|
||||
align-items: center;
|
||||
justify-content: 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);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 4rem 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 {
|
||||
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);
|
||||
}
|
||||
|
||||
.divide {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-xs);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.divide::before,
|
||||
.divide::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--c-border);
|
||||
}
|
||||
|
||||
.picked {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface-sunken);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.picked-ico {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
.picked-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -0,0 +1,847 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 规则页:全局分流规则的编辑,加上规则组(rule-providers)的管理。
|
||||
*
|
||||
* 和路由页同一条界线:增删改规则、增删规则组是配置动作,都走 commit(),有校验和
|
||||
* 重载的等待;「立刻更新」规则组是运行时动作,不写配置文件,直接调内核接口。
|
||||
*
|
||||
* 顺序是分流语义的一部分。这张表的任何操作都只动被动到的那一行,其余行的相对
|
||||
* 顺序不变——排序只有上移下移,没有任何隐式重排。
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { collectOutboundNames } from '@shared/inbounds.ts'
|
||||
import { isGenerated } from '@shared/inbound-groups.ts'
|
||||
import {
|
||||
findRuleIssues,
|
||||
firstMatchIndex,
|
||||
insertBeforeMatch,
|
||||
missingLanRules,
|
||||
moveRule,
|
||||
parseRule,
|
||||
ruleProviderPathConflict,
|
||||
ruleProvidersOf,
|
||||
ruleSetReferences,
|
||||
rulesOf,
|
||||
setRuleProviders,
|
||||
setRules,
|
||||
subRuleSetReferences,
|
||||
} from '@shared/rules.ts'
|
||||
import type { IniImportPlan, RuleIssue } from '@shared/rules.ts'
|
||||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||
import { ApiError, api } from '../api'
|
||||
import { toast, toastError } from '../toast'
|
||||
import type { RuleProviderInfo } from '../api'
|
||||
import { kernel } from '../runtime'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import RuleForm from './RuleForm.vue'
|
||||
import RuleGroupForm from './RuleGroupForm.vue'
|
||||
import IniImportForm from './IniImportForm.vue'
|
||||
import IconAdd from '~icons/ri/add-line'
|
||||
import IconUp from '~icons/ri/arrow-up-line'
|
||||
import IconDown from '~icons/ri/arrow-down-line'
|
||||
import IconEdit from '~icons/ri/edit-line'
|
||||
import IconDelete from '~icons/ri/delete-bin-line'
|
||||
import IconRefresh from '~icons/ri/refresh-line'
|
||||
import IconLoad from '~icons/ri/loader-4-line'
|
||||
|
||||
const props = defineProps<{ data: ConfigObject; revision: string }>()
|
||||
|
||||
/** 配置文件变了,需要重读整份配置。运行时的变化不走这里。 */
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
|
||||
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
|
||||
|
||||
const rules = computed(() => rulesOf(props.data))
|
||||
/**
|
||||
* 每行拆好的样子,模板直接读,不在渲染里反复拆。
|
||||
* culprit 是拦路的那条 MATCH(后面还有行时才算,作为表底兜底是正常用法);
|
||||
* dead 是被它拦住、永远轮不到的行。
|
||||
*/
|
||||
const rows = computed(() => {
|
||||
const at = firstMatchIndex(rules.value)
|
||||
return rules.value.map((rule, index) => ({
|
||||
index,
|
||||
raw: rule,
|
||||
...parseRule(rule),
|
||||
culprit: index === at && index < rules.value.length - 1,
|
||||
dead: at >= 0 && index > at,
|
||||
}))
|
||||
})
|
||||
const providers = computed(() =>
|
||||
Object.entries(ruleProvidersOf(props.data)).map(([name, decl]) => ({
|
||||
name,
|
||||
behavior: typeof decl.behavior === 'string' ? decl.behavior : '',
|
||||
source:
|
||||
typeof decl.url === 'string'
|
||||
? decl.url
|
||||
: typeof decl.path === 'string'
|
||||
? decl.path
|
||||
: '',
|
||||
})),
|
||||
)
|
||||
const providerNames = computed(() => providers.value.map((p) => p.name))
|
||||
|
||||
const outbounds = computed(() => collectOutboundNames(props.data))
|
||||
const outboundNames = computed(() => [
|
||||
...outbounds.value.builtins,
|
||||
...outbounds.value.groups,
|
||||
...outbounds.value.ownProxies,
|
||||
])
|
||||
|
||||
/**
|
||||
* 目标的建议:日常用的两个内置出站、用户自己建的策略组和自有线路。
|
||||
* 生成的专属组不列——每路入站三个,会把列表灌成一面墙,真要指定就手填。
|
||||
*/
|
||||
const targetOptions = computed(() => [
|
||||
'DIRECT',
|
||||
'REJECT',
|
||||
...outbounds.value.groups.filter((name) => !isGenerated(name)),
|
||||
...outbounds.value.ownProxies,
|
||||
])
|
||||
|
||||
/** 第一条 MATCH 的下标,没有时是 -1。ini 导入拿它判断要不要收配方的兜底。 */
|
||||
const matchAt = computed(() => firstMatchIndex(rules.value))
|
||||
|
||||
/** 断引用按行归好,模板里直接查。 */
|
||||
const issuesByIndex = computed(() => {
|
||||
const issueMap = new Map<number, RuleIssue[]>()
|
||||
for (const issue of findRuleIssues(
|
||||
rules.value,
|
||||
new Set(outboundNames.value),
|
||||
new Set(providerNames.value),
|
||||
)) {
|
||||
issueMap.set(issue.index, [...(issueMap.get(issue.index) ?? []), issue])
|
||||
}
|
||||
return issueMap
|
||||
})
|
||||
|
||||
const missingLan = computed(() => missingLanRules(rules.value))
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
/** 所有改配置文件的动作都从这里走。反馈走右上角轻提示,返回值告知成败。 */
|
||||
async function commit(raw: ConfigObject, success: string): Promise<boolean> {
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await api.applyConfig(raw, props.revision)
|
||||
// 没重载有两种情况(内容没有变化、内核不在线只写了文件),都要如实说
|
||||
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
|
||||
emit('saved')
|
||||
return true
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function withRules(next: string[]): ConfigObject {
|
||||
const data = clone()
|
||||
setRules(data, next)
|
||||
return data
|
||||
}
|
||||
|
||||
// ── 全局规则 ────────────────────────────────────
|
||||
|
||||
const ruleFormOpen = ref(false)
|
||||
/** 编辑时是下标,新增时是 null */
|
||||
const ruleEditing = ref<number | null>(null)
|
||||
|
||||
function openAddRule() {
|
||||
ruleEditing.value = null
|
||||
ruleFormOpen.value = true
|
||||
}
|
||||
|
||||
function openEditRule(index: number) {
|
||||
ruleEditing.value = index
|
||||
ruleFormOpen.value = true
|
||||
}
|
||||
|
||||
async function submitRule(ruleText: string) {
|
||||
const editing = ruleEditing.value
|
||||
// 规则表从零开始时先垫上内网直连——保留地址不直连的话,第一条代理规则一生效,
|
||||
// 访问路由器、局域网服务的流量就全进了代理。ini 导入不垫:配方自带直连规则
|
||||
const seed = editing === null && rules.value.length === 0 ? missingLan.value : []
|
||||
const next =
|
||||
editing === null
|
||||
? insertBeforeMatch([...seed, ...rules.value], [ruleText])
|
||||
: rules.value.map((rule, i) => (i === editing ? ruleText : rule))
|
||||
const ok = await commit(
|
||||
withRules(next),
|
||||
editing === null
|
||||
? seed.length > 0
|
||||
? `已新增规则,并附带 ${seed.length} 条内网直连`
|
||||
: '已新增规则'
|
||||
: '已保存',
|
||||
)
|
||||
// 失败时不能关对话框——关了再打开就是空表单
|
||||
if (ok) ruleFormOpen.value = false
|
||||
}
|
||||
|
||||
async function move(index: number, delta: -1 | 1) {
|
||||
if (saving.value) return
|
||||
await commit(withRules(moveRule(rules.value, index, delta)), '顺序已保存')
|
||||
}
|
||||
|
||||
// 删除不设二次确认:删错了有撤回,规则原文放进提示里让人看清删掉的是哪条
|
||||
async function removeRule(index: number) {
|
||||
const rule = rules.value[index]
|
||||
if (rule === undefined || saving.value) return
|
||||
await commit(withRules(rules.value.filter((_, i) => i !== index)), `已删除全局规则 [ ${rule} ]`)
|
||||
}
|
||||
|
||||
async function applyLanPreset() {
|
||||
if (saving.value || missingLan.value.length === 0) return
|
||||
await commit(
|
||||
withRules(insertBeforeMatch(rules.value, missingLan.value)),
|
||||
`已加入 ${missingLan.value.length} 条内网直连规则`,
|
||||
)
|
||||
}
|
||||
|
||||
// ── 规则组 ─────────────────────────────────────
|
||||
|
||||
/** 每个规则组的运行时状态:条数、上次更新。内核不在线时是空的。 */
|
||||
const providerState = ref<Record<string, RuleProviderInfo>>({})
|
||||
|
||||
async function reloadProviderState() {
|
||||
if (!kernel.value.online) {
|
||||
providerState.value = {}
|
||||
return
|
||||
}
|
||||
providerState.value = await api
|
||||
.ruleProviders()
|
||||
.then((r) => r.providers)
|
||||
.catch(() => ({}))
|
||||
}
|
||||
|
||||
onMounted(() => void reloadProviderState())
|
||||
// 配置重读(保存成功、别的页面改完回来)之后跟着刷一遍,条数可能刚变过
|
||||
watch(
|
||||
() => props.revision,
|
||||
() => void reloadProviderState(),
|
||||
)
|
||||
|
||||
/** 还没拉取过时内核给的是零值时间,显示成横线而不是 0001 年。 */
|
||||
function formatTime(value: string | undefined): string {
|
||||
if (!value) return '—'
|
||||
const time = new Date(value)
|
||||
if (Number.isNaN(time.getTime()) || time.getFullYear() <= 1) return '—'
|
||||
return time.toLocaleString()
|
||||
}
|
||||
|
||||
const groupFormOpen = ref(false)
|
||||
const importOpen = ref(false)
|
||||
|
||||
async function submitGroup(entry: {
|
||||
name: string
|
||||
declaration: ConfigObject
|
||||
file: { content: string } | null
|
||||
}) {
|
||||
// 名字查重放行了、但净化后的清单路径和别人相同的,在这里拦
|
||||
if (typeof entry.declaration.path === 'string') {
|
||||
const clash = ruleProviderPathConflict(props.data, entry.name, entry.declaration.path)
|
||||
if (clash) {
|
||||
toast(`「${entry.name}」和「${clash}」的清单文件会是同一个`, {
|
||||
kind: 'error',
|
||||
hint: '名字只在符号或空格上有差异时会净化成同一个文件名,换一个名字。',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 上传的清单先落盘再写声明。顺序不能反——声明先生效的话,内核重载会去读
|
||||
// 一个还不存在的文件
|
||||
if (entry.file && typeof entry.declaration.path === 'string') {
|
||||
try {
|
||||
await api.uploadProviderFile(entry.declaration.path, entry.file.content)
|
||||
} catch (error) {
|
||||
toast(error instanceof ApiError ? error.message : String(error), {
|
||||
kind: 'error',
|
||||
hint: '清单没有写入,配置文件也没有改动。',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
const next = clone()
|
||||
setRuleProviders(next, { ...ruleProvidersOf(next), [entry.name]: entry.declaration })
|
||||
const ok = await commit(next, `已新增规则组 ${entry.name}`)
|
||||
if (ok) {
|
||||
groupFormOpen.value = false
|
||||
void reloadProviderState()
|
||||
}
|
||||
}
|
||||
|
||||
async function submitImport(plan: IniImportPlan) {
|
||||
const next = clone()
|
||||
setRuleProviders(next, { ...ruleProvidersOf(next), ...plan.providers })
|
||||
// 表里已有 MATCH 时丢弃配方的兜底:那是你自己安排的分流终点,配方不该
|
||||
// 偷偷替换;追加在 MATCH 之后则永远是死行。firstMatchIndex 没有时给 -1,
|
||||
// 之前误写成 === null,配方兜底从来没被导入过
|
||||
const keepFinals = matchAt.value < 0
|
||||
const finals = keepFinals ? plan.finals : []
|
||||
setRules(next, [...insertBeforeMatch(rules.value, plan.rules), ...finals])
|
||||
const added = plan.rules.length + finals.length
|
||||
const dropped = keepFinals ? '' : ';配方的兜底规则未导入,你已有自己的 MATCH'
|
||||
const ok = await commit(
|
||||
next,
|
||||
`已导入 ${Object.keys(plan.providers).length} 个规则组、${String(added)} 条规则${dropped}`,
|
||||
)
|
||||
if (ok) {
|
||||
importOpen.value = false
|
||||
void reloadProviderState()
|
||||
}
|
||||
}
|
||||
|
||||
/** 待确认删除的规则组。 */
|
||||
const removingProvider = ref<string | null>(null)
|
||||
/** 删除被拦下的规则组:rules 里还有 RULE-SET 引用它,先把引用列出来。 */
|
||||
const blockedProvider = ref<{ name: string; refs: string[] } | null>(null)
|
||||
const blockedDialog = ref<HTMLDialogElement | null>(null)
|
||||
watch(blockedProvider, (blocked) => {
|
||||
if (blocked) blockedDialog.value?.showModal()
|
||||
else blockedDialog.value?.close()
|
||||
})
|
||||
|
||||
/** 点在遮罩上就关闭。内容区的点击 target 是里面的元素,到不了 dialog 本身。 */
|
||||
function onBlockedBackdrop(event: MouseEvent) {
|
||||
if (event.target === blockedDialog.value) blockedProvider.value = null
|
||||
}
|
||||
|
||||
function askRemoveProvider(name: string) {
|
||||
// 附加规则(sub-rules)里的引用也要查:漏了的话删除会被内核重载拒掉,
|
||||
// 用户看到的是解析错误原文而不是「谁在引用它」
|
||||
const refs = [
|
||||
...ruleSetReferences(rules.value, name).map((i) => rules.value[i]!),
|
||||
...subRuleSetReferences(props.data, name),
|
||||
]
|
||||
if (refs.length > 0) {
|
||||
blockedProvider.value = { name, refs }
|
||||
} else {
|
||||
removingProvider.value = name
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProviderConfirmed() {
|
||||
const name = removingProvider.value
|
||||
removingProvider.value = null
|
||||
if (name === null || saving.value) return
|
||||
const next = clone()
|
||||
const rest = ruleProvidersOf(next)
|
||||
delete rest[name]
|
||||
setRuleProviders(next, rest)
|
||||
const ok = await commit(next, `已删除规则组 ${name}`)
|
||||
if (ok) void reloadProviderState()
|
||||
}
|
||||
|
||||
/** 正在等内核重新拉取的规则组名字。 */
|
||||
const updating = ref<string | null>(null)
|
||||
|
||||
/** 立刻更新是运行时动作:不写配置文件,不走 commit(),也就没有保存的等待感。 */
|
||||
async function updateNow(name: string) {
|
||||
if (updating.value !== null || saving.value) return
|
||||
updating.value = name
|
||||
try {
|
||||
await api.updateRuleProvider(name)
|
||||
await reloadProviderState()
|
||||
toast(`已更新 ${name}`)
|
||||
} catch (error) {
|
||||
toast(error instanceof ApiError ? error.message : String(error), {
|
||||
kind: 'error',
|
||||
hint: '该操作仅修改内核运行状态,配置文件未被修改。',
|
||||
})
|
||||
} finally {
|
||||
updating.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="head">
|
||||
<div class="sums">
|
||||
<div class="sum">
|
||||
<span class="sum-n">{{ rules.length }}</span>
|
||||
<span class="sum-k">全局规则</span>
|
||||
</div>
|
||||
<div class="sum">
|
||||
<span class="sum-n">{{ providers.length }}</span>
|
||||
<span class="sum-k">规则组</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="sec">
|
||||
<header class="sec-head">
|
||||
<h2>全局规则</h2>
|
||||
<div class="sec-acts">
|
||||
<button
|
||||
v-if="missingLan.length > 0 && rules.length > 0"
|
||||
type="button"
|
||||
class="btn"
|
||||
:disabled="saving"
|
||||
:title="`补上缺的 ${missingLan.length} 条保留地址直连规则`"
|
||||
@click="applyLanPreset"
|
||||
>
|
||||
补全内网直连
|
||||
</button>
|
||||
<button type="button" class="btn" :disabled="saving" @click="openAddRule">
|
||||
<IconAdd />
|
||||
<span>新增</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ol v-if="rules.length > 0" class="rows">
|
||||
<li v-for="row in rows" :key="row.index">
|
||||
<div
|
||||
:class="['line', row.dead && 'is-dead', row.culprit && 'is-culprit']"
|
||||
:title="
|
||||
row.culprit
|
||||
? 'MATCH 命中所有流量,它之后的规则永远不会被检查'
|
||||
: row.dead
|
||||
? '排在 MATCH 之后,永远不会被检查'
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<span class="idx mono">{{ row.index + 1 }}</span>
|
||||
<span class="rtype mono">{{ row.type }}</span>
|
||||
<span class="payload mono">
|
||||
<template v-if="row.payload !== null">
|
||||
<span :class="{ bad: issuesByIndex.get(row.index)?.some((i) => i.kind === 'rule-set') }">
|
||||
{{ row.payload }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="row.extras.length > 0" class="extras">
|
||||
{{ row.extras.join(', ') }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="rtarget mono">
|
||||
<span :class="{ bad: issuesByIndex.get(row.index)?.some((i) => i.kind === 'target') }">
|
||||
{{ row.target }}
|
||||
</span>
|
||||
<span v-if="issuesByIndex.has(row.index)" class="tag tag-bad">断引用</span>
|
||||
</span>
|
||||
<span class="acts">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="上移"
|
||||
:disabled="saving || row.index === 0"
|
||||
@click="move(row.index, -1)"
|
||||
>
|
||||
<IconUp />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="下移"
|
||||
:disabled="saving || row.index === rules.length - 1"
|
||||
@click="move(row.index, 1)"
|
||||
>
|
||||
<IconDown />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="编辑"
|
||||
:disabled="saving"
|
||||
@click="openEditRule(row.index)"
|
||||
>
|
||||
<IconEdit />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
title="删除"
|
||||
:disabled="saving"
|
||||
@click="removeRule(row.index)"
|
||||
>
|
||||
<IconDelete />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="empty">没有全局规则,走规则的流量都会直连。</p>
|
||||
</section>
|
||||
|
||||
<section class="sec">
|
||||
<header class="sec-head">
|
||||
<h2>规则组</h2>
|
||||
<div class="sec-acts">
|
||||
<button type="button" class="btn" :disabled="saving" @click="importOpen = true">
|
||||
导入 ini
|
||||
</button>
|
||||
<button type="button" class="btn" :disabled="saving" @click="groupFormOpen = true">
|
||||
<IconAdd />
|
||||
<span>新增</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ol v-if="providers.length > 0" class="rows">
|
||||
<li v-for="provider in providers" :key="provider.name">
|
||||
<div class="line gline">
|
||||
<span class="gname">{{ provider.name }}</span>
|
||||
<span class="gbehavior mono">{{ provider.behavior || '—' }}</span>
|
||||
<span class="gsource mono" :title="provider.source">{{ provider.source }}</span>
|
||||
<span class="gcount mono">{{ providerState[provider.name]?.ruleCount ?? '—' }}</span>
|
||||
<span class="gtime mono">{{ formatTime(providerState[provider.name]?.updatedAt) }}</span>
|
||||
<span class="acts">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:title="kernel.online ? '立刻更新' : '内核未连接'"
|
||||
:disabled="!kernel.online || updating !== null || saving"
|
||||
@click="updateNow(provider.name)"
|
||||
>
|
||||
<IconLoad v-if="updating === provider.name" class="spin" />
|
||||
<IconRefresh v-else />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
title="删除"
|
||||
:disabled="saving"
|
||||
@click="askRemoveProvider(provider.name)"
|
||||
>
|
||||
<IconDelete />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="empty">没有规则组。</p>
|
||||
</section>
|
||||
|
||||
<RuleForm
|
||||
:open="ruleFormOpen"
|
||||
:editing="ruleEditing === null ? null : { index: ruleEditing, raw: rules[ruleEditing]! }"
|
||||
:provider-names="providerNames"
|
||||
:target-options="targetOptions"
|
||||
:saving="saving"
|
||||
@close="ruleFormOpen = false"
|
||||
@submit="submitRule"
|
||||
/>
|
||||
|
||||
<RuleGroupForm
|
||||
:open="groupFormOpen"
|
||||
:taken-names="providerNames"
|
||||
:saving="saving"
|
||||
@close="groupFormOpen = false"
|
||||
@submit="submitGroup"
|
||||
/>
|
||||
|
||||
<IniImportForm
|
||||
:open="importOpen"
|
||||
:outbound-names="outboundNames"
|
||||
:existing-provider-names="providerNames"
|
||||
:saving="saving"
|
||||
@close="importOpen = false"
|
||||
@submit="submitImport"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="removingProvider !== null"
|
||||
:message="removingProvider === null ? '' : `删除规则组「${removingProvider}」?`"
|
||||
action="删除"
|
||||
@close="removingProvider = null"
|
||||
@confirm="removeProviderConfirmed"
|
||||
/>
|
||||
|
||||
<!-- 删除被 RULE-SET 引用拦下时不给确认键:先改掉这些规则,才轮得到删 -->
|
||||
<dialog
|
||||
ref="blockedDialog"
|
||||
class="dlg"
|
||||
@click="onBlockedBackdrop"
|
||||
@close="blockedProvider = null"
|
||||
@cancel.prevent="blockedProvider = null"
|
||||
>
|
||||
<div v-if="blockedProvider" class="dlg-body">
|
||||
<p class="msg">
|
||||
规则组「{{ blockedProvider.name }}」正被 {{ blockedProvider.refs.length }} 条规则引用:
|
||||
</p>
|
||||
<ul class="refs">
|
||||
<li v-for="rule in blockedProvider.refs" :key="rule" class="mono">{{ rule }}</li>
|
||||
</ul>
|
||||
<footer class="dlg-foot">
|
||||
<button type="button" class="btn" @click="blockedProvider = null">关闭</button>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-4);
|
||||
margin-bottom: var(--s-5);
|
||||
}
|
||||
|
||||
.sums {
|
||||
display: flex;
|
||||
gap: var(--s-6);
|
||||
}
|
||||
|
||||
.sum {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.sum-n {
|
||||
font-size: var(--t-2xl);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sum-k {
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.sec + .sec {
|
||||
margin-top: var(--s-7);
|
||||
}
|
||||
|
||||
.sec-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-4);
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
|
||||
.sec-head h2 {
|
||||
font-size: var(--t-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sec-acts {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
.btn:disabled {
|
||||
color: var(--c-text-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rows li + li {
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.line {
|
||||
display: grid;
|
||||
grid-template-columns: 2rem 8.5rem minmax(0, 1fr) minmax(6rem, auto) auto;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
}
|
||||
|
||||
.idx {
|
||||
font-size: var(--t-xs);
|
||||
color: var(--c-text-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rtype {
|
||||
font-size: var(--t-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.payload {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-3);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.extras {
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-xs);
|
||||
}
|
||||
|
||||
.rtarget {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--s-2);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
/* 不可达:在第一条 MATCH 之后,永远轮不到它 */
|
||||
.is-dead .rtype,
|
||||
.is-dead .payload,
|
||||
.is-dead .rtarget {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
/* 拦路的那条 MATCH:它后面还有行,后面全是死行 */
|
||||
.is-culprit .rtype,
|
||||
.is-culprit .rtarget {
|
||||
color: var(--c-warn);
|
||||
}
|
||||
|
||||
.bad {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.tag {
|
||||
flex: none;
|
||||
padding: 0 var(--s-2);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: 999px;
|
||||
font-size: var(--t-xs);
|
||||
color: var(--c-text-soft);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.tag-bad {
|
||||
border-color: color-mix(in srgb, var(--c-danger) 45%, var(--c-border));
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.icon-btn.danger:hover:not(:disabled) {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.gline {
|
||||
grid-template-columns: minmax(8rem, 1fr) 5.5rem minmax(0, 1.6fr) 4rem 10.5rem auto;
|
||||
}
|
||||
|
||||
.gname {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gbehavior {
|
||||
font-size: var(--t-sm);
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
.gsource {
|
||||
font-size: var(--t-sm);
|
||||
color: var(--c-text-soft);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gcount {
|
||||
font-size: var(--t-sm);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.gtime {
|
||||
font-size: var(--t-xs);
|
||||
color: var(--c-text-soft);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.dlg {
|
||||
width: min(480px, 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-body {
|
||||
padding: var(--s-5);
|
||||
}
|
||||
|
||||
.refs {
|
||||
margin: var(--s-3) 0 0;
|
||||
padding-left: 1.2em;
|
||||
font-size: var(--t-sm);
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
.dlg-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--s-5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,936 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 设置页。分两类,反馈方式不同:
|
||||
* - 本地偏好(主题、测速地址)点了就生效,没有等待
|
||||
* - 动配置文件的(内核参数、GEO 下载地址、上传恢复/导入)走服务端完整的
|
||||
* 保护链路,有校验、备份、重载和失败回退,反馈方式照路由页的 commit()
|
||||
*
|
||||
* 单值编辑统一用 InlineEdit:平时纯文本,点铅笔才出输入框,Enter 或失焦提交。
|
||||
* 备份列表不摆出来:自动备份是落盘链路的安全网,不是日常操作对象;
|
||||
* 「配置文件」一组给下载和上传,上传按 Patchbay 标记分「恢复」和「导入」两个入口。
|
||||
*/
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { parse } from 'yaml'
|
||||
import { hasValidMarker } from '@shared/config-marker.ts'
|
||||
import { formatAgo, formatBytes } from '@shared/format.ts'
|
||||
import type { GeoFileStatus } from '@shared/geo.ts'
|
||||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||
import { api } from '../api'
|
||||
import { toast, toastError } from '../toast'
|
||||
import { DEFAULT_TEST_URL, testUrl, theme } from '../prefs'
|
||||
import type { Theme } from '../prefs'
|
||||
import { kernel } from '../runtime'
|
||||
import { DISCLAIMER, DISCLAIMER_TITLE } from '../legal'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import InlineEdit from '../components/InlineEdit.vue'
|
||||
import SelectBox from '../components/SelectBox.vue'
|
||||
import HostsForm from './HostsForm.vue'
|
||||
import IconRight from '~icons/ri/arrow-right-s-line'
|
||||
import IconBack from '~icons/ri/arrow-left-s-line'
|
||||
import IconDownload from '~icons/ri/download-2-line'
|
||||
import IconEdit from '~icons/ri/pencil-line'
|
||||
import IconSync from '~icons/ri/refresh-line'
|
||||
|
||||
const props = defineProps<{ data: ConfigObject; revision: string }>()
|
||||
|
||||
/** 动了配置文件,外壳需要重读整份配置。 */
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
|
||||
/* ── 外观与测速 ───────────────────────────────── */
|
||||
|
||||
const themeOptions: { value: Theme; label: string }[] = [
|
||||
{ value: 'system', label: '跟随系统' },
|
||||
{ value: 'light', label: '浅色' },
|
||||
{ value: 'dark', label: '深色' },
|
||||
]
|
||||
|
||||
// 模板里直接给 import 进来的 ref 赋值编译结果不可靠,走显式的赋值函数
|
||||
function pickTheme(value: Theme) {
|
||||
theme.value = value
|
||||
}
|
||||
|
||||
const isUrlOrEmpty = (value: string) => value === '' || /^https?:\/\//.test(value)
|
||||
|
||||
/** 本地偏好,立刻生效。清空等于回到默认。 */
|
||||
function saveTestUrl(value: string) {
|
||||
testUrl.value = value || DEFAULT_TEST_URL
|
||||
}
|
||||
|
||||
/* ── 状态与反馈 ───────────────────────────────── */
|
||||
/* 动作结果一律走右上角的轻提示(web/toast.ts),页面里不再放常驻的横幅 */
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
/** 所有改配置文件的动作都从这里走。反馈走轻提示,返回值告知成败。 */
|
||||
async function commitConfig(next: ConfigObject, success: string): Promise<boolean> {
|
||||
if (saving.value) return false
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await api.applyConfig(next, props.revision)
|
||||
// 内核不在线时只写了文件没重载,如实说,不能只报成功
|
||||
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
|
||||
emit('saved')
|
||||
return true
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
|
||||
|
||||
/* ── 内核参数 ─────────────────────────────────── */
|
||||
|
||||
/** 老式顶层端口,键名用配置原词。空表示没启用(配置里不写这个键)。 */
|
||||
const PORT_KEYS = ['mixed-port', 'port', 'socks-port', 'redir-port', 'tproxy-port'] as const
|
||||
|
||||
function numberText(key: string): string {
|
||||
const value = props.data[key]
|
||||
return typeof value === 'number' ? String(value) : ''
|
||||
}
|
||||
|
||||
const isPortOrEmpty = (value: string) =>
|
||||
value === '' || (/^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= 65535)
|
||||
|
||||
const isPosIntOrEmpty = (value: string) => value === '' || (/^\d+$/.test(value) && Number(value) >= 1)
|
||||
|
||||
/** 单个数字键的保存:空值删键,其余写数字。端口和 keep-alive 共用。 */
|
||||
function saveNumberKey(key: string, value: string, success: string) {
|
||||
const next = clone()
|
||||
if (value === '') delete next[key]
|
||||
else next[key] = Number(value)
|
||||
void commitConfig(next, success)
|
||||
}
|
||||
|
||||
/* ── 追加 Hosts ───────────────────────────────── */
|
||||
|
||||
const hostsOpen = ref(false)
|
||||
|
||||
const hostsOf = (): ConfigObject => {
|
||||
const value = props.data.hosts
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as ConfigObject)
|
||||
: {}
|
||||
}
|
||||
|
||||
async function submitHosts(hosts: ConfigObject) {
|
||||
const next = clone()
|
||||
if (Object.keys(hosts).length === 0) delete next.hosts
|
||||
else next.hosts = hosts
|
||||
const ok = await commitConfig(next, 'hosts 已保存')
|
||||
if (ok) hostsOpen.value = false
|
||||
}
|
||||
|
||||
/* ── 资源(GEO 数据库) ───────────────────────── */
|
||||
|
||||
const geoFiles = ref<GeoFileStatus[] | null>(null)
|
||||
const geoBusy = ref(false)
|
||||
|
||||
async function loadGeo() {
|
||||
// 失败给空数组而不是停在「载入中」,那是个出不去的死状态
|
||||
geoFiles.value = await api
|
||||
.geoStatus()
|
||||
.then((r) => r.files)
|
||||
.catch(() => [])
|
||||
}
|
||||
|
||||
onMounted(() => void loadGeo())
|
||||
// 保存过配置(比如改了下载地址)后重新问一遍生效中的地址
|
||||
watch(() => props.data, () => void loadGeo())
|
||||
|
||||
function geoMeta(g: GeoFileStatus): string {
|
||||
if (g.size === null || !g.updatedAt) return '未下载'
|
||||
return `${formatBytes(g.size)} · ${formatAgo(new Date(g.updatedAt), new Date())}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新全部:让内核自己更新(运行时动作,带内核的完整校验和热加载,
|
||||
* 但只更新配置实际用到的类型)。
|
||||
*/
|
||||
async function updateGeo() {
|
||||
if (geoBusy.value || geoSyncing.value !== null || !kernel.value.online) return
|
||||
geoBusy.value = true
|
||||
try {
|
||||
await api.updateGeo()
|
||||
toast('GEO 数据库已更新')
|
||||
await loadGeo()
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
geoBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 正在单独下载的那一行(configKey) */
|
||||
const geoSyncing = ref<string | null>(null)
|
||||
|
||||
/** 单独更新一个文件:服务端自己下载,内核重启后换用新文件。 */
|
||||
async function syncOne(g: GeoFileStatus) {
|
||||
if (geoBusy.value || geoSyncing.value !== null) return
|
||||
geoSyncing.value = g.configKey
|
||||
try {
|
||||
await api.syncGeoFile(g.configKey)
|
||||
toast(`${g.key} 已下载`, { hint: '内核还在用旧数据,重启内核后换用新文件。' })
|
||||
await loadGeo()
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
geoSyncing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 正在编辑下载地址的那一行(configKey),一次只开一行 */
|
||||
const geoEditing = ref<string | null>(null)
|
||||
const geoDraft = ref('')
|
||||
const geoBad = ref(false)
|
||||
const geoInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// 输入框在 v-for 里,字符串 ref 会被 Vue 收成数组;同时只开一行,函数 ref 存单个就够
|
||||
function setGeoInput(el: unknown) {
|
||||
geoInput.value = el instanceof HTMLInputElement ? el : null
|
||||
}
|
||||
|
||||
async function openGeoEdit(g: GeoFileStatus) {
|
||||
geoDraft.value = g.url
|
||||
geoBad.value = false
|
||||
geoEditing.value = g.configKey
|
||||
await nextTick()
|
||||
geoInput.value?.focus()
|
||||
geoInput.value?.select()
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑按钮在输入框打开期间还留在行里。直接点它会先触发输入框的 blur(关闭)
|
||||
* 再触发 click(重新打开),肉眼看就是闪一下。按钮上 mousedown.prevent 保住
|
||||
* 焦点不掉,click 再显式决定开还是关。
|
||||
*/
|
||||
function toggleGeoEdit(g: GeoFileStatus) {
|
||||
if (geoEditing.value === g.configKey) finishGeoEdit(g, true)
|
||||
else void openGeoEdit(g)
|
||||
}
|
||||
|
||||
/** lenient 是失焦路径:无效就放弃改动。空值 = 回到内核默认(配置里删掉这个键)。 */
|
||||
function finishGeoEdit(g: GeoFileStatus, lenient: boolean) {
|
||||
if (geoEditing.value !== g.configKey) return
|
||||
const next = geoDraft.value.trim()
|
||||
if (!isUrlOrEmpty(next)) {
|
||||
if (lenient) geoEditing.value = null
|
||||
else geoBad.value = true
|
||||
return
|
||||
}
|
||||
geoEditing.value = null
|
||||
if (next === g.url) return
|
||||
const data = clone()
|
||||
const geox: ConfigObject = { ...((data['geox-url'] ?? {}) as ConfigObject) }
|
||||
if (next === '') delete geox[g.configKey]
|
||||
else geox[g.configKey] = next
|
||||
if (Object.keys(geox).length === 0) delete data['geox-url']
|
||||
else data['geox-url'] = geox
|
||||
void commitConfig(data, `${g.key} 的下载地址已保存`)
|
||||
}
|
||||
|
||||
/* ── 配置文件 ─────────────────────────────────── */
|
||||
|
||||
async function downloadConfig() {
|
||||
try {
|
||||
const text = await api.configRaw()
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'text/yaml' }))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'config.yaml'
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
}
|
||||
}
|
||||
|
||||
type UploadKind = 'restore' | 'import'
|
||||
|
||||
const strategy = ref<'compatible' | 'override'>('compatible')
|
||||
const STRATEGY_OPTIONS = [
|
||||
{ value: 'compatible', label: '兼容' },
|
||||
{ value: 'override', label: '覆盖' },
|
||||
]
|
||||
|
||||
/** 窗口里正拖着文件。为真时两个上传行整行变成投放区。 */
|
||||
const dragging = ref(false)
|
||||
/** 悬在哪个投放区上,给高亮用 */
|
||||
const overZone = ref<UploadKind | null>(null)
|
||||
let dragDepth = 0
|
||||
|
||||
function hasFiles(event: DragEvent): boolean {
|
||||
return Array.from(event.dataTransfer?.types ?? []).includes('Files')
|
||||
}
|
||||
|
||||
function onWindowEnter(event: DragEvent) {
|
||||
if (!hasFiles(event)) return
|
||||
dragDepth += 1
|
||||
dragging.value = true
|
||||
}
|
||||
|
||||
function onWindowLeave(event: DragEvent) {
|
||||
if (!hasFiles(event)) return
|
||||
dragDepth = Math.max(0, dragDepth - 1)
|
||||
if (dragDepth === 0) {
|
||||
dragging.value = false
|
||||
overZone.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// dragover 不阻止默认行为浏览器不允许 drop;窗口级兜底 drop 防止误放导致页面跳走
|
||||
function onWindowOver(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function onWindowDrop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
dragDepth = 0
|
||||
dragging.value = false
|
||||
overZone.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('dragenter', onWindowEnter)
|
||||
window.addEventListener('dragleave', onWindowLeave)
|
||||
window.addEventListener('dragover', onWindowOver)
|
||||
window.addEventListener('drop', onWindowDrop)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('dragenter', onWindowEnter)
|
||||
window.removeEventListener('dragleave', onWindowLeave)
|
||||
window.removeEventListener('dragover', onWindowOver)
|
||||
window.removeEventListener('drop', onWindowDrop)
|
||||
})
|
||||
|
||||
const restoreInput = ref<HTMLInputElement | null>(null)
|
||||
const importInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function onPick(kind: UploadKind, event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (file) void readUpload(kind, file)
|
||||
}
|
||||
|
||||
function onDrop(kind: UploadKind, event: DragEvent) {
|
||||
const file = event.dataTransfer?.files[0]
|
||||
if (file) void readUpload(kind, file)
|
||||
}
|
||||
|
||||
/** 待确认的上传 */
|
||||
const pendingUpload = ref<{ content: string; kind: UploadKind } | null>(null)
|
||||
|
||||
async function readUpload(kind: UploadKind, file: File) {
|
||||
const content = await file.text()
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = parse(content)
|
||||
} catch (error) {
|
||||
toast(`这份文件不是合法的 YAML:${String(error)}`, { kind: 'error' })
|
||||
return
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
toast('这份文件不是一份 mihomo 配置。', { kind: 'error' })
|
||||
return
|
||||
}
|
||||
// 恢复入口只收 Patchbay 自己下载的文件,认标记。别家的配置走导入
|
||||
if (kind === 'restore' && !hasValidMarker(parsed as ConfigObject)) {
|
||||
toast('这份文件没有 Patchbay 标记,不像是从 Patchbay 下载的配置。', {
|
||||
kind: 'error',
|
||||
hint: '如果确实要用它,走「导入配置文件」。',
|
||||
})
|
||||
return
|
||||
}
|
||||
pendingUpload.value = { content, kind }
|
||||
}
|
||||
|
||||
function uploadMessage(): string {
|
||||
const kind = pendingUpload.value?.kind
|
||||
const head = kind === 'import' ? '导入这份配置文件?' : '从这份文件恢复配置?'
|
||||
const note =
|
||||
strategy.value === 'compatible'
|
||||
? '策略为兼容:只替换 Patchbay 接管的段落(入站、专属组、规则、订阅、规则组),其余段落维持当前配置。'
|
||||
: '策略为覆盖:整份替换当前配置。'
|
||||
return `${head}${note}当前配置会先自动备份。`
|
||||
}
|
||||
|
||||
async function uploadConfirmed() {
|
||||
const pending = pendingUpload.value
|
||||
pendingUpload.value = null
|
||||
if (!pending || saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await api.importConfig(pending.content, strategy.value)
|
||||
const success = pending.kind === 'restore' ? '已从文件恢复配置' : '已导入配置文件'
|
||||
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
|
||||
emit('saved')
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 子页 ─────────────────────────────────────── */
|
||||
|
||||
const sub = ref<null | 'disclaimer' | 'about'>(null)
|
||||
|
||||
/** 接口请求都发往当前源(开发时由 vite 转发给后端),这就是后端地址。 */
|
||||
const backend = location.origin
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 子页:左上角「< 设置」返回,标题加内容 -->
|
||||
<div v-if="sub" class="page">
|
||||
<button type="button" class="back" @click="sub = null"><IconBack /> 设置</button>
|
||||
|
||||
<template v-if="sub === 'disclaimer'">
|
||||
<h1 class="sub-title">{{ DISCLAIMER_TITLE }}</h1>
|
||||
<p v-for="(para, i) in DISCLAIMER" :key="i" class="para">{{ para }}</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<h1 class="sub-title">关于</h1>
|
||||
<p class="para">编辑 mihomo 配置文件的网页界面:把每一路入站接到它自己的出站上。</p>
|
||||
<div class="row">
|
||||
<span class="key">适配内核</span>
|
||||
<span class="mono">mihomo v1.19.29</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="key">当前内核</span>
|
||||
<span class="mono">{{ kernel.online && kernel.version ? kernel.version : '内核未连接' }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="key">后端地址</span>
|
||||
<span class="mono">{{ backend }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-else class="page">
|
||||
<section class="group">
|
||||
<h2 class="title">外观</h2>
|
||||
<div class="row">
|
||||
<span class="key">主题</span>
|
||||
<div class="seg">
|
||||
<button
|
||||
v-for="o in themeOptions"
|
||||
:key="o.value"
|
||||
type="button"
|
||||
:class="['seg-item', theme === o.value && 'is-on']"
|
||||
@click="pickTheme(o.value)"
|
||||
>
|
||||
{{ o.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2 class="title">测速</h2>
|
||||
<div class="row">
|
||||
<span class="key">测速地址</span>
|
||||
<InlineEdit
|
||||
:value="testUrl"
|
||||
mono
|
||||
:can-reset="testUrl !== DEFAULT_TEST_URL"
|
||||
:validate="isUrlOrEmpty"
|
||||
style="--ie-width: 320px"
|
||||
@submit="saveTestUrl"
|
||||
@reset="testUrl = DEFAULT_TEST_URL"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2 class="title">内核参数</h2>
|
||||
<div v-for="key in PORT_KEYS" :key="key" class="row">
|
||||
<span class="key mono">{{ key }}</span>
|
||||
<InlineEdit
|
||||
:value="numberText(key)"
|
||||
placeholder="未启用"
|
||||
mono
|
||||
:busy="saving"
|
||||
:validate="isPortOrEmpty"
|
||||
style="--ie-width: 120px"
|
||||
@submit="(v) => saveNumberKey(key, v, `${key} 已保存`)"
|
||||
/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="key">TCP 保持活动间隔</span>
|
||||
<InlineEdit
|
||||
:value="numberText('keep-alive-interval')"
|
||||
placeholder="内核默认"
|
||||
unit="秒"
|
||||
mono
|
||||
:busy="saving"
|
||||
:validate="isPosIntOrEmpty"
|
||||
style="--ie-width: 120px"
|
||||
@submit="(v) => saveNumberKey('keep-alive-interval', v, 'TCP 保持活动间隔已保存')"
|
||||
/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="key mono">hosts</span>
|
||||
<span class="g-right">
|
||||
<span :class="['g-meta', Object.keys(hostsOf()).length === 0 && 'faint']">
|
||||
{{
|
||||
Object.keys(hostsOf()).length > 0 ? `${Object.keys(hostsOf()).length} 条` : '未设置'
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ico"
|
||||
title="编辑"
|
||||
:disabled="saving"
|
||||
@click="hostsOpen = true"
|
||||
>
|
||||
<IconEdit />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<div class="g-head">
|
||||
<h2 class="title">资源</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="tbtn"
|
||||
:title="kernel.online ? '让内核更新它用到的全部 GEO 文件' : '内核未连接'"
|
||||
:disabled="geoBusy || geoSyncing !== null || !kernel.online"
|
||||
@click="updateGeo"
|
||||
>
|
||||
<IconSync :class="geoBusy && 'spin'" /> 更新全部
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="geoFiles === null" class="faint">载入中</p>
|
||||
<p v-else-if="geoFiles.length === 0" class="faint">读不到 GEO 文件的状态。</p>
|
||||
<template v-else>
|
||||
<div v-for="g in geoFiles" :key="g.key" class="geo">
|
||||
<div class="row" :title="g.url">
|
||||
<span class="key mono">{{ g.key }}</span>
|
||||
<span class="g-right">
|
||||
<span :class="['g-meta', g.size === null && 'faint']">{{ geoMeta(g) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ico"
|
||||
title="编辑下载地址"
|
||||
:disabled="saving"
|
||||
@mousedown.prevent
|
||||
@click="toggleGeoEdit(g)"
|
||||
>
|
||||
<IconEdit />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ico"
|
||||
title="单独下载这一个文件(重启内核后生效)"
|
||||
:disabled="geoBusy || geoSyncing !== null"
|
||||
@click="syncOne(g)"
|
||||
>
|
||||
<IconSync :class="geoSyncing === g.configKey && 'spin'" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
v-if="geoEditing === g.configKey"
|
||||
:ref="setGeoInput"
|
||||
v-model="geoDraft"
|
||||
:class="['g-input mono', geoBad && 'is-bad']"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:placeholder="g.url"
|
||||
@keydown.enter.prevent="finishGeoEdit(g, false)"
|
||||
@keydown.esc.prevent="geoEditing = null"
|
||||
@blur="finishGeoEdit(g, true)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2 class="title">配置文件</h2>
|
||||
<div class="row">
|
||||
<span class="key">备份到本地</span>
|
||||
<button type="button" class="btn with-ico" @click="downloadConfig">
|
||||
<IconDownload /> 下载文件
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 拖着文件进窗口时,两个上传行整行变成投放区 -->
|
||||
<div
|
||||
v-if="dragging"
|
||||
:class="['drop', overZone === 'restore' && 'is-over']"
|
||||
@dragenter="overZone = 'restore'"
|
||||
@dragleave="overZone = null"
|
||||
@dragover.prevent
|
||||
@drop.prevent="onDrop('restore', $event)"
|
||||
>
|
||||
拖拽到此处,恢复配置
|
||||
</div>
|
||||
<div v-else class="row">
|
||||
<span class="key">通过文件恢复配置</span>
|
||||
<button type="button" class="btn" :disabled="saving" @click="restoreInput?.click()">
|
||||
选择或拖放文件
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="key">恢复策略</span>
|
||||
<SelectBox v-model="strategy" :options="STRATEGY_OPTIONS" class="strategy" aria-label="恢复策略" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="dragging"
|
||||
:class="['drop', overZone === 'import' && 'is-over']"
|
||||
@dragenter="overZone = 'import'"
|
||||
@dragleave="overZone = null"
|
||||
@dragover.prevent
|
||||
@drop.prevent="onDrop('import', $event)"
|
||||
>
|
||||
拖拽到此处,导入配置文件
|
||||
</div>
|
||||
<div v-else class="row">
|
||||
<span class="key">导入配置文件</span>
|
||||
<button type="button" class="btn" :disabled="saving" @click="importInput?.click()">
|
||||
选择或拖放文件
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="restoreInput"
|
||||
type="file"
|
||||
accept=".yaml,.yml"
|
||||
hidden
|
||||
@change="onPick('restore', $event)"
|
||||
/>
|
||||
<input
|
||||
ref="importInput"
|
||||
type="file"
|
||||
accept=".yaml,.yml"
|
||||
hidden
|
||||
@change="onPick('import', $event)"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2 class="title">连接</h2>
|
||||
<div class="row">
|
||||
<span class="key">后端地址</span>
|
||||
<span class="mono">{{ backend }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="key">内核状态</span>
|
||||
<span>{{ kernel.online ? '已连接' : '内核未连接' }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2 class="title">其他</h2>
|
||||
<button type="button" class="more" @click="sub = 'disclaimer'">
|
||||
<span>{{ DISCLAIMER_TITLE }}</span>
|
||||
<IconRight aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" class="more" @click="sub = 'about'">
|
||||
<span>关于</span>
|
||||
<IconRight aria-hidden="true" />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<HostsForm
|
||||
:open="hostsOpen"
|
||||
:hosts="hostsOf()"
|
||||
@close="hostsOpen = false"
|
||||
@save="submitHosts"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="pendingUpload !== null"
|
||||
:message="uploadMessage()"
|
||||
:action="pendingUpload?.kind === 'import' ? '导入' : '恢复'"
|
||||
@close="pendingUpload = null"
|
||||
@confirm="uploadConfirmed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 内容少的页面收窄居中,段落之间用分割线而不是卡片——一屏最多两层容器 */
|
||||
.page {
|
||||
max-width: 640px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.group {
|
||||
padding: var(--s-5) 0;
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.group:first-of-type {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--t-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-4);
|
||||
padding: var(--s-2) 0;
|
||||
}
|
||||
|
||||
.key {
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.faint {
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
}
|
||||
|
||||
.seg-item {
|
||||
padding: var(--s-1) var(--s-3);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seg-item + .seg-item {
|
||||
border-left: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.seg-item.is-on {
|
||||
background: var(--c-accent-soft);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
/* 焦点圈画在里面,外面的会被相邻边框和圆角裁掉 */
|
||||
.seg-item:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.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:hover:not(:disabled) {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
.btn:disabled {
|
||||
color: var(--c-text-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.with-ico {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
/* ── 资源 ────────────────────────────────────── */
|
||||
.g-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.g-head .title {
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
|
||||
/* 文字按钮:图标加一个词,不占按钮的份量 */
|
||||
.tbtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-1) var(--s-2);
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tbtn:hover:not(:disabled) {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.tbtn:disabled {
|
||||
color: var(--c-text-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.tbtn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.g-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.g-meta {
|
||||
font-size: var(--t-sm);
|
||||
color: var(--c-text);
|
||||
margin-right: var(--s-2);
|
||||
}
|
||||
|
||||
.ico {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ico:hover:not(:disabled) {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.ico:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ico svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 下载地址很长,编辑框独占一行放在行下面 */
|
||||
.g-input {
|
||||
width: 100%;
|
||||
margin: 0 0 var(--s-2);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
.g-input.is-bad {
|
||||
border-color: var(--c-danger);
|
||||
}
|
||||
|
||||
/* ── 配置文件 ────────────────────────────────── */
|
||||
.drop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--s-3) 0;
|
||||
margin: var(--s-1) 0;
|
||||
border: 1px dashed var(--c-border-strong);
|
||||
border-radius: var(--r-md);
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.drop.is-over {
|
||||
border-color: var(--c-accent);
|
||||
background: var(--c-accent-soft);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.strategy {
|
||||
width: 140px;
|
||||
--select-bg: var(--c-surface);
|
||||
}
|
||||
|
||||
/* ── 其他:整行入口,形态与信息行一致,右侧箭头示意可进入 ── */
|
||||
.more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: var(--s-2) 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: var(--t-sm);
|
||||
color: var(--c-text-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
.more svg {
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
.more:hover,
|
||||
.more:hover svg {
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
/* ── 子页 ────────────────────────────────────── */
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-1) var(--s-2);
|
||||
margin-bottom: var(--s-4);
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.back:hover {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: var(--t-xl);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--s-4);
|
||||
}
|
||||
|
||||
.para {
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
line-height: var(--line);
|
||||
}
|
||||
|
||||
.para + .para {
|
||||
margin-top: var(--s-3);
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,317 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 选中那个订阅的详情:地址、更新间隔,和它带来的线路。
|
||||
*
|
||||
* 健康检查不在字段区展示,只是右上角一个开关动作:默认就是开着的,
|
||||
* 值得占一行的只有「被谁关掉了」这件事,而按钮文案本身已经说明了状态。
|
||||
*
|
||||
* 线路列表只读。这些线路不在内核的全局名字表里,不能被入站直接引用,
|
||||
* 只能经策略组的 `use:` 整批引入,所以这里没有「换线」可点。
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import type { Subscription } from '@shared/providers.ts'
|
||||
import { latestDelay } from '../api'
|
||||
import type { ProviderInfo } from '../api'
|
||||
import IconEdit from '~icons/ri/edit-line'
|
||||
import IconDelete from '~icons/ri/delete-bin-line'
|
||||
import IconRefresh from '~icons/ri/refresh-line'
|
||||
import IconOffline from '~icons/ri/cloud-off-line'
|
||||
import IconPulse from '~icons/ri/pulse-line'
|
||||
import IconSync from '~icons/ri/loop-right-line'
|
||||
import IconLoad from '~icons/ri/loader-4-line'
|
||||
|
||||
const props = defineProps<{
|
||||
subscription: Subscription
|
||||
/** 运行时数据:当前有哪些线路、上次更新时间。内核没连接时是 undefined */
|
||||
info: ProviderInfo | undefined
|
||||
online: boolean
|
||||
/** 正在等内核重新拉取 */
|
||||
refreshing: boolean
|
||||
/** 正在等内核测完这个订阅的线路 */
|
||||
checking: boolean
|
||||
/** 配置正在保存 */
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: []
|
||||
remove: []
|
||||
/** 让内核立刻重新拉取。运行时动作,不写配置文件 */
|
||||
refresh: []
|
||||
/** 让内核立刻测一遍这个订阅的线路。运行时动作,不写配置文件 */
|
||||
check: []
|
||||
/** 开关健康检查。写配置文件,走保存链路 */
|
||||
toggleHealthCheck: []
|
||||
}>()
|
||||
|
||||
const lines = computed(() => props.info?.proxies ?? [])
|
||||
|
||||
function delayClass(ms: number | null): string {
|
||||
if (ms === null) return ''
|
||||
if (ms < 200) return 'd-ok'
|
||||
if (ms < 500) return 'd-warn'
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="detail">
|
||||
<header class="top">
|
||||
<div class="title">
|
||||
<span class="name">{{ subscription.name }}</span>
|
||||
<div class="acts">
|
||||
<button type="button" :disabled="saving" @click="emit('toggleHealthCheck')">
|
||||
<IconPulse /> {{ subscription.healthCheck ? '关闭健康检查' : '开启健康检查' }}
|
||||
</button>
|
||||
<button type="button" :disabled="!online || refreshing" @click="emit('refresh')">
|
||||
<IconRefresh /> {{ refreshing ? '更新中' : '更新订阅' }}
|
||||
</button>
|
||||
<button type="button" :disabled="saving" @click="emit('edit')">
|
||||
<IconEdit /> 编辑
|
||||
</button>
|
||||
<button type="button" class="danger" :disabled="saving" @click="emit('remove')">
|
||||
<IconDelete /> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="facts">
|
||||
<template v-if="subscription.type === 'file'">
|
||||
<dt>文件</dt>
|
||||
<dd class="mono url">{{ subscription.path ?? '—' }}</dd>
|
||||
</template>
|
||||
<template v-else>
|
||||
<dt>地址</dt>
|
||||
<dd class="mono url">{{ subscription.url }}</dd>
|
||||
<dt>自动更新</dt>
|
||||
<dd class="mono">
|
||||
{{ subscription.interval !== null ? `每 ${subscription.interval} 秒` : '已关闭' }}
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<section class="block">
|
||||
<h3>
|
||||
线路
|
||||
<button
|
||||
type="button"
|
||||
class="check"
|
||||
:title="checking ? '检查中' : '检查全部线路的延迟'"
|
||||
:disabled="!online || checking || lines.length === 0"
|
||||
@click="emit('check')"
|
||||
>
|
||||
<IconLoad v-if="checking" class="spin" />
|
||||
<IconSync v-else />
|
||||
</button>
|
||||
</h3>
|
||||
|
||||
<p v-if="!online" class="hint"><IconOffline /> 内核未连接</p>
|
||||
<p v-else-if="lines.length === 0" class="hint">这个订阅现在没有线路</p>
|
||||
<ol v-else class="lines">
|
||||
<li v-for="line in lines" :key="line.name" class="row">
|
||||
<span class="line-name">{{ line.name }}</span>
|
||||
<span class="line-type mono">{{ line.type }}</span>
|
||||
<span :class="['line-delay mono', delayClass(latestDelay(line))]">
|
||||
{{ latestDelay(line) !== null ? `${latestDelay(line)} ms` : '—' }}
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail {
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
padding: var(--s-4) var(--s-5) var(--s-5);
|
||||
align-self: start;
|
||||
position: sticky;
|
||||
top: var(--s-6);
|
||||
}
|
||||
|
||||
.top {
|
||||
padding-bottom: var(--s-4);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-3);
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: var(--t-xl);
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.acts button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-1) var(--s-2);
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.acts button:hover:not(:disabled) {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.acts button.danger:hover:not(:disabled) {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.acts button:disabled {
|
||||
color: var(--c-text-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--s-1) var(--s-4);
|
||||
margin: 0;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.facts dt {
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
.facts dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.url {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.block {
|
||||
padding-top: var(--s-4);
|
||||
}
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--t-sm);
|
||||
font-weight: 600;
|
||||
color: var(--c-text);
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.check:hover:not(:disabled) {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.check:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.lines {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
/* 细杆加透明轨道,别让系统滚动条压在延迟列上;gutter 常驻,
|
||||
线路数跨过滚动阈值时右缘不横跳 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-strong) transparent;
|
||||
scrollbar-gutter: stable;
|
||||
padding-right: var(--s-2);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto 4.5rem;
|
||||
align-items: baseline;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-1) 0;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.row + .row {
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.line-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-type {
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-xs);
|
||||
}
|
||||
|
||||
.line-delay {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
.d-ok {
|
||||
color: var(--c-ok);
|
||||
}
|
||||
.d-warn {
|
||||
color: var(--c-warn);
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user