From 2580c03bfac415eec59095b3e2080596d3be3ec0 Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Wed, 12 Aug 2026 01:40:49 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E8=BD=AE=EF=BC=9A=E8=A1=8C=E5=86=85=E7=BC=96=E8=BE=91=E3=80=81?= =?UTF-8?q?GEO=20=E8=B5=84=E6=BA=90=E5=B1=95=E5=BC=80=E3=80=81=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E4=B8=8E=E5=AF=BC=E5=85=A5=E5=88=86=E5=AE=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单值设置从常驻输入框改成 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,覆盖整份替换)。平时是按钮,拖着文件 进窗口时整行变成投放区。 --- server/config-store.test.ts | 29 +- server/config-store.ts | 40 ++- server/geo.ts | 39 +++ server/routes.ts | 18 +- shared/format.test.ts | 22 +- shared/format.ts | 14 + shared/geo.ts | 37 ++ web/api.ts | 16 +- web/components/InlineEdit.vue | 165 +++++++++ web/views/SettingsPage.vue | 639 +++++++++++++++++++++++----------- 10 files changed, 790 insertions(+), 229 deletions(-) create mode 100644 server/geo.ts create mode 100644 shared/geo.ts create mode 100644 web/components/InlineEdit.vue diff --git a/server/config-store.test.ts b/server/config-store.test.ts index ff6ceec..ba567e3 100644 --- a/server/config-store.test.ts +++ b/server/config-store.test.ts @@ -2,7 +2,7 @@ import { afterAll, describe, expect, it } from 'bun:test' import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts' +import { applyConfig, importConfig, listBackups, readConfig, restoreBackup } from './config-store.ts' import type { Env } from './env.ts' /** @@ -149,6 +149,33 @@ describe('落盘保护链路(内核在线)', () => { expect(body).toContain('patchbay:') }) + it('覆盖导入整份替换,兼容导入只搬接管段落', async () => { + const env = makeEnv() + const uploaded = 'mixed-port: 9999\nrules:\n - MATCH,DIRECT\n' + + const compat = await importConfig(env, uploaded, 'compatible') + expect(compat.ok).toBe(true) + let body = readFileSync(env.configPath, 'utf8') + // rules 是接管段落被搬过来,mixed-port 属于机器底座维持当前值 + expect(body).toContain('MATCH,DIRECT') + expect(body).toContain('mixed-port: 7890') + expect(body).not.toContain('9999') + + const over = await importConfig(env, uploaded, 'override') + expect(over.ok).toBe(true) + body = readFileSync(env.configPath, 'utf8') + expect(body).toContain('mixed-port: 9999') + }) + + it('导入解析不过的文件时一个字节都不动', async () => { + const env = makeEnv() + const result = await importConfig(env, '{{{ 不是 yaml', 'override') + + expect(result.ok).toBe(false) + if (!result.ok) expect(result.stage).toBe('self-check') + expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL) + }) + it('恢复只认列表里的名字,路径穿越进不来', async () => { const env = makeEnv() const result = await restoreBackup(env, '../config.yaml') diff --git a/server/config-store.ts b/server/config-store.ts index d4bcc17..3cf0603 100644 --- a/server/config-store.ts +++ b/server/config-store.ts @@ -286,14 +286,29 @@ async function pruneBackups(env: Env): Promise { await Promise.all(stale.map((name) => unlink(join(dir, name)).catch(() => {}))) } +export type ImportMode = 'override' | 'compatible' + /** - * 用上传的 YAML 原文整份替换当前配置。 - * - * 解析用和读配置同一个引擎,之后走 applyConfig 完整的保护链路(自检、备份、 - * 重载、失败回退)。没有 baseRevision——整份替换本来就是覆盖语义,同还原备份。 + * 兼容模式搬运的段落:Patchbay 接管的那几个(决策记录 0003)。 + * 老式顶层端口、dns、tun 这类属于机器本身的设置不在其中——兼容恢复的语义是 + * 「把我在 Patchbay 里做的接线搬过来」,不动当前机器的底座。 */ -export async function importConfig(env: Env, content: string): Promise { - let data: ConfigObject +const MANAGED_KEYS = ['listeners', 'proxy-groups', 'rules', 'proxy-providers', 'rule-providers'] + +/** + * 用上传的 YAML 替换当前配置。 + * + * `override` 整份替换;`compatible` 只把上传文件里 Patchbay 接管的段落搬到 + * 当前配置上(上传文件里没有的段落维持当前的样子)。解析用和读配置同一个引擎, + * 之后走 applyConfig 完整的保护链路(自检、备份、重载、失败回退)。 + * 没有 baseRevision——替换本来就是覆盖语义,同还原备份。 + */ +export async function importConfig( + env: Env, + content: string, + mode: ImportMode, +): Promise { + let uploaded: ConfigObject try { const doc = openDocument(content) const broken = doc.diagnostics.filter((d) => d.severity === 'error') @@ -306,7 +321,7 @@ export async function importConfig(env: Env, content: string): Promise, +): Promise { + const entries = await readdir(env.mihomoHome).catch(() => [] as string[]) + return Promise.all( + GEO_FILES.map(async ({ key, configKey, file }) => { + // 内核找 GeoIP.dat/GeoSite.dat 时大小写不敏感(constant/path.go:193-224),这里照做 + const actual = entries.find((name) => name.toLowerCase() === file.toLowerCase()) + let size: number | null = null + let updatedAt: string | null = null + if (actual) { + const info = await stat(join(env.mihomoHome, actual)).catch(() => null) + if (info) { + size = info.size + 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 } + }), + ) +} diff --git a/server/routes.ts b/server/routes.ts index 6656029..44c9d2e 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -13,6 +13,7 @@ import { resolve, sep } from 'node:path' import listenerFields from '../shared/listener-fields.json' with { type: 'json' } import { isProviderFilePath } from '../shared/provider-files.ts' import { applyConfig, importConfig, listBackups, readConfig, restoreBackup } from './config-store.ts' +import { geoStatus } from './geo.ts' import { hostInfo, probeExit } from './host.ts' import { kernelVersion, @@ -194,13 +195,17 @@ function makeRoute(env: Env, token: string) { }) } - // 上传的 YAML 整份替换当前配置。走 config-store 完整的保护链路 + // 上传的 YAML 替换当前配置(mode=override 整份,compatible 只搬接管段落)。 + // 走 config-store 完整的保护链路 if (path === '/api/config/import' && req.method === 'POST') { - const body = (await req.json()) as { content?: string } + const body = (await req.json()) as { content?: string; mode?: string } if (typeof body.content !== 'string' || body.content.trim() === '') { return json({ message: '请求缺少 content 字段' }, 400) } - const result = await importConfig(env, body.content) + if (body.mode !== 'override' && body.mode !== 'compatible') { + return json({ message: 'mode 必须是 override 或 compatible' }, 400) + } + const result = await importConfig(env, body.content, body.mode) return result.ok ? json(result) : json(result, applyFailureStatus(result.stage)) } @@ -264,6 +269,13 @@ function makeRoute(env: Env, token: string) { : json({ message: upstream.message || '内核拒绝了这次切换' }, 422) } + // GEO 数据库文件的本地状态(大小、更新时间)和生效中的下载地址 + if (path === '/api/geo' && req.method === 'GET') { + const snapshot = await readConfig(env) + const geox = (snapshot.data['geox-url'] ?? {}) as Record + return json({ files: await geoStatus(env, geox) }) + } + // 更新 GEO 数据库。运行时动作;同步接口,透传层给了长超时 if (path === '/api/kernel/geo' && req.method === 'POST') { const upstream = await updateGeoDatabases(env) diff --git a/shared/format.test.ts b/shared/format.test.ts index eb75367..266b0f3 100644 --- a/shared/format.test.ts +++ b/shared/format.test.ts @@ -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('刚刚') + }) +}) diff --git a/shared/format.ts b/shared/format.ts index a279187..c57dee7 100644 --- a/shared/format.ts +++ b/shared/format.ts @@ -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 '—' diff --git a/shared/geo.ts b/shared/geo.ts new file mode 100644 index 0000000..e992bc1 --- /dev/null +++ b/shared/geo.ts @@ -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 = { + 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', +} diff --git a/web/api.ts b/web/api.ts index c0785aa..189bb3b 100644 --- a/web/api.ts +++ b/web/api.ts @@ -7,6 +7,7 @@ import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts' import type { ConnectionSnapshot } from '@shared/connections.ts' import type { ExitProbe, HostInfo } from '@shared/host-info.ts' +import type { GeoFileStatus } from '@shared/geo.ts' import { DEFAULT_TEST_URL, testUrl } from './prefs' const TOKEN_KEY = 'patchbay.token' @@ -156,11 +157,11 @@ export const api = { }, /** - * 用上传的 YAML 原文整份替换当前配置。走服务端完整的保护链路 - * (校验、备份、重载、失败回退),没有 baseRevision——整份替换本来就是覆盖语义。 + * 用上传的 YAML 替换当前配置:override 整份替换,compatible 只搬 + * Patchbay 接管的段落。走服务端完整的保护链路(校验、备份、重载、失败回退)。 */ - importConfig: (content: string) => - request('POST', '/api/config/import', { content }), + importConfig: (content: string, mode: 'override' | 'compatible') => + request('POST', '/api/config/import', { content, mode }), restoreBackup: (name: string) => request('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`), @@ -189,11 +190,14 @@ export const api = { restartKernel: () => request<{ ok: true }>('POST', '/api/kernel/restart'), /** - * 让内核更新 GEO 数据库(GEOIP、GEOSITE、MMDB、ASN)。 - * 同步接口,下载完才回话,可能要等上几十秒。 + * 让内核更新 GEO 数据库。同步接口,下载完才回话,可能要等上几十秒。 + * 内核只有全量更新这一个口子,没变化的文件靠内容哈希跳过重写。 */ updateGeo: () => request<{ ok: true }>('POST', '/api/kernel/geo'), + /** 四类 GEO 文件的本地状态:大小、更新时间、生效中的下载地址。 */ + geoStatus: () => request<{ files: GeoFileStatus[] }>('GET', '/api/geo'), + /** * 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。 * diff --git a/web/components/InlineEdit.vue b/web/components/InlineEdit.vue new file mode 100644 index 0000000..3eb4d2b --- /dev/null +++ b/web/components/InlineEdit.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/web/views/SettingsPage.vue b/web/views/SettingsPage.vue index 072b920..20f2f45 100644 --- a/web/views/SettingsPage.vue +++ b/web/views/SettingsPage.vue @@ -2,16 +2,18 @@ /** * 设置页。分两类,反馈方式不同: * - 本地偏好(主题、测速地址)点了就生效,没有等待 - * - 动配置文件的(内核参数、上传替换)走服务端完整的保护链路, - * 有校验、备份、重载和失败回退,反馈方式照路由页的 commit() + * - 动配置文件的(内核参数、GEO 下载地址、上传恢复/导入)走服务端完整的 + * 保护链路,有校验、备份、重载和失败回退,反馈方式照路由页的 commit() * - * 备份列表不再摆出来:自动备份是落盘链路的安全网,不是日常操作对象。 - * 「配置文件」一组提供下载和上传替换;上传时用配置里的 Patchbay 标记 - * 区分「还原自己导出的」和「导入外来的」,后者要多确认一步。 + * 单值编辑统一用 InlineEdit:平时纯文本,点铅笔才出输入框,Enter 或失焦提交。 + * 备份列表不摆出来:自动备份是落盘链路的安全网,不是日常操作对象; + * 「配置文件」一组给下载和上传,上传按 Patchbay 标记分「恢复」和「导入」两个入口。 */ -import { computed, ref, watch } from 'vue' +import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { parse } from 'yaml' import { hasValidMarker } from '@shared/config-marker.ts' +import { formatAgo, formatBytes } from '@shared/format.ts' +import type { GeoFileStatus } from '@shared/geo.ts' import type { ConfigObject } from '../../vendor/config-editor/index.ts' import { ApiError, api } from '../api' import { DEFAULT_TEST_URL, testUrl, theme } from '../prefs' @@ -19,11 +21,14 @@ import type { Theme } from '../prefs' import { kernel } from '../runtime' import { DISCLAIMER, DISCLAIMER_TITLE } from '../legal' import ConfirmDialog from '../components/ConfirmDialog.vue' -import DropFile from '../components/DropFile.vue' +import InlineEdit from '../components/InlineEdit.vue' +import SelectBox from '../components/SelectBox.vue' import IconCloseTip from '~icons/ri/close-line' 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' +import IconEdit from '~icons/ri/pencil-line' +import IconSync from '~icons/ri/refresh-line' const props = defineProps<{ data: ConfigObject; revision: string }>() @@ -43,18 +48,19 @@ function pickTheme(value: Theme) { theme.value = value } +const isUrlOrEmpty = (value: string) => value === '' || /^https?:\/\//.test(value) + +/** 本地偏好,立刻生效。清空等于回到默认。 */ +function saveTestUrl(value: string) { + testUrl.value = value || DEFAULT_TEST_URL +} + /* ── 状态与反馈 ───────────────────────────────── */ const saving = ref(false) const failure = ref<{ message: string; hint: string } | null>(null) const done = ref('') -/** 走配置文件保护链路的动作共用的收尾。 */ -function settle(result: { reloaded: boolean; reloadMessage: string }, success: string) { - done.value = result.reloaded ? success : `${success}(${result.reloadMessage})` - emit('saved') -} - function fail(error: unknown) { failure.value = error instanceof ApiError @@ -62,73 +68,17 @@ function fail(error: unknown) { : { message: String(error), hint: '' } } -/* ── 内核参数 ─────────────────────────────────── */ - -/** 老式顶层端口,键名用配置原词。空表示没启用(配置里不写这个键)。 */ -const PORT_KEYS = ['mixed-port', 'port', 'socks-port', 'redir-port', 'tproxy-port'] as const - -const ports = ref>({}) -const keepAlive = ref('') - -// 配置重读后回填表单。保存成功后 data 换新,这里跟着对齐 -watch( - () => props.data, - () => { - const next: Record = {} - for (const key of PORT_KEYS) { - const value = props.data[key] - next[key] = typeof value === 'number' ? String(value) : '' - } - ports.value = next - const ka = props.data['keep-alive-interval'] - keepAlive.value = typeof ka === 'number' ? String(ka) : '' - }, - { immediate: true }, -) - -const portInvalid = (raw: string): boolean => { - const text = raw.trim() - if (text === '') return false - const n = Number(text) - return !/^\d+$/.test(text) || n < 1 || n > 65535 -} - -const keepAliveInvalid = computed(() => { - const text = keepAlive.value.trim() - return text !== '' && (!/^\d+$/.test(text) || Number(text) < 1) -}) - -const kernelParamsInvalid = computed( - () => keepAliveInvalid.value || PORT_KEYS.some((key) => portInvalid(ports.value[key] ?? '')), -) - -const kernelParamsDirty = computed(() => - PORT_KEYS.some((key) => { - const current = props.data[key] - const shown = (ports.value[key] ?? '').trim() - return (typeof current === 'number' ? String(current) : '') !== shown - }) || - (typeof props.data['keep-alive-interval'] === 'number' - ? String(props.data['keep-alive-interval']) - : '') !== keepAlive.value.trim(), -) - -async function saveKernelParams() { - if (saving.value || kernelParamsInvalid.value || !kernelParamsDirty.value) return +/** 所有改配置文件的动作都从这里走。 */ +async function commitConfig(next: ConfigObject, success: string) { + if (saving.value) return saving.value = true failure.value = null done.value = '' - const next = JSON.parse(JSON.stringify(props.data)) as ConfigObject - for (const key of PORT_KEYS) { - const raw = (ports.value[key] ?? '').trim() - if (raw === '') delete next[key] - else next[key] = Number(raw) - } - const ka = keepAlive.value.trim() - if (ka === '') delete next['keep-alive-interval'] - else next['keep-alive-interval'] = Number(ka) try { - settle(await api.applyConfig(next, props.revision), '内核参数已保存') + const result = await api.applyConfig(next, props.revision) + // 内核不在线时只写了文件没重载,如实说,不能只报成功 + done.value = result.reloaded ? success : `${success}(${result.reloadMessage})` + emit('saved') } catch (error) { fail(error) } finally { @@ -136,11 +86,54 @@ async function saveKernelParams() { } } -/* ── 资源 ─────────────────────────────────────── */ +const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject +/* ── 内核参数 ─────────────────────────────────── */ + +/** 老式顶层端口,键名用配置原词。空表示没启用(配置里不写这个键)。 */ +const PORT_KEYS = ['mixed-port', 'port', 'socks-port', 'redir-port', 'tproxy-port'] as const + +function numberText(key: string): string { + const value = props.data[key] + return typeof value === 'number' ? String(value) : '' +} + +const isPortOrEmpty = (value: string) => + value === '' || (/^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= 65535) + +const isPosIntOrEmpty = (value: string) => value === '' || (/^\d+$/.test(value) && Number(value) >= 1) + +/** 单个数字键的保存:空值删键,其余写数字。端口和 keep-alive 共用。 */ +function saveNumberKey(key: string, value: string, success: string) { + const next = clone() + if (value === '') delete next[key] + else next[key] = Number(value) + void commitConfig(next, success) +} + +/* ── 资源(GEO 数据库) ───────────────────────── */ + +const geoFiles = ref(null) const geoBusy = ref(false) -/** GEO 更新是运行时动作,不写配置文件。同步接口,下载完才回话。 */ +async function loadGeo() { + // 失败给空数组而不是停在「载入中」,那是个出不去的死状态 + geoFiles.value = await api + .geoStatus() + .then((r) => r.files) + .catch(() => []) +} + +onMounted(() => void loadGeo()) +// 保存过配置(比如改了下载地址)后重新问一遍生效中的地址 +watch(() => props.data, () => void loadGeo()) + +function geoMeta(g: GeoFileStatus): string { + if (g.size === null || !g.updatedAt) return '未下载' + return `${formatBytes(g.size)} · ${formatAgo(new Date(g.updatedAt), new Date())}` +} + +/** GEO 更新是运行时动作,不写配置文件。内核只有全量更新的口子,四行共用一个忙态。 */ async function updateGeo() { if (geoBusy.value || !kernel.value.online) return geoBusy.value = true @@ -149,6 +142,7 @@ async function updateGeo() { try { await api.updateGeo() done.value = 'GEO 数据库已更新' + await loadGeo() } catch (error) { fail(error) } finally { @@ -156,6 +150,46 @@ async function updateGeo() { } } +/** 正在编辑下载地址的那一行(configKey),一次只开一行 */ +const geoEditing = ref(null) +const geoDraft = ref('') +const geoBad = ref(false) +const geoInput = ref(null) + +// 输入框在 v-for 里,字符串 ref 会被 Vue 收成数组;同时只开一行,函数 ref 存单个就够 +function setGeoInput(el: unknown) { + geoInput.value = el instanceof HTMLInputElement ? el : null +} + +async function openGeoEdit(g: GeoFileStatus) { + geoDraft.value = g.url + geoBad.value = false + geoEditing.value = g.configKey + await nextTick() + geoInput.value?.focus() + geoInput.value?.select() +} + +/** lenient 是失焦路径:无效就放弃改动。空值 = 回到内核默认(配置里删掉这个键)。 */ +function finishGeoEdit(g: GeoFileStatus, lenient: boolean) { + if (geoEditing.value !== g.configKey) return + const next = geoDraft.value.trim() + if (!isUrlOrEmpty(next)) { + if (lenient) geoEditing.value = null + else geoBad.value = true + return + } + geoEditing.value = null + if (next === g.url) return + const data = clone() + const geox: ConfigObject = { ...((data['geox-url'] ?? {}) as ConfigObject) } + if (next === '') delete geox[g.configKey] + else geox[g.configKey] = next + if (Object.keys(geox).length === 0) delete data['geox-url'] + else data['geox-url'] = geox + void commitConfig(data, `${g.key} 的下载地址已保存`) +} + /* ── 配置文件 ─────────────────────────────────── */ async function downloadConfig() { @@ -173,12 +207,81 @@ async function downloadConfig() { } } -/** 待确认的上传。foreign 表示没有 Patchbay 标记,要用更重的措辞确认 */ -const pendingImport = ref<{ content: string; foreign: boolean } | null>(null) +type UploadKind = 'restore' | 'import' -function onUpload(_name: string, content: string) { +const strategy = ref<'compatible' | 'override'>('compatible') +const STRATEGY_OPTIONS = [ + { value: 'compatible', label: '兼容' }, + { value: 'override', label: '覆盖' }, +] + +/** 窗口里正拖着文件。为真时两个上传行整行变成投放区。 */ +const dragging = ref(false) +let dragDepth = 0 + +function hasFiles(event: DragEvent): boolean { + return Array.from(event.dataTransfer?.types ?? []).includes('Files') +} + +function onWindowEnter(event: DragEvent) { + if (!hasFiles(event)) return + dragDepth += 1 + dragging.value = true +} + +function onWindowLeave(event: DragEvent) { + if (!hasFiles(event)) return + dragDepth = Math.max(0, dragDepth - 1) + if (dragDepth === 0) dragging.value = false +} + +// dragover 不阻止默认行为浏览器不允许 drop;窗口级兜底 drop 防止误放导致页面跳走 +function onWindowOver(event: DragEvent) { + event.preventDefault() +} + +function onWindowDrop(event: DragEvent) { + event.preventDefault() + dragDepth = 0 + dragging.value = false +} + +onMounted(() => { + window.addEventListener('dragenter', onWindowEnter) + window.addEventListener('dragleave', onWindowLeave) + window.addEventListener('dragover', onWindowOver) + window.addEventListener('drop', onWindowDrop) +}) + +onUnmounted(() => { + window.removeEventListener('dragenter', onWindowEnter) + window.removeEventListener('dragleave', onWindowLeave) + window.removeEventListener('dragover', onWindowOver) + window.removeEventListener('drop', onWindowDrop) +}) + +const restoreInput = ref(null) +const importInput = ref(null) + +function onPick(kind: UploadKind, event: Event) { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + input.value = '' + if (file) void readUpload(kind, file) +} + +function onDrop(kind: UploadKind, event: DragEvent) { + const file = event.dataTransfer?.files[0] + if (file) void readUpload(kind, file) +} + +/** 待确认的上传 */ +const pendingUpload = ref<{ content: string; kind: UploadKind } | null>(null) + +async function readUpload(kind: UploadKind, file: File) { failure.value = null done.value = '' + const content = await file.text() let parsed: unknown try { parsed = parse(content) @@ -190,18 +293,39 @@ function onUpload(_name: string, content: string) { failure.value = { message: '这份文件不是一份 mihomo 配置。', hint: '' } return } - pendingImport.value = { content, foreign: !hasValidMarker(parsed as ConfigObject) } + // 恢复入口只收 Patchbay 自己下载的文件,认标记。别家的配置走导入 + if (kind === 'restore' && !hasValidMarker(parsed as ConfigObject)) { + failure.value = { + message: '这份文件没有 Patchbay 标记,不像是从 Patchbay 下载的配置。', + hint: '如果确实要用它,走「导入配置文件」。', + } + return + } + pendingUpload.value = { content, kind } } -async function importConfirmed() { - const pending = pendingImport.value - pendingImport.value = null +function uploadMessage(): string { + const kind = pendingUpload.value?.kind + const head = kind === 'import' ? '导入这份配置文件?' : '从这份文件恢复配置?' + const note = + strategy.value === 'compatible' + ? '策略为兼容:只替换 Patchbay 接管的段落(入站、专属组、规则、订阅、规则组),其余段落维持当前配置。' + : '策略为覆盖:整份替换当前配置。' + return `${head}${note}当前配置会先自动备份。` +} + +async function uploadConfirmed() { + const pending = pendingUpload.value + pendingUpload.value = null if (!pending || saving.value) return saving.value = true failure.value = null done.value = '' try { - settle(await api.importConfig(pending.content), '已替换为上传的配置') + const result = await api.importConfig(pending.content, strategy.value) + const success = pending.kind === 'restore' ? '已从文件恢复配置' : '已导入配置文件' + done.value = result.reloaded ? success : `${success}(${result.reloadMessage})` + emit('saved') } catch (error) { fail(error) } finally { @@ -279,81 +403,148 @@ const backend = location.origin

测速

测速地址 -

内核参数

-
- +
+ {{ key }} +
TCP 保持活动间隔 - - - 秒 - +
-

资源

-
- GEO 数据库(GEOIP、GEOSITE、MMDB、ASN) - -
+

载入中

+

读不到 GEO 文件的状态。

+

配置文件

- 当前配置 + 备份到本地
- + + +
+ 拖拽到此处,恢复配置 +
+
+ 通过文件恢复配置 + +
+ +
+ 拖拽到此处,导入配置文件 +
+
+ 导入配置文件 + +
+ +
+ 恢复策略 + +
+ + +
@@ -381,15 +572,11 @@ const backend = location.origin
@@ -430,6 +617,11 @@ const backend = location.origin font-size: var(--t-sm); } +.faint { + color: var(--c-text-faint); + font-size: var(--t-sm); +} + .seg { display: flex; border: 1px solid var(--c-border-strong); @@ -460,58 +652,6 @@ const backend = location.origin outline-offset: -2px; } -.input { - width: 260px; - max-width: 100%; - padding: var(--s-2) var(--s-3); - border: 1px solid var(--c-border-strong); - border-radius: var(--r-sm); - background: var(--c-surface); - font-size: var(--t-sm); -} - -.input.is-bad { - border-color: var(--c-danger); -} - -.with-unit { - display: flex; - align-items: center; - gap: var(--s-2); -} - -.with-unit .input { - width: 120px; -} - -.unit { - color: var(--c-text-soft); - font-size: var(--t-sm); -} - -/* 端口一组:键名原词在上,输入框在下,窄格子排开 */ -.ports { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: var(--s-3); - padding: var(--s-2) 0; -} - -.pfield { - display: flex; - flex-direction: column; - gap: var(--s-1); -} - -.pfield .input { - width: 100%; -} - -.pk { - color: var(--c-text-faint); - font-size: var(--t-xs); -} - .btn { padding: var(--s-2) var(--s-4); border: 1px solid var(--c-border-strong); @@ -534,26 +674,105 @@ const backend = location.origin gap: var(--s-2); } -/* 「其他」里的整行入口:左标题右箭头,整行点击 */ +/* ── 资源 ────────────────────────────────────── */ +.g-right { + display: inline-flex; + align-items: center; + gap: var(--s-2); +} + +.g-meta { + font-size: var(--t-sm); + color: var(--c-text); + margin-right: var(--s-2); +} + +.ico { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: none; + border-radius: var(--r-sm); + background: none; + color: var(--c-text-faint); + cursor: pointer; +} +.ico:hover:not(:disabled) { + background: var(--c-surface-sunken); + color: var(--c-text); +} +.ico:disabled { + cursor: not-allowed; +} +.ico svg { + width: 15px; + height: 15px; +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* 下载地址很长,编辑框独占一行放在行下面 */ +.g-input { + width: 100%; + margin: 0 0 var(--s-2); + padding: var(--s-2) var(--s-3); + border: 1px solid var(--c-border-strong); + border-radius: var(--r-sm); + background: var(--c-surface); + font-size: var(--t-sm); +} +.g-input.is-bad { + border-color: var(--c-danger); +} + +/* ── 配置文件 ────────────────────────────────── */ +.drop { + display: flex; + align-items: center; + justify-content: center; + padding: var(--s-3) 0; + margin: var(--s-1) 0; + border: 1px dashed var(--c-border-strong); + border-radius: var(--r-md); + color: var(--c-text-soft); + font-size: var(--t-sm); +} + +.strategy { + width: 140px; + --select-bg: var(--c-surface); +} + +/* ── 其他:整行入口,形态与信息行一致,右侧箭头示意可进入 ── */ .more { display: flex; align-items: center; justify-content: space-between; width: 100%; - padding: var(--s-3) var(--s-2); + padding: var(--s-2) 0; border: none; - border-radius: var(--r-sm); background: none; - font-size: var(--t-base); - color: var(--c-text); + font-size: var(--t-sm); + color: var(--c-text-soft); cursor: pointer; } -.more:hover { - background: var(--c-surface-sunken); +.more svg { + color: var(--c-text-faint); } -.more + .more { - border-top: 1px solid var(--c-border); - border-radius: 0; +.more:hover, +.more:hover svg { + color: var(--c-accent-text); } /* ── 子页 ────────────────────────────────────── */