From 158dc9fca7e963f370cbec40bde53a54d1dd1cec Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Wed, 12 Aug 2026 03:07:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B6=88=E5=8C=96=E4=B8=80=E6=89=B9=20P3?= =?UTF-8?q?=EF=BC=9AGEOIP=20=E9=99=84=E5=8A=A0=E5=8F=82=E6=95=B0=E3=80=81?= =?UTF-8?q?=E5=9D=8F=20JSON=20=E8=B7=B3=E8=A1=8C=E3=80=81=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E4=B8=A2=E5=BC=83=EF=BC=9B=E8=AF=B7=E6=B1=82=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E4=B8=A4=E9=A1=B5=E4=BF=9D=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ini 里 []GEOIP,CN,no-resolve 的附加参数原来被并进类别名,生成字段错位 的规则;现在拆出来放在目标之后。表里已有 MATCH 时配方的 []FINAL 不再 追加成死行,改为丢弃并在成功提示里注明——已有兜底是用户自己安排的 分流终点。 遥测流里偶发的坏 JSON 行改为跳过,原来一行坏数据会杀掉整个流进 5 秒 重试循环。请求页:跟随底部的阈值从 24px 降到 8px(原值比行高还大, 向上滚不足一行就被新日志拽回);silent 级别下的空状态不再说「没有流量 经过」。logs.test 的 localStorage 桩用完删掉,bun test 单进程跑,全局 残留会影响之后的测试文件。api.ts 的 format 注释照 provider.go:37 纠正。 请求页和连接页包进 KeepAlive:切走不销毁,筛选词、排序、滚动位置留住。 KeepAlive 常驻、子组件自己 v-if 进出;连接页的轮询改挂 activated—— 挂载时它也触发,继续用 onMounted 会叠两个定时器,切走时停掉轮询。 --- shared/rules.test.ts | 9 +++++++++ shared/rules.ts | 22 +++++++++++++++------- web/App.vue | 19 ++++++++++++++----- web/api.ts | 2 +- web/logs.test.ts | 12 +++++++++--- web/telemetry.ts | 9 ++++++++- web/views/ConnectionsPage.vue | 10 ++++++++-- web/views/RequestsPage.vue | 15 ++++++++++++--- web/views/RulesPage.vue | 13 +++++++++---- 9 files changed, 85 insertions(+), 26 deletions(-) diff --git a/shared/rules.test.ts b/shared/rules.test.ts index 145c873..1a64098 100644 --- a/shared/rules.test.ts +++ b/shared/rules.test.ts @@ -202,6 +202,15 @@ custom_proxy_group=🚀 节点选择\`select\`[]AUTO it('custom_proxy_group 和残缺行不产生条目', () => { expect(parseIniRulesets('ruleset=\nruleset=只有目标,\ncustom_proxy_group=x`select`y')).toEqual([]) }) + + it('GEOIP 类别后的附加参数不并进类别名,生成规则时放在目标之后', () => { + const entries = parseIniRulesets('ruleset=🎯 全球直连,[]GEOIP,CN,no-resolve') + expect(entries).toEqual([ + { target: '🎯 全球直连', kind: 'geoip', source: 'CN', flags: ['no-resolve'] }, + ]) + const plan = planIniImport(entries, { '🎯 全球直连': 'DIRECT' }, new Set()) + expect(plan.rules).toEqual(['GEOIP,CN,DIRECT,no-resolve']) + }) }) describe('ini 导入计划', () => { diff --git a/shared/rules.ts b/shared/rules.ts index 1a29741..6a2df02 100644 --- a/shared/rules.ts +++ b/shared/rules.ts @@ -253,6 +253,8 @@ export interface IniRuleset { kind: 'url' | 'file' | 'geoip' | 'geosite' | 'final' /** kind 为 url/file 时是来源,为 geoip/geosite 时是类别名,final 时为空 */ source: string + /** geoip/geosite 类别后面的附加参数(no-resolve 这类)。生成规则时放在目标之后 */ + flags?: string[] } /** @@ -281,10 +283,19 @@ export function parseIniRulesets(text: string): IniRuleset[] { const inline = source.slice(2).trim() const geo = /^(GEOIP|GEOSITE)\s*,\s*(.+)$/i.exec(inline) if (geo) { + // []GEOIP,CN,no-resolve:第二段才是类别,再往后是 no-resolve 这类附加参数。 + // 附加参数在 mihomo 规则里的位置是目标之后,混进类别名会生成字段错位的规则 + const fields = geo[2]! + .split(',') + .map((field) => field.trim()) + .filter(Boolean) + const [category, ...flags] = fields + if (!category) continue result.push({ target, kind: geo[1]!.toUpperCase() === 'GEOIP' ? 'geoip' : 'geosite', - source: geo[2]!.trim(), + source: category, + ...(flags.length > 0 ? { flags } : {}), }) } else if (inline.toUpperCase() === 'FINAL' || inline.toUpperCase() === 'MATCH') { result.push({ target, kind: 'final', source: '' }) @@ -342,12 +353,9 @@ export function planIniImport( const target = mapping[entry.target] if (!target) continue - if (entry.kind === 'geoip') { - rules.push(`GEOIP,${entry.source},${target}`) - continue - } - if (entry.kind === 'geosite') { - rules.push(`GEOSITE,${entry.source},${target}`) + if (entry.kind === 'geoip' || entry.kind === 'geosite') { + const type = entry.kind === 'geoip' ? 'GEOIP' : 'GEOSITE' + rules.push([type, entry.source, target, ...(entry.flags ?? [])].join(',')) continue } if (entry.kind === 'final') { diff --git a/web/App.vue b/web/App.vue index e359ec1..d1c1b21 100644 --- a/web/App.vue +++ b/web/App.vue @@ -348,10 +348,6 @@ const current = computed(() => NAV.find((n) => n.key === active.value)) @saved="load" /> - - - - NAV.find((n) => n.key === active.value)) @saved="load" /> -
+

{{ current?.label }} 尚未实现

+ + + + + + + + diff --git a/web/api.ts b/web/api.ts index ab74332..9acb363 100644 --- a/web/api.ts +++ b/web/api.ts @@ -124,7 +124,7 @@ export interface ListenerFields { export interface RuleProviderInfo { /** domain / ipcidr / classical */ behavior: string - /** yaml / text / mrs。inline 类型的没有这个字段 */ + /** yaml / text / mrs。内核始终序列化它(provider.go:37 无 omitempty),inline 类型给的是空串 */ format?: string name: string /** 当前装了多少条规则 */ diff --git a/web/logs.test.ts b/web/logs.test.ts index 0e72122..3d00765 100644 --- a/web/logs.test.ts +++ b/web/logs.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, mock } from 'bun:test' +import { afterAll, afterEach, describe, expect, it, mock } from 'bun:test' import { LOG_CAP, parseLogLine, pushCapped, streamLogs } from './logs.ts' import { LogStreamError } from './logs.ts' @@ -49,8 +49,10 @@ function streamOf(chunks: string[]): ReadableStream { const realFetch = globalThis.fetch -// bun 的测试环境没有 localStorage,而 streamLogs 要从里面取令牌 -if (typeof globalThis.localStorage === 'undefined') { +// bun 的测试环境没有 localStorage,而 streamLogs 要从里面取令牌。 +// 桩只在本文件生效:bun test 是单进程,全局残留会影响之后跑的其它测试文件 +const hadStorage = typeof globalThis.localStorage !== 'undefined' +if (!hadStorage) { ;(globalThis as Record).localStorage = { getItem: () => '', setItem: () => {}, @@ -58,6 +60,10 @@ if (typeof globalThis.localStorage === 'undefined') { } } +afterAll(() => { + if (!hadStorage) delete (globalThis as Record).localStorage +}) + afterEach(() => { globalThis.fetch = realFetch }) diff --git a/web/telemetry.ts b/web/telemetry.ts index ce2789c..55ac3e0 100644 --- a/web/telemetry.ts +++ b/web/telemetry.ts @@ -65,7 +65,14 @@ async function consume( ) { const body = await api.openStream(path, ctrl.signal) for await (const line of readLines(body)) { - onLine(JSON.parse(line) as Record) + // 偶发的坏行跳过就行,抛出去会杀掉整个流、进 5 秒重试循环 + let payload: Record + try { + payload = JSON.parse(line) as Record + } catch { + continue + } + onLine(payload) } } diff --git a/web/views/ConnectionsPage.vue b/web/views/ConnectionsPage.vue index 209af9b..14b1784 100644 --- a/web/views/ConnectionsPage.vue +++ b/web/views/ConnectionsPage.vue @@ -6,7 +6,7 @@ * 显示层的问题(主机名怎么摆、出站链按什么方向、持续时间多长)都在 shared/connections.ts * 里回答,这里只管取数、状态和断开动作。 */ -import { computed, onMounted, onUnmounted, ref } from 'vue' +import { computed, onActivated, onDeactivated, onUnmounted, ref } from 'vue' import { useVirtualizer } from '@tanstack/vue-virtual' import { chainLabel, @@ -62,11 +62,17 @@ async function refresh() { } } -onMounted(() => { +// 组件包在 KeepAlive 里:首次挂载和每次切回都走 activated(挂载时它也会触发, +// 不能再用 onMounted 起轮询,否则会叠两个定时器),切走时停掉轮询不白打后端 +onActivated(() => { void refresh() timer = window.setInterval(() => void refresh(), 1000) }) +onDeactivated(() => { + window.clearInterval(timer) +}) + onUnmounted(() => { window.clearInterval(timer) }) diff --git a/web/views/RequestsPage.vue b/web/views/RequestsPage.vue index 88d5a8c..12cf358 100644 --- a/web/views/RequestsPage.vue +++ b/web/views/RequestsPage.vue @@ -5,7 +5,7 @@ * 收集在 web/log-store.ts 的常驻单例里——日志跨页面留存,切走再回来不清空。 * 这个组件只管呈现:筛选、暂停、清空、跟随滚动。 */ -import { computed, nextTick, onMounted, ref, watch } from 'vue' +import { computed, nextTick, onActivated, onMounted, ref, watch } from 'vue' import { kernel } from '../runtime' import { LEVELS } from '../logs' import { clearLogs, ended, failure, level, paused, revision, rows } from '../log-store' @@ -34,7 +34,9 @@ let follow = true function onScroll() { const el = listEl.value if (!el) return - follow = el.scrollTop + el.clientHeight >= el.scrollHeight - 24 + // 阈值必须小于一行的高度(约 25px),否则向上滚不足一行仍算「在底部」, + // 新日志会把人拽回去。8px 只容浏览器的取整误差 + follow = el.scrollTop + el.clientHeight >= el.scrollHeight - 8 } async function scrollToBottom(force = false) { @@ -49,6 +51,9 @@ watch(revision, () => void scrollToBottom()) // 切回这一页时接上离开时的进度,直接落到最新一条 onMounted(() => void scrollToBottom(true)) +// 组件包在 KeepAlive 里,切走期间元素脱离文档、scrollHeight 不可靠, +// 跟随模式的自动滚动可能把位置写歪;切回来重新贴底(手动上滚过的不动) +onActivated(() => void scrollToBottom()) const visible = computed(() => { const q = query.value.trim().toLowerCase() @@ -94,7 +99,11 @@ const visible = computed(() => { {{ rows.length > 0 ? '内核未连接,日志不再更新。' : '内核未连接,无法读取日志。' }}

- 暂无日志。没有流量经过内核时不会产生请求记录。 + {{ + level === 'silent' + ? '级别为 silent 时内核不输出日志。' + : '暂无日志。没有流量经过内核时不会产生请求记录。' + }}

没有包含「{{ query.trim() }}」的日志。 diff --git a/web/views/RulesPage.vue b/web/views/RulesPage.vue index b610711..84b21c0 100644 --- a/web/views/RulesPage.vue +++ b/web/views/RulesPage.vue @@ -257,11 +257,16 @@ async function submitGroup(entry: { async function submitImport(plan: IniImportPlan) { const next = clone() setRuleProviders(next, { ...ruleProvidersOf(next), ...plan.providers }) - setRules(next, [...insertBeforeMatch(rules.value, plan.rules), ...plan.finals]) - const added = plan.rules.length + plan.finals.length + // 表里已有 MATCH 时丢弃配方的兜底:那是你自己安排的分流终点,配方不该 + // 偷偷替换;追加在 MATCH 之后则永远是死行 + const keepFinals = matchAt.value === null + const finals = keepFinals ? plan.finals : [] + setRules(next, [...insertBeforeMatch(rules.value, plan.rules), ...finals]) + const added = plan.rules.length + finals.length + const dropped = keepFinals ? '' : ';配方的兜底规则未导入,你已有自己的 MATCH' const ok = await commit( next, - `已导入 ${Object.keys(plan.providers).length} 个规则组、${String(added)} 条规则`, + `已导入 ${Object.keys(plan.providers).length} 个规则组、${String(added)} 条规则${dropped}`, ) if (ok) { importOpen.value = false @@ -536,7 +541,7 @@ async function updateNow(name: string) {

  • {{ rule }}