Compare commits
26
Commits
158dc9fca7
..
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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"
|
||||||
+11
-7
@@ -10,9 +10,9 @@
|
|||||||
mihomo 上的一个监听端口,客户端连上它把流量交进来。
|
mihomo 上的一个监听端口,客户端连上它把流量交进来。
|
||||||
_Avoid_: inbound、监听器、listener、进站
|
_Avoid_: inbound、监听器、listener、进站
|
||||||
|
|
||||||
**默认入站**:
|
**legacy 入站**:
|
||||||
由配置文件顶层端口字段声明的入站(`mixed-port`、`port`、`socks-port`、`redir-port`、`tproxy-port`)。每种类型只能有一个,且无法单独指定出站。界面上不可删除。
|
由配置文件顶层端口字段声明的入站(`mixed-port`、`port`、`socks-port`、`redir-port`、`tproxy-port`)。每种类型只能有一个,且无法单独指定出站。界面上不可删除,可升级为命名入站。
|
||||||
_Avoid_: 主端口、老式端口、默认端口
|
_Avoid_: 默认入站、主端口、老式端口、默认端口
|
||||||
|
|
||||||
**命名入站**:
|
**命名入站**:
|
||||||
由 `listeners:` 声明的入站。数量不限,每个可以有自己的出站和附加规则。
|
由 `listeners:` 声明的入站。数量不限,每个可以有自己的出站和附加规则。
|
||||||
@@ -85,9 +85,13 @@ _Avoid_: 无效引用、悬空引用、坏引用
|
|||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
**分流规则**:
|
**分流规则**:
|
||||||
配置文件 `rules:` 里的有序列表,自上而下匹配,决定一条连接走哪个出站。顺序本身是语义的一部分。
|
自上而下匹配、决定一条连接走哪个出站的有序规则列表,第一条命中即生效。顺序本身是语义的一部分。全局规则和附加规则都是分流规则。
|
||||||
_Avoid_: 路由规则、规则表
|
_Avoid_: 路由规则、规则表
|
||||||
|
|
||||||
|
**全局规则**:
|
||||||
|
配置文件顶层 `rules:` 里的分流规则表。只对没有附加规则的入站生效——带附加规则的入站整表改查自己的表,不再看它。
|
||||||
|
_Avoid_: 顶层规则、主规则、默认规则
|
||||||
|
|
||||||
**附加规则**:
|
**附加规则**:
|
||||||
挂在某一路入站上的一组分流规则,只对从该入站进来的流量生效。
|
挂在某一路入站上的一组分流规则,只对从该入站进来的流量生效。
|
||||||
_Avoid_: sub-rules、子规则、私有规则
|
_Avoid_: sub-rules、子规则、私有规则
|
||||||
@@ -98,6 +102,6 @@ _Avoid_: sub-rules、子规则、私有规则
|
|||||||
Patchbay 负责读写的配置段落。其余段落原样保留、界面上也不呈现。
|
Patchbay 负责读写的配置段落。其余段落原样保留、界面上也不呈现。
|
||||||
_Avoid_: 托管段落、受控段落
|
_Avoid_: 托管段落、受控段落
|
||||||
|
|
||||||
**默认路由**:
|
**legacy 路由**:
|
||||||
默认入站在界面上的呈现。它对应配置文件顶层的端口字段而非 `listeners:`,因此不可删除。
|
legacy 入站在界面上的呈现。它对应配置文件顶层的端口字段而非 `listeners:`,因此不可删除。
|
||||||
_Avoid_: 主路由、根路由
|
_Avoid_: 默认路由、主路由、根路由
|
||||||
|
|||||||
@@ -2,11 +2,9 @@
|
|||||||
|
|
||||||
一个跑在服务器上的网页,用来编辑那台机器上 mihomo 的 `config.yaml` 并让改动生效。
|
一个跑在服务器上的网页,用来编辑那台机器上 mihomo 的 `config.yaml` 并让改动生效。
|
||||||
|
|
||||||
核心动作是把某一路入站接到某一个出站上——名字取自录音棚的插线板。典型用途是给每个容器开一个端口,各自指定走哪条线路。
|
核心动作是把某一路入站接到某一个出站上,名字取自录音棚的插线板。典型用途是给每个容器开一个端口,各自指定走哪条线路。
|
||||||
|
|
||||||
聚合订阅不在范围内(那是 Sub-Store 的活),安装升级内核也不在(那是 `mihomo-up.sh` 的活)。
|
聚合订阅不在范围内(那是 Sub-Store 的活),安装升级内核也不在(那是 `mihomo-up.sh` 的活)。术语以 [CONTEXT.md](./CONTEXT.md) 为准。
|
||||||
|
|
||||||
术语以 [CONTEXT.md](./CONTEXT.md) 为准。为什么这么设计记在仓库外面的 `docs/adr/`。
|
|
||||||
|
|
||||||
## 跑起来
|
## 跑起来
|
||||||
|
|
||||||
@@ -18,49 +16,36 @@ cp .env.example .env # 按里面的注释填
|
|||||||
bun run start # 打包前端并启动,默认 http://127.0.0.1:7580
|
bun run start # 打包前端并启动,默认 http://127.0.0.1:7580
|
||||||
```
|
```
|
||||||
|
|
||||||
首次启动会生成一个访问令牌,打印在控制台上,同时存到配置文件旁边的 `.patchbay-token`。
|
首次启动会生成访问令牌,打印在控制台上,也存在配置文件旁边的 `.patchbay-token`。
|
||||||
|
|
||||||
**内核不在线也能用**,只是看不到订阅里有哪些线路、也没法让改动立刻生效。这是有意的——配置写坏导致内核起不来的时候,正是最需要这个界面的时候。
|
内核不在线也能用,只是看不到订阅里有哪些线路,改动也要等内核启动才生效。这是有意的:配置写坏导致内核起不来的时候,正是最需要这个界面的时候。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
改代码的时候用这个,前后端都不用重启:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev:kernel # 终端 1:本地测试内核,开着不用管
|
bun run dev:kernel # 终端 1:本地测试内核,开着不用管
|
||||||
bun run dev:all # 终端 2:后端 + 前端,改了自动生效
|
bun run dev:all # 终端 2:后端 + 前端,改了自动生效
|
||||||
```
|
```
|
||||||
|
|
||||||
然后访问 **http://localhost:5173**(不是 7580)。后端代码改动由 Bun 的 `--hot` 重载,
|
然后访问 http://localhost:5173(不是 7580)。第一次先运行 `bun run dev:reset` 生成测试配置:模板在 `dev/config.yaml`,订阅地址从 `.env` 取。
|
||||||
界面改动由 Vite 直接更新浏览器。
|
|
||||||
|
|
||||||
`bun run start` 不具备这个能力——它把前端打成静态文件再由后端提供,代码一改就得重新
|
|
||||||
打包重启。那种方式适合最终验证和部署。
|
|
||||||
|
|
||||||
本地测试内核的配置由模板生成(`dev/config.yaml` 进 git,订阅地址从 `.env` 取,
|
|
||||||
生成的 `dev/home/config.yaml` 不进 git):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run dev:reset # 第一次,或想把测试配置恢复成模板时
|
|
||||||
```
|
|
||||||
|
|
||||||
## 检查
|
## 检查
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun test # 单元测试,不需要内核
|
bun test # 单元测试,不需要内核
|
||||||
bun run smoke # 接口冒烟测试,需要内核和后端都在跑
|
bun run smoke # 接口冒烟测试,需要内核和后端都在跑
|
||||||
bun run typecheck
|
bun run typecheck
|
||||||
```
|
```
|
||||||
|
|
||||||
另有两个一次性脚本:`scripts/roundtrip.ts` 验证改一处配置之后 diff 里只出现那一处,`scripts/safety-net.ts` 验证配置写坏时能不能把用户救回来。
|
`scripts/` 里另有几个手动验证脚本(写回保真、落盘保护链路、链式代理的生成产物),用法写在各文件头部。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
```
|
```
|
||||||
server/ 后端。config-store.ts 是核心,那条保护链路在里面
|
server/ 后端。config-store.ts 是核心,落盘保护链路在里面
|
||||||
web/ 前端。styles/tokens.css 顶部写着设计基调的三条规则
|
web/ 前端。styles/tokens.css 顶部写着设计基调
|
||||||
shared/ 两边共用的领域逻辑,纯函数,带测试
|
shared/ 两边共用的领域逻辑,纯函数,带测试
|
||||||
vendor/ 从别的项目复制来的代码,来源和本地改动记在各自的 ORIGIN.md
|
vendor/ 从别的项目复制来的代码,来源和本地改动记在各自的 ORIGIN.md
|
||||||
scripts/ 验证用的脚本,不参与打包
|
scripts/ 验证脚本,不参与打包
|
||||||
dev/ 本地测试内核的配置
|
dev/ 本地测试内核的配置
|
||||||
```
|
```
|
||||||
|
|||||||
+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,6 +4,7 @@ import { tmpdir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import {
|
import {
|
||||||
applyConfig,
|
applyConfig,
|
||||||
|
backupDiff,
|
||||||
importConfig,
|
importConfig,
|
||||||
listBackups,
|
listBackups,
|
||||||
listRedo,
|
listRedo,
|
||||||
@@ -253,3 +254,83 @@ describe('落盘保护链路(内核在线)', () => {
|
|||||||
expect(backups).not.toContain('config.yaml.bak-20200101-000010')
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+55
-6
@@ -30,6 +30,7 @@ import {
|
|||||||
} from '../vendor/config-editor/index.ts'
|
} from '../vendor/config-editor/index.ts'
|
||||||
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
||||||
import { restoreInlineCommentAlignment } from '../shared/yaml-cosmetics.ts'
|
import { restoreInlineCommentAlignment } from '../shared/yaml-cosmetics.ts'
|
||||||
|
import { backupDate } from '../shared/backups.ts'
|
||||||
import { withMarker } from '../shared/config-marker.ts'
|
import { withMarker } from '../shared/config-marker.ts'
|
||||||
import { kernelVersion, validateConfigFile, reloadConfig } from './kernel.ts'
|
import { kernelVersion, validateConfigFile, reloadConfig } from './kernel.ts'
|
||||||
import type { Env } from './env.ts'
|
import type { Env } from './env.ts'
|
||||||
@@ -37,6 +38,22 @@ import type { Env } from './env.ts'
|
|||||||
/** 备份保留的份数。再多没有意义——真要回到很久以前的版本,那是版本控制的活。 */
|
/** 备份保留的份数。再多没有意义——真要回到很久以前的版本,那是版本控制的活。 */
|
||||||
const KEEP_BACKUPS = 10
|
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 {
|
export interface ConfigSnapshot {
|
||||||
/** 解析出来的配置数据 */
|
/** 解析出来的配置数据 */
|
||||||
data: ConfigObject
|
data: ConfigObject
|
||||||
@@ -110,12 +127,15 @@ export function rewriteYaml(currentYaml: string, nextData: ConfigObject): string
|
|||||||
* `opts.restoreFrom`:撤回/重做专用。给了它就不再新建备份(否则备份链会被
|
* `opts.restoreFrom`:撤回/重做专用。给了它就不再新建备份(否则备份链会被
|
||||||
* 撤回本身污染,撤两次等于原地打转),重载失败时用它当还原源;同时不清空
|
* 撤回本身污染,撤两次等于原地打转),重载失败时用它当还原源;同时不清空
|
||||||
* 重做栈——只有「正常的新改动」才让已撤回的未来失效。
|
* 重做栈——只有「正常的新改动」才让已撤回的未来失效。
|
||||||
|
*
|
||||||
|
* `opts.merge`:界面上的普通保存专用。最新备份还在合并窗内时不新建备份,
|
||||||
|
* 连续操作共享一条历史。还原备份、上传导入这类独立动作不传,各占一条。
|
||||||
*/
|
*/
|
||||||
export async function applyConfig(
|
export async function applyConfig(
|
||||||
env: Env,
|
env: Env,
|
||||||
nextData: ConfigObject,
|
nextData: ConfigObject,
|
||||||
baseRevision?: string,
|
baseRevision?: string,
|
||||||
opts?: { restoreFrom?: string },
|
opts?: { restoreFrom?: string; merge?: boolean },
|
||||||
): Promise<ApplyOutcome> {
|
): Promise<ApplyOutcome> {
|
||||||
const current = await readDocument(env)
|
const current = await readDocument(env)
|
||||||
|
|
||||||
@@ -189,9 +209,11 @@ export async function applyConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验过了才动真文件。备份放在写入之前。
|
// 校验过了才动真文件。备份放在写入之前;合并窗内跳过新建,重载失败的还原
|
||||||
const backupPath = opts?.restoreFrom ?? (await createBackup(env))
|
// 不受影响——没有备份文件时用刚读出的原文写回(见下面的还原分支)。
|
||||||
const backupTiming = opts?.restoreFrom ? '' : 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 {
|
try {
|
||||||
await writeFile(env.configPath, nextYaml, 'utf8')
|
await writeFile(env.configPath, nextYaml, 'utf8')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -226,9 +248,11 @@ export async function applyConfig(
|
|||||||
if (!reloaded.ok) {
|
if (!reloaded.ok) {
|
||||||
// 文件已经写进去了但内核没接受。把文件还原,再让内核载回原样,
|
// 文件已经写进去了但内核没接受。把文件还原,再让内核载回原样,
|
||||||
// 避免留下「文件是新的、内核跑的是旧的」这种最难排查的状态。
|
// 避免留下「文件是新的、内核跑的是旧的」这种最难排查的状态。
|
||||||
|
// 合并窗内没有备份文件,还原源用刚读出的原文。
|
||||||
let restored = false
|
let restored = false
|
||||||
try {
|
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)
|
await reloadConfig(env, env.configPath)
|
||||||
restored = true
|
restored = true
|
||||||
} catch {
|
} catch {
|
||||||
@@ -240,7 +264,9 @@ export async function applyConfig(
|
|||||||
message: reloaded.message,
|
message: reloaded.message,
|
||||||
hint: restored
|
hint: restored
|
||||||
? '已经把配置还原成改动前的样子,内核也载回去了。改动没有生效。'
|
? '已经把配置还原成改动前的样子,内核也载回去了。改动没有生效。'
|
||||||
: `还原也失败了。改动前的配置留在 ${backupPath},需要手动拷回 ${env.configPath}。`,
|
: backupPath !== null
|
||||||
|
? `还原也失败了。改动前的配置留在 ${backupPath},需要手动拷回 ${env.configPath}。`
|
||||||
|
: '还原也失败了。历史记录里最新的备份和改动前至多差几秒内的操作,可以从它还原。',
|
||||||
restored,
|
restored,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,6 +469,29 @@ export async function redoConfig(env: Env): Promise<ApplyOutcome> {
|
|||||||
return result
|
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> {
|
export async function restoreBackup(env: Env, name: string): Promise<ApplyOutcome> {
|
||||||
const dir = await backupDir(env)
|
const dir = await backupDir(env)
|
||||||
|
|||||||
+2
-1
@@ -12,6 +12,7 @@ import {
|
|||||||
cpuUsageBetween,
|
cpuUsageBetween,
|
||||||
parseGateway,
|
parseGateway,
|
||||||
parseMemAvailable,
|
parseMemAvailable,
|
||||||
|
parseMemTotal,
|
||||||
parseNameservers,
|
parseNameservers,
|
||||||
} from '../shared/host-info.ts'
|
} from '../shared/host-info.ts'
|
||||||
import type { CpuTimes, ExitProbe, HostInfo } from '../shared/host-info.ts'
|
import type { CpuTimes, ExitProbe, HostInfo } from '../shared/host-info.ts'
|
||||||
@@ -56,7 +57,7 @@ export async function hostInfo(): Promise<HostInfo> {
|
|||||||
return {
|
return {
|
||||||
cpuUsage,
|
cpuUsage,
|
||||||
cpuCount: os.cpus().length,
|
cpuCount: os.cpus().length,
|
||||||
memTotal: os.totalmem(),
|
memTotal: parseMemTotal(meminfo) ?? os.totalmem(),
|
||||||
memAvailable: parseMemAvailable(meminfo) ?? os.freemem(),
|
memAvailable: parseMemAvailable(meminfo) ?? os.freemem(),
|
||||||
// loadavg 在 Windows 上恒为 0,那不是「负载为零」而是「没有这个概念」
|
// loadavg 在 Windows 上恒为 0,那不是「负载为零」而是「没有这个概念」
|
||||||
load1: process.platform === 'win32' ? null : load,
|
load1: process.platform === 'win32' ? null : load,
|
||||||
|
|||||||
+7
-2
@@ -184,8 +184,13 @@ export async function updateGeoDatabases(env: Env): Promise<ValidateResult> {
|
|||||||
* 持续吐 JSON 的分块流(内核源码 hub/route/server.go:380-383),给它们设超时等于
|
* 持续吐 JSON 的分块流(内核源码 hub/route/server.go:380-383),给它们设超时等于
|
||||||
* 每隔一会儿就把连接掐断一次。
|
* 每隔一会儿就把连接掐断一次。
|
||||||
*/
|
*/
|
||||||
export async function proxyGet(env: Env, path: string, stream = false): Promise<Response> {
|
export async function proxyGet(
|
||||||
return callApi(env, path, stream ? {} : { signal: AbortSignal.timeout(15_000) })
|
env: Env,
|
||||||
|
path: string,
|
||||||
|
stream = false,
|
||||||
|
timeoutMs = 15_000,
|
||||||
|
): Promise<Response> {
|
||||||
|
return callApi(env, path, stream ? {} : { signal: AbortSignal.timeout(timeoutMs) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 透传一个写操作。目前只有断开连接和刷新订阅两处。 */
|
/** 透传一个写操作。目前只有断开连接和刷新订阅两处。 */
|
||||||
|
|||||||
+20
-2
@@ -14,6 +14,7 @@ import listenerFields from '../shared/listener-fields.json' with { type: 'json'
|
|||||||
import { isProviderFilePath } from '../shared/provider-files.ts'
|
import { isProviderFilePath } from '../shared/provider-files.ts'
|
||||||
import {
|
import {
|
||||||
applyConfig,
|
applyConfig,
|
||||||
|
backupDiff,
|
||||||
importConfig,
|
importConfig,
|
||||||
listBackups,
|
listBackups,
|
||||||
listRedo,
|
listRedo,
|
||||||
@@ -186,7 +187,8 @@ function makeRoute(env: Env, token: string) {
|
|||||||
if (!body.data || typeof body.data !== 'object') {
|
if (!body.data || typeof body.data !== 'object') {
|
||||||
return json({ message: '请求缺少 data 字段' }, 400)
|
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))
|
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +233,14 @@ function makeRoute(env: Env, token: string) {
|
|||||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
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)
|
const restoreMatch = /^\/api\/config\/backups\/([^/]+)\/restore$/.exec(path)
|
||||||
if (restoreMatch && req.method === 'POST') {
|
if (restoreMatch && req.method === 'POST') {
|
||||||
const name = decodeURIComponent(restoreMatch[1]!)
|
const name = decodeURIComponent(restoreMatch[1]!)
|
||||||
@@ -356,7 +366,15 @@ function makeRoute(env: Env, token: string) {
|
|||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
return json({ message: `不允许访问该路径:${target}` }, 403)
|
return json({ message: `不允许访问该路径:${target}` }, 403)
|
||||||
}
|
}
|
||||||
const upstream = await proxyGet(env, target + url.search, STREAM_PATHS.has(target))
|
// 健康检查是同步的:内核测完才回话,一批 10 条、每条最多 5 秒
|
||||||
|
// (内核源码 adapter/provider/healthcheck.go:132、181、201),
|
||||||
|
// 整条订阅测且线路多又都测不通时会远超默认的 15 秒
|
||||||
|
const upstream = await proxyGet(
|
||||||
|
env,
|
||||||
|
target + url.search,
|
||||||
|
STREAM_PATHS.has(target),
|
||||||
|
HEALTHCHECK_PATH.test(target) ? 120_000 : undefined,
|
||||||
|
)
|
||||||
return new Response(upstream.body, {
|
return new Response(upstream.body, {
|
||||||
status: upstream.status,
|
status: upstream.status,
|
||||||
headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' },
|
headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' },
|
||||||
|
|||||||
@@ -13,3 +13,17 @@ export function backupTime(name: string): string | null {
|
|||||||
if (!m) return null
|
if (!m) return null
|
||||||
return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}`
|
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]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ describe('按入站聚合', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('默认入站用内核给的内部名,附上端口', () => {
|
it('legacy 入站用内核给的内部名,附上端口', () => {
|
||||||
const list = [conn({ metadata: meta({ inboundName: 'DEFAULT-MIXED', inboundPort: '17890' }) })]
|
const list = [conn({ metadata: meta({ inboundName: 'DEFAULT-MIXED', inboundPort: '17890' }) })]
|
||||||
expect(groupByInbound(list)[0]?.label).toBe('DEFAULT-MIXED(17890)')
|
expect(groupByInbound(list)[0]?.label).toBe('DEFAULT-MIXED(17890)')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface ConnectionMeta {
|
|||||||
host: string
|
host: string
|
||||||
destinationIP: string
|
destinationIP: string
|
||||||
destinationPort: string
|
destinationPort: string
|
||||||
/** 从哪一路入站进来:命名入站是它的名字,默认入站是内核造的 DEFAULT-* */
|
/** 从哪一路入站进来:命名入站是它的名字,legacy 入站是内核造的 DEFAULT-* */
|
||||||
inboundName: string
|
inboundName: string
|
||||||
inboundPort: string
|
inboundPort: string
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ export function filterConnections(list: ConnectionItem[], query: string): Connec
|
|||||||
|
|
||||||
/** 一路入站当前有多少条连接。 */
|
/** 一路入站当前有多少条连接。 */
|
||||||
export interface InboundUse {
|
export interface InboundUse {
|
||||||
/** 命名入站显示名字;默认入站内核只给 DEFAULT-* 这类内部名,附上端口好认 */
|
/** 命名入站显示名字;legacy 入站内核只给 DEFAULT-* 这类内部名,附上端口好认 */
|
||||||
label: string
|
label: string
|
||||||
count: number
|
count: number
|
||||||
upload: number
|
upload: number
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+14
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'bun:test'
|
import { describe, expect, it } from 'bun:test'
|
||||||
import { formatAgo, formatBytes } from './format.ts'
|
import { flagEmoji, formatAgo, formatBytes } from './format.ts'
|
||||||
|
|
||||||
describe('流量数字', () => {
|
describe('流量数字', () => {
|
||||||
it('零和不满 1KB 的按字节显示,不带小数', () => {
|
it('零和不满 1KB 的按字节显示,不带小数', () => {
|
||||||
@@ -29,6 +29,19 @@ describe('流量数字', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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('相对时间', () => {
|
describe('相对时间', () => {
|
||||||
const now = new Date('2026-08-12T12:00:00Z')
|
const now = new Date('2026-08-12T12:00:00Z')
|
||||||
const ago = (ms: number) => new Date(now.getTime() - ms)
|
const ago = (ms: number) => new Date(now.getTime() - ms)
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ export function formatAgo(at: Date, now: Date): string {
|
|||||||
return `${String(at.getFullYear())}-${p(at.getMonth() + 1)}-${p(at.getDate())}`
|
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 {
|
export function formatUptime(seconds: number): string {
|
||||||
if (!Number.isFinite(seconds) || seconds < 0) return '—'
|
if (!Number.isFinite(seconds) || seconds < 0) return '—'
|
||||||
|
|||||||
@@ -3,9 +3,21 @@ import {
|
|||||||
cpuUsageBetween,
|
cpuUsageBetween,
|
||||||
parseGateway,
|
parseGateway,
|
||||||
parseMemAvailable,
|
parseMemAvailable,
|
||||||
|
parseMemTotal,
|
||||||
parseNameservers,
|
parseNameservers,
|
||||||
} from './host-info.ts'
|
} 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', () => {
|
describe('parseMemAvailable', () => {
|
||||||
test('取 MemAvailable 并换算成字节', () => {
|
test('取 MemAvailable 并换算成字节', () => {
|
||||||
const meminfo = 'MemTotal: 2048000 kB\nMemFree: 100000 kB\nMemAvailable: 512000 kB\n'
|
const meminfo = 'MemTotal: 2048000 kB\nMemFree: 100000 kB\nMemAvailable: 512000 kB\n'
|
||||||
|
|||||||
+19
-3
@@ -11,6 +11,7 @@ export interface HostInfo {
|
|||||||
/** 0-100。第一次请求没有参照快照,是 null */
|
/** 0-100。第一次请求没有参照快照,是 null */
|
||||||
cpuUsage: number | null
|
cpuUsage: number | null
|
||||||
cpuCount: number
|
cpuCount: number
|
||||||
|
/** 内存总量。Linux 用 MemTotal,其余平台退回 totalmem */
|
||||||
memTotal: number
|
memTotal: number
|
||||||
/** 还能用的内存。Linux 用 MemAvailable,其余平台退回 freemem */
|
/** 还能用的内存。Linux 用 MemAvailable,其余平台退回 freemem */
|
||||||
memAvailable: number
|
memAvailable: number
|
||||||
@@ -37,13 +38,28 @@ export interface ExitProbe {
|
|||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /proc/meminfo 里的 MemAvailable(内核估算的「还能用多少」),换算成字节。 */
|
function meminfoBytes(meminfo: string, field: string): number | null {
|
||||||
export function parseMemAvailable(meminfo: string): number | null {
|
const match = new RegExp(`^${field}:\\s+(\\d+)\\s*kB`, 'm').exec(meminfo)
|
||||||
const match = /^MemAvailable:\s+(\d+)\s*kB/m.exec(meminfo)
|
|
||||||
if (!match) return null
|
if (!match) return null
|
||||||
return Number(match[1]) * 1024
|
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 里的默认网关。
|
* /proc/net/route 里的默认网关。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { describe, expect, it } from 'bun:test'
|
import { describe, expect, it } from 'bun:test'
|
||||||
import {
|
import {
|
||||||
applySetup,
|
applySetup,
|
||||||
|
applySubRules,
|
||||||
chainGroupName,
|
chainGroupName,
|
||||||
chainProviderName,
|
chainProviderName,
|
||||||
|
managedWiring,
|
||||||
panelGroups,
|
panelGroups,
|
||||||
|
readSubRules,
|
||||||
sharedPeers,
|
sharedPeers,
|
||||||
|
subRuleName,
|
||||||
syncGenerated,
|
syncGenerated,
|
||||||
isGenerated,
|
isGenerated,
|
||||||
linesMatching,
|
linesMatching,
|
||||||
proxyGroupName,
|
proxyGroupName,
|
||||||
pruneGenerated,
|
pruneGenerated,
|
||||||
readSetup,
|
readSetup,
|
||||||
|
upgradeLegacyInbound,
|
||||||
} from './inbound-groups.ts'
|
} from './inbound-groups.ts'
|
||||||
import type { OutboundSetup } from './inbound-groups.ts'
|
import type { OutboundSetup } from './inbound-groups.ts'
|
||||||
import { collectInbounds } from './inbounds.ts'
|
import { collectInbounds } from './inbounds.ts'
|
||||||
@@ -110,6 +115,28 @@ describe('一路入站的三个组', () => {
|
|||||||
expect((groups[2] as ConfigObject).type).toBe('fallback')
|
expect((groups[2] as ConfigObject).type).toBe('fallback')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('一个来源都没选时只有 SELECT,成员只有 DIRECT', () => {
|
||||||
|
// AUTO 和 FALLBACK 只有 use: 没有点名成员,来源为空就是空组,内核会拒绝
|
||||||
|
// 整份配置(parser.go:117-119)。新增入站落在这个状态上,等用户自己挑订阅
|
||||||
|
const empty: OutboundSetup = { proxy: { sources: [], filter: null }, chain: null }
|
||||||
|
const groups = applySetup(base(), 'test1', empty)['proxy-groups'] as ConfigObject[]
|
||||||
|
expect(groups).toEqual([{ name: 'patchbay:test1', type: 'select', proxies: ['DIRECT'] }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('从空来源选上订阅后,AUTO 和 FALLBACK 建回来', () => {
|
||||||
|
const empty: OutboundSetup = { proxy: { sources: [], filter: null }, chain: null }
|
||||||
|
const data = applySetup(applySetup(base(), 'test1', empty), 'test1', plain)
|
||||||
|
const groups = data['proxy-groups'] as ConfigObject[]
|
||||||
|
expect(groups.map((g) => g.name)).toEqual([
|
||||||
|
'patchbay:test1',
|
||||||
|
'patchbay:test1 AUTO',
|
||||||
|
'patchbay:test1 FALLBACK',
|
||||||
|
])
|
||||||
|
// 反过来把订阅全取消,那两个组要跟着消失,否则留下的是内核不收的空组
|
||||||
|
const back = applySetup(data, 'test1', empty)['proxy-groups'] as ConfigObject[]
|
||||||
|
expect(back.map((g) => g.name)).toEqual(['patchbay:test1'])
|
||||||
|
})
|
||||||
|
|
||||||
it('tab 的顺序是 SELECT、AUTO、FALLBACK,只有 SELECT 能点', () => {
|
it('tab 的顺序是 SELECT、AUTO、FALLBACK,只有 SELECT 能点', () => {
|
||||||
// 另外两个自己决定用哪条,手动指定会被它们自己撤销,见 outbound-chain.ts
|
// 另外两个自己决定用哪条,手动指定会被它们自己撤销,见 outbound-chain.ts
|
||||||
const data = applySetup(base(), 'test1', plain)
|
const data = applySetup(base(), 'test1', plain)
|
||||||
@@ -356,3 +383,140 @@ describe('清理', () => {
|
|||||||
expect((pruneGenerated(orphaned)['proxy-groups'] as ConfigObject[]).length).toBe(3)
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+204
-24
@@ -18,7 +18,7 @@
|
|||||||
* 副本的来源 path 和哪个用户订阅相同(读同一个缓存文件)
|
* 副本的来源 path 和哪个用户订阅相同(读同一个缓存文件)
|
||||||
*/
|
*/
|
||||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
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 type { Inbound } from './inbounds.ts'
|
||||||
import { lastSwitchableGroup, resolveChain } from './outbound-chain.ts'
|
import { lastSwitchableGroup, resolveChain } from './outbound-chain.ts'
|
||||||
import type { RuntimeNode } 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} 链式`
|
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'],
|
[' AUTO', 'AUTO'],
|
||||||
[' FALLBACK', 'FALLBACK'],
|
[' FALLBACK', 'FALLBACK'],
|
||||||
[' 链式', '链式代理'],
|
[' 链式', '链式代理'],
|
||||||
|
[' 附加', '附加规则'],
|
||||||
] as const) {
|
] as const) {
|
||||||
if (rest.endsWith(suffix)) return label
|
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(
|
function makeGroup(
|
||||||
name: string,
|
name: string,
|
||||||
type: string,
|
type: string,
|
||||||
@@ -305,19 +390,30 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
|
|||||||
|
|
||||||
const { filter } = setup.proxy
|
const { filter } = setup.proxy
|
||||||
let groups = groupsOf(next)
|
let groups = groupsOf(next)
|
||||||
// AUTO 排第一位:内核给没有选中记录的组默认用第一个成员,选中的线路被订阅刷新删掉时
|
if (use.length === 0) {
|
||||||
// 也静默回退到第一个成员(内核源码 adapter/outboundgroup/selector.go:103-112)。这两种
|
// 一个来源都没选。AUTO 和 FALLBACK 只有 `use:` 没有点名成员,来源为空就是空组,
|
||||||
// 情况都该落到自动测速上;排 DIRECT 就是静默直连,对按端口定向出站的场景是泄露。
|
// 内核会拒绝整份配置(内核源码 adapter/outboundgroup/parser.go:117-119),所以这时
|
||||||
groups = upsert(
|
// 只建 SELECT、成员只有 DIRECT。新增入站落在这个状态上,等用户自己挑订阅。
|
||||||
groups,
|
// filter 一并不写:没有来源可筛。
|
||||||
makeGroup(proxyGroupName(inbound), 'select', use, filter, [
|
groups = upsert(groups, makeGroup(proxyGroupName(inbound), 'select', [], null, ['DIRECT']))
|
||||||
autoGroupName(inbound),
|
groups = groups.filter(
|
||||||
fallbackGroupName(inbound),
|
(g) => g.name !== autoGroupName(inbound) && g.name !== fallbackGroupName(inbound),
|
||||||
'DIRECT',
|
)
|
||||||
]),
|
} else {
|
||||||
)
|
// AUTO 排第一位:内核给没有选中记录的组默认用第一个成员,选中的线路被订阅刷新删掉时
|
||||||
groups = upsert(groups, makeGroup(autoGroupName(inbound), 'url-test', use, filter))
|
// 也静默回退到第一个成员(内核源码 adapter/outboundgroup/selector.go:103-112)。这两种
|
||||||
groups = upsert(groups, makeGroup(fallbackGroupName(inbound), 'fallback', use, filter))
|
// 情况都该落到自动测速上;排 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) {
|
if (chained) {
|
||||||
groups = upsert(
|
groups = upsert(
|
||||||
@@ -332,16 +428,72 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
|
|||||||
if (Object.keys(providers).length === 0) delete next['proxy-providers']
|
if (Object.keys(providers).length === 0) delete next['proxy-providers']
|
||||||
else next['proxy-providers'] = providers
|
else next['proxy-providers'] = providers
|
||||||
|
|
||||||
const listeners = (Array.isArray(next.listeners) ? next.listeners : []).map((entry) =>
|
// 有附加规则时 listener 走 rule 形态,proxy 会短路规则匹配、不能写;
|
||||||
isPlainObject(entry) && entry.name === inbound
|
// 表底的兜底 MATCH 已经指向专属组,接线效果一样
|
||||||
? { ...entry, proxy: proxyGroupName(inbound) }
|
const viaSubRules = hasSubRules(next, inbound)
|
||||||
: entry,
|
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
|
if (listeners.length > 0) next.listeners = listeners
|
||||||
|
|
||||||
return next
|
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 {
|
function referencesTo(data: ConfigObject, name: string): number {
|
||||||
let count = 0
|
let count = 0
|
||||||
@@ -365,6 +517,17 @@ function referencesTo(data: ConfigObject, name: string): number {
|
|||||||
if (typeof rule === 'string' && rule.split(',').some((part) => part.trim() === name)) count += 1
|
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
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,7 +555,21 @@ export function pruneGenerated(data: ConfigObject): ConfigObject {
|
|||||||
)
|
)
|
||||||
const providersChanged = keptProviders.length !== Object.keys(providers).length
|
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 }
|
const next = { ...data }
|
||||||
if (keptGroups.length === 0) delete next['proxy-groups']
|
if (keptGroups.length === 0) delete next['proxy-groups']
|
||||||
@@ -403,6 +580,11 @@ export function pruneGenerated(data: ConfigObject): ConfigObject {
|
|||||||
else next['proxy-providers'] = Object.fromEntries(keptProviders)
|
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)
|
return pruneGenerated(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,8 +599,7 @@ export function syncGenerated(data: ConfigObject, inbounds: Inbound[]): ConfigOb
|
|||||||
let next = data
|
let next = data
|
||||||
for (const inbound of inbounds) {
|
for (const inbound of inbounds) {
|
||||||
const setup = readSetup(next, inbound.name)
|
const setup = readSetup(next, inbound.name)
|
||||||
const mode = inbound.outbound
|
if (!setup || !managedWiring(next, inbound)) continue
|
||||||
if (!setup || mode.kind !== 'named' || mode.name !== proxyGroupName(inbound.name)) continue
|
|
||||||
next = applySetup(next, inbound.name, setup)
|
next = applySetup(next, inbound.name, setup)
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
@@ -449,8 +630,7 @@ export function panelGroups(
|
|||||||
runtime: Record<string, RuntimeNode>,
|
runtime: Record<string, RuntimeNode>,
|
||||||
inbound: Inbound,
|
inbound: Inbound,
|
||||||
): { groups: PanelGroup[]; shared: boolean } {
|
): { groups: PanelGroup[]; shared: boolean } {
|
||||||
const mode = inbound.outbound
|
const managed = managedWiring(data, inbound)
|
||||||
const managed = mode.kind === 'named' && mode.name === proxyGroupName(inbound.name)
|
|
||||||
|
|
||||||
if (managed) {
|
if (managed) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe('合并两套写法的入站', () => {
|
|||||||
expect(list[1]!.listenerIndex).toBe(0)
|
expect(list[1]!.listenerIndex).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('默认入站的出站永远是「跟着规则走」,因为旧写法没处写', () => {
|
it('legacy 入站的出站永远是「跟着规则走」,因为旧写法没处写', () => {
|
||||||
expect(collectInbounds({ 'mixed-port': 7777 })[0]!.outbound).toEqual({ kind: 'rules' })
|
expect(collectInbounds({ 'mixed-port': 7777 })[0]!.outbound).toEqual({ kind: 'rules' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+143
-3
@@ -1,17 +1,28 @@
|
|||||||
import { describe, expect, it } from 'bun:test'
|
import { describe, expect, it } from 'bun:test'
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_HEALTH_CHECK,
|
||||||
DEFAULT_INTERVAL,
|
DEFAULT_INTERVAL,
|
||||||
addFileSubscription,
|
addFileSubscription,
|
||||||
addSubscription,
|
addSubscription,
|
||||||
listSubscriptions,
|
listSubscriptions,
|
||||||
|
moveSubscription,
|
||||||
providerNames,
|
providerNames,
|
||||||
removeSubscription,
|
removeSubscription,
|
||||||
|
renameSubscription,
|
||||||
|
setSubscriptionHealthCheck,
|
||||||
subscriptionPathConflict,
|
subscriptionPathConflict,
|
||||||
subscriptionReferences,
|
subscriptionReferences,
|
||||||
updateSubscription,
|
updateSubscription,
|
||||||
} from './providers.ts'
|
} from './providers.ts'
|
||||||
import { applySetup, chainProviderName, providerCachePath } from './inbound-groups.ts'
|
import {
|
||||||
|
applySetup,
|
||||||
|
chainProviderName,
|
||||||
|
providerCachePath,
|
||||||
|
pruneGenerated,
|
||||||
|
syncGenerated,
|
||||||
|
} from './inbound-groups.ts'
|
||||||
import type { OutboundSetup } 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'
|
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||||
|
|
||||||
const URL = 'http://127.0.0.1:3001/token/download/V2House'
|
const URL = 'http://127.0.0.1:3001/token/download/V2House'
|
||||||
@@ -77,7 +88,7 @@ describe('读出订阅列表', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('新增订阅', () => {
|
describe('新增订阅', () => {
|
||||||
it('自动生成缓存路径和默认间隔', () => {
|
it('自动生成缓存路径、默认间隔和默认开启的健康检查', () => {
|
||||||
const next = addSubscription({}, 'subs2', URL)
|
const next = addSubscription({}, 'subs2', URL)
|
||||||
expect(next['proxy-providers']).toEqual({
|
expect(next['proxy-providers']).toEqual({
|
||||||
subs2: {
|
subs2: {
|
||||||
@@ -85,6 +96,7 @@ describe('新增订阅', () => {
|
|||||||
url: URL,
|
url: URL,
|
||||||
path: './providers/subs2.yaml',
|
path: './providers/subs2.yaml',
|
||||||
interval: DEFAULT_INTERVAL,
|
interval: DEFAULT_INTERVAL,
|
||||||
|
'health-check': DEFAULT_HEALTH_CHECK,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -106,11 +118,97 @@ describe('新增订阅', () => {
|
|||||||
it('从文件导入的订阅是 file 类型,没有地址和间隔', () => {
|
it('从文件导入的订阅是 file 类型,没有地址和间隔', () => {
|
||||||
const next = addFileSubscription({}, 'subs2')
|
const next = addFileSubscription({}, 'subs2')
|
||||||
expect(next['proxy-providers']).toEqual({
|
expect(next['proxy-providers']).toEqual({
|
||||||
subs2: { type: 'file', path: './providers/subs2.yaml' },
|
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('编辑订阅', () => {
|
describe('编辑订阅', () => {
|
||||||
it('只改地址和间隔,其余字段原样保留', () => {
|
it('只改地址和间隔,其余字段原样保留', () => {
|
||||||
const next = updateSubscription(base(), 'subs', 'http://example.com/new', 3600)
|
const next = updateSubscription(base(), 'subs', 'http://example.com/new', 3600)
|
||||||
@@ -183,6 +281,48 @@ describe('删除前的引用检查', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('调整订阅顺序', () => {
|
||||||
|
/** 三个用户订阅,外加一份接在中间的订阅副本。 */
|
||||||
|
function three(): ConfigObject {
|
||||||
|
let data: ConfigObject = {}
|
||||||
|
for (const name of ['A', 'B', 'C']) data = addSubscription(data, name, `https://${name}.example`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
it('上移和下移交换相邻两个订阅', () => {
|
||||||
|
expect(listSubscriptions(moveSubscription(three(), 'B', -1)).map((s) => s.name)).toEqual([
|
||||||
|
'B',
|
||||||
|
'A',
|
||||||
|
'C',
|
||||||
|
])
|
||||||
|
expect(listSubscriptions(moveSubscription(three(), 'B', 1)).map((s) => s.name)).toEqual([
|
||||||
|
'A',
|
||||||
|
'C',
|
||||||
|
'B',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('到头了原样返回,配置对象不变', () => {
|
||||||
|
const data = three()
|
||||||
|
expect(moveSubscription(data, 'A', -1)).toBe(data)
|
||||||
|
expect(moveSubscription(data, 'C', 1)).toBe(data)
|
||||||
|
expect(moveSubscription(data, '不存在', 1)).toBe(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('夹在中间的订阅副本留在原地', () => {
|
||||||
|
const withCopy = applySetup(
|
||||||
|
{ ...three(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
|
||||||
|
'test1',
|
||||||
|
{ proxy: { sources: ['B'], filter: null }, chain: { sources: ['B'], filter: null } },
|
||||||
|
)
|
||||||
|
const copy = chainProviderName('test1', 'B')
|
||||||
|
const before = Object.keys(withCopy['proxy-providers'] as ConfigObject).indexOf(copy)
|
||||||
|
const after = moveSubscription(withCopy, 'A', 1)
|
||||||
|
expect(listSubscriptions(after).map((s) => s.name)).toEqual(['B', 'A', 'C'])
|
||||||
|
expect(Object.keys(after['proxy-providers'] as ConfigObject).indexOf(copy)).toBe(before)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('缓存路径撞名', () => {
|
describe('缓存路径撞名', () => {
|
||||||
it('名字只差符号时净化成同一个路径,能认出来', () => {
|
it('名字只差符号时净化成同一个路径,能认出来', () => {
|
||||||
const data = addSubscription({}, '订阅 A', 'https://a.example/sub')
|
const data = addSubscription({}, '订阅 A', 'https://a.example/sub')
|
||||||
|
|||||||
+109
-3
@@ -6,8 +6,8 @@
|
|||||||
* 这个模块只操作用户订阅;副本的增删由 `inbound-groups.ts` 的 applySetup/pruneGenerated 负责,
|
* 这个模块只操作用户订阅;副本的增删由 `inbound-groups.ts` 的 applySetup/pruneGenerated 负责,
|
||||||
* 写配置的链路(页面里的 commit)每次都会跑它们,所以这里改完不用补同步。
|
* 写配置的链路(页面里的 commit)每次都会跑它们,所以这里改完不用补同步。
|
||||||
*
|
*
|
||||||
* 订阅不提供改名:名字被策略组的 `use:` 和生成的副本引用,改名等于改一个被引用的
|
* 改名必须走 renameSubscription:名字被策略组的 `use:` 和生成的副本引用,改名和
|
||||||
* 标识符,要同步更新所有引用,漏一处就是断引用。删了重建更简单。
|
* 同步引用要在同一次变换里完成,散开做漏一处就是断引用。
|
||||||
*/
|
*/
|
||||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||||
import { isGenerated, providerCachePath } from './inbound-groups.ts'
|
import { isGenerated, providerCachePath } from './inbound-groups.ts'
|
||||||
@@ -15,6 +15,19 @@ import { isGenerated, providerCachePath } from './inbound-groups.ts'
|
|||||||
/** 新增订阅时的默认更新间隔,秒。一天一次。 */
|
/** 新增订阅时的默认更新间隔,秒。一天一次。 */
|
||||||
export const DEFAULT_INTERVAL = 86400
|
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)。 */
|
/** 一个用户订阅在界面上的样子。只读配置文件的 `proxy-providers` 段(决策记录 0004)。 */
|
||||||
export interface Subscription {
|
export interface Subscription {
|
||||||
name: string
|
name: string
|
||||||
@@ -104,6 +117,7 @@ export function addSubscription(
|
|||||||
url,
|
url,
|
||||||
path: providerCachePath(name),
|
path: providerCachePath(name),
|
||||||
...(interval !== null ? { interval } : {}),
|
...(interval !== null ? { interval } : {}),
|
||||||
|
'health-check': { ...DEFAULT_HEALTH_CHECK },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
@@ -137,7 +151,11 @@ export function addFileSubscription(data: ConfigObject, name: string): ConfigObj
|
|||||||
const next = { ...data }
|
const next = { ...data }
|
||||||
next['proxy-providers'] = {
|
next['proxy-providers'] = {
|
||||||
...providersOf(data),
|
...providersOf(data),
|
||||||
[name]: { type: 'file', path: providerCachePath(name) },
|
[name]: {
|
||||||
|
type: 'file',
|
||||||
|
path: providerCachePath(name),
|
||||||
|
'health-check': { ...DEFAULT_HEALTH_CHECK },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
@@ -162,6 +180,94 @@ export function updateSubscription(
|
|||||||
return next
|
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 {
|
export function removeSubscription(data: ConfigObject, name: string): ConfigObject {
|
||||||
const providers = { ...providersOf(data) }
|
const providers = { ...providersOf(data) }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
firstMatchIndex,
|
firstMatchIndex,
|
||||||
inferRuleFormat,
|
inferRuleFormat,
|
||||||
insertBeforeMatch,
|
insertBeforeMatch,
|
||||||
|
isRawOnlyRule,
|
||||||
isStructuredEditable,
|
isStructuredEditable,
|
||||||
missingLanRules,
|
missingLanRules,
|
||||||
moveRule,
|
moveRule,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
ruleSetReferences,
|
ruleSetReferences,
|
||||||
setRuleProviders,
|
setRuleProviders,
|
||||||
setRules,
|
setRules,
|
||||||
|
subRuleSetReferences,
|
||||||
} from './rules.ts'
|
} from './rules.ts'
|
||||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||||
|
|
||||||
@@ -81,6 +83,13 @@ describe('规则字符串的拆分与拼装', () => {
|
|||||||
expect(isStructuredEditable('DOMAIN-REGEX,^ads?,b$,REJECT')).toBe(false)
|
expect(isStructuredEditable('DOMAIN-REGEX,^ads?,b$,REJECT')).toBe(false)
|
||||||
expect(isStructuredEditable('孤零零一段')).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('规则表的顺序', () => {
|
describe('规则表的顺序', () => {
|
||||||
@@ -316,3 +325,17 @@ describe('清单格式与缓存路径', () => {
|
|||||||
expect(ruleProviderPathConflict(data, 'x', ruleProviderPath('x', 'yaml'))).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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+29
-3
@@ -58,10 +58,14 @@ const RAW_ONLY_TYPES = new Set([
|
|||||||
'PROCESS-PATH-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 {
|
export function isStructuredEditable(rule: string): boolean {
|
||||||
const parsed = parseRule(rule)
|
if (isRawOnlyRule(rule)) return false
|
||||||
if (RAW_ONLY_TYPES.has(parsed.type.toUpperCase())) return false
|
return parseRule(rule).target !== null
|
||||||
return parsed.target !== null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 第一条 MATCH 的下标。它之后的规则永远到不了。没有 MATCH 返回 -1。 */
|
/** 第一条 MATCH 的下标。它之后的规则永远到不了。没有 MATCH 返回 -1。 */
|
||||||
@@ -168,6 +172,28 @@ export function ruleSetReferences(rules: string[], providerName: string): number
|
|||||||
return result
|
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 段。不是映射就当没有。 */
|
/** 配置里的 rule-providers 段。不是映射就当没有。 */
|
||||||
export function ruleProvidersOf(data: ConfigObject): Record<string, ConfigObject> {
|
export function ruleProvidersOf(data: ConfigObject): Record<string, ConfigObject> {
|
||||||
const value = data['rule-providers']
|
const value = data['rule-providers']
|
||||||
|
|||||||
Vendored
+1
@@ -12,6 +12,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `index.test.ts` | 测试框架的导入从 `vitest` 换成 `bun:test` | 本项目用 Bun 自带的测试运行器,不引入 vitest。两者的 `describe` / `it` / `expect` 接口一致,用例本身一行没动 |
|
| `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` | `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
|
return diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+48
-14
@@ -53,7 +53,7 @@ function openInbound(id: string) {
|
|||||||
* 选中的那一路入站丢失,改完一个设置还要重新找回原来的位置。所以只有首次加载显示占位,
|
* 选中的那一路入站丢失,改完一个设置还要重新找回原来的位置。所以只有首次加载显示占位,
|
||||||
* 之后都是原地更新。
|
* 之后都是原地更新。
|
||||||
*/
|
*/
|
||||||
async function load() {
|
async function load(): Promise<'ok' | 'unauthorized' | 'error'> {
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -64,22 +64,32 @@ async function load() {
|
|||||||
needsToken.value = false
|
needsToken.value = false
|
||||||
// 撤回/重做按钮的可用性跟着每次重读走
|
// 撤回/重做按钮的可用性跟着每次重读走
|
||||||
void refreshHistory()
|
void refreshHistory()
|
||||||
|
return 'ok'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError && error.unauthorized) {
|
if (error instanceof ApiError && error.unauthorized) {
|
||||||
clearToken()
|
clearToken()
|
||||||
needsToken.value = true
|
needsToken.value = true
|
||||||
} else {
|
return 'unauthorized'
|
||||||
loadError.value = error instanceof Error ? error.message : String(error)
|
|
||||||
}
|
}
|
||||||
|
loadError.value = error instanceof Error ? error.message : String(error)
|
||||||
|
return 'error'
|
||||||
} finally {
|
} finally {
|
||||||
refreshing.value = false
|
refreshing.value = false
|
||||||
booted.value = true
|
booted.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitToken(value: string) {
|
/**
|
||||||
|
* 提交登录页填的令牌。
|
||||||
|
*
|
||||||
|
* 401 必须当场说一声:登录页在提交前后都是它自己,组件不重建、输入框也不清空,
|
||||||
|
* 不提示的话点了「进入」界面一个字都不变,看着像没反应。
|
||||||
|
* 只在这里提示,不在 load() 里——存着的令牌失效时弹回登录页,页面本身就是说明。
|
||||||
|
*/
|
||||||
|
async function submitToken(value: string) {
|
||||||
setToken(value)
|
setToken(value)
|
||||||
void load()
|
// 令牌在哪儿取,登录页上就写着,不必在提示里再说一遍
|
||||||
|
if ((await load()) === 'unauthorized') toast('令牌不正确', { kind: 'error' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
@@ -100,8 +110,24 @@ const busyAct = ref(false)
|
|||||||
const historyOpen = ref(false)
|
const historyOpen = ref(false)
|
||||||
const askRestart = 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() {
|
async function undo() {
|
||||||
if (busyAct.value) return
|
if (busyAct.value || cooling.value.undo) return
|
||||||
|
coolDown('undo')
|
||||||
busyAct.value = true
|
busyAct.value = true
|
||||||
try {
|
try {
|
||||||
const result = await api.undoConfig()
|
const result = await api.undoConfig()
|
||||||
@@ -115,7 +141,8 @@ async function undo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function redo() {
|
async function redo() {
|
||||||
if (busyAct.value) return
|
if (busyAct.value || cooling.value.redo) return
|
||||||
|
coolDown('redo')
|
||||||
busyAct.value = true
|
busyAct.value = true
|
||||||
try {
|
try {
|
||||||
const result = await api.redoConfig()
|
const result = await api.redoConfig()
|
||||||
@@ -239,7 +266,7 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
|||||||
type="button"
|
type="button"
|
||||||
class="act"
|
class="act"
|
||||||
title="撤回"
|
title="撤回"
|
||||||
:disabled="busyAct || !snapshot || history.backups.length === 0"
|
:disabled="busyAct || cooling.undo || !snapshot || history.backups.length === 0"
|
||||||
@click="undo"
|
@click="undo"
|
||||||
>
|
>
|
||||||
<IconUndo />
|
<IconUndo />
|
||||||
@@ -248,7 +275,7 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
|||||||
type="button"
|
type="button"
|
||||||
class="act"
|
class="act"
|
||||||
title="重做"
|
title="重做"
|
||||||
:disabled="busyAct || !snapshot || history.redo === 0"
|
:disabled="busyAct || cooling.redo || !snapshot || history.redo === 0"
|
||||||
@click="redo"
|
@click="redo"
|
||||||
>
|
>
|
||||||
<IconRedo />
|
<IconRedo />
|
||||||
@@ -377,9 +404,10 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
|||||||
|
|
||||||
<HistoryDialog :open="historyOpen" @close="historyOpen = false" @restored="load" />
|
<HistoryDialog :open="historyOpen" @close="historyOpen = false" @restored="load" />
|
||||||
|
|
||||||
|
<!-- 文案与仪表盘的重启确认一致,同一件事不说两种话 -->
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="askRestart"
|
:open="askRestart"
|
||||||
message="重启内核进程?所有连接会断开,几秒后自动恢复;策略组的选中依赖 profile.store-selected 找回。"
|
message="重启内核会断开它当前的所有连接,几秒后自动恢复。"
|
||||||
action="重启"
|
action="重启"
|
||||||
@close="askRestart = false"
|
@close="askRestart = false"
|
||||||
@confirm="restartConfirmed"
|
@confirm="restartConfirmed"
|
||||||
@@ -410,14 +438,18 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
|||||||
|
|
||||||
/* ── 侧栏 ───────────────────────────────────── */
|
/* ── 侧栏 ───────────────────────────────────── */
|
||||||
/*
|
/*
|
||||||
* 钉在视口里。不加 sticky 的话它会被网格行撑到和主区一样高,页面一长,
|
* 钉在视口里。用 fixed 而不是 sticky:sticky 元素的位置跟着 scrollTop 走,
|
||||||
* 导航就跟着滚出屏幕外,而它是全局导航,任何时候都该够得着。
|
* 而滚动到底时 scrollTop = scrollHeight - clientHeight,在 Windows 显示缩放下
|
||||||
|
* 是小数——侧栏落在小数像素上,图标和文字取整方向不同,部分图标会跳 1px。
|
||||||
|
* fixed 按视口摆放,不参与滚动偏移的运算。网格的第一列只当占位。
|
||||||
*/
|
*/
|
||||||
.side {
|
.side {
|
||||||
position: sticky;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
align-self: start;
|
left: 0;
|
||||||
|
width: var(--side-w);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
transition: width 0.3s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--s-5);
|
gap: var(--s-5);
|
||||||
@@ -695,7 +727,9 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
|||||||
|
|
||||||
|
|
||||||
/* ── 主区 ───────────────────────────────────── */
|
/* ── 主区 ───────────────────────────────────── */
|
||||||
|
/* 侧栏是 fixed、不占网格位,主区要自己站到第二列,否则会顶进第一列 */
|
||||||
.main {
|
.main {
|
||||||
|
grid-column: 2;
|
||||||
padding: var(--s-6) var(--s-6) var(--s-7);
|
padding: var(--s-6) var(--s-6) var(--s-7);
|
||||||
max-width: 1600px;
|
max-width: 1600px;
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -173,6 +173,13 @@ export const api = {
|
|||||||
restoreBackup: (name: string) =>
|
restoreBackup: (name: string) =>
|
||||||
request<ApplySuccess>('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`),
|
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'),
|
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -303,6 +310,19 @@ export const api = {
|
|||||||
refreshProvider: (name: string) =>
|
refreshProvider: (name: string) =>
|
||||||
request<unknown>('PUT', `/api/kernel/providers/proxies/${encodeURIComponent(name)}`),
|
request<unknown>('PUT', `/api/kernel/providers/proxies/${encodeURIComponent(name)}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 让内核立刻对一个订阅的全部线路做一次健康检查:逐条 URLTest,测通与否和延迟一起
|
||||||
|
* 记进历史(内核源码 adapter/provider/healthcheck.go:123-189)。
|
||||||
|
*
|
||||||
|
* 用的是这个订阅 `health-check.url` 里的地址而不是设置页的测速地址——内核这个接口
|
||||||
|
* 不收 url 参数。它是同步的,测完全部线路才回话,所以可能要等上几十秒。
|
||||||
|
*/
|
||||||
|
checkProviderLines: (name: string) =>
|
||||||
|
request<unknown>(
|
||||||
|
'GET',
|
||||||
|
`/api/kernel/providers/proxies/${encodeURIComponent(name)}/healthcheck`,
|
||||||
|
),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 每个规则组的运行时状态:装了多少条规则、上次什么时候更新。
|
* 每个规则组的运行时状态:装了多少条规则、上次什么时候更新。
|
||||||
* 配置文件里只有声明,这两个数字只有运行中的内核知道。
|
* 配置文件里只有声明,这两个数字只有运行中的内核知道。
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
* 手填任何值都合法(规则的目标可以是候选之外的出站名)。换掉 datalist
|
* 手填任何值都合法(规则的目标可以是候选之外的出站名)。换掉 datalist
|
||||||
* 的理由和 SelectBox 一样:原生建议列表的样式、动画、开合状态都管不到。
|
* 的理由和 SelectBox 一样:原生建议列表的样式、动画、开合状态都管不到。
|
||||||
*
|
*
|
||||||
|
* 过滤只在本次展开后输入过才生效:带着已有值展开时按值过滤的话,
|
||||||
|
* 列表只剩当前值自己,其它选项等于不存在。
|
||||||
|
*
|
||||||
* Enter 的归属:弹层开着且有高亮项时选中它、不提交表单;弹层收起时不拦,
|
* Enter 的归属:弹层开着且有高亮项时选中它、不提交表单;弹层收起时不拦,
|
||||||
* 交给对话框的表单提交——填完直接回车保存是原生 input 的习惯,不能破坏。
|
* 交给对话框的表单提交——填完直接回车保存是原生 input 的习惯,不能破坏。
|
||||||
*/
|
*/
|
||||||
@@ -30,10 +33,12 @@ const open = ref(false)
|
|||||||
const openUp = ref(false)
|
const openUp = ref(false)
|
||||||
const active = ref(0)
|
const active = ref(0)
|
||||||
const menuStyle = ref<Record<string, string>>({})
|
const menuStyle = ref<Record<string, string>>({})
|
||||||
|
/** 本次展开后是否输入过。没输入过就给全量列表 */
|
||||||
|
const typed = ref(false)
|
||||||
|
|
||||||
const filtered = computed(() => {
|
const filtered = computed(() => {
|
||||||
const q = text.value.trim().toLowerCase()
|
const q = text.value.trim().toLowerCase()
|
||||||
if (!q) return props.options
|
if (!typed.value || !q) return props.options
|
||||||
return props.options.filter((name) => name.toLowerCase().includes(q))
|
return props.options.filter((name) => name.toLowerCase().includes(q))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,13 +85,17 @@ onBeforeUnmount(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function show() {
|
function show() {
|
||||||
|
typed.value = false
|
||||||
active.value = 0
|
active.value = 0
|
||||||
place()
|
place()
|
||||||
open.value = true
|
open.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function onInput() {
|
function onInput() {
|
||||||
show()
|
typed.value = true
|
||||||
|
active.value = 0
|
||||||
|
place()
|
||||||
|
open.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFromChevron() {
|
function toggleFromChevron() {
|
||||||
|
|||||||
@@ -2,12 +2,16 @@
|
|||||||
/**
|
/**
|
||||||
* 备份链(历史记录)的弹窗:列出每次改动前留下的备份,点还原直接跳回去。
|
* 备份链(历史记录)的弹窗:列出每次改动前留下的备份,点还原直接跳回去。
|
||||||
* 还原走服务端同一条保护链路,当前状态会先自动备份,所以不需要再套一层确认。
|
* 还原走服务端同一条保护链路,当前状态会先自动备份,所以不需要再套一层确认。
|
||||||
|
* 点一行展开这次改动的 diff:改动前是备份本身,改动后是更新一份的状态。
|
||||||
*/
|
*/
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { backupTime } from '@shared/backups.ts'
|
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 { api } from '../api'
|
||||||
import { toast, toastError } from '../toast'
|
import { toast, toastError } from '../toast'
|
||||||
import IconClose from '~icons/ri/close-line'
|
import IconClose from '~icons/ri/close-line'
|
||||||
|
import IconRight from '~icons/ri/arrow-right-s-line'
|
||||||
|
|
||||||
const props = defineProps<{ open: boolean }>()
|
const props = defineProps<{ open: boolean }>()
|
||||||
|
|
||||||
@@ -18,12 +22,19 @@ const dialog = ref<HTMLDialogElement | null>(null)
|
|||||||
const backups = ref<string[] | null>(null)
|
const backups = ref<string[] | null>(null)
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
|
|
||||||
|
/** 展开着的那条。同时只开一条,diff 是逐条看的东西,摊开一串没法读 */
|
||||||
|
const expanded = ref<string | null>(null)
|
||||||
|
/** 已取回并算好的 diff 片段。null 表示取失败了 */
|
||||||
|
const diffs = ref<Record<string, DiffLine[][] | null>>({})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.open,
|
() => props.open,
|
||||||
(open) => {
|
(open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
dialog.value?.showModal()
|
dialog.value?.showModal()
|
||||||
backups.value = null
|
backups.value = null
|
||||||
|
expanded.value = null
|
||||||
|
diffs.value = {}
|
||||||
void api
|
void api
|
||||||
.listBackups()
|
.listBackups()
|
||||||
.then((r) => (backups.value = r.backups))
|
.then((r) => (backups.value = r.backups))
|
||||||
@@ -34,6 +45,19 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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) {
|
async function restore(name: string) {
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
busy.value = true
|
busy.value = true
|
||||||
@@ -75,9 +99,35 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
没有备份。每次保存都会把改动前的状态留在这里,最多 10 份。
|
没有备份。每次保存都会把改动前的状态留在这里,最多 10 份。
|
||||||
</p>
|
</p>
|
||||||
<ul v-else class="list">
|
<ul v-else class="list">
|
||||||
<li v-for="name in backups" :key="name" class="item" :title="name">
|
<li v-for="name in backups" :key="name" class="item">
|
||||||
<span class="mono time">{{ backupTime(name) ?? name }}</span>
|
<div
|
||||||
<button type="button" class="btn" :disabled="busy" @click="restore(name)">还原</button>
|
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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,7 +136,7 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dlg {
|
.dlg {
|
||||||
width: min(420px, calc(100vw - 2 * var(--s-5)));
|
width: min(640px, calc(100vw - 2 * var(--s-5)));
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid var(--c-border-strong);
|
border: 1px solid var(--c-border-strong);
|
||||||
border-radius: var(--r-lg);
|
border-radius: var(--r-lg);
|
||||||
@@ -140,25 +190,37 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
max-height: 50vh;
|
max-height: 60vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--c-border-strong) transparent;
|
scrollbar-color: var(--c-border-strong) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--s-4);
|
|
||||||
padding: var(--s-2) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item + .item {
|
.item + .item {
|
||||||
border-top: 1px solid var(--c-border);
|
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 {
|
.time {
|
||||||
|
flex: 1;
|
||||||
font-size: var(--t-sm);
|
font-size: var(--t-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,4 +240,43 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
color: var(--c-text-faint);
|
color: var(--c-text-faint);
|
||||||
cursor: not-allowed;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ function outboundOf(inbound: Inbound): { text: string; chained: boolean } {
|
|||||||
<span class="type mono">{{ inboundTypeLabel(inbound.type) }}</span>
|
<span class="type mono">{{ inboundTypeLabel(inbound.type) }}</span>
|
||||||
<span class="name">
|
<span class="name">
|
||||||
{{ inbound.name }}
|
{{ inbound.name }}
|
||||||
<span v-if="inbound.isDefault" class="tag">默认</span>
|
<span v-if="inbound.isDefault" class="tag">legacy</span>
|
||||||
</span>
|
</span>
|
||||||
<span :class="['out mono', isUnsetOutbound(inbound.outbound) && 'is-muted']">
|
<span :class="['out mono', isUnsetOutbound(inbound.outbound) && 'is-muted']">
|
||||||
<span v-if="outboundOf(inbound).chained" class="chain" title="经过链式代理">
|
<span v-if="outboundOf(inbound).chained" class="chain" title="经过链式代理">
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 订阅列表。每行一个订阅:名字、当前线路数、上次更新时间、健康检查开没开。
|
* 订阅列表。每行一个订阅:名字、当前线路数、上次更新时间,末尾是调顺序的两个箭头。
|
||||||
*
|
*
|
||||||
* 只管显示和选中,不碰任何数据。
|
* 只管显示、选中和「往哪边挪一位」,不碰任何数据。
|
||||||
*/
|
*/
|
||||||
|
import IconUp from '~icons/ri/arrow-up-line'
|
||||||
|
import IconDown from '~icons/ri/arrow-down-line'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
/** 一行要显示的东西,由页面按配置和运行时数据拼好 */
|
/** 一行要显示的东西,由页面按配置和运行时数据拼好 */
|
||||||
@@ -13,12 +15,15 @@ defineProps<{
|
|||||||
lines: number | null
|
lines: number | null
|
||||||
/** 上次拉取成功的时刻。null 表示不知道或从没拉取过 */
|
/** 上次拉取成功的时刻。null 表示不知道或从没拉取过 */
|
||||||
updated: Date | null
|
updated: Date | null
|
||||||
/** 健康检查开没开 */
|
|
||||||
healthCheck: boolean
|
|
||||||
}>
|
}>
|
||||||
|
/** 配置正在保存。调顺序要写配置文件,保存期间不能再点 */
|
||||||
|
saving: boolean
|
||||||
}>()
|
}>()
|
||||||
const selected = defineModel<string | null>('selected', { required: true })
|
const selected = defineModel<string | null>('selected', { required: true })
|
||||||
|
|
||||||
|
/** 把这个订阅在列表里挪一位。写配置文件,由页面走保存链路。 */
|
||||||
|
const emit = defineEmits<{ move: [name: string, delta: -1 | 1] }>()
|
||||||
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
|
||||||
function formatUpdated(at: Date): string {
|
function formatUpdated(at: Date): string {
|
||||||
@@ -28,22 +33,37 @@ function formatUpdated(at: Date): string {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ol class="rows">
|
<ol class="rows">
|
||||||
<li v-for="row in rows" :key="row.name">
|
<li
|
||||||
<button
|
v-for="(row, i) in rows"
|
||||||
type="button"
|
:key="row.name"
|
||||||
:class="['line', selected === row.name && 'is-on']"
|
:class="['row', selected === row.name && 'is-on']"
|
||||||
@click="selected = row.name"
|
>
|
||||||
>
|
<button type="button" class="line" @click="selected = row.name">
|
||||||
<span class="name">{{ row.name }}</span>
|
<span class="name">{{ row.name }}</span>
|
||||||
<span class="lines mono">
|
<span class="lines mono">
|
||||||
<template v-if="row.lines !== null">{{ row.lines }} 条</template>
|
<template v-if="row.lines !== null">{{ row.lines }} 条</template>
|
||||||
<template v-else>—</template>
|
<template v-else>—</template>
|
||||||
</span>
|
</span>
|
||||||
<span class="updated mono">{{ row.updated ? formatUpdated(row.updated) : '—' }}</span>
|
<span class="updated mono">{{ row.updated ? formatUpdated(row.updated) : '—' }}</span>
|
||||||
<span :class="['hc', !row.healthCheck && 'is-off']">
|
|
||||||
{{ row.healthCheck ? '健康检查' : '—' }}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
<span class="acts">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="上移"
|
||||||
|
:disabled="saving || i === 0"
|
||||||
|
@click="emit('move', row.name, -1)"
|
||||||
|
>
|
||||||
|
<IconUp />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="下移"
|
||||||
|
:disabled="saving || i === rows.length - 1"
|
||||||
|
@click="emit('move', row.name, 1)"
|
||||||
|
>
|
||||||
|
<IconDown />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</template>
|
</template>
|
||||||
@@ -63,27 +83,34 @@ function formatUpdated(at: Date): string {
|
|||||||
border-top: 1px solid var(--c-border);
|
border-top: 1px solid var(--c-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-right: var(--s-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: var(--c-surface-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.is-on {
|
||||||
|
background: var(--c-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
.line {
|
.line {
|
||||||
width: 100%;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 4rem 6.5rem 4.5rem;
|
grid-template-columns: minmax(0, 1fr) 4rem 6.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s-3);
|
gap: var(--s-3);
|
||||||
padding: var(--s-3) var(--s-4);
|
padding: var(--s-3) var(--s-2) var(--s-3) var(--s-4);
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line:hover {
|
|
||||||
background: var(--c-surface-sunken);
|
|
||||||
}
|
|
||||||
|
|
||||||
.line.is-on {
|
|
||||||
background: var(--c-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -99,13 +126,38 @@ function formatUpdated(at: Date): string {
|
|||||||
color: var(--c-text-soft);
|
color: var(--c-text-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hc {
|
/* 平时不显示:调顺序不是常用动作,每行常驻两个箭头只是噪声。
|
||||||
text-align: right;
|
隐藏用 opacity 而不是 display,位置一直占着,鼠标划过时右边一列才不会跳 */
|
||||||
font-size: var(--t-xs);
|
.acts {
|
||||||
color: var(--c-text-soft);
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.row:hover .acts,
|
||||||
|
.acts:focus-within {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hc.is-off {
|
.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);
|
color: var(--c-text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.acts button:hover:not(:disabled) {
|
||||||
|
background: var(--c-surface);
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.acts button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,45 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 轻提示的渲染宿主,挂在外壳根部。进场从右滑入,退场上移淡出;
|
* 轻提示的渲染宿主,挂在外壳根部。进场从右滑入,退场上移淡出。
|
||||||
* 点一下立刻消掉。内容来自 web/toast.ts 的单例队列。
|
* 点一下:普通提示立刻消掉;错误提示是要拿去粘贴求助的,点击复制而不是关闭,
|
||||||
|
* 消失交给 toast.ts 的定时器。内容来自 web/toast.ts 的单例队列。
|
||||||
*/
|
*/
|
||||||
|
import { ref } from 'vue'
|
||||||
import { dismiss, toasts } from '../toast'
|
import { dismiss, toasts } from '../toast'
|
||||||
|
import type { Toast } from '../toast'
|
||||||
import IconOk from '~icons/ri/checkbox-circle-line'
|
import IconOk from '~icons/ri/checkbox-circle-line'
|
||||||
import IconErr from '~icons/ri/error-warning-line'
|
import IconErr from '~icons/ri/error-warning-line'
|
||||||
import IconInfo from '~icons/ri/information-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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -16,14 +49,16 @@ import IconInfo from '~icons/ri/information-line'
|
|||||||
v-for="t in toasts"
|
v-for="t in toasts"
|
||||||
:key="t.id"
|
:key="t.id"
|
||||||
:class="['tst', t.kind === 'error' && 'is-error']"
|
:class="['tst', t.kind === 'error' && 'is-error']"
|
||||||
@click="dismiss(t.id)"
|
:title="t.kind === 'error' ? '点击复制错误信息' : undefined"
|
||||||
|
@click="onClick(t)"
|
||||||
>
|
>
|
||||||
<IconErr v-if="t.kind === 'error'" class="tico" />
|
<IconErr v-if="t.kind === 'error'" class="tico" />
|
||||||
<IconInfo v-else-if="t.kind === 'info'" class="tico is-info" />
|
<IconInfo v-else-if="t.kind === 'info'" class="tico is-info" />
|
||||||
<IconOk v-else class="tico" />
|
<IconOk v-else class="tico" />
|
||||||
<div class="tbody">
|
<div class="tbody">
|
||||||
<strong class="tmsg">{{ t.message }}</strong>
|
<strong class="tmsg">{{ t.message }}</strong>
|
||||||
<span v-if="t.hint" class="thint">{{ t.hint }}</span>
|
<span v-if="copied.has(t.id)" class="thint">已复制到剪贴板</span>
|
||||||
|
<span v-else-if="t.hint" class="thint">{{ t.hint }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ export function modeLabel(mode: unknown): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一个监听中的代理端口。name 用配置键原词,和路由页默认入站的命名一致。 */
|
/** 一个监听中的代理端口。name 用配置键原词,和路由页legacy 入站的命名一致。 */
|
||||||
export interface KernelPort {
|
export interface KernelPort {
|
||||||
name: string
|
name: string
|
||||||
port: number
|
port: number
|
||||||
|
|||||||
@@ -4,6 +4,15 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<!-- 标签页图标只声明 .ico:它里面有 16/32/48/64 四个尺寸,是照着小尺寸调过的。
|
||||||
|
再声明一个大 PNG 的话,浏览器会按显示密度在两者之间挑,而这套素材的 .ico 是
|
||||||
|
深底浅图、PNG 是白底黑图,挑到哪个不一定,同一个标签页会换样子。 -->
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48 64x64" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<!-- 移动端浏览器地址栏的底色,跟着系统主题走,和 tokens.css 里的 --c-bg 一致 -->
|
||||||
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fbfbfc" />
|
||||||
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#16181c" />
|
||||||
<title>Patchbay</title>
|
<title>Patchbay</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@ import IconRoute from '~icons/ri/route-line'
|
|||||||
import IconDashboard from '~icons/ri/dashboard-line'
|
import IconDashboard from '~icons/ri/dashboard-line'
|
||||||
import IconConfig from '~icons/ri/file-list-3-line'
|
import IconConfig from '~icons/ri/file-list-3-line'
|
||||||
import IconRules from '~icons/ri/filter-3-line'
|
import IconRules from '~icons/ri/filter-3-line'
|
||||||
import IconRequests from '~icons/ri/pulse-line'
|
import IconLogs from '~icons/ri/terminal-line'
|
||||||
import IconConnections from '~icons/ri/links-line'
|
import IconConnections from '~icons/ri/links-line'
|
||||||
import IconSettings from '~icons/ri/settings-3-line'
|
import IconSettings from '~icons/ri/settings-3-line'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
@@ -23,7 +23,7 @@ export const NAV: NavItem[] = [
|
|||||||
{ key: 'router', label: '路由', icon: IconRoute },
|
{ key: 'router', label: '路由', icon: IconRoute },
|
||||||
{ key: 'config', label: '配置', icon: IconConfig },
|
{ key: 'config', label: '配置', icon: IconConfig },
|
||||||
{ key: 'rules', label: '规则', icon: IconRules },
|
{ key: 'rules', label: '规则', icon: IconRules },
|
||||||
{ key: 'requests', label: '请求', icon: IconRequests },
|
{ key: 'requests', label: '日志', icon: IconLogs },
|
||||||
{ key: 'connections', label: '连接', icon: IconConnections },
|
{ key: 'connections', label: '连接', icon: IconConnections },
|
||||||
{ key: 'settings', label: '设置', icon: IconSettings },
|
{ key: 'settings', label: '设置', icon: IconSettings },
|
||||||
]
|
]
|
||||||
|
|||||||
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 |
+22
-1
@@ -182,6 +182,12 @@ html {
|
|||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 模态框开着时锁住背后的页面滚动。:modal 只匹配 showModal() 打开的 dialog;
|
||||||
|
滚动条的位置由上面的 scrollbar-gutter 常驻留出,锁定解锁都不会横向跳动 */
|
||||||
|
html:has(:modal) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--c-bg);
|
background: var(--c-bg);
|
||||||
@@ -238,7 +244,7 @@ select {
|
|||||||
border-color 0.15s ease;
|
border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 对话框入场:快速淡入加一点上移。关闭不做动画,动效只留给打开的那一下 */
|
/* 对话框入场:快速淡入加一点上移 */
|
||||||
dialog[open] {
|
dialog[open] {
|
||||||
animation: dialog-in 0.15s ease;
|
animation: dialog-in 0.15s ease;
|
||||||
}
|
}
|
||||||
@@ -247,6 +253,21 @@ dialog[open]::backdrop {
|
|||||||
animation: backdrop-in 0.15s ease;
|
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 {
|
@keyframes dialog-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ import {
|
|||||||
addFileSubscription,
|
addFileSubscription,
|
||||||
addSubscription,
|
addSubscription,
|
||||||
listSubscriptions,
|
listSubscriptions,
|
||||||
|
moveSubscription,
|
||||||
providerNames,
|
providerNames,
|
||||||
removeSubscription,
|
removeSubscription,
|
||||||
|
renameSubscription,
|
||||||
|
setSubscriptionHealthCheck,
|
||||||
subscriptionPathConflict,
|
subscriptionPathConflict,
|
||||||
subscriptionReferences,
|
subscriptionReferences,
|
||||||
updateSubscription,
|
updateSubscription,
|
||||||
@@ -48,6 +51,8 @@ const formEditing = ref<Subscription | null>(null)
|
|||||||
const removing = ref<Subscription | null>(null)
|
const removing = ref<Subscription | null>(null)
|
||||||
/** 正在等内核重新拉取的订阅名 */
|
/** 正在等内核重新拉取的订阅名 */
|
||||||
const refreshing = ref<string | null>(null)
|
const refreshing = ref<string | null>(null)
|
||||||
|
/** 正在等内核测完线路的订阅名 */
|
||||||
|
const checking = ref<string | null>(null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 每个订阅的运行时数据:当前线路、上次更新时间。
|
* 每个订阅的运行时数据:当前线路、上次更新时间。
|
||||||
@@ -103,7 +108,6 @@ const rows = computed(() =>
|
|||||||
name: s.name,
|
name: s.name,
|
||||||
lines: info ? info.proxies.length : null,
|
lines: info ? info.proxies.length : null,
|
||||||
updated: parseUpdated(info?.updatedAt),
|
updated: parseUpdated(info?.updatedAt),
|
||||||
healthCheck: s.healthCheck !== null,
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -149,7 +153,8 @@ async function submitForm(draft: {
|
|||||||
}) {
|
}) {
|
||||||
const editing = formEditing.value
|
const editing = formEditing.value
|
||||||
|
|
||||||
// 名字查重放行了、但净化后的缓存路径和别人相同的,在这里拦(编辑不改名,只查新增)
|
// 名字查重放行了、但净化后的缓存路径和别人相同的,在这里拦
|
||||||
|
// (只查新增:改名不动缓存路径,不会制造新的撞车)
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
const path = providerCachePath(draft.name)
|
const path = providerCachePath(draft.name)
|
||||||
const clash = subscriptionPathConflict(props.data, draft.name, path)
|
const clash = subscriptionPathConflict(props.data, draft.name, path)
|
||||||
@@ -173,8 +178,17 @@ async function submitForm(draft: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 编辑时先改名再改字段。名字没动时 renameSubscription 原样返回;
|
||||||
|
// file 类型只有名称可改,不能走 updateSubscription——它会写进 url
|
||||||
const next = editing
|
const next = editing
|
||||||
? updateSubscription(clone(), editing.name, draft.url, draft.interval)
|
? editing.type === 'file'
|
||||||
|
? renameSubscription(clone(), editing.name, draft.name)
|
||||||
|
: updateSubscription(
|
||||||
|
renameSubscription(clone(), editing.name, draft.name),
|
||||||
|
draft.name,
|
||||||
|
draft.url,
|
||||||
|
draft.interval,
|
||||||
|
)
|
||||||
: draft.file
|
: draft.file
|
||||||
? addFileSubscription(clone(), draft.name)
|
? addFileSubscription(clone(), draft.name)
|
||||||
: addSubscription(clone(), draft.name, draft.url, draft.interval)
|
: addSubscription(clone(), draft.name, draft.url, draft.interval)
|
||||||
@@ -182,7 +196,8 @@ async function submitForm(draft: {
|
|||||||
// 失败时不能关对话框——关了再打开就是空表单,填的名称、地址、间隔全没了
|
// 失败时不能关对话框——关了再打开就是空表单,填的名称、地址、间隔全没了
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
formOpen.value = false
|
formOpen.value = false
|
||||||
if (!editing) selectedName.value = draft.name
|
// 新增和改名都要把选中跟过去,否则选中名失效会被重置到第一行
|
||||||
|
selectedName.value = draft.name
|
||||||
}
|
}
|
||||||
|
|
||||||
function askRemove() {
|
function askRemove() {
|
||||||
@@ -205,6 +220,17 @@ async function removeConfirmed() {
|
|||||||
await commit(removeSubscription(clone(), target.name), `已删除 ${target.name}`)
|
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()。 */
|
/** 让内核立刻重新拉取。运行时动作,不写配置文件,所以不走 commit()。 */
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const target = selected.value
|
const target = selected.value
|
||||||
@@ -224,6 +250,39 @@ async function refresh() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 让内核立刻测一遍选中订阅的线路。运行时动作,不写配置文件,所以不走 commit()。
|
||||||
|
*
|
||||||
|
* 内核是测完全部线路才回话的,线路多又都测不通时要等上几十秒,期间按钮转着。
|
||||||
|
*/
|
||||||
|
async function check() {
|
||||||
|
const target = selected.value
|
||||||
|
if (!target || checking.value !== null) return
|
||||||
|
checking.value = target.name
|
||||||
|
try {
|
||||||
|
await api.checkProviderLines(target.name)
|
||||||
|
await reloadProviders()
|
||||||
|
toast(`已检查 ${target.name} 的线路`)
|
||||||
|
} catch (error) {
|
||||||
|
toast(error instanceof ApiError ? error.message : String(error), {
|
||||||
|
kind: 'error',
|
||||||
|
hint: '该操作仅修改内核运行状态,配置文件未被修改。',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
checking.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把一个订阅在列表里挪一位。顺序在配置文件里,所以要走 commit()。 */
|
||||||
|
async function move(name: string, delta: -1 | 1) {
|
||||||
|
if (saving.value) return
|
||||||
|
const before = clone()
|
||||||
|
const next = moveSubscription(before, name, delta)
|
||||||
|
// 到头了 moveSubscription 原样返回,这时不该发起一次什么都没改的保存
|
||||||
|
if (next === before) return
|
||||||
|
await commit(next, '顺序已保存')
|
||||||
|
}
|
||||||
|
|
||||||
const refreshingAll = ref(false)
|
const refreshingAll = ref(false)
|
||||||
|
|
||||||
/** 挨个让内核重新拉取所有订阅。失败的点名,成功的不逐个报。 */
|
/** 挨个让内核重新拉取所有订阅。失败的点名,成功的不逐个报。 */
|
||||||
@@ -278,7 +337,12 @@ async function refreshAll() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="split">
|
<div class="split">
|
||||||
<ProviderList v-model:selected="selectedName" :rows="rows" />
|
<ProviderList
|
||||||
|
v-model:selected="selectedName"
|
||||||
|
:rows="rows"
|
||||||
|
:saving="saving"
|
||||||
|
@move="move"
|
||||||
|
/>
|
||||||
|
|
||||||
<SubscriptionDetail
|
<SubscriptionDetail
|
||||||
v-if="selected"
|
v-if="selected"
|
||||||
@@ -286,10 +350,13 @@ async function refreshAll() {
|
|||||||
:info="providerInfo[selected.name]"
|
:info="providerInfo[selected.name]"
|
||||||
:online="kernel.online"
|
:online="kernel.online"
|
||||||
:refreshing="refreshing === selected.name"
|
:refreshing="refreshing === selected.name"
|
||||||
|
:checking="checking === selected.name"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
@edit="openEdit"
|
@edit="openEdit"
|
||||||
@remove="askRemove"
|
@remove="askRemove"
|
||||||
@refresh="refresh"
|
@refresh="refresh"
|
||||||
|
@check="check"
|
||||||
|
@toggle-health-check="toggleHealthCheck"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+31
-15
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { collectInbounds, inboundStatKey } from '@shared/inbounds.ts'
|
import { collectInbounds, inboundStatKey } from '@shared/inbounds.ts'
|
||||||
import { formatBytes, formatUptime } from '@shared/format.ts'
|
import { flagEmoji, formatBytes, formatUptime } from '@shared/format.ts'
|
||||||
import { ACTIVE_BYTES, ROUTE_WINDOW_MS, bucketRates, lastRate, windowBytes } from '@shared/telemetry.ts'
|
import { ACTIVE_BYTES, ROUTE_WINDOW_MS, bucketRates, lastRate, windowBytes } from '@shared/telemetry.ts'
|
||||||
import type { RouteSample } from '@shared/telemetry.ts'
|
import type { RouteSample } from '@shared/telemetry.ts'
|
||||||
import type { HostInfo } from '@shared/host-info.ts'
|
import type { HostInfo } from '@shared/host-info.ts'
|
||||||
@@ -41,6 +41,8 @@ import IconLan from '~icons/ri/router-line'
|
|||||||
import IconKernel from '~icons/ri/terminal-box-line'
|
import IconKernel from '~icons/ri/terminal-box-line'
|
||||||
import IconOverview from '~icons/ri/layout-grid-line'
|
import IconOverview from '~icons/ri/layout-grid-line'
|
||||||
import IconGo from '~icons/ri/arrow-right-s-line'
|
import IconGo from '~icons/ri/arrow-right-s-line'
|
||||||
|
import IconProbe from '~icons/ri/global-line'
|
||||||
|
import IconRestart from '~icons/ri/restart-line'
|
||||||
|
|
||||||
const props = defineProps<{ data: ConfigObject }>()
|
const props = defineProps<{ data: ConfigObject }>()
|
||||||
|
|
||||||
@@ -135,7 +137,7 @@ function exitCell(probeable: boolean, port: number | null): ExitCell {
|
|||||||
return {
|
return {
|
||||||
kind: 'ok',
|
kind: 'ok',
|
||||||
port,
|
port,
|
||||||
text: [result.countryCode, result.ip].filter(Boolean).join(' ') || '未知地址',
|
text: [flagEmoji(result.countryCode ?? ''), result.ip].filter(Boolean).join(' ') || '未知地址',
|
||||||
title: `${result.country ?? ''} 往返 ${String(result.ms ?? '?')} ms,点击重测`,
|
title: `${result.country ?? ''} 往返 ${String(result.ms ?? '?')} ms,点击重测`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -431,7 +433,7 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
|
|||||||
:disabled="restarting"
|
:disabled="restarting"
|
||||||
@click="confirmRestart = true"
|
@click="confirmRestart = true"
|
||||||
>
|
>
|
||||||
{{ restarting ? '重启中' : '重启内核' }}
|
<IconRestart /> {{ restarting ? '重启中' : '重启内核' }}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="sect-b">
|
<div class="sect-b">
|
||||||
@@ -490,9 +492,10 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
|
|||||||
v-if="row.exit.kind === 'idle'"
|
v-if="row.exit.kind === 'idle'"
|
||||||
type="button"
|
type="button"
|
||||||
class="probe"
|
class="probe"
|
||||||
|
title="检测公网 IP"
|
||||||
@click.stop="void probeExit(row.exit.port)"
|
@click.stop="void probeExit(row.exit.port)"
|
||||||
>
|
>
|
||||||
检测公网 IP
|
<IconProbe /> 检测
|
||||||
</button>
|
</button>
|
||||||
<span v-else-if="row.exit.kind === 'loading'" class="probe-wait">检测中</span>
|
<span v-else-if="row.exit.kind === 'loading'" class="probe-wait">检测中</span>
|
||||||
<button
|
<button
|
||||||
@@ -850,23 +853,30 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
|
|||||||
|
|
||||||
/* ── 内核 ─────────────────────────────────────── */
|
/* ── 内核 ─────────────────────────────────────── */
|
||||||
.restart {
|
.restart {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 2px var(--s-3);
|
padding: 2px var(--s-2);
|
||||||
border: 1px solid var(--c-border-strong);
|
border: none;
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
background: var(--c-surface);
|
background: none;
|
||||||
color: var(--c-text-soft);
|
color: var(--c-text-soft);
|
||||||
font-size: var(--t-xs);
|
font-size: var(--t-xs);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.restart:hover:not(:disabled) {
|
.restart:hover:not(:disabled) {
|
||||||
border-color: color-mix(in srgb, var(--c-danger) 45%, var(--c-border));
|
background: var(--c-surface-sunken);
|
||||||
color: var(--c-danger);
|
color: var(--c-danger);
|
||||||
}
|
}
|
||||||
.restart:disabled {
|
.restart:disabled {
|
||||||
color: var(--c-text-faint);
|
color: var(--c-text-faint);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
.restart svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 路由 ─────────────────────────────────────── */
|
/* ── 路由 ─────────────────────────────────────── */
|
||||||
.routes {
|
.routes {
|
||||||
@@ -940,27 +950,33 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
|
|||||||
|
|
||||||
.probe,
|
.probe,
|
||||||
.exit {
|
.exit {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1);
|
||||||
padding: 1px var(--s-2);
|
padding: 1px var(--s-2);
|
||||||
border: 1px solid var(--c-border-strong);
|
border: none;
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
background: var(--c-surface);
|
background: none;
|
||||||
|
color: var(--c-text-soft);
|
||||||
font-size: var(--t-xs);
|
font-size: var(--t-xs);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.probe:hover,
|
.probe:hover,
|
||||||
.exit:hover {
|
.exit:hover {
|
||||||
border-color: var(--c-accent);
|
background: var(--c-surface-sunken);
|
||||||
color: var(--c-accent-text);
|
color: var(--c-accent-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.exit {
|
.probe svg {
|
||||||
border-color: transparent;
|
width: 13px;
|
||||||
color: var(--c-text-soft);
|
height: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.probe-fail {
|
.probe-fail {
|
||||||
color: var(--c-danger);
|
color: var(--c-danger);
|
||||||
border-color: color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
|
}
|
||||||
|
.probe-fail:hover {
|
||||||
|
color: var(--c-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.probe-wait,
|
.probe-wait,
|
||||||
|
|||||||
+261
-13
@@ -18,7 +18,9 @@ import {
|
|||||||
fallbackGroupName,
|
fallbackGroupName,
|
||||||
linesMatching,
|
linesMatching,
|
||||||
proxyGroupName,
|
proxyGroupName,
|
||||||
|
subRuleName,
|
||||||
} from '@shared/inbound-groups.ts'
|
} from '@shared/inbound-groups.ts'
|
||||||
|
import { firstMatchIndex, missingLanRules } from '@shared/rules.ts'
|
||||||
import type { GroupSpec, OutboundSetup, PanelGroup } from '@shared/inbound-groups.ts'
|
import type { GroupSpec, OutboundSetup, PanelGroup } from '@shared/inbound-groups.ts'
|
||||||
import { resolveChain } from '@shared/outbound-chain.ts'
|
import { resolveChain } from '@shared/outbound-chain.ts'
|
||||||
import { latestDelay } from '../api'
|
import { latestDelay } from '../api'
|
||||||
@@ -30,8 +32,12 @@ import OutboundCard from '../components/OutboundCard.vue'
|
|||||||
import SourcePicker from '../components/SourcePicker.vue'
|
import SourcePicker from '../components/SourcePicker.vue'
|
||||||
import IconEdit from '~icons/ri/edit-line'
|
import IconEdit from '~icons/ri/edit-line'
|
||||||
import IconDelete from '~icons/ri/delete-bin-line'
|
import IconDelete from '~icons/ri/delete-bin-line'
|
||||||
|
import IconUpgrade from '~icons/ri/arrow-up-circle-line'
|
||||||
import IconRight from '~icons/ri/arrow-right-s-line'
|
import IconRight from '~icons/ri/arrow-right-s-line'
|
||||||
import IconOffline from '~icons/ri/cloud-off-line'
|
import IconOffline from '~icons/ri/cloud-off-line'
|
||||||
|
import IconAdd from '~icons/ri/add-line'
|
||||||
|
import IconUp from '~icons/ri/arrow-up-line'
|
||||||
|
import IconDown from '~icons/ri/arrow-down-line'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
inbound: Inbound
|
inbound: Inbound
|
||||||
@@ -45,6 +51,8 @@ const props = defineProps<{
|
|||||||
subscriptions: string[]
|
subscriptions: string[]
|
||||||
/** 当前的来源和筛选,配置表单的初值 */
|
/** 当前的来源和筛选,配置表单的初值 */
|
||||||
setup: OutboundSetup | null
|
setup: OutboundSetup | null
|
||||||
|
/** 这一路的附加规则(不含表底的兜底 MATCH,那条由 Patchbay 自动维护) */
|
||||||
|
subRules: string[]
|
||||||
/** 正在测延迟的线路 */
|
/** 正在测延迟的线路 */
|
||||||
testing: string[]
|
testing: string[]
|
||||||
/** 配置正在保存 */
|
/** 配置正在保存 */
|
||||||
@@ -54,6 +62,8 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
edit: []
|
edit: []
|
||||||
remove: []
|
remove: []
|
||||||
|
/** 把 legacy 入站升级成命名入站。写配置文件,由父组件先弹确认 */
|
||||||
|
upgrade: []
|
||||||
/** 切换某个策略组当前选中谁 */
|
/** 切换某个策略组当前选中谁 */
|
||||||
select: [group: string, member: string]
|
select: [group: string, member: string]
|
||||||
test: [name: string, provider: string]
|
test: [name: string, provider: string]
|
||||||
@@ -61,9 +71,29 @@ const emit = defineEmits<{
|
|||||||
save: [setup: OutboundSetup, picks: { proxy: string | null; chain: string | null }]
|
save: [setup: OutboundSetup, picks: { proxy: string | null; chain: string | null }]
|
||||||
/** 把这一路改成专属代理组模式 */
|
/** 把这一路改成专属代理组模式 */
|
||||||
adopt: []
|
adopt: []
|
||||||
|
/** 附加规则的增删改和排序。规则文本的编辑走父组件的 RuleForm */
|
||||||
|
'sub-add': []
|
||||||
|
'sub-edit': [index: number]
|
||||||
|
'sub-remove': [index: number]
|
||||||
|
'sub-move': [index: number, delta: -1 | 1]
|
||||||
|
'sub-lan': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
/** 默认入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删。 */
|
/** 手写的 rule 字段(不是 Patchbay 的附加规则表)才在事实行里原样展示 */
|
||||||
|
const foreignRuleSet = computed(
|
||||||
|
() => props.inbound.ruleSet !== null && props.inbound.ruleSet !== subRuleName(props.inbound.name),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 内网直连还缺几条。全齐了按钮就不出现 */
|
||||||
|
const lanMissing = computed(() => missingLanRules(props.subRules).length)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户自己写的第一条 MATCH 的下标,没有时是 -1。它之后的行——包括表底的
|
||||||
|
* 自动兜底——永远轮不到,标红;它自己标黄,hover 说明原因。
|
||||||
|
*/
|
||||||
|
const subMatchAt = computed(() => firstMatchIndex(props.subRules))
|
||||||
|
|
||||||
|
/** legacy 入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删,只能升级。 */
|
||||||
const locked = computed(() => props.inbound.isDefault)
|
const locked = computed(() => props.inbound.isDefault)
|
||||||
|
|
||||||
const activeTab = ref('')
|
const activeTab = ref('')
|
||||||
@@ -255,7 +285,9 @@ watch([() => props.inbound.id, () => JSON.stringify(props.setup)], reset, { imme
|
|||||||
const chainOn = computed({
|
const chainOn = computed({
|
||||||
get: () => form.value.chain !== null,
|
get: () => form.value.chain !== null,
|
||||||
set: (on: boolean) => {
|
set: (on: boolean) => {
|
||||||
form.value.chain = on ? { sources: [...props.subscriptions], filter: null } : null
|
// 入口订阅不预设:入口是「先落到哪一跳」,用户心里有具体的那一条,
|
||||||
|
// 把订阅全给上只是让他保存前先去取消勾选
|
||||||
|
form.value.chain = on ? { sources: [], filter: null } : null
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -396,9 +428,15 @@ const flowPreview = computed(
|
|||||||
pendingEntry.value !== null,
|
pendingEntry.value !== null,
|
||||||
)
|
)
|
||||||
/**
|
/**
|
||||||
* 至少要选一个订阅。AUTO 和 FALLBACK 只有 `use:` 没有点名成员,一个订阅都不选就是空组,
|
* 两边都至少要选一个订阅。
|
||||||
* 内核会直接拒绝整份配置(内核源码 adapter/outboundgroup/parser.go:117-119)。
|
*
|
||||||
* 在这里拦住,比让用户按下保存再读一段内核报错好。
|
* 入口组只有 `use:` 没有点名成员,一个订阅都不选就是空组,内核会直接拒绝整份配置
|
||||||
|
* (内核源码 adapter/outboundgroup/parser.go:117-119)。在这里拦住,比让用户按下
|
||||||
|
* 保存再读一段内核报错好。
|
||||||
|
*
|
||||||
|
* 代理这边空来源是能落盘的(applySetup 只建 SELECT、成员只有 DIRECT,新增入站就停在
|
||||||
|
* 这个状态),但那是「还没配」的起点,不该是取消勾选保存出来的结果——一路已经在走代理
|
||||||
|
* 的入站不能这么悄悄变成直连。
|
||||||
*/
|
*/
|
||||||
const filled = (spec: { sources: string[] }) => spec.sources.length > 0
|
const filled = (spec: { sources: string[] }) => spec.sources.length > 0
|
||||||
const complete = computed(
|
const complete = computed(
|
||||||
@@ -421,10 +459,15 @@ const complete = computed(
|
|||||||
<div class="title">
|
<div class="title">
|
||||||
<span class="port mono">{{ inbound.port }}</span>
|
<span class="port mono">{{ inbound.port }}</span>
|
||||||
<span class="name">{{ inbound.name }}</span>
|
<span class="name">{{ inbound.name }}</span>
|
||||||
<div v-if="!locked" class="acts">
|
<div class="acts">
|
||||||
<button type="button" @click="emit('edit')"><IconEdit /> 编辑</button>
|
<template v-if="!locked">
|
||||||
<button type="button" class="danger" @click="emit('remove')">
|
<button type="button" @click="emit('edit')"><IconEdit /> 编辑</button>
|
||||||
<IconDelete /> 删除
|
<button type="button" class="danger" @click="emit('remove')">
|
||||||
|
<IconDelete /> 删除
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<button v-else type="button" :disabled="saving" @click="emit('upgrade')">
|
||||||
|
<IconUpgrade /> 改为命名入站
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -434,7 +477,8 @@ const complete = computed(
|
|||||||
<dd class="mono">{{ inbound.type }}</dd>
|
<dd class="mono">{{ inbound.type }}</dd>
|
||||||
<dt>监听</dt>
|
<dt>监听</dt>
|
||||||
<dd class="mono">{{ inbound.listen ?? '0.0.0.0' }}</dd>
|
<dd class="mono">{{ inbound.listen ?? '0.0.0.0' }}</dd>
|
||||||
<template v-if="inbound.ruleSet">
|
<!-- Patchbay 自己的附加规则表有专门的编辑区块,这里只展示手写的 -->
|
||||||
|
<template v-if="foreignRuleSet">
|
||||||
<dt>附加规则</dt>
|
<dt>附加规则</dt>
|
||||||
<dd class="mono">{{ inbound.ruleSet }}</dd>
|
<dd class="mono">{{ inbound.ruleSet }}</dd>
|
||||||
</template>
|
</template>
|
||||||
@@ -550,7 +594,8 @@ const complete = computed(
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- 草稿态:显示保存后的成员。现有成员点了立刻生效,保存后才加入的点了是预选 -->
|
<!-- 草稿态:显示保存后的成员。现有成员点了立刻生效,保存后才加入的点了是预选 -->
|
||||||
<div v-if="chainDraft" class="grid">
|
<p v-if="chainDraft && form.chain.sources.length === 0" class="hint">还没有选订阅</p>
|
||||||
|
<div v-else-if="chainDraft" class="grid">
|
||||||
<OutboundCard
|
<OutboundCard
|
||||||
v-for="card in chainCards"
|
v-for="card in chainCards"
|
||||||
:key="card.name"
|
:key="card.name"
|
||||||
@@ -671,6 +716,89 @@ const complete = computed(
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 附加规则:只对从这一路进来的流量生效的分流。表底的兜底 MATCH 是生成物,
|
||||||
|
把没被分走的流量送回这一路自己的出站,单独画、不参与编辑 -->
|
||||||
|
<section v-if="setup !== null" class="block">
|
||||||
|
<h3>
|
||||||
|
附加规则
|
||||||
|
<span class="h3-acts">
|
||||||
|
<button
|
||||||
|
v-if="lanMissing > 0"
|
||||||
|
type="button"
|
||||||
|
class="mini"
|
||||||
|
:disabled="saving"
|
||||||
|
:title="`加入 ${lanMissing} 条内网直连规则:回环、私网和链路本地地址不进代理`"
|
||||||
|
@click="emit('sub-lan')"
|
||||||
|
>
|
||||||
|
内网直连
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mini" :disabled="saving" @click="emit('sub-add')">
|
||||||
|
<IconAdd /> 添加
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<ul class="srules">
|
||||||
|
<li
|
||||||
|
v-for="(rule, i) in subRules"
|
||||||
|
:key="i"
|
||||||
|
:class="['srule', subMatchAt >= 0 && i > subMatchAt && 'is-dead', i === subMatchAt && 'is-culprit']"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mono s-text"
|
||||||
|
:title="
|
||||||
|
i === subMatchAt
|
||||||
|
? 'MATCH 命中所有流量,它之后的规则永远不会被检查'
|
||||||
|
: subMatchAt >= 0 && i > subMatchAt
|
||||||
|
? '排在 MATCH 之后,永远不会被检查'
|
||||||
|
: rule
|
||||||
|
"
|
||||||
|
>{{ rule }}</span
|
||||||
|
>
|
||||||
|
<span class="s-acts">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="上移"
|
||||||
|
:disabled="saving || i === 0"
|
||||||
|
@click="emit('sub-move', i, -1)"
|
||||||
|
>
|
||||||
|
<IconUp />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="下移"
|
||||||
|
:disabled="saving || i === subRules.length - 1"
|
||||||
|
@click="emit('sub-move', i, 1)"
|
||||||
|
>
|
||||||
|
<IconDown />
|
||||||
|
</button>
|
||||||
|
<button type="button" title="编辑" :disabled="saving" @click="emit('sub-edit', i)">
|
||||||
|
<IconEdit />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="s-danger"
|
||||||
|
title="删除"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="emit('sub-remove', i)"
|
||||||
|
>
|
||||||
|
<IconDelete />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
:class="['srule is-final', subMatchAt >= 0 && 'is-dead']"
|
||||||
|
:title="
|
||||||
|
subMatchAt >= 0
|
||||||
|
? '上面的 MATCH 已拦下所有流量,这条自动兜底不会再被使用'
|
||||||
|
: '兜底:没被上面的规则分走的流量走这一路自己的出站。这一条由 Patchbay 自动维护'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="mono s-text">MATCH,{{ proxyGroupName(inbound.name) }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -731,16 +859,21 @@ const complete = computed(
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.acts button:hover {
|
.acts button:hover:not(:disabled) {
|
||||||
border-color: var(--c-accent);
|
border-color: var(--c-accent);
|
||||||
color: var(--c-accent-text);
|
color: var(--c-accent-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.acts button.danger:hover {
|
.acts button.danger:hover:not(:disabled) {
|
||||||
border-color: var(--c-danger);
|
border-color: var(--c-danger);
|
||||||
color: var(--c-danger);
|
color: var(--c-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.acts button:disabled {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.facts {
|
.facts {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
@@ -798,6 +931,121 @@ const complete = computed(
|
|||||||
border-top: 1px solid var(--c-border);
|
border-top: 1px solid var(--c-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 附加规则 ────────────────────────────────── */
|
||||||
|
.h3-acts {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: var(--s-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1);
|
||||||
|
padding: 2px var(--s-2);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
font-weight: 400;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mini:hover:not(:disabled) {
|
||||||
|
background: var(--c-surface-sunken);
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.mini:disabled {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.mini svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.srules {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.srule {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
|
padding: var(--s-1) var(--s-2);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
}
|
||||||
|
.srule:hover {
|
||||||
|
background: var(--c-surface-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 兜底那行是生成物:淡色示意,不给操作 */
|
||||||
|
.is-final .s-text {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 不可达:在用户自己的 MATCH 之后(含自动兜底),永远轮不到 */
|
||||||
|
.srule.is-dead .s-text {
|
||||||
|
color: var(--c-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拦路的那条 MATCH:它后面的行全是死行 */
|
||||||
|
.srule.is-culprit .s-text {
|
||||||
|
color: var(--c-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作按钮悬停到行上才现身,规则文本是主角 */
|
||||||
|
.s-acts {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.srule:hover .s-acts,
|
||||||
|
.s-acts:focus-within {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-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;
|
||||||
|
}
|
||||||
|
.s-acts button:hover:not(:disabled) {
|
||||||
|
background: var(--c-surface);
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.s-acts button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.s-acts .s-danger:hover:not(:disabled) {
|
||||||
|
color: var(--c-danger);
|
||||||
|
}
|
||||||
|
.s-acts svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -154,13 +154,15 @@ function submit() {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="row">
|
<!-- SelectBox 的行不用 label 包:没有 for 的 label 绑定第一个可关联控件,
|
||||||
|
正好是它的开关按钮,悬停行内任何位置都会点亮它、点空白处会展开面板 -->
|
||||||
|
<div class="row">
|
||||||
<span class="k">类型</span>
|
<span class="k">类型</span>
|
||||||
<span class="v">
|
<span class="v">
|
||||||
<SelectBox v-model="type" :options="typeOptions" />
|
<SelectBox v-model="type" :options="typeOptions" aria-label="类型" />
|
||||||
<span v-if="typeNote" class="note">{{ typeNote }}</span>
|
<span v-if="typeNote" class="note">{{ typeNote }}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</div>
|
||||||
|
|
||||||
<label class="row">
|
<label class="row">
|
||||||
<span class="k">端口</span>
|
<span class="k">端口</span>
|
||||||
@@ -178,7 +180,7 @@ function submit() {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</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="k">{{ f.key }}</span>
|
||||||
<span class="v">
|
<span class="v">
|
||||||
<SelectBox
|
<SelectBox
|
||||||
@@ -186,11 +188,19 @@ function submit() {
|
|||||||
v-model="extras[f.key]"
|
v-model="extras[f.key]"
|
||||||
:options="f.enum.map((opt) => ({ value: String(opt) }))"
|
:options="f.enum.map((opt) => ({ value: String(opt) }))"
|
||||||
placeholder="请选择"
|
placeholder="请选择"
|
||||||
|
:aria-label="f.key"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="extras[f.key]"
|
||||||
|
class="mono"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
:aria-label="f.key"
|
||||||
/>
|
/>
|
||||||
<input v-else v-model="extras[f.key]" class="mono" type="text" autocomplete="off" />
|
|
||||||
<span v-if="f.note" class="note">{{ f.note }}</span>
|
<span v-if="f.note" class="note">{{ f.note }}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="dlg-foot">
|
<footer class="dlg-foot">
|
||||||
|
|||||||
@@ -90,6 +90,16 @@ function submit() {
|
|||||||
emit('submit', plan.value)
|
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 本身。 */
|
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
|
||||||
function onBackdrop(event: MouseEvent) {
|
function onBackdrop(event: MouseEvent) {
|
||||||
if (event.target === dialog.value) emit('close')
|
if (event.target === dialog.value) emit('close')
|
||||||
@@ -102,7 +112,7 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
class="dlg"
|
class="dlg"
|
||||||
@click="onBackdrop"
|
@click="onBackdrop"
|
||||||
@close="emit('close')"
|
@close="emit('close')"
|
||||||
@cancel.prevent="emit('close')"
|
@cancel="onCancel"
|
||||||
>
|
>
|
||||||
<form method="dialog" @submit.prevent="submit">
|
<form method="dialog" @submit.prevent="submit">
|
||||||
<header class="dlg-head">
|
<header class="dlg-head">
|
||||||
@@ -111,7 +121,10 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<label class="row">
|
<!-- 不能用 label 包:没有 for 的 label 绑定内部第一个可关联控件,
|
||||||
|
正好是 DropFile 的按钮——悬停 textarea 会点亮投放区,点到行内
|
||||||
|
空白处还会弹出文件选择框 -->
|
||||||
|
<div class="row">
|
||||||
<span class="k">内容</span>
|
<span class="k">内容</span>
|
||||||
<span class="v">
|
<span class="v">
|
||||||
<DropFile accept=".ini,.conf,.txt" @load="(_, content) => (text = content)" />
|
<DropFile accept=".ini,.conf,.txt" @load="(_, content) => (text = content)" />
|
||||||
@@ -121,9 +134,10 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
rows="8"
|
rows="8"
|
||||||
placeholder="或把 ini 文本粘贴到这里"
|
placeholder="或把 ini 文本粘贴到这里"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
|
aria-label="ini 内容"
|
||||||
></textarea>
|
></textarea>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</div>
|
||||||
|
|
||||||
<template v-if="entries.length > 0">
|
<template v-if="entries.length > 0">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -138,12 +152,18 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label v-for="target in targets" :key="target" class="row">
|
<!-- 同上:SelectBox 的面板开关也是 button,label 会把悬停和点击都转给它 -->
|
||||||
|
<div v-for="target in targets" :key="target" class="row">
|
||||||
<span class="k map-name" :title="target">{{ target }}</span>
|
<span class="k map-name" :title="target">{{ target }}</span>
|
||||||
<span class="v">
|
<span class="v">
|
||||||
<SelectBox v-model="mapping[target]" :options="outboundOptions" placeholder="映射到…" />
|
<SelectBox
|
||||||
|
v-model="mapping[target]"
|
||||||
|
:options="outboundOptions"
|
||||||
|
placeholder="映射到…"
|
||||||
|
:aria-label="`${target} 映射到`"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+152
-3
@@ -7,20 +7,25 @@
|
|||||||
* 运行时动作(切换选中、测延迟)不经过它,因为它们不写文件也不需要校验。
|
* 运行时动作(切换选中、测延迟)不经过它,因为它们不写文件也不需要校验。
|
||||||
*/
|
*/
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
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 type { Inbound } from '@shared/inbounds.ts'
|
||||||
import {
|
import {
|
||||||
applySetup,
|
applySetup,
|
||||||
|
applySubRules,
|
||||||
chainGroupName,
|
chainGroupName,
|
||||||
groupUses,
|
groupUses,
|
||||||
|
isGenerated,
|
||||||
panelGroups,
|
panelGroups,
|
||||||
proxyGroupName,
|
proxyGroupName,
|
||||||
pruneGenerated,
|
pruneGenerated,
|
||||||
|
readSubRules,
|
||||||
sharedPeers,
|
sharedPeers,
|
||||||
syncGenerated,
|
syncGenerated,
|
||||||
readSetup,
|
readSetup,
|
||||||
|
upgradeLegacyInbound,
|
||||||
userProviders,
|
userProviders,
|
||||||
} from '@shared/inbound-groups.ts'
|
} from '@shared/inbound-groups.ts'
|
||||||
|
import { missingLanRules, moveRule, ruleProvidersOf } from '@shared/rules.ts'
|
||||||
import type { OutboundSetup } from '@shared/inbound-groups.ts'
|
import type { OutboundSetup } from '@shared/inbound-groups.ts'
|
||||||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||||
import { ApiError, api } from '../api'
|
import { ApiError, api } from '../api'
|
||||||
@@ -33,6 +38,7 @@ import ConfirmDialog from '../components/ConfirmDialog.vue'
|
|||||||
import InboundList from '../components/InboundList.vue'
|
import InboundList from '../components/InboundList.vue'
|
||||||
import InboundDetail from './InboundDetail.vue'
|
import InboundDetail from './InboundDetail.vue'
|
||||||
import InboundForm from './InboundForm.vue'
|
import InboundForm from './InboundForm.vue'
|
||||||
|
import RuleForm from './RuleForm.vue'
|
||||||
import IconAdd from '~icons/ri/add-line'
|
import IconAdd from '~icons/ri/add-line'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -48,12 +54,22 @@ const emit = defineEmits<{ saved: []; 'focus-done': [] }>()
|
|||||||
const inbounds = computed(() => collectInbounds(props.data))
|
const inbounds = computed(() => collectInbounds(props.data))
|
||||||
const subscriptions = computed(() => userProviders(props.data))
|
const subscriptions = computed(() => userProviders(props.data))
|
||||||
|
|
||||||
/** 刚接管一路入站时给它什么:订阅全给上,不筛。三个组由 applySetup 一并建出来。 */
|
/**
|
||||||
|
* 接管一路已有入站时给它什么:订阅全给上,不筛。三个组由 applySetup 一并建出来。
|
||||||
|
* 来源不留空,是因为这一路本来就在把流量送到某个出站上,接管不该顺手把它改成直连。
|
||||||
|
*/
|
||||||
const defaultSetup = (): OutboundSetup => ({
|
const defaultSetup = (): OutboundSetup => ({
|
||||||
proxy: { sources: [...subscriptions.value], filter: null },
|
proxy: { sources: [...subscriptions.value], filter: null },
|
||||||
chain: null,
|
chain: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增入站时给它什么:一个订阅都不预设。全新的端口还没有人在用,替用户挑一批订阅
|
||||||
|
* 只会让他保存完再回来取消勾选。来源为空的专属组只有 DIRECT(见 applySetup),
|
||||||
|
* 用户在详情页的配置里选好订阅再保存。
|
||||||
|
*/
|
||||||
|
const emptySetup = (): OutboundSetup => ({ proxy: { sources: [], filter: null }, chain: null })
|
||||||
|
|
||||||
const selectedId = ref<string | null>(null)
|
const selectedId = ref<string | null>(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const fields = ref<ListenerFields | null>(null)
|
const fields = ref<ListenerFields | null>(null)
|
||||||
@@ -172,6 +188,30 @@ async function adopt() {
|
|||||||
await commit(applySetup(clone(), target.name, defaultSetup()), `${target.name} 已改为逐条选线路`)
|
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() {
|
function openAdd() {
|
||||||
formEditing.value = null
|
formEditing.value = null
|
||||||
formOpen.value = true
|
formOpen.value = true
|
||||||
@@ -195,7 +235,7 @@ async function submitForm(entry: ConfigObject) {
|
|||||||
// 新增的入站直接进专属组模式。否则新建完看到的是一块「这一路不由 Patchbay 管理」
|
// 新增的入站直接进专属组模式。否则新建完看到的是一块「这一路不由 Patchbay 管理」
|
||||||
// 的提示,还得再点一次才能用。
|
// 的提示,还得再点一次才能用。
|
||||||
const name = String(entry.name)
|
const name = String(entry.name)
|
||||||
const withGroup = editingIndex === null ? applySetup(next, name, defaultSetup()) : next
|
const withGroup = editingIndex === null ? applySetup(next, name, emptySetup()) : next
|
||||||
|
|
||||||
const ok = await commit(withGroup, editingIndex === null ? `已新增 ${name}` : '已保存')
|
const ok = await commit(withGroup, editingIndex === null ? `已新增 ${name}` : '已保存')
|
||||||
// 失败时不能关对话框——关了再打开就是空表单,用户填的名称、端口、类型全没了
|
// 失败时不能关对话框——关了再打开就是空表单,用户填的名称、端口、类型全没了
|
||||||
@@ -219,6 +259,84 @@ async function selectMember(group: string, member: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 附加规则 ─────────────────────────────────── */
|
||||||
|
|
||||||
|
/** 选中这一路的附加规则(不含表底的兜底 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 时确认对话框打开着。 */
|
/** 待确认删除的那一路。非 null 时确认对话框打开着。 */
|
||||||
const removing = ref<Inbound | null>(null)
|
const removing = ref<Inbound | null>(null)
|
||||||
|
|
||||||
@@ -280,14 +398,21 @@ async function removeConfirmed() {
|
|||||||
:chain="chainView"
|
:chain="chainView"
|
||||||
:subscriptions="subscriptions"
|
:subscriptions="subscriptions"
|
||||||
:setup="setup"
|
:setup="setup"
|
||||||
|
:sub-rules="subRules"
|
||||||
:testing="testing"
|
:testing="testing"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
@edit="openEdit"
|
@edit="openEdit"
|
||||||
@remove="askRemove"
|
@remove="askRemove"
|
||||||
|
@upgrade="askUpgrade"
|
||||||
@select="selectMember"
|
@select="selectMember"
|
||||||
@test="test"
|
@test="test"
|
||||||
@save="saveSetup"
|
@save="saveSetup"
|
||||||
@adopt="adopt"
|
@adopt="adopt"
|
||||||
|
@sub-add="openSubAdd"
|
||||||
|
@sub-edit="openSubEdit"
|
||||||
|
@sub-remove="removeSubRule"
|
||||||
|
@sub-move="moveSubRule"
|
||||||
|
@sub-lan="addLanSubRules"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -310,6 +435,30 @@ async function removeConfirmed() {
|
|||||||
@close="removing = null"
|
@close="removing = null"
|
||||||
@confirm="removeConfirmed"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
+11
-12
@@ -8,7 +8,7 @@
|
|||||||
* 列出来的多得多。
|
* 列出来的多得多。
|
||||||
*/
|
*/
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { buildRule, isStructuredEditable, parseRule } from '@shared/rules.ts'
|
import { buildRule, isRawOnlyRule, isStructuredEditable, parseRule } from '@shared/rules.ts'
|
||||||
import ComboBox from '../components/ComboBox.vue'
|
import ComboBox from '../components/ComboBox.vue'
|
||||||
import IconClose from '~icons/ri/close-line'
|
import IconClose from '~icons/ri/close-line'
|
||||||
|
|
||||||
@@ -18,6 +18,8 @@ const props = defineProps<{
|
|||||||
editing: { index: number; raw: string } | null
|
editing: { index: number; raw: string } | null
|
||||||
/** RULE-SET 载荷的候选:已声明的规则组 */
|
/** RULE-SET 载荷的候选:已声明的规则组 */
|
||||||
providerNames: string[]
|
providerNames: string[]
|
||||||
|
/** 目标的建议。只是建议,手填任何出站名都合法 */
|
||||||
|
targetOptions: string[]
|
||||||
saving: boolean
|
saving: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -67,16 +69,10 @@ const RULE_TYPES = [
|
|||||||
'MATCH',
|
'MATCH',
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
|
||||||
* 目标的建议只给内置出站里日常用的两个。策略组名允许手填——
|
|
||||||
* 全部列出来的话,每路入站自动生成的三个专属组会把列表灌成一面墙。
|
|
||||||
*/
|
|
||||||
const TARGET_SUGGESTIONS = ['DIRECT', 'REJECT']
|
|
||||||
|
|
||||||
const dialog = ref<HTMLDialogElement | null>(null)
|
const dialog = ref<HTMLDialogElement | null>(null)
|
||||||
const rawMode = ref(false)
|
const rawMode = ref(false)
|
||||||
const raw = ref('')
|
const raw = ref('')
|
||||||
const type = ref('DOMAIN-SUFFIX')
|
const type = ref('')
|
||||||
const payload = ref('')
|
const payload = ref('')
|
||||||
const target = ref('')
|
const target = ref('')
|
||||||
const extras = ref('')
|
const extras = ref('')
|
||||||
@@ -115,7 +111,8 @@ watch(
|
|||||||
} else {
|
} else {
|
||||||
rawMode.value = false
|
rawMode.value = false
|
||||||
const parsed = parseRule(source)
|
const parsed = parseRule(source)
|
||||||
type.value = parsed.type || 'DOMAIN-SUFFIX'
|
// 新增时留空:预填的类型会把下拉的建议过滤到只剩它自己
|
||||||
|
type.value = parsed.type
|
||||||
payload.value = parsed.payload ?? ''
|
payload.value = parsed.payload ?? ''
|
||||||
target.value = parsed.target ?? ''
|
target.value = parsed.target ?? ''
|
||||||
extras.value = parsed.extras.join(',')
|
extras.value = parsed.extras.join(',')
|
||||||
@@ -131,7 +128,9 @@ watch(
|
|||||||
function toggleMode() {
|
function toggleMode() {
|
||||||
if (rawMode.value) {
|
if (rawMode.value) {
|
||||||
const line = raw.value.trim()
|
const line = raw.value.trim()
|
||||||
if (!isStructuredEditable(line)) return
|
// 只有逻辑规则这类整行编辑的类型留在原文模式;空行、没写完的行都带回表单
|
||||||
|
// 接着填——按 isStructuredEditable 拦的话,新增时的空原文会让人永远切不回去
|
||||||
|
if (isRawOnlyRule(line)) return
|
||||||
const parsed = parseRule(line)
|
const parsed = parseRule(line)
|
||||||
type.value = parsed.type
|
type.value = parsed.type
|
||||||
payload.value = parsed.payload ?? ''
|
payload.value = parsed.payload ?? ''
|
||||||
@@ -189,7 +188,7 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
<label class="row">
|
<label class="row">
|
||||||
<span class="k">类型</span>
|
<span class="k">类型</span>
|
||||||
<span class="v">
|
<span class="v">
|
||||||
<ComboBox v-model="type" mono :options="RULE_TYPES" />
|
<ComboBox v-model="type" mono :options="RULE_TYPES" placeholder="DOMAIN-SUFFIX" />
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -218,7 +217,7 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
<label class="row">
|
<label class="row">
|
||||||
<span class="k">目标</span>
|
<span class="k">目标</span>
|
||||||
<span class="v">
|
<span class="v">
|
||||||
<ComboBox v-model="target" mono :options="TARGET_SUGGESTIONS" placeholder="DIRECT" />
|
<ComboBox v-model="target" mono :options="targetOptions" placeholder="DIRECT" />
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
+35
-22
@@ -3,8 +3,10 @@
|
|||||||
* 新增一个规则组(rule-providers 里的一条声明)。
|
* 新增一个规则组(rule-providers 里的一条声明)。
|
||||||
*
|
*
|
||||||
* 内容的拉取、缓存、定时更新全是内核的活,这里只写声明:URL 生成 http 类型,
|
* 内容的拉取、缓存、定时更新全是内核的活,这里只写声明:URL 生成 http 类型,
|
||||||
* 本地路径生成 file 类型,缓存路径自动补一个确定的——不写 path 时内核按 url
|
* 上传的清单生成 file 类型。来源不收本地路径——浏览器里填服务器上的路径既没法
|
||||||
* 哈希自己生成,那个路径这里算不出来。
|
* 验证也容易写错,真有本地清单走文件上传,落盘位置由 Patchbay 统一管。
|
||||||
|
* 缓存路径自动补一个确定的——不写 path 时内核按 url 哈希自己生成,那个路径
|
||||||
|
* 这里算不出来。
|
||||||
*/
|
*/
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { inferRuleFormat, ruleProviderPath } from '@shared/rules.ts'
|
import { inferRuleFormat, ruleProviderPath } from '@shared/rules.ts'
|
||||||
@@ -61,7 +63,7 @@ const mrsConflict = computed(() => format.value === 'mrs' && behavior.value ===
|
|||||||
const canSubmit = computed(
|
const canSubmit = computed(
|
||||||
() =>
|
() =>
|
||||||
name.value.trim() !== '' &&
|
name.value.trim() !== '' &&
|
||||||
(file.value !== null || source.value.trim() !== '') &&
|
(file.value !== null || isUrl.value) &&
|
||||||
!nameTaken.value &&
|
!nameTaken.value &&
|
||||||
!mrsConflict.value &&
|
!mrsConflict.value &&
|
||||||
!props.saving,
|
!props.saving,
|
||||||
@@ -95,16 +97,14 @@ function submit() {
|
|||||||
path: ruleProviderPath(trimmedName, format.value),
|
path: ruleProviderPath(trimmedName, format.value),
|
||||||
format: format.value,
|
format: format.value,
|
||||||
}
|
}
|
||||||
: isUrl.value
|
: {
|
||||||
? {
|
type: 'http',
|
||||||
type: 'http',
|
behavior: behavior.value,
|
||||||
behavior: behavior.value,
|
url: trimmedSource,
|
||||||
url: trimmedSource,
|
path: ruleProviderPath(trimmedName, format.value),
|
||||||
path: ruleProviderPath(trimmedName, format.value),
|
format: format.value,
|
||||||
format: format.value,
|
interval: 86400,
|
||||||
interval: 86400,
|
}
|
||||||
}
|
|
||||||
: { type: 'file', behavior: behavior.value, path: trimmedSource, format: format.value }
|
|
||||||
emit('submit', {
|
emit('submit', {
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
declaration,
|
declaration,
|
||||||
@@ -112,6 +112,16 @@ function submit() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Esc 关弹窗走这里。文件选择框被取消时 input 的 cancel 事件会冒泡到 dialog,
|
||||||
|
* 不是发给 dialog 自己的一律不理——否则取消选文件会把整个弹窗带关。
|
||||||
|
*/
|
||||||
|
function onCancel(event: Event) {
|
||||||
|
if (event.target !== dialog.value) return
|
||||||
|
event.preventDefault()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
|
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
|
||||||
function onBackdrop(event: MouseEvent) {
|
function onBackdrop(event: MouseEvent) {
|
||||||
if (event.target === dialog.value) emit('close')
|
if (event.target === dialog.value) emit('close')
|
||||||
@@ -124,7 +134,7 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
class="dlg"
|
class="dlg"
|
||||||
@click="onBackdrop"
|
@click="onBackdrop"
|
||||||
@close="emit('close')"
|
@close="emit('close')"
|
||||||
@cancel.prevent="emit('close')"
|
@cancel="onCancel"
|
||||||
>
|
>
|
||||||
<form method="dialog" @submit.prevent="submit">
|
<form method="dialog" @submit.prevent="submit">
|
||||||
<header class="dlg-head">
|
<header class="dlg-head">
|
||||||
@@ -148,38 +158,41 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
v-model="source"
|
v-model="source"
|
||||||
class="mono"
|
class="mono"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="https://… 或本地文件路径"
|
placeholder="https://example.com/rules.list"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
/>
|
/>
|
||||||
<span v-if="source.trim() !== ''" class="note">
|
<span v-if="source.trim() !== '' && !isUrl" class="bad">
|
||||||
{{ isUrl ? 'http 类型,由内核定时拉取' : 'file 类型,读本机文件' }}
|
来源要是 http(s) 地址,本地清单走下面的文件导入
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="row">
|
<!-- SelectBox 的行不用 label 包:没有 for 的 label 绑定第一个可关联控件,
|
||||||
|
正好是它的开关按钮,悬停行内任何位置都会点亮它、点空白处会展开面板 -->
|
||||||
|
<div class="row">
|
||||||
<span class="k">行为</span>
|
<span class="k">行为</span>
|
||||||
<span class="v">
|
<span class="v">
|
||||||
<SelectBox v-model="behavior" :options="BEHAVIOR_OPTIONS" />
|
<SelectBox v-model="behavior" :options="BEHAVIOR_OPTIONS" aria-label="行为" />
|
||||||
<span class="note">
|
<span class="note">
|
||||||
清单每行带 DOMAIN,、IP-CIDR, 这类前缀的是 classical;纯域名列表是 domain;
|
清单每行带 DOMAIN,、IP-CIDR, 这类前缀的是 classical;纯域名列表是 domain;
|
||||||
纯 IP 网段列表是 ipcidr
|
纯 IP 网段列表是 ipcidr
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</div>
|
||||||
|
|
||||||
<label class="row">
|
<div class="row">
|
||||||
<span class="k">格式</span>
|
<span class="k">格式</span>
|
||||||
<span class="v">
|
<span class="v">
|
||||||
<SelectBox
|
<SelectBox
|
||||||
v-model="format"
|
v-model="format"
|
||||||
:options="FORMAT_OPTIONS"
|
:options="FORMAT_OPTIONS"
|
||||||
|
aria-label="格式"
|
||||||
@update:model-value="formatTouched = true"
|
@update:model-value="formatTouched = true"
|
||||||
/>
|
/>
|
||||||
<span v-if="mrsConflict" class="bad">mrs 格式只支持 domain 和 ipcidr 两种行为</span>
|
<span v-if="mrsConflict" class="bad">mrs 格式只支持 domain 和 ipcidr 两种行为</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</div>
|
||||||
|
|
||||||
<div class="divide" role="presentation"><span>或从文件导入</span></div>
|
<div class="divide" role="presentation"><span>或从文件导入</span></div>
|
||||||
|
|
||||||
|
|||||||
+67
-36
@@ -10,6 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { collectOutboundNames } from '@shared/inbounds.ts'
|
import { collectOutboundNames } from '@shared/inbounds.ts'
|
||||||
|
import { isGenerated } from '@shared/inbound-groups.ts'
|
||||||
import {
|
import {
|
||||||
findRuleIssues,
|
findRuleIssues,
|
||||||
firstMatchIndex,
|
firstMatchIndex,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
rulesOf,
|
rulesOf,
|
||||||
setRuleProviders,
|
setRuleProviders,
|
||||||
setRules,
|
setRules,
|
||||||
|
subRuleSetReferences,
|
||||||
} from '@shared/rules.ts'
|
} from '@shared/rules.ts'
|
||||||
import type { IniImportPlan, RuleIssue } from '@shared/rules.ts'
|
import type { IniImportPlan, RuleIssue } from '@shared/rules.ts'
|
||||||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||||
@@ -50,10 +52,21 @@ const emit = defineEmits<{ saved: [] }>()
|
|||||||
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
|
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
|
||||||
|
|
||||||
const rules = computed(() => rulesOf(props.data))
|
const rules = computed(() => rulesOf(props.data))
|
||||||
/** 每行拆好的样子,模板直接读,不在渲染里反复拆。 */
|
/**
|
||||||
const rows = computed(() =>
|
* 每行拆好的样子,模板直接读,不在渲染里反复拆。
|
||||||
rules.value.map((rule, index) => ({ index, raw: rule, ...parseRule(rule) })),
|
* 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(() =>
|
const providers = computed(() =>
|
||||||
Object.entries(ruleProvidersOf(props.data)).map(([name, decl]) => ({
|
Object.entries(ruleProvidersOf(props.data)).map(([name, decl]) => ({
|
||||||
name,
|
name,
|
||||||
@@ -75,7 +88,18 @@ const outboundNames = computed(() => [
|
|||||||
...outbounds.value.ownProxies,
|
...outbounds.value.ownProxies,
|
||||||
])
|
])
|
||||||
|
|
||||||
/** 第一条 MATCH 的下标。它之后的行标成不可达。 */
|
/**
|
||||||
|
* 目标的建议:日常用的两个内置出站、用户自己建的策略组和自有线路。
|
||||||
|
* 生成的专属组不列——每路入站三个,会把列表灌成一面墙,真要指定就手填。
|
||||||
|
*/
|
||||||
|
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 matchAt = computed(() => firstMatchIndex(rules.value))
|
||||||
|
|
||||||
/** 断引用按行归好,模板里直接查。 */
|
/** 断引用按行归好,模板里直接查。 */
|
||||||
@@ -118,7 +142,7 @@ function withRules(next: string[]): ConfigObject {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 分流规则 ────────────────────────────────────
|
// ── 全局规则 ────────────────────────────────────
|
||||||
|
|
||||||
const ruleFormOpen = ref(false)
|
const ruleFormOpen = ref(false)
|
||||||
/** 编辑时是下标,新增时是 null */
|
/** 编辑时是下标,新增时是 null */
|
||||||
@@ -160,17 +184,11 @@ async function move(index: number, delta: -1 | 1) {
|
|||||||
await commit(withRules(moveRule(rules.value, index, delta)), '顺序已保存')
|
await commit(withRules(moveRule(rules.value, index, delta)), '顺序已保存')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 待确认删除的规则下标。非 null 时确认对话框打开着。 */
|
// 删除不设二次确认:删错了有撤回,规则原文放进提示里让人看清删掉的是哪条
|
||||||
const removingRule = ref<number | null>(null)
|
async function removeRule(index: number) {
|
||||||
|
const rule = rules.value[index]
|
||||||
async function removeRuleConfirmed() {
|
if (rule === undefined || saving.value) return
|
||||||
const index = removingRule.value
|
await commit(withRules(rules.value.filter((_, i) => i !== index)), `已删除全局规则 [ ${rule} ]`)
|
||||||
removingRule.value = null
|
|
||||||
if (index === null || saving.value) return
|
|
||||||
await commit(
|
|
||||||
withRules(rules.value.filter((_, i) => i !== index)),
|
|
||||||
'已删除这条规则',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyLanPreset() {
|
async function applyLanPreset() {
|
||||||
@@ -258,8 +276,9 @@ async function submitImport(plan: IniImportPlan) {
|
|||||||
const next = clone()
|
const next = clone()
|
||||||
setRuleProviders(next, { ...ruleProvidersOf(next), ...plan.providers })
|
setRuleProviders(next, { ...ruleProvidersOf(next), ...plan.providers })
|
||||||
// 表里已有 MATCH 时丢弃配方的兜底:那是你自己安排的分流终点,配方不该
|
// 表里已有 MATCH 时丢弃配方的兜底:那是你自己安排的分流终点,配方不该
|
||||||
// 偷偷替换;追加在 MATCH 之后则永远是死行
|
// 偷偷替换;追加在 MATCH 之后则永远是死行。firstMatchIndex 没有时给 -1,
|
||||||
const keepFinals = matchAt.value === null
|
// 之前误写成 === null,配方兜底从来没被导入过
|
||||||
|
const keepFinals = matchAt.value < 0
|
||||||
const finals = keepFinals ? plan.finals : []
|
const finals = keepFinals ? plan.finals : []
|
||||||
setRules(next, [...insertBeforeMatch(rules.value, plan.rules), ...finals])
|
setRules(next, [...insertBeforeMatch(rules.value, plan.rules), ...finals])
|
||||||
const added = plan.rules.length + finals.length
|
const added = plan.rules.length + finals.length
|
||||||
@@ -290,9 +309,14 @@ function onBlockedBackdrop(event: MouseEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function askRemoveProvider(name: string) {
|
function askRemoveProvider(name: string) {
|
||||||
const refs = ruleSetReferences(rules.value, name)
|
// 附加规则(sub-rules)里的引用也要查:漏了的话删除会被内核重载拒掉,
|
||||||
|
// 用户看到的是解析错误原文而不是「谁在引用它」
|
||||||
|
const refs = [
|
||||||
|
...ruleSetReferences(rules.value, name).map((i) => rules.value[i]!),
|
||||||
|
...subRuleSetReferences(props.data, name),
|
||||||
|
]
|
||||||
if (refs.length > 0) {
|
if (refs.length > 0) {
|
||||||
blockedProvider.value = { name, refs: refs.map((i) => rules.value[i]!) }
|
blockedProvider.value = { name, refs }
|
||||||
} else {
|
} else {
|
||||||
removingProvider.value = name
|
removingProvider.value = name
|
||||||
}
|
}
|
||||||
@@ -338,7 +362,7 @@ async function updateNow(name: string) {
|
|||||||
<div class="sums">
|
<div class="sums">
|
||||||
<div class="sum">
|
<div class="sum">
|
||||||
<span class="sum-n">{{ rules.length }}</span>
|
<span class="sum-n">{{ rules.length }}</span>
|
||||||
<span class="sum-k">分流规则</span>
|
<span class="sum-k">全局规则</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sum">
|
<div class="sum">
|
||||||
<span class="sum-n">{{ providers.length }}</span>
|
<span class="sum-n">{{ providers.length }}</span>
|
||||||
@@ -349,7 +373,7 @@ async function updateNow(name: string) {
|
|||||||
|
|
||||||
<section class="sec">
|
<section class="sec">
|
||||||
<header class="sec-head">
|
<header class="sec-head">
|
||||||
<h2>分流规则</h2>
|
<h2>全局规则</h2>
|
||||||
<div class="sec-acts">
|
<div class="sec-acts">
|
||||||
<button
|
<button
|
||||||
v-if="missingLan.length > 0 && rules.length > 0"
|
v-if="missingLan.length > 0 && rules.length > 0"
|
||||||
@@ -370,7 +394,16 @@ async function updateNow(name: string) {
|
|||||||
|
|
||||||
<ol v-if="rules.length > 0" class="rows">
|
<ol v-if="rules.length > 0" class="rows">
|
||||||
<li v-for="row in rows" :key="row.index">
|
<li v-for="row in rows" :key="row.index">
|
||||||
<div :class="['line', matchAt >= 0 && row.index > matchAt && 'is-dead']">
|
<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="idx mono">{{ row.index + 1 }}</span>
|
||||||
<span class="rtype mono">{{ row.type }}</span>
|
<span class="rtype mono">{{ row.type }}</span>
|
||||||
<span class="payload mono">
|
<span class="payload mono">
|
||||||
@@ -388,7 +421,6 @@ async function updateNow(name: string) {
|
|||||||
{{ row.target }}
|
{{ row.target }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="issuesByIndex.has(row.index)" class="tag tag-bad">断引用</span>
|
<span v-if="issuesByIndex.has(row.index)" class="tag tag-bad">断引用</span>
|
||||||
<span v-if="matchAt >= 0 && row.index > matchAt" class="tag">不可达</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span class="acts">
|
<span class="acts">
|
||||||
<button
|
<button
|
||||||
@@ -423,7 +455,7 @@ async function updateNow(name: string) {
|
|||||||
class="icon-btn danger"
|
class="icon-btn danger"
|
||||||
title="删除"
|
title="删除"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
@click="removingRule = row.index"
|
@click="removeRule(row.index)"
|
||||||
>
|
>
|
||||||
<IconDelete />
|
<IconDelete />
|
||||||
</button>
|
</button>
|
||||||
@@ -431,7 +463,7 @@ async function updateNow(name: string) {
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p v-else class="empty">没有分流规则,走规则的流量都会直连。</p>
|
<p v-else class="empty">没有全局规则,走规则的流量都会直连。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="sec">
|
<section class="sec">
|
||||||
@@ -487,6 +519,7 @@ async function updateNow(name: string) {
|
|||||||
:open="ruleFormOpen"
|
:open="ruleFormOpen"
|
||||||
:editing="ruleEditing === null ? null : { index: ruleEditing, raw: rules[ruleEditing]! }"
|
:editing="ruleEditing === null ? null : { index: ruleEditing, raw: rules[ruleEditing]! }"
|
||||||
:provider-names="providerNames"
|
:provider-names="providerNames"
|
||||||
|
:target-options="targetOptions"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
@close="ruleFormOpen = false"
|
@close="ruleFormOpen = false"
|
||||||
@submit="submitRule"
|
@submit="submitRule"
|
||||||
@@ -509,14 +542,6 @@ async function updateNow(name: string) {
|
|||||||
@submit="submitImport"
|
@submit="submitImport"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
:open="removingRule !== null"
|
|
||||||
:message="removingRule === null ? '' : `删除这条规则?${rules[removingRule]}`"
|
|
||||||
action="删除"
|
|
||||||
@close="removingRule = null"
|
|
||||||
@confirm="removeRuleConfirmed"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="removingProvider !== null"
|
:open="removingProvider !== null"
|
||||||
:message="removingProvider === null ? '' : `删除规则组「${removingProvider}」?`"
|
:message="removingProvider === null ? '' : `删除规则组「${removingProvider}」?`"
|
||||||
@@ -683,7 +708,13 @@ async function updateNow(name: string) {
|
|||||||
.is-dead .rtype,
|
.is-dead .rtype,
|
||||||
.is-dead .payload,
|
.is-dead .payload,
|
||||||
.is-dead .rtarget {
|
.is-dead .rtarget {
|
||||||
color: var(--c-text-faint);
|
color: var(--c-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拦路的那条 MATCH:它后面还有行,后面全是死行 */
|
||||||
|
.is-culprit .rtype,
|
||||||
|
.is-culprit .rtarget {
|
||||||
|
color: var(--c-warn);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bad {
|
.bad {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 选中那个订阅的详情:地址、更新间隔、健康检查,和它带来的线路。
|
* 选中那个订阅的详情:地址、更新间隔,和它带来的线路。
|
||||||
|
*
|
||||||
|
* 健康检查不在字段区展示,只是右上角一个开关动作:默认就是开着的,
|
||||||
|
* 值得占一行的只有「被谁关掉了」这件事,而按钮文案本身已经说明了状态。
|
||||||
*
|
*
|
||||||
* 线路列表只读。这些线路不在内核的全局名字表里,不能被入站直接引用,
|
* 线路列表只读。这些线路不在内核的全局名字表里,不能被入站直接引用,
|
||||||
* 只能经策略组的 `use:` 整批引入,所以这里没有「换线」可点。
|
* 只能经策略组的 `use:` 整批引入,所以这里没有「换线」可点。
|
||||||
@@ -13,6 +16,9 @@ import IconEdit from '~icons/ri/edit-line'
|
|||||||
import IconDelete from '~icons/ri/delete-bin-line'
|
import IconDelete from '~icons/ri/delete-bin-line'
|
||||||
import IconRefresh from '~icons/ri/refresh-line'
|
import IconRefresh from '~icons/ri/refresh-line'
|
||||||
import IconOffline from '~icons/ri/cloud-off-line'
|
import IconOffline from '~icons/ri/cloud-off-line'
|
||||||
|
import IconPulse from '~icons/ri/pulse-line'
|
||||||
|
import IconSync from '~icons/ri/loop-right-line'
|
||||||
|
import IconLoad from '~icons/ri/loader-4-line'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
subscription: Subscription
|
subscription: Subscription
|
||||||
@@ -21,6 +27,8 @@ const props = defineProps<{
|
|||||||
online: boolean
|
online: boolean
|
||||||
/** 正在等内核重新拉取 */
|
/** 正在等内核重新拉取 */
|
||||||
refreshing: boolean
|
refreshing: boolean
|
||||||
|
/** 正在等内核测完这个订阅的线路 */
|
||||||
|
checking: boolean
|
||||||
/** 配置正在保存 */
|
/** 配置正在保存 */
|
||||||
saving: boolean
|
saving: boolean
|
||||||
}>()
|
}>()
|
||||||
@@ -30,6 +38,10 @@ const emit = defineEmits<{
|
|||||||
remove: []
|
remove: []
|
||||||
/** 让内核立刻重新拉取。运行时动作,不写配置文件 */
|
/** 让内核立刻重新拉取。运行时动作,不写配置文件 */
|
||||||
refresh: []
|
refresh: []
|
||||||
|
/** 让内核立刻测一遍这个订阅的线路。运行时动作,不写配置文件 */
|
||||||
|
check: []
|
||||||
|
/** 开关健康检查。写配置文件,走保存链路 */
|
||||||
|
toggleHealthCheck: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const lines = computed(() => props.info?.proxies ?? [])
|
const lines = computed(() => props.info?.proxies ?? [])
|
||||||
@@ -48,15 +60,13 @@ function delayClass(ms: number | null): string {
|
|||||||
<div class="title">
|
<div class="title">
|
||||||
<span class="name">{{ subscription.name }}</span>
|
<span class="name">{{ subscription.name }}</span>
|
||||||
<div class="acts">
|
<div class="acts">
|
||||||
|
<button type="button" :disabled="saving" @click="emit('toggleHealthCheck')">
|
||||||
|
<IconPulse /> {{ subscription.healthCheck ? '关闭健康检查' : '开启健康检查' }}
|
||||||
|
</button>
|
||||||
<button type="button" :disabled="!online || refreshing" @click="emit('refresh')">
|
<button type="button" :disabled="!online || refreshing" @click="emit('refresh')">
|
||||||
<IconRefresh /> {{ refreshing ? '更新中' : '更新订阅' }}
|
<IconRefresh /> {{ refreshing ? '更新中' : '更新订阅' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" :disabled="saving" @click="emit('edit')">
|
||||||
type="button"
|
|
||||||
:disabled="saving || subscription.type === 'file'"
|
|
||||||
:title="subscription.type === 'file' ? '文件导入的订阅没有可编辑的字段' : undefined"
|
|
||||||
@click="emit('edit')"
|
|
||||||
>
|
|
||||||
<IconEdit /> 编辑
|
<IconEdit /> 编辑
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="danger" :disabled="saving" @click="emit('remove')">
|
<button type="button" class="danger" :disabled="saving" @click="emit('remove')">
|
||||||
@@ -78,19 +88,23 @@ function delayClass(ms: number | null): string {
|
|||||||
{{ subscription.interval !== null ? `每 ${subscription.interval} 秒` : '已关闭' }}
|
{{ subscription.interval !== null ? `每 ${subscription.interval} 秒` : '已关闭' }}
|
||||||
</dd>
|
</dd>
|
||||||
</template>
|
</template>
|
||||||
<dt>健康检查</dt>
|
|
||||||
<dd class="mono">
|
|
||||||
<template v-if="subscription.healthCheck">
|
|
||||||
{{ subscription.healthCheck.url
|
|
||||||
}}{{ subscription.healthCheck.interval !== null ? `,每 ${subscription.healthCheck.interval} 秒` : '' }}
|
|
||||||
</template>
|
|
||||||
<template v-else>未开启</template>
|
|
||||||
</dd>
|
|
||||||
</dl>
|
</dl>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<h3>线路</h3>
|
<h3>
|
||||||
|
线路
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="check"
|
||||||
|
:title="checking ? '检查中' : '检查全部线路的延迟'"
|
||||||
|
:disabled="!online || checking || lines.length === 0"
|
||||||
|
@click="emit('check')"
|
||||||
|
>
|
||||||
|
<IconLoad v-if="checking" class="spin" />
|
||||||
|
<IconSync v-else />
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
|
||||||
<p v-if="!online" class="hint"><IconOffline /> 内核未连接</p>
|
<p v-if="!online" class="hint"><IconOffline /> 内核未连接</p>
|
||||||
<p v-else-if="lines.length === 0" class="hint">这个订阅现在没有线路</p>
|
<p v-else-if="lines.length === 0" class="hint">这个订阅现在没有线路</p>
|
||||||
@@ -149,22 +163,22 @@ function delayClass(ms: number | null): string {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s-1);
|
gap: var(--s-1);
|
||||||
padding: var(--s-1) var(--s-3);
|
padding: var(--s-1) var(--s-2);
|
||||||
border: 1px solid var(--c-border-strong);
|
border: none;
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
background: var(--c-surface);
|
background: none;
|
||||||
|
color: var(--c-text-soft);
|
||||||
font-size: var(--t-sm);
|
font-size: var(--t-sm);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.acts button:hover:not(:disabled) {
|
.acts button:hover:not(:disabled) {
|
||||||
border-color: var(--c-accent);
|
background: var(--c-surface-sunken);
|
||||||
color: var(--c-accent-text);
|
color: var(--c-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.acts button.danger:hover:not(:disabled) {
|
.acts button.danger:hover:not(:disabled) {
|
||||||
border-color: var(--c-danger);
|
|
||||||
color: var(--c-danger);
|
color: var(--c-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +213,8 @@ function delayClass(ms: number | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
font-size: var(--t-sm);
|
font-size: var(--t-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--c-text);
|
color: var(--c-text);
|
||||||
@@ -206,6 +222,38 @@ h3 {
|
|||||||
margin-bottom: var(--s-3);
|
margin-bottom: var(--s-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: auto;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.check:hover:not(:disabled) {
|
||||||
|
background: var(--c-surface-sunken);
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.check:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -221,6 +269,12 @@ h3 {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
max-height: 420px;
|
max-height: 420px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
/* 细杆加透明轨道,别让系统滚动条压在延迟列上;gutter 常驻,
|
||||||
|
线路数跨过滚动阈值时右缘不横跳 */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--c-border-strong) transparent;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
padding-right: var(--s-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
* 新增或编辑一个订阅。
|
* 新增或编辑一个订阅。
|
||||||
*
|
*
|
||||||
* 原生 <dialog>,同 InboundForm。缓存路径不写进表单:它由名字自动生成,
|
* 原生 <dialog>,同 InboundForm。缓存路径不写进表单:它由名字自动生成,
|
||||||
* 让用户填只会填出和链式代理对不上的路径。订阅不能改名——名字被策略组的 `use:`
|
* 让用户填只会填出和链式代理对不上的路径。编辑时名字也能改,引用同步和
|
||||||
* 和生成的副本引用,改名要同步所有引用,所以编辑时名字只是静态展示,想换名就删了重建。
|
* 副本重建由保存链路负责(renameSubscription + syncGenerated)。
|
||||||
*
|
*
|
||||||
* 新增时地址之外还能从文件导入:选了文件就是 file 类型的订阅,没有地址也没有
|
* 新增时地址之外还能从文件导入:选了文件就是 file 类型的订阅,没有地址也没有
|
||||||
* 自动更新(内核靠文件监视跟进变化),相关字段随之收起。
|
* 自动更新(内核靠文件监视跟进变化),相关字段随之收起。
|
||||||
@@ -28,7 +28,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
/** 编辑时 name 是被编辑那个订阅的原名。file 非 null 表示从文件导入 */
|
/** 编辑时 name 和原名不同就是改名。file 非 null 表示从文件导入 */
|
||||||
submit: [
|
submit: [
|
||||||
draft: {
|
draft: {
|
||||||
name: string
|
name: string
|
||||||
@@ -66,6 +66,8 @@ watch(
|
|||||||
const nameError = computed(() => {
|
const nameError = computed(() => {
|
||||||
const n = name.value.trim()
|
const n = name.value.trim()
|
||||||
if (n === '') return ''
|
if (n === '') return ''
|
||||||
|
// 编辑时名字没动不算重名,takenNames 里有它自己
|
||||||
|
if (n === props.editing?.name) return ''
|
||||||
// 带这个前缀的会被当成 Patchbay 生成物:列表里不显示,pruneGenerated 还会把它清掉
|
// 带这个前缀的会被当成 Patchbay 生成物:列表里不显示,pruneGenerated 还会把它清掉
|
||||||
if (n.startsWith(GENERATED_PREFIX)) return `名称不能以 ${GENERATED_PREFIX} 开头`
|
if (n.startsWith(GENERATED_PREFIX)) return `名称不能以 ${GENERATED_PREFIX} 开头`
|
||||||
if (props.takenNames.includes(n)) return '名称已存在'
|
if (props.takenNames.includes(n)) return '名称已存在'
|
||||||
@@ -80,8 +82,9 @@ const intervalInvalid = computed(() => {
|
|||||||
|
|
||||||
const canSubmit = computed(() => {
|
const canSubmit = computed(() => {
|
||||||
if (props.saving) return false
|
if (props.saving) return false
|
||||||
if (props.editing === null && (name.value.trim() === '' || nameError.value !== '')) return false
|
if (name.value.trim() === '' || nameError.value !== '') return false
|
||||||
if (file.value !== null) return true
|
if (file.value !== null) return true
|
||||||
|
if (props.editing?.type === 'file') return true
|
||||||
return url.value.trim() !== '' && !intervalInvalid.value
|
return url.value.trim() !== '' && !intervalInvalid.value
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,13 +95,23 @@ function onFile(fileName: string, content: string) {
|
|||||||
function submit() {
|
function submit() {
|
||||||
if (!canSubmit.value) return
|
if (!canSubmit.value) return
|
||||||
emit('submit', {
|
emit('submit', {
|
||||||
name: props.editing?.name ?? name.value.trim(),
|
name: name.value.trim(),
|
||||||
url: url.value.trim(),
|
url: url.value.trim(),
|
||||||
interval: autoUpdate.value ? Number(interval.value.trim()) : null,
|
interval: autoUpdate.value ? Number(interval.value.trim()) : null,
|
||||||
file: file.value,
|
file: file.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Esc 关弹窗走这里。文件选择框被取消时 input 的 cancel 事件会冒泡到 dialog,
|
||||||
|
* 不是发给 dialog 自己的一律不理——否则取消选文件会把整个弹窗带关。
|
||||||
|
*/
|
||||||
|
function onCancel(event: Event) {
|
||||||
|
if (event.target !== dialog.value) return
|
||||||
|
event.preventDefault()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
|
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
|
||||||
function onBackdrop(event: MouseEvent) {
|
function onBackdrop(event: MouseEvent) {
|
||||||
if (event.target === dialog.value) emit('close')
|
if (event.target === dialog.value) emit('close')
|
||||||
@@ -111,7 +124,7 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
class="dlg"
|
class="dlg"
|
||||||
@click="onBackdrop"
|
@click="onBackdrop"
|
||||||
@close="emit('close')"
|
@close="emit('close')"
|
||||||
@cancel.prevent="emit('close')"
|
@cancel="onCancel"
|
||||||
>
|
>
|
||||||
<form method="dialog" @submit.prevent="submit">
|
<form method="dialog" @submit.prevent="submit">
|
||||||
<header class="dlg-head">
|
<header class="dlg-head">
|
||||||
@@ -120,7 +133,7 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<label v-if="!editing" class="row">
|
<label class="row">
|
||||||
<span class="k">名称</span>
|
<span class="k">名称</span>
|
||||||
<span class="v">
|
<span class="v">
|
||||||
<input v-model="name" type="text" placeholder="subs" autocomplete="off" />
|
<input v-model="name" type="text" placeholder="subs" autocomplete="off" />
|
||||||
@@ -128,7 +141,8 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<template v-if="file === null">
|
<!-- file 类型没有地址和自动更新,编辑它只有名称可改 -->
|
||||||
|
<template v-if="file === null && editing?.type !== 'file'">
|
||||||
<label class="row">
|
<label class="row">
|
||||||
<span class="k">地址</span>
|
<span class="k">地址</span>
|
||||||
<span class="v">
|
<span class="v">
|
||||||
@@ -271,22 +285,30 @@ input {
|
|||||||
background: var(--c-bg);
|
background: var(--c-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 输入框加单位后缀:数字和它的量纲摆在一起 */
|
/* 单位画在输入框内侧右缘:框和其它行齐宽,数字和量纲还在一起 */
|
||||||
.with-unit {
|
.with-unit {
|
||||||
display: flex;
|
position: relative;
|
||||||
align-items: center;
|
}
|
||||||
gap: var(--s-2);
|
|
||||||
|
.with-unit input {
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 2.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unit {
|
.unit {
|
||||||
flex: none;
|
position: absolute;
|
||||||
color: var(--c-text-soft);
|
right: var(--s-3);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--c-text-faint);
|
||||||
font-size: var(--t-sm);
|
font-size: var(--t-sm);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 开关。align-self 抵消 row 的 baseline 对齐,胶囊不该被拉到文字基线上 */
|
/* 开关。align-self 抵消 row 的 baseline 对齐,胶囊不该被拉到文字基线上 */
|
||||||
.sw {
|
.sw {
|
||||||
align-self: center;
|
/* 靠右:和输入框的右缘同一条线,也是设置页「标签左、控件右」的行模式 */
|
||||||
|
align-self: flex-end;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
Reference in New Issue
Block a user