From 275f5364293db07905b02cecded1fde963046a74 Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Wed, 12 Aug 2026 02:47:17 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=BD=E5=8A=A0=20Hosts=E3=80=81=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E6=92=9E=E5=90=8D=E6=8B=A6=E6=88=AA=E3=80=81=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E9=A1=B5=E8=A1=A8=E5=A4=B4=E9=9A=8F=E8=A1=8C=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E3=80=81GEO=20=E5=80=9F=E5=86=85=E6=A0=B8=E9=80=9A?= =?UTF-8?q?=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置页内核参数加 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。 --- server/geo.ts | 30 +++- server/routes.ts | 8 +- shared/providers.test.ts | 18 ++- shared/providers.ts | 20 +++ shared/rules.test.ts | 13 ++ shared/rules.ts | 19 +++ vite.config.ts | 9 ++ web/views/ConfigPage.vue | 14 ++ web/views/ConnectionsPage.vue | 62 +++++--- web/views/HostsForm.vue | 289 ++++++++++++++++++++++++++++++++++ web/views/RulesPage.vue | 13 ++ web/views/SettingsPage.vue | 54 ++++++- 12 files changed, 513 insertions(+), 36 deletions(-) create mode 100644 web/views/HostsForm.vue diff --git a/server/geo.ts b/server/geo.ts index 573e25b..2ed92d3 100644 --- a/server/geo.ts +++ b/server/geo.ts @@ -41,17 +41,31 @@ export async function geoStatus( ) } +async function download(url: string, proxy: string | null): Promise { + const res = await fetch(url, { + signal: AbortSignal.timeout(180_000), + redirect: 'follow', + ...(proxy ? { proxy } : {}), + }) + if (!res.ok) throw new Error(`HTTP ${String(res.status)}`) + return new Uint8Array(await res.arrayBuffer()) +} + /** * 下载单个 GEO 文件写进内核工作目录。 * * 内核的更新接口是全量的(POST /configs/geo,按 enable 标记更新它用到的类型), * 单文件只能 Patchbay 自己下。代价是拿不到内核那套完整校验,只能做形状检查 * (shared/geo.ts),也不触发热加载——内核用 mmap 缓存着旧数据,重启后才换新。 + * + * `proxy` 给的话先经它出去(内核自己更新 GEO 时走的是它自己的分流,直连 + * GitHub 不一定通,Patchbay 借内核的混合端口能拿到同样的通路),失败再直连。 */ export async function syncGeoFile( env: Env, configKey: string, geoxUrl: Record, + proxy: string | null, ): Promise<{ ok: true } | { ok: false; message: string }> { const spec = GEO_FILES.find((g) => g.configKey === configKey) if (!spec) return { ok: false, message: `未知的 GEO 类型:${configKey}` } @@ -59,11 +73,17 @@ export async function syncGeoFile( let bytes: Uint8Array try { - const res = await fetch(url, { signal: AbortSignal.timeout(300_000), redirect: 'follow' }) - if (!res.ok) return { ok: false, message: `下载失败:HTTP ${String(res.status)}` } - bytes = new Uint8Array(await res.arrayBuffer()) - } catch (error) { - return { ok: false, message: `下载失败:${String(error)}` } + bytes = await download(url, proxy) + } catch (viaProxy) { + if (!proxy) return { ok: false, message: `下载失败:${String(viaProxy)}` } + try { + bytes = await download(url, null) + } catch (direct) { + return { + ok: false, + message: `经内核代理(${String(viaProxy)})和直连(${String(direct)})都没能下载。`, + } + } } const shapeOk = diff --git a/server/routes.ts b/server/routes.ts index 77e77cb..99346ab 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -312,7 +312,13 @@ function makeRoute(env: Env, token: string) { if (typeof body.key !== 'string') return json({ message: '请求缺少 key 字段' }, 400) const snapshot = await readConfig(env) const geox = (snapshot.data['geox-url'] ?? {}) as Record - const result = await syncGeoFile(env, body.key, geox) + // 内核在线且开着老式 http 入口时借它的通路下载,直连 GitHub 不一定通 + const port = [snapshot.data['mixed-port'], snapshot.data.port].find( + (p): p is number => typeof p === 'number' && p > 0, + ) + const kernelUp = port !== undefined && (await kernelVersion(env)).online + const proxy = kernelUp ? `http://127.0.0.1:${String(port)}` : null + const result = await syncGeoFile(env, body.key, geox, proxy) return result.ok ? json({ ok: true }) : json({ message: result.message }, 502) } diff --git a/shared/providers.test.ts b/shared/providers.test.ts index 6788781..d46768b 100644 --- a/shared/providers.test.ts +++ b/shared/providers.test.ts @@ -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() + }) +}) diff --git a/shared/providers.ts b/shared/providers.ts index 6c4e3f3..e851848 100644 --- a/shared/providers.ts +++ b/shared/providers.ts @@ -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 指的文件里(内容由上传流程先写好), * 内核靠文件监视跟进变化,没有地址也没有更新间隔。 diff --git a/shared/rules.test.ts b/shared/rules.test.ts index e1d3f2e..145c873 100644 --- a/shared/rules.test.ts +++ b/shared/rules.test.ts @@ -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() + }) }) diff --git a/shared/rules.ts b/shared/rules.ts index 1ab4673..1a29741 100644 --- a/shared/rules.ts +++ b/shared/rules.ts @@ -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() diff --git a/vite.config.ts b/vite.config.ts index 3bb3a83..ed2dc6a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -35,5 +35,14 @@ export default defineConfig({ build: { outDir: '../web-dist', emptyOutDir: true, + rollupOptions: { + output: { + // ECharts 单独一个 chunk。应用一进来就是仪表盘,懒加载省不了首屏, + // 拆开图的是缓存粒度:业务代码天天变,这 400KB 的库不跟着换 hash 重下 + manualChunks: { + echarts: ['echarts/core', 'echarts/charts', 'echarts/components', 'echarts/renderers'], + }, + }, + }, }, }) diff --git a/web/views/ConfigPage.vue b/web/views/ConfigPage.vue index aa3ee52..e5f5d39 100644 --- a/web/views/ConfigPage.vue +++ b/web/views/ConfigPage.vue @@ -15,6 +15,7 @@ import { listSubscriptions, providerNames, removeSubscription, + subscriptionPathConflict, subscriptionReferences, updateSubscription, } from '@shared/providers.ts' @@ -148,6 +149,19 @@ async function submitForm(draft: { }) { const editing = formEditing.value + // 名字查重放行了、但净化后的缓存路径和别人相同的,在这里拦(编辑不改名,只查新增) + if (!editing) { + const path = providerCachePath(draft.name) + const clash = subscriptionPathConflict(props.data, draft.name, path) + if (clash) { + toast(`「${draft.name}」和「${clash}」的缓存文件会是同一个`, { + kind: 'error', + hint: '名字只在符号或空格上有差异时会净化成同一个文件名,换一个名字。', + }) + return + } + } + // 从文件导入:先把内容写到订阅的缓存路径上,成了再写声明。 // 顺序不能反——声明先落盘的话,内核重载会去读一个还不存在的文件 if (draft.file) { diff --git a/web/views/ConnectionsPage.vue b/web/views/ConnectionsPage.vue index 01a9803..209af9b 100644 --- a/web/views/ConnectionsPage.vue +++ b/web/views/ConnectionsPage.vue @@ -175,30 +175,32 @@ async function closeAllConfirmed() {

没有主机匹配「{{ query.trim() }}」。

-
- 主机 - 规则 - 出站链 - 上传 - - - 入站 - -
- +
+
+ 主机 + 规则 + 出站链 + 上传 + + + 入站 + +
+/** + * 追加 Hosts 的编辑弹窗:配置顶层的 `hosts:` 映射(config.go:454,值可以是 + * 单个 IP/域名或数组,parseHosts config.go:1131)。 + * + * 在弹窗里随便改,点保存才一次性提交——写配置要走校验和重载,每行失焦 + * 就提交会把几秒的等待塞进每一次输入。 + * + * 值的往返规则:没动过的行原样保留(数组仍是数组);动过的行按显示文本 + * 重建,含逗号拆成数组,否则存字符串。 + */ +import { ref, watch } from 'vue' +import type { ConfigObject } from '../../vendor/config-editor/index.ts' +import IconAdd from '~icons/ri/add-line' +import IconClose from '~icons/ri/close-line' + +const props = defineProps<{ open: boolean; hosts: ConfigObject }>() + +const emit = defineEmits<{ close: []; save: [hosts: ConfigObject] }>() + +interface Row { + key: string + value: string + /** 值被编辑过。没动过的保存时用 original 原样写回 */ + dirty: boolean + original: unknown +} + +const dialog = ref(null) +const rows = ref([]) +/** 保存被拦下的行号(键空着或和别的行重复) */ +const badRows = ref>(new Set()) + +function display(value: unknown): string { + if (typeof value === 'string') return value + if (Array.isArray(value)) return value.map(String).join(', ') + return JSON.stringify(value) +} + +watch( + () => props.open, + (open) => { + if (open) { + rows.value = Object.entries(props.hosts).map(([key, value]) => ({ + key, + value: display(value), + dirty: false, + original: value, + })) + badRows.value = new Set() + dialog.value?.showModal() + } else { + dialog.value?.close() + } + }, +) + +function addRow() { + rows.value = [...rows.value, { key: '', value: '', dirty: true, original: undefined }] +} + +function removeRow(index: number) { + rows.value = rows.value.filter((_, i) => i !== index) + badRows.value = new Set() +} + +function submit() { + const bad = new Set() + const seen = new Set() + const next: ConfigObject = {} + for (const [index, row] of rows.value.entries()) { + const key = row.key.trim() + const raw = row.value.trim() + if (key === '' && raw === '') continue + if (key === '' || seen.has(key)) { + bad.add(index) + continue + } + seen.add(key) + if (!row.dirty && row.original !== undefined) { + next[key] = row.original as ConfigObject[string] + } else { + next[key] = raw.includes(',') + ? raw.split(',').map((part) => part.trim()).filter(Boolean) + : raw + } + } + if (bad.size > 0) { + badRows.value = bad + return + } + emit('save', next) +} + +/** 点在遮罩上就关闭。内容区的点击 target 是里面的元素,到不了 dialog 本身。 */ +function onBackdrop(event: MouseEvent) { + if (event.target === dialog.value) emit('close') +} + + + + + diff --git a/web/views/RulesPage.vue b/web/views/RulesPage.vue index 5a8d1f2..b610711 100644 --- a/web/views/RulesPage.vue +++ b/web/views/RulesPage.vue @@ -17,6 +17,7 @@ import { missingLanRules, moveRule, parseRule, + ruleProviderPathConflict, ruleProvidersOf, ruleSetReferences, rulesOf, @@ -219,6 +220,18 @@ async function submitGroup(entry: { declaration: ConfigObject file: { content: string } | null }) { + // 名字查重放行了、但净化后的清单路径和别人相同的,在这里拦 + if (typeof entry.declaration.path === 'string') { + const clash = ruleProviderPathConflict(props.data, entry.name, entry.declaration.path) + if (clash) { + toast(`「${entry.name}」和「${clash}」的清单文件会是同一个`, { + kind: 'error', + hint: '名字只在符号或空格上有差异时会净化成同一个文件名,换一个名字。', + }) + return + } + } + // 上传的清单先落盘再写声明。顺序不能反——声明先生效的话,内核重载会去读 // 一个还不存在的文件 if (entry.file && typeof entry.declaration.path === 'string') { diff --git a/web/views/SettingsPage.vue b/web/views/SettingsPage.vue index 86c9155..8a9f62b 100644 --- a/web/views/SettingsPage.vue +++ b/web/views/SettingsPage.vue @@ -24,6 +24,7 @@ import { DISCLAIMER, DISCLAIMER_TITLE } from '../legal' import ConfirmDialog from '../components/ConfirmDialog.vue' import InlineEdit from '../components/InlineEdit.vue' import SelectBox from '../components/SelectBox.vue' +import HostsForm from './HostsForm.vue' import IconRight from '~icons/ri/arrow-right-s-line' import IconBack from '~icons/ri/arrow-left-s-line' import IconDownload from '~icons/ri/download-2-line' @@ -60,17 +61,19 @@ function saveTestUrl(value: string) { const saving = ref(false) -/** 所有改配置文件的动作都从这里走。 */ -async function commitConfig(next: ConfigObject, success: string) { - if (saving.value) return +/** 所有改配置文件的动作都从这里走。反馈走轻提示,返回值告知成败。 */ +async function commitConfig(next: ConfigObject, success: string): Promise { + if (saving.value) return false saving.value = true try { const result = await api.applyConfig(next, props.revision) // 内核不在线时只写了文件没重载,如实说,不能只报成功 toast(result.reloaded ? success : `${success}(${result.reloadMessage})`) emit('saved') + return true } catch (error) { toastError(error) + return false } finally { saving.value = false } @@ -101,6 +104,25 @@ function saveNumberKey(key: string, value: string, success: string) { void commitConfig(next, success) } +/* ── 追加 Hosts ───────────────────────────────── */ + +const hostsOpen = ref(false) + +const hostsOf = (): ConfigObject => { + const value = props.data.hosts + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as ConfigObject) + : {} +} + +async function submitHosts(hosts: ConfigObject) { + const next = clone() + if (Object.keys(hosts).length === 0) delete next.hosts + else next.hosts = hosts + const ok = await commitConfig(next, 'hosts 已保存') + if (ok) hostsOpen.value = false +} + /* ── 资源(GEO 数据库) ───────────────────────── */ const geoFiles = ref(null) @@ -451,6 +473,25 @@ const backend = location.origin @submit="(v) => saveNumberKey('keep-alive-interval', v, 'TCP 保持活动间隔已保存')" />
+
+ hosts + + + {{ + Object.keys(hostsOf()).length > 0 ? `${Object.keys(hostsOf()).length} 条` : '未设置' + }} + + + +
@@ -601,6 +642,13 @@ const backend = location.origin
+ +