消化一批 P3:GEOIP 附加参数、坏 JSON 跳行、兜底丢弃;请求连接两页保活
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 会叠两个定时器,切走时停掉轮询。
This commit is contained in:
@@ -202,6 +202,15 @@ custom_proxy_group=🚀 节点选择\`select\`[]AUTO
|
|||||||
it('custom_proxy_group 和残缺行不产生条目', () => {
|
it('custom_proxy_group 和残缺行不产生条目', () => {
|
||||||
expect(parseIniRulesets('ruleset=\nruleset=只有目标,\ncustom_proxy_group=x`select`y')).toEqual([])
|
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 导入计划', () => {
|
describe('ini 导入计划', () => {
|
||||||
|
|||||||
+15
-7
@@ -253,6 +253,8 @@ export interface IniRuleset {
|
|||||||
kind: 'url' | 'file' | 'geoip' | 'geosite' | 'final'
|
kind: 'url' | 'file' | 'geoip' | 'geosite' | 'final'
|
||||||
/** kind 为 url/file 时是来源,为 geoip/geosite 时是类别名,final 时为空 */
|
/** kind 为 url/file 时是来源,为 geoip/geosite 时是类别名,final 时为空 */
|
||||||
source: string
|
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 inline = source.slice(2).trim()
|
||||||
const geo = /^(GEOIP|GEOSITE)\s*,\s*(.+)$/i.exec(inline)
|
const geo = /^(GEOIP|GEOSITE)\s*,\s*(.+)$/i.exec(inline)
|
||||||
if (geo) {
|
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({
|
result.push({
|
||||||
target,
|
target,
|
||||||
kind: geo[1]!.toUpperCase() === 'GEOIP' ? 'geoip' : 'geosite',
|
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') {
|
} else if (inline.toUpperCase() === 'FINAL' || inline.toUpperCase() === 'MATCH') {
|
||||||
result.push({ target, kind: 'final', source: '' })
|
result.push({ target, kind: 'final', source: '' })
|
||||||
@@ -342,12 +353,9 @@ export function planIniImport(
|
|||||||
const target = mapping[entry.target]
|
const target = mapping[entry.target]
|
||||||
if (!target) continue
|
if (!target) continue
|
||||||
|
|
||||||
if (entry.kind === 'geoip') {
|
if (entry.kind === 'geoip' || entry.kind === 'geosite') {
|
||||||
rules.push(`GEOIP,${entry.source},${target}`)
|
const type = entry.kind === 'geoip' ? 'GEOIP' : 'GEOSITE'
|
||||||
continue
|
rules.push([type, entry.source, target, ...(entry.flags ?? [])].join(','))
|
||||||
}
|
|
||||||
if (entry.kind === 'geosite') {
|
|
||||||
rules.push(`GEOSITE,${entry.source},${target}`)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (entry.kind === 'final') {
|
if (entry.kind === 'final') {
|
||||||
|
|||||||
+14
-5
@@ -348,10 +348,6 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
|||||||
@saved="load"
|
@saved="load"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RequestsPage v-else-if="active === 'requests'" />
|
|
||||||
|
|
||||||
<ConnectionsPage v-else-if="active === 'connections'" />
|
|
||||||
|
|
||||||
<SettingsPage
|
<SettingsPage
|
||||||
v-else-if="active === 'settings'"
|
v-else-if="active === 'settings'"
|
||||||
:data="snapshot.data"
|
:data="snapshot.data"
|
||||||
@@ -359,10 +355,23 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
|||||||
@saved="load"
|
@saved="load"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-else class="note">
|
<div
|
||||||
|
v-else-if="active !== 'requests' && active !== 'connections'"
|
||||||
|
class="note"
|
||||||
|
>
|
||||||
<component :is="current?.icon" class="note-icon" />
|
<component :is="current?.icon" class="note-icon" />
|
||||||
<p>{{ current?.label }} 尚未实现</p>
|
<p>{{ current?.label }} 尚未实现</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 请求页和连接页保活:切走不销毁,筛选词、排序、滚动位置都留住。
|
||||||
|
KeepAlive 必须常驻、让子组件自己 v-if 进出——把 v-if 放在 KeepAlive
|
||||||
|
上的话它随条件卸载,缓存跟着没了。轮询的起停走 activated 钩子 -->
|
||||||
|
<KeepAlive>
|
||||||
|
<RequestsPage v-if="active === 'requests'" />
|
||||||
|
</KeepAlive>
|
||||||
|
<KeepAlive>
|
||||||
|
<ConnectionsPage v-if="active === 'connections'" />
|
||||||
|
</KeepAlive>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -124,7 +124,7 @@ export interface ListenerFields {
|
|||||||
export interface RuleProviderInfo {
|
export interface RuleProviderInfo {
|
||||||
/** domain / ipcidr / classical */
|
/** domain / ipcidr / classical */
|
||||||
behavior: string
|
behavior: string
|
||||||
/** yaml / text / mrs。inline 类型的没有这个字段 */
|
/** yaml / text / mrs。内核始终序列化它(provider.go:37 无 omitempty),inline 类型给的是空串 */
|
||||||
format?: string
|
format?: string
|
||||||
name: string
|
name: string
|
||||||
/** 当前装了多少条规则 */
|
/** 当前装了多少条规则 */
|
||||||
|
|||||||
+9
-3
@@ -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 { LOG_CAP, parseLogLine, pushCapped, streamLogs } from './logs.ts'
|
||||||
import { LogStreamError } from './logs.ts'
|
import { LogStreamError } from './logs.ts'
|
||||||
|
|
||||||
@@ -49,8 +49,10 @@ function streamOf(chunks: string[]): ReadableStream<Uint8Array> {
|
|||||||
|
|
||||||
const realFetch = globalThis.fetch
|
const realFetch = globalThis.fetch
|
||||||
|
|
||||||
// bun 的测试环境没有 localStorage,而 streamLogs 要从里面取令牌
|
// bun 的测试环境没有 localStorage,而 streamLogs 要从里面取令牌。
|
||||||
if (typeof globalThis.localStorage === 'undefined') {
|
// 桩只在本文件生效:bun test 是单进程,全局残留会影响之后跑的其它测试文件
|
||||||
|
const hadStorage = typeof globalThis.localStorage !== 'undefined'
|
||||||
|
if (!hadStorage) {
|
||||||
;(globalThis as Record<string, unknown>).localStorage = {
|
;(globalThis as Record<string, unknown>).localStorage = {
|
||||||
getItem: () => '',
|
getItem: () => '',
|
||||||
setItem: () => {},
|
setItem: () => {},
|
||||||
@@ -58,6 +60,10 @@ if (typeof globalThis.localStorage === 'undefined') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (!hadStorage) delete (globalThis as Record<string, unknown>).localStorage
|
||||||
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
globalThis.fetch = realFetch
|
globalThis.fetch = realFetch
|
||||||
})
|
})
|
||||||
|
|||||||
+8
-1
@@ -65,7 +65,14 @@ async function consume(
|
|||||||
) {
|
) {
|
||||||
const body = await api.openStream(path, ctrl.signal)
|
const body = await api.openStream(path, ctrl.signal)
|
||||||
for await (const line of readLines(body)) {
|
for await (const line of readLines(body)) {
|
||||||
onLine(JSON.parse(line) as Record<string, unknown>)
|
// 偶发的坏行跳过就行,抛出去会杀掉整个流、进 5 秒重试循环
|
||||||
|
let payload: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(line) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
onLine(payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* 显示层的问题(主机名怎么摆、出站链按什么方向、持续时间多长)都在 shared/connections.ts
|
* 显示层的问题(主机名怎么摆、出站链按什么方向、持续时间多长)都在 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 { useVirtualizer } from '@tanstack/vue-virtual'
|
||||||
import {
|
import {
|
||||||
chainLabel,
|
chainLabel,
|
||||||
@@ -62,11 +62,17 @@ async function refresh() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
// 组件包在 KeepAlive 里:首次挂载和每次切回都走 activated(挂载时它也会触发,
|
||||||
|
// 不能再用 onMounted 起轮询,否则会叠两个定时器),切走时停掉轮询不白打后端
|
||||||
|
onActivated(() => {
|
||||||
void refresh()
|
void refresh()
|
||||||
timer = window.setInterval(() => void refresh(), 1000)
|
timer = window.setInterval(() => void refresh(), 1000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.clearInterval(timer)
|
window.clearInterval(timer)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* 收集在 web/log-store.ts 的常驻单例里——日志跨页面留存,切走再回来不清空。
|
* 收集在 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 { kernel } from '../runtime'
|
||||||
import { LEVELS } from '../logs'
|
import { LEVELS } from '../logs'
|
||||||
import { clearLogs, ended, failure, level, paused, revision, rows } from '../log-store'
|
import { clearLogs, ended, failure, level, paused, revision, rows } from '../log-store'
|
||||||
@@ -34,7 +34,9 @@ let follow = true
|
|||||||
function onScroll() {
|
function onScroll() {
|
||||||
const el = listEl.value
|
const el = listEl.value
|
||||||
if (!el) return
|
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) {
|
async function scrollToBottom(force = false) {
|
||||||
@@ -49,6 +51,9 @@ watch(revision, () => void scrollToBottom())
|
|||||||
|
|
||||||
// 切回这一页时接上离开时的进度,直接落到最新一条
|
// 切回这一页时接上离开时的进度,直接落到最新一条
|
||||||
onMounted(() => void scrollToBottom(true))
|
onMounted(() => void scrollToBottom(true))
|
||||||
|
// 组件包在 KeepAlive 里,切走期间元素脱离文档、scrollHeight 不可靠,
|
||||||
|
// 跟随模式的自动滚动可能把位置写歪;切回来重新贴底(手动上滚过的不动)
|
||||||
|
onActivated(() => void scrollToBottom())
|
||||||
|
|
||||||
const visible = computed(() => {
|
const visible = computed(() => {
|
||||||
const q = query.value.trim().toLowerCase()
|
const q = query.value.trim().toLowerCase()
|
||||||
@@ -94,7 +99,11 @@ const visible = computed(() => {
|
|||||||
{{ rows.length > 0 ? '内核未连接,日志不再更新。' : '内核未连接,无法读取日志。' }}
|
{{ rows.length > 0 ? '内核未连接,日志不再更新。' : '内核未连接,无法读取日志。' }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="rows.length === 0 && !failure && !ended" class="state">
|
<p v-else-if="rows.length === 0 && !failure && !ended" class="state">
|
||||||
暂无日志。没有流量经过内核时不会产生请求记录。
|
{{
|
||||||
|
level === 'silent'
|
||||||
|
? '级别为 silent 时内核不输出日志。'
|
||||||
|
: '暂无日志。没有流量经过内核时不会产生请求记录。'
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="visible.length === 0 && rows.length > 0" class="state">
|
<p v-else-if="visible.length === 0 && rows.length > 0" class="state">
|
||||||
没有包含「{{ query.trim() }}」的日志。
|
没有包含「{{ query.trim() }}」的日志。
|
||||||
|
|||||||
@@ -257,11 +257,16 @@ async function submitGroup(entry: {
|
|||||||
async function submitImport(plan: IniImportPlan) {
|
async function submitImport(plan: IniImportPlan) {
|
||||||
const next = clone()
|
const next = clone()
|
||||||
setRuleProviders(next, { ...ruleProvidersOf(next), ...plan.providers })
|
setRuleProviders(next, { ...ruleProvidersOf(next), ...plan.providers })
|
||||||
setRules(next, [...insertBeforeMatch(rules.value, plan.rules), ...plan.finals])
|
// 表里已有 MATCH 时丢弃配方的兜底:那是你自己安排的分流终点,配方不该
|
||||||
const added = plan.rules.length + plan.finals.length
|
// 偷偷替换;追加在 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(
|
const ok = await commit(
|
||||||
next,
|
next,
|
||||||
`已导入 ${Object.keys(plan.providers).length} 个规则组、${String(added)} 条规则`,
|
`已导入 ${Object.keys(plan.providers).length} 个规则组、${String(added)} 条规则${dropped}`,
|
||||||
)
|
)
|
||||||
if (ok) {
|
if (ok) {
|
||||||
importOpen.value = false
|
importOpen.value = false
|
||||||
@@ -536,7 +541,7 @@ async function updateNow(name: string) {
|
|||||||
<li v-for="rule in blockedProvider.refs" :key="rule" class="mono">{{ rule }}</li>
|
<li v-for="rule in blockedProvider.refs" :key="rule" class="mono">{{ rule }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<footer class="dlg-foot">
|
<footer class="dlg-foot">
|
||||||
<button type="button" class="btn" @click="blockedProvider = null">知道了</button>
|
<button type="button" class="btn" @click="blockedProvider = null">关闭</button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
Reference in New Issue
Block a user