追加 Hosts、路径撞名拦截、连接页表头随行滚动、GEO 借内核通路

设置页内核参数加 hosts 行,铅笔进弹窗编辑(顶层 hosts: 键,
config.go:454,值单个或数组 parseHosts config.go:1131)。弹窗里随便改、
保存才一次性提交——写配置有几秒的校验重载,逐行失焦提交会把等待塞进
每次输入。没动过的行原样写回,数组不被重整形。

订阅和规则组新增时查缓存路径撞名:路径由名字净化而来,「订阅 A」和
「订阅/A」是两个名字、同一个文件,名字查重挡不住,会互相覆盖缓存。
纯函数在 shared 带测试,页面提交前拦。

连接页表头挪进滚动容器加 sticky:.scroller 声明 overflow-y 后按规范
连带拿到横向滚动上下文,表头留在外面时窄屏横向滚动两者脱节。表头
40px 的窗口偏移由 overscan 8 行吸收,不配 scrollMargin。

GEO 单文件下载先借内核的混合端口出去再退直连——内核自己更新 GEO 走
的是它的分流,Patchbay 直连 GitHub 不一定通。ECharts 拆独立 chunk,
业务代码改动不再让这 500KB 跟着换 hash。
This commit is contained in:
2026-08-12 02:47:17 +08:00
parent b276c4a8fe
commit 275f536429
12 changed files with 513 additions and 36 deletions
+17 -1
View File
@@ -6,10 +6,11 @@ import {
listSubscriptions,
providerNames,
removeSubscription,
subscriptionPathConflict,
subscriptionReferences,
updateSubscription,
} from './providers.ts'
import { applySetup, chainProviderName } from './inbound-groups.ts'
import { applySetup, chainProviderName, providerCachePath } from './inbound-groups.ts'
import type { OutboundSetup } from './inbound-groups.ts'
import type { ConfigObject } from '../vendor/config-editor/index.ts'
@@ -181,3 +182,18 @@ describe('删除前的引用检查', () => {
expect(subscriptionReferences(base(), 'ghost')).toEqual([])
})
})
describe('缓存路径撞名', () => {
it('名字只差符号时净化成同一个路径,能认出来', () => {
const data = addSubscription({}, '订阅 A', 'https://a.example/sub')
const path = providerCachePath('订阅/A')
expect(path).toBe(providerCachePath('订阅 A'))
expect(subscriptionPathConflict(data, '订阅/A', path)).toBe('订阅 A')
})
it('不同路径、或者就是自己,都不算撞', () => {
const data = addSubscription({}, '订阅 A', 'https://a.example/sub')
expect(subscriptionPathConflict(data, 'B', providerCachePath('B'))).toBeNull()
expect(subscriptionPathConflict(data, '订阅 A', providerCachePath('订阅 A'))).toBeNull()
})
})
+20
View File
@@ -109,6 +109,26 @@ export function addSubscription(
return next
}
/**
* 新订阅的缓存路径是否和现有订阅的撞车。
*
* 名字查重挡不住这种:路径由名字净化而来,「订阅 A」和「订阅/A」是两个名字、
* 同一个 `providers/订阅-A.yaml`,两个订阅会互相覆盖对方的缓存文件。
* 返回撞上的那个订阅名,没撞返回 null。
*/
export function subscriptionPathConflict(
data: ConfigObject,
selfName: string,
path: string,
): string | null {
for (const [name, decl] of Object.entries(providersOf(data))) {
if (name === selfName) continue
// 没写 path 的声明由内核按 url 哈希生成路径,和这里的净化路径撞不上
if ((decl as ConfigObject).path === path) return name
}
return null
}
/**
* 新增一个来自本机文件的订阅。数据就在 path 指的文件里(内容由上传流程先写好),
* 内核靠文件监视跟进变化,没有地址也没有更新间隔。
+13
View File
@@ -12,6 +12,7 @@ import {
parseRule,
planIniImport,
ruleProviderPath,
ruleProviderPathConflict,
ruleSetReferences,
setRuleProviders,
setRules,
@@ -293,4 +294,16 @@ describe('清单格式与缓存路径', () => {
expect(ruleProviderPath('ads', 'yaml')).toBe('./providers/rules/ads.yaml')
expect(ruleProviderPath('🛑 广告', 'yaml')).toBe('./providers/rules/广告.yaml')
})
it('名字只差符号时清单路径撞车,能认出来;自己不算', () => {
const data = {
'rule-providers': {
'🛑 广告': { behavior: 'domain', path: './providers/rules/广告.yaml' },
},
}
const path = ruleProviderPath('广告', 'yaml')
expect(ruleProviderPathConflict(data, '广告', path)).toBe('🛑 广告')
expect(ruleProviderPathConflict(data, '🛑 广告', path)).toBeNull()
expect(ruleProviderPathConflict(data, 'x', ruleProviderPath('x', 'yaml'))).toBeNull()
})
})
+19
View File
@@ -219,6 +219,25 @@ export function ruleProviderPath(name: string, format: string): string {
return `./providers/rules/${safeName(name) || shortHash(name)}.${ext}`
}
/**
* 新规则组的清单路径是否和现有规则组的撞车。
*
* 名字查重挡不住这种:路径由名字净化而来,「广告 A」和「广告/A」是两个名字、
* 同一个清单文件,互相覆盖。返回撞上的那个规则组名,没撞返回 null。
*/
export function ruleProviderPathConflict(
data: ConfigObject,
selfName: string,
path: string,
): string | null {
for (const [name, decl] of Object.entries(ruleProvidersOf(data))) {
if (name === selfName) continue
// 没写 path 的声明由内核按 url 哈希生成路径,和这里的净化路径撞不上
if (decl.path === path) return name
}
return null
}
/** 清单格式按来源的扩展名推断。拿不准时回到 yaml——那是内核没写 format 时的默认。 */
export function inferRuleFormat(source: string): 'yaml' | 'text' | 'mrs' {
const clean = (source.split(/[?#]/)[0] ?? '').toLowerCase()