设置页第二轮:行内编辑、GEO 资源展开、恢复与导入分家

单值设置从常驻输入框改成 InlineEdit(纯文本加铅笔,Enter 或失焦提交,
失焦无效直接放弃——失焦是「我走了」,不该把人拽回来)。测速地址加恢复
默认按钮;内核参数逐项提交,不再攒一个保存按钮。

资源一节按文件展开:四类 GEO 文件的大小和更新时间由服务端 stat 内核
工作目录得来(GET /api/geo;文件名和大小写不敏感匹配照 constant/
path.go:18-20,193-224),下载地址可改(geox-url,config.go:363-368,
默认值 578-583)。行内的更新按钮仍是全量更新——内核只有这一个口子,
没变化的文件靠内容哈希跳过。

上传拆成两个入口:恢复只认带 Patchbay 标记的文件,导入收任意配置;
新增恢复策略(兼容只搬接管段落 listeners/proxy-groups/rules/
proxy-providers/rule-providers,覆盖整份替换)。平时是按钮,拖着文件
进窗口时整行变成投放区。
This commit is contained in:
2026-08-12 01:40:49 +08:00
parent 8eb98e2e9a
commit 2580c03bfa
10 changed files with 790 additions and 229 deletions
+21 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'bun:test'
import { formatBytes } from './format.ts'
import { formatAgo, formatBytes } from './format.ts'
describe('流量数字', () => {
it('零和不满 1KB 的按字节显示,不带小数', () => {
@@ -28,3 +28,23 @@ describe('流量数字', () => {
expect(formatBytes(Number.NaN)).toBe('0 B')
})
})
describe('相对时间', () => {
const now = new Date('2026-08-12T12:00:00Z')
const ago = (ms: number) => new Date(now.getTime() - ms)
it('一分钟内是刚刚,之后按分、时、天换档', () => {
expect(formatAgo(ago(30_000), now)).toBe('刚刚')
expect(formatAgo(ago(21 * 60_000), now)).toBe('21 分钟前')
expect(formatAgo(ago(3 * 3600_000), now)).toBe('3 小时前')
expect(formatAgo(ago(5 * 86400_000), now)).toBe('5 天前')
})
it('超过 30 天给日期', () => {
expect(formatAgo(new Date('2026-05-01T00:00:00'), now)).toBe('2026-05-01')
})
it('未来的时间当成刚刚,不出负数', () => {
expect(formatAgo(ago(-60_000), now)).toBe('刚刚')
})
})
+14
View File
@@ -19,6 +19,20 @@ export function formatBytes(bytes: number): string {
return `${String(Number(value.toFixed(digits)))} ${UNITS[unit]}`
}
/**
* 一个过去时刻距今多久,「21 分钟前」的形态。now 由调用方传,纯函数才测得了。
* 超过 30 天相对时间就没有信息量了,直接给日期。
*/
export function formatAgo(at: Date, now: Date): string {
const seconds = Math.max(0, (now.getTime() - at.getTime()) / 1000)
if (seconds < 60) return '刚刚'
if (seconds < 3600) return `${String(Math.floor(seconds / 60))} 分钟前`
if (seconds < 86400) return `${String(Math.floor(seconds / 3600))} 小时前`
if (seconds < 30 * 86400) return `${String(Math.floor(seconds / 86400))} 天前`
const p = (n: number) => String(n).padStart(2, '0')
return `${String(at.getFullYear())}-${p(at.getMonth() + 1)}-${p(at.getDate())}`
}
/** 运行时长。一小时内说分钟,一天内说时分,再长说天和小时;秒级抖动没有信息量。 */
export function formatUptime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '—'
+37
View File
@@ -0,0 +1,37 @@
/**
* GEO 数据库的四类文件。名字和默认下载地址都来自内核源码,
* 界面(资源一节)和服务端(查文件大小和时间)共用这一份。
*/
export interface GeoFileStatus {
/** 界面上显示的名字 */
key: string
/** 在配置 geox-url 段落里的键名(config.go:363-368) */
configKey: string
/** 下载地址:配置里写了用配置的,没写是内核默认 */
url: string
/** 文件字节数。文件还没下载过是 null */
size: number | null
/** 文件修改时间,ISO 格式。没下载过是 null */
updatedAt: string | null
}
/**
* 文件名与内核一致(constant/path.go:18-20、144)。
* GeoIP.dat 和 GeoSite.dat 内核在目录里大小写不敏感地找(path.go:193-224),
* 服务端查文件时也要同样处理。
*/
export const GEO_FILES = [
{ key: 'MMDB', configKey: 'mmdb', file: 'geoip.metadb' },
{ key: 'ASN', configKey: 'asn', file: 'ASN.mmdb' },
{ key: 'GEOIP', configKey: 'geoip', file: 'GeoIP.dat' },
{ key: 'GEOSITE', configKey: 'geosite', file: 'GeoSite.dat' },
] as const
/** 内核的默认下载地址(config.go:578-583)。配置里没写 geox-url 时就是这些。 */
export const DEFAULT_GEOX_URLS: Record<string, string> = {
mmdb: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb',
asn: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb',
geoip: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat',
geosite: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat',
}