15 Commits
Author SHA1 Message Date
im0o 27ebdac034 新增规则组的来源只收 URL
浏览器里填服务器上的本地路径既没法验证也容易写错,真有本地清单走
文件上传,落盘位置由 Patchbay 统一管。已有的 file 类型声明不受影响。
2026-08-12 14:16:07 +08:00
im0o 9c302b2a33 README 精简
修正验证脚本的数量说法(是三个不是两个),改成指向文件头的用法说明,
以后加脚本不再过时。删掉指向仓库外 docs/adr 的引用:克隆者拿到的是
断链,工作区内有 AGENTS.md 指路。开发一节只留命令和「访问 5173 不是
7580」这个坑。
2026-08-12 13:43:39 +08:00
im0o 57690a159c 规则列表标出不可达行,「分流规则」改名「全局规则」
MATCH 的实现是无条件返回 true(rules/common/final.go:16-18),第一条
MATCH 之后的行永远轮不到。全局规则和附加规则的列表都把这类行标红、
拦路的 MATCH 标黄,hover 给原因;表底作兜底的 MATCH 不标黄,那是正常
用法。附加规则里用户自己的 MATCH 总会架空表底的自动兜底,兜底行跟着
标红。

规则页的「分流规则」改名「全局规则」,和「附加规则」对仗;术语表里
「分流规则」退为两者的统称。

顺手修 ini 导入的兜底判断:firstMatchIndex 没有 MATCH 时返回 -1,
之前误写成 === null,条件恒假,配方的兜底规则从来没被导入过。
2026-08-12 13:43:38 +08:00
im0o f57eb55328 外壳:侧栏改 fixed,请求页改名日志,错误提示点击复制,弹窗锁滚动
侧栏从 sticky 改 fixed:sticky 的位置跟着 scrollTop 走,滚到底时
scrollTop 是 scrollHeight 减 clientHeight,在 Windows 显示缩放下是
小数,部分图标会跳 1px;fixed 按视口摆放,不参与这个运算。网格第一列
只当占位,主区显式站到第二列。

错误提示是拿去粘贴排查的,点击改成复制而不是关闭,关闭交给自动消失;
局域网 http 下没有 clipboard API,退回 execCommand。撤回/重做各加
2.5 秒冷却,窗内连点算一次,顺带避开重做备份名的同秒覆盖。弹窗打开
时锁住背景滚动,滚动条位置由 scrollbar-gutter 常驻,不横向跳。
2026-08-12 13:17:35 +08:00
im0o 129a76696b 仪表盘和订阅详情的操作按钮改 text-button,公网 IP 带旗帜
检测公网 IP、重启内核、订阅详情右上三个按钮都去掉边框,hover 出浅底。
检测结果由国家代码换成旗帜 emoji 加 IP;Windows 的系统字体没有旗帜
字形,浏览器会退回显示两个字母,和原来的效果一致。
2026-08-12 13:17:21 +08:00
im0o a40bd6a01b 历史记录:每条可展开 diff,连续保存合并进 5 秒窗
备份存的是「改动前」,所以一条记录的 diff 两端是它自己和更新一份的
状态(最新一条对比当前文件)。行级 diff 先掐公共前后缀再做 LCS,一次
改动通常只碰几行,表撑得住;大面积不同时退化成整段删增。

合并窗做在服务端而不是前端防抖:普通保存(opts.merge)发现最新备份
还在窗内就不新建,连点删除共享一条历史、一步撤回。窗锚定在这一串的
第一下,不滚雪球;还原、导入、撤回重做不合并,各占一条。合并时没有
备份文件,重载失败的还原改用刚读出的原文写回。顺带消除了备份名只精
确到秒、同秒保存互相覆盖的隐患。
2026-08-12 13:17:11 +08:00
im0o 48e97bd91f 规则编辑:建议列表全量展开,目标补齐候选,删除免确认
组合框展开先给全量、本次展开输入过才过滤——按当前值过滤的话,预填的
类型会把列表滤到只剩它自己,MATCH 这类排在后面的选项等于不存在。类型
因此可以默认留空,placeholder 当示例。

目标建议由页面传入:全局规则给内置两个加用户自建的组和自有线路;附加
规则多一项本路专属组——「这个载荷走本路代理」写的就是它,放在拦截规则
前面还能做豁免。生成的专属组不列,每路三个会把列表灌成一面墙。

原文/字段切换只拦真正整行编辑的类型(isRawOnlyRule):之前用
isStructuredEditable 拦,新增时的空原文永远过不了,卡死在原文模式。

删除规则和附加规则不再二次确认——删错了有撤回,确认框只是多一步;
toast 带上被删规则的原文。
2026-08-12 13:16:58 +08:00
im0o 955fa340af 删除规则组的引用拦截连附加规则一起查
之前只扫顶层 rules。附加规则(sub-rules)里的 RULE-SET 引用漏过的话,
删除会被内核重载拒掉——保护链路能回滚,但用户看到的是解析错误原文
而不是「谁在引用它」。
2026-08-12 04:02:56 +08:00
im0o c3c0f56e56 路由详情加附加规则:每路入站自己的分流表
用内核的 sub-rules 机制:listener 写 rule: 指向顶层 sub-rules 里的表
(listener/inbound/base.go:96,tunnel.go:713-721)。两个硬约束决定了
形态:proxy 优先级更高且完全短路规则匹配(tunnel.go:318-325),有附加
规则时 listener 只能写 rule;子规则表没命中时兜底是 DIRECT 而不是这一
路的出站(tunnel.go match 循环末尾),表底那条 MATCH,专属组 是承重的,
由 Patchbay 自动维护、界面上单独画不参与编辑。不变式:附加规则空 ⇔
proxy 直连专属组,非空 ⇔ rule 形态。

三处既有逻辑教会认 rule 形态:applySetup 不再无条件写 proxy(会把附加
规则静默短路掉);referencesTo 扫 sub-rules 里的规则——表底 MATCH 是
rule 形态下专属组活着的唯一理由,不数它就会被当孤儿清掉;pruneGenerated
清理没有 listener 引用的生成表。panelGroups 和 syncGenerated 的「是否
由 Patchbay 接线」收敛成一个 managedWiring。

listener 的 rule 指向不存在的表时内核静默退回全局规则(getRules 的
else 分支),-t 和重载都不拦,自检里补上这条(vendor 本地改动,记入
ORIGIN.md)。界面:详情列新增附加规则区块,逐条增删改和排序复用
RuleForm,带内网直连预设;生成形状过了 mihomo -t 实测。
2026-08-12 04:01:08 +08:00
im0o d1214d4cbd 侧栏的重启确认文案改成和仪表盘同一句
同一个动作两处入口不该说两种话。上一提交按配置动态措辞的那套撤掉。
2026-08-12 03:36:55 +08:00
im0o 8d4579f987 弹窗加快速淡出退场;重启确认的文案按配置说话
退场用 opacity 过渡加 display/overlay 的 allow-discrete——过渡结束前
元素留在 top layer,不支持的浏览器退化为立即消失。所有 dialog 一并
生效。

重启确认原来把 profile.store-selected 的配置项名直接甩给用户。现在读
配置判断(内核默认记住选中,config.go:576,显式 false 才会丢),按
实际情况说「选中的线路重启后不变」或「会回到默认成员」。
2026-08-12 03:32:53 +08:00
im0o 6ac0a63f28 订阅表单的自动更新开关靠右
原来 align-self: center 悬在值列中间,哪条对齐线都不沾;靠右后和
输入框右缘同线,也是设置页「标签左、控件右」的行模式。
2026-08-12 03:28:20 +08:00
im0o 14d26bec71 订阅表单的间隔输入框和其它行对齐,单位画进框内
秒挂在框外占掉一截宽度,这一行就比名称、地址短一块。单位改成绝对
定位贴在输入框内侧右缘,框本身回到整行宽。
2026-08-12 03:24:34 +08:00
im0o 91243354de 取消文件选择不再把弹窗一起关掉
input[type=file] 在用户取消选择时发出的 cancel 事件会冒泡,带 DropFile
的三个弹窗把它当成了 dialog 自己的 Esc 取消。cancel 处理器现在只认
target 是 dialog 本身的事件。
2026-08-12 03:22:53 +08:00
im0o 25d60f9718 表单行不再用 label 包 button 型控件
没有 for 的 label 会绑定内部第一个可关联控件。导入 ini 的「内容」行里
那是 DropFile 的按钮:悬停 textarea 会点亮投放区,点到行内空白处直接
弹出文件选择框。SelectBox 的开关按钮同理(InboundForm、RuleGroupForm、
ini 映射行),悬停整行都会点亮它。这些行改用 div,无障碍名换成控件
自己的 aria-label。ComboBox 的行保持 label——它第一个可关联控件是文本
输入框,label 转发过去正是想要的行为。
2026-08-12 03:18:43 +08:00
33 changed files with 1415 additions and 192 deletions
+5 -1
View File
@@ -85,9 +85,13 @@ _Avoid_: 无效引用、悬空引用、坏引用
## 规则 ## 规则
**分流规则**: **分流规则**:
配置文件 `rules:` 里的有序列表,自上而下匹配,决定一条连接走哪个出站。顺序本身是语义的一部分。 自上而下匹配、决定一条连接走哪个出站的有序规则列表,第一条命中即生效。顺序本身是语义的一部分。全局规则和附加规则都是分流规则。
_Avoid_: 路由规则、规则表 _Avoid_: 路由规则、规则表
**全局规则**:
配置文件顶层 `rules:` 里的分流规则表。只对没有附加规则的入站生效——带附加规则的入站整表改查自己的表,不再看它。
_Avoid_: 顶层规则、主规则、默认规则
**附加规则**: **附加规则**:
挂在某一路入站上的一组分流规则,只对从该入站进来的流量生效。 挂在某一路入站上的一组分流规则,只对从该入站进来的流量生效。
_Avoid_: sub-rules、子规则、私有规则 _Avoid_: sub-rules、子规则、私有规则
+11 -26
View File
@@ -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/ 本地测试内核的配置
``` ```
+81
View File
@@ -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
View File
@@ -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)
+11 -1
View File
@@ -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]!)
+14
View File
@@ -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]),
)
}
+64
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+11
View File
@@ -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 '—'
+71
View File
@@ -1,10 +1,14 @@
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,
@@ -356,3 +360,70 @@ 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()
})
})
+129 -10
View File
@@ -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,
@@ -332,11 +417,17 @@ 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
@@ -365,6 +456,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 +494,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 +519,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 +538,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 +569,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 {
+23
View File
@@ -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
View File
@@ -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']
+1
View File
@@ -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` 和重载都不会拦,只有这层自检能拦住 |
## 配套的本项目代码 ## 配套的本项目代码
+19
View File
@@ -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
} }
+33 -9
View File
@@ -100,8 +100,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 +131,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 +256,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 +265,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 +394,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 +428,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 +717,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;
} }
+7
View File
@@ -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'),
/** /**
+11 -2
View File
@@ -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() {
+114 -13
View File
@@ -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>
+39 -4
View File
@@ -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>
+2 -2
View File
@@ -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 },
] ]
+22 -1
View File
@@ -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;
+31 -15
View File
@@ -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,
+227 -1
View File
@@ -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'
@@ -32,6 +34,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 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 +50,8 @@ const props = defineProps<{
subscriptions: string[] subscriptions: string[]
/** 当前的来源和筛选,配置表单的初值 */ /** 当前的来源和筛选,配置表单的初值 */
setup: OutboundSetup | null setup: OutboundSetup | null
/** 这一路的附加规则(不含表底的兜底 MATCH,那条由 Patchbay 自动维护) */
subRules: string[]
/** 正在测延迟的线路 */ /** 正在测延迟的线路 */
testing: string[] testing: string[]
/** 配置正在保存 */ /** 配置正在保存 */
@@ -61,8 +68,28 @@ 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': []
}>() }>()
/** 手写的 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))
/** 默认入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删。 */ /** 默认入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删。 */
const locked = computed(() => props.inbound.isDefault) const locked = computed(() => props.inbound.isDefault)
@@ -434,7 +461,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>
@@ -671,6 +699,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>
@@ -798,6 +909,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;
+16 -6
View File
@@ -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">
+26 -6
View File
@@ -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>
+101 -1
View File
@@ -7,20 +7,24 @@
* 运行时动作(切换选中、测延迟)不经过它,因为它们不写文件也不需要校验。 * 运行时动作(切换选中、测延迟)不经过它,因为它们不写文件也不需要校验。
*/ */
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,
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 +37,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<{
@@ -219,6 +224,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,6 +363,7 @@ 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"
@@ -288,6 +372,11 @@ async function removeConfirmed() {
@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 +399,17 @@ async function removeConfirmed() {
@close="removing = null" @close="removing = null"
@confirm="removeConfirmed" @confirm="removeConfirmed"
/> />
<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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+6 -6
View File
@@ -149,22 +149,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);
} }
+26 -8
View File
@@ -99,6 +99,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')
@@ -111,7 +121,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">
@@ -271,22 +281,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;