轻提示铺开到全部页面;内核状态悬停变重启;GEO 支持单文件下载

侧栏动作排撤掉重启图标,内核状态(圆点加版本号)本身变成按钮:悬停
换层显示「重启内核」,不在线时只当状态看。行高压成 1 修掉圆点和等宽
字体不居中的问题。

四个页面的一次性反馈(保存、删除、更新订阅、断开连接、测延迟结论)
全部改走 toast,commit() 改返回成败给「失败不关对话框」用;持续状态
(连接页轮询失败、请求页日志流失败)留在页内,会自动消失的提示装不下
需要一直可见的信息。toast 加 info 类别给中性结论用。

GEO 每行的更新改成单独下载那一个文件:内核只有全量更新的口子,单文件
只能服务端自己下(POST /api/geo/sync),代价是只有形状校验(MaxMind
末尾标记、geodata 首字节,shared/geo.ts 带测试)且内核重启后才换用新
文件;「更新全部」文字按钮保留内核的全量口子,有完整校验和热加载。
上传两行的按钮改「选择或拖放文件」,投放区拖入高亮,恢复策略挪到
恢复行下方。
This commit is contained in:
2026-08-12 02:20:46 +08:00
parent f761d13579
commit b276c4a8fe
13 changed files with 366 additions and 304 deletions
+56 -8
View File
@@ -4,12 +4,20 @@
* 内核不提供这个信息,但文件就躺在它的工作目录里,和 Patchbay 服务端
* 在同一台机器上,直接 stat 就是。
*/
import { readdir, stat } from 'node:fs/promises'
import { readdir, rename, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { DEFAULT_GEOX_URLS, GEO_FILES } from '../shared/geo.ts'
import { DEFAULT_GEOX_URLS, GEO_FILES, looksLikeGeodata, looksLikeMmdb } from '../shared/geo.ts'
import type { GeoFileStatus } from '../shared/geo.ts'
import type { Env } from './env.ts'
/** 配置里写了就用配置的,否则退回内核默认。 */
function urlFor(geoxUrl: Record<string, unknown>, configKey: string): string {
const configured = geoxUrl[configKey]
return typeof configured === 'string' && configured !== ''
? configured
: (DEFAULT_GEOX_URLS[configKey] ?? '')
}
export async function geoStatus(
env: Env,
geoxUrl: Record<string, unknown>,
@@ -28,12 +36,52 @@ export async function geoStatus(
updatedAt = info.mtime.toISOString()
}
}
const configured = geoxUrl[configKey]
const url =
typeof configured === 'string' && configured !== ''
? configured
: (DEFAULT_GEOX_URLS[configKey] ?? '')
return { key, configKey, url, size, updatedAt }
return { key, configKey, url: urlFor(geoxUrl, configKey), size, updatedAt }
}),
)
}
/**
* 下载单个 GEO 文件写进内核工作目录。
*
* 内核的更新接口是全量的(POST /configs/geo,按 enable 标记更新它用到的类型),
* 单文件只能 Patchbay 自己下。代价是拿不到内核那套完整校验,只能做形状检查
* (shared/geo.ts),也不触发热加载——内核用 mmap 缓存着旧数据,重启后才换新。
*/
export async function syncGeoFile(
env: Env,
configKey: string,
geoxUrl: Record<string, unknown>,
): Promise<{ ok: true } | { ok: false; message: string }> {
const spec = GEO_FILES.find((g) => g.configKey === configKey)
if (!spec) return { ok: false, message: `未知的 GEO 类型:${configKey}` }
const url = urlFor(geoxUrl, configKey)
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)}` }
}
const shapeOk =
configKey === 'mmdb' || configKey === 'asn' ? looksLikeMmdb(bytes) : looksLikeGeodata(bytes)
if (!shapeOk) {
return { ok: false, message: `下载到的内容不像 ${spec.key} 文件,已丢弃,本地文件没有动。` }
}
// 沿用目录里现有的大小写写法,内核就是这样找文件的
const entries = await readdir(env.mihomoHome).catch(() => [] as string[])
const actual = entries.find((name) => name.toLowerCase() === spec.file.toLowerCase()) ?? spec.file
const finalPath = join(env.mihomoHome, actual)
const tempPath = `${finalPath}.patchbay-tmp`
try {
await writeFile(tempPath, bytes)
await rename(tempPath, finalPath)
} catch (error) {
return { ok: false, message: `写入失败:${String(error)}` }
}
return { ok: true }
}