设置页第二轮:行内编辑、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:
@@ -2,7 +2,7 @@ import { afterAll, describe, expect, it } from 'bun:test'
|
|||||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
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'
|
import type { Env } from './env.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,6 +149,33 @@ describe('落盘保护链路(内核在线)', () => {
|
|||||||
expect(body).toContain('patchbay:')
|
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 () => {
|
it('恢复只认列表里的名字,路径穿越进不来', async () => {
|
||||||
const env = makeEnv()
|
const env = makeEnv()
|
||||||
const result = await restoreBackup(env, '../config.yaml')
|
const result = await restoreBackup(env, '../config.yaml')
|
||||||
|
|||||||
+32
-8
@@ -286,14 +286,29 @@ async function pruneBackups(env: Env): Promise<void> {
|
|||||||
await Promise.all(stale.map((name) => unlink(join(dir, name)).catch(() => {})))
|
await Promise.all(stale.map((name) => unlink(join(dir, name)).catch(() => {})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImportMode = 'override' | 'compatible'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用上传的 YAML 原文整份替换当前配置。
|
* 兼容模式搬运的段落:Patchbay 接管的那几个(决策记录 0003)。
|
||||||
*
|
* 老式顶层端口、dns、tun 这类属于机器本身的设置不在其中——兼容恢复的语义是
|
||||||
* 解析用和读配置同一个引擎,之后走 applyConfig 完整的保护链路(自检、备份、
|
* 「把我在 Patchbay 里做的接线搬过来」,不动当前机器的底座。
|
||||||
* 重载、失败回退)。没有 baseRevision——整份替换本来就是覆盖语义,同还原备份。
|
|
||||||
*/
|
*/
|
||||||
export async function importConfig(env: Env, content: string): Promise<ApplyOutcome> {
|
const MANAGED_KEYS = ['listeners', 'proxy-groups', 'rules', 'proxy-providers', 'rule-providers']
|
||||||
let data: ConfigObject
|
|
||||||
|
/**
|
||||||
|
* 用上传的 YAML 替换当前配置。
|
||||||
|
*
|
||||||
|
* `override` 整份替换;`compatible` 只把上传文件里 Patchbay 接管的段落搬到
|
||||||
|
* 当前配置上(上传文件里没有的段落维持当前的样子)。解析用和读配置同一个引擎,
|
||||||
|
* 之后走 applyConfig 完整的保护链路(自检、备份、重载、失败回退)。
|
||||||
|
* 没有 baseRevision——替换本来就是覆盖语义,同还原备份。
|
||||||
|
*/
|
||||||
|
export async function importConfig(
|
||||||
|
env: Env,
|
||||||
|
content: string,
|
||||||
|
mode: ImportMode,
|
||||||
|
): Promise<ApplyOutcome> {
|
||||||
|
let uploaded: ConfigObject
|
||||||
try {
|
try {
|
||||||
const doc = openDocument(content)
|
const doc = openDocument(content)
|
||||||
const broken = doc.diagnostics.filter((d) => d.severity === 'error')
|
const broken = doc.diagnostics.filter((d) => d.severity === 'error')
|
||||||
@@ -306,7 +321,7 @@ export async function importConfig(env: Env, content: string): Promise<ApplyOutc
|
|||||||
restored: false,
|
restored: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
data = doc.data
|
uploaded = doc.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -316,7 +331,16 @@ export async function importConfig(env: Env, content: string): Promise<ApplyOutc
|
|||||||
restored: false,
|
restored: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return applyConfig(env, data)
|
|
||||||
|
if (mode === 'override') return applyConfig(env, uploaded)
|
||||||
|
|
||||||
|
const current = await readDocument(env)
|
||||||
|
const merged: ConfigObject = { ...current.data }
|
||||||
|
for (const key of MANAGED_KEYS) {
|
||||||
|
const value = uploaded[key]
|
||||||
|
if (value !== undefined) merged[key] = value
|
||||||
|
}
|
||||||
|
return applyConfig(env, merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把某个备份恢复成当前配置。走的是和普通改动同一条保护链路。 */
|
/** 把某个备份恢复成当前配置。走的是和普通改动同一条保护链路。 */
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* GEO 数据库文件的本地状态:多大、多久没更新了。
|
||||||
|
*
|
||||||
|
* 内核不提供这个信息,但文件就躺在它的工作目录里,和 Patchbay 服务端
|
||||||
|
* 在同一台机器上,直接 stat 就是。
|
||||||
|
*/
|
||||||
|
import { readdir, stat } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { DEFAULT_GEOX_URLS, GEO_FILES } from '../shared/geo.ts'
|
||||||
|
import type { GeoFileStatus } from '../shared/geo.ts'
|
||||||
|
import type { Env } from './env.ts'
|
||||||
|
|
||||||
|
export async function geoStatus(
|
||||||
|
env: Env,
|
||||||
|
geoxUrl: Record<string, unknown>,
|
||||||
|
): Promise<GeoFileStatus[]> {
|
||||||
|
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 }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
+15
-3
@@ -13,6 +13,7 @@ import { resolve, sep } from 'node:path'
|
|||||||
import listenerFields from '../shared/listener-fields.json' with { type: 'json' }
|
import listenerFields from '../shared/listener-fields.json' with { type: 'json' }
|
||||||
import { isProviderFilePath } from '../shared/provider-files.ts'
|
import { isProviderFilePath } from '../shared/provider-files.ts'
|
||||||
import { applyConfig, importConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
|
import { applyConfig, importConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
|
||||||
|
import { geoStatus } from './geo.ts'
|
||||||
import { hostInfo, probeExit } from './host.ts'
|
import { hostInfo, probeExit } from './host.ts'
|
||||||
import {
|
import {
|
||||||
kernelVersion,
|
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') {
|
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() === '') {
|
if (typeof body.content !== 'string' || body.content.trim() === '') {
|
||||||
return json({ message: '请求缺少 content 字段' }, 400)
|
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))
|
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)
|
: 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<string, unknown>
|
||||||
|
return json({ files: await geoStatus(env, geox) })
|
||||||
|
}
|
||||||
|
|
||||||
// 更新 GEO 数据库。运行时动作;同步接口,透传层给了长超时
|
// 更新 GEO 数据库。运行时动作;同步接口,透传层给了长超时
|
||||||
if (path === '/api/kernel/geo' && req.method === 'POST') {
|
if (path === '/api/kernel/geo' && req.method === 'POST') {
|
||||||
const upstream = await updateGeoDatabases(env)
|
const upstream = await updateGeoDatabases(env)
|
||||||
|
|||||||
+21
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'bun:test'
|
import { describe, expect, it } from 'bun:test'
|
||||||
import { formatBytes } from './format.ts'
|
import { formatAgo, formatBytes } from './format.ts'
|
||||||
|
|
||||||
describe('流量数字', () => {
|
describe('流量数字', () => {
|
||||||
it('零和不满 1KB 的按字节显示,不带小数', () => {
|
it('零和不满 1KB 的按字节显示,不带小数', () => {
|
||||||
@@ -28,3 +28,23 @@ describe('流量数字', () => {
|
|||||||
expect(formatBytes(Number.NaN)).toBe('0 B')
|
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('刚刚')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ export function formatBytes(bytes: number): string {
|
|||||||
return `${String(Number(value.toFixed(digits)))} ${UNITS[unit]}`
|
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 {
|
export function formatUptime(seconds: number): string {
|
||||||
if (!Number.isFinite(seconds) || seconds < 0) return '—'
|
if (!Number.isFinite(seconds) || seconds < 0) return '—'
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
+10
-6
@@ -7,6 +7,7 @@
|
|||||||
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
||||||
import type { ConnectionSnapshot } from '@shared/connections.ts'
|
import type { ConnectionSnapshot } from '@shared/connections.ts'
|
||||||
import type { ExitProbe, HostInfo } from '@shared/host-info.ts'
|
import type { ExitProbe, HostInfo } from '@shared/host-info.ts'
|
||||||
|
import type { GeoFileStatus } from '@shared/geo.ts'
|
||||||
import { DEFAULT_TEST_URL, testUrl } from './prefs'
|
import { DEFAULT_TEST_URL, testUrl } from './prefs'
|
||||||
|
|
||||||
const TOKEN_KEY = 'patchbay.token'
|
const TOKEN_KEY = 'patchbay.token'
|
||||||
@@ -156,11 +157,11 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用上传的 YAML 原文整份替换当前配置。走服务端完整的保护链路
|
* 用上传的 YAML 替换当前配置:override 整份替换,compatible 只搬
|
||||||
* (校验、备份、重载、失败回退),没有 baseRevision——整份替换本来就是覆盖语义。
|
* Patchbay 接管的段落。走服务端完整的保护链路(校验、备份、重载、失败回退)。
|
||||||
*/
|
*/
|
||||||
importConfig: (content: string) =>
|
importConfig: (content: string, mode: 'override' | 'compatible') =>
|
||||||
request<ApplySuccess>('POST', '/api/config/import', { content }),
|
request<ApplySuccess>('POST', '/api/config/import', { content, mode }),
|
||||||
|
|
||||||
restoreBackup: (name: string) =>
|
restoreBackup: (name: string) =>
|
||||||
request<ApplySuccess>('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`),
|
request<ApplySuccess>('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`),
|
||||||
@@ -189,11 +190,14 @@ export const api = {
|
|||||||
restartKernel: () => request<{ ok: true }>('POST', '/api/kernel/restart'),
|
restartKernel: () => request<{ ok: true }>('POST', '/api/kernel/restart'),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 让内核更新 GEO 数据库(GEOIP、GEOSITE、MMDB、ASN)。
|
* 让内核更新 GEO 数据库。同步接口,下载完才回话,可能要等上几十秒。
|
||||||
* 同步接口,下载完才回话,可能要等上几十秒。
|
* 内核只有全量更新这一个口子,没变化的文件靠内容哈希跳过重写。
|
||||||
*/
|
*/
|
||||||
updateGeo: () => request<{ ok: true }>('POST', '/api/kernel/geo'),
|
updateGeo: () => request<{ ok: true }>('POST', '/api/kernel/geo'),
|
||||||
|
|
||||||
|
/** 四类 GEO 文件的本地状态:大小、更新时间、生效中的下载地址。 */
|
||||||
|
geoStatus: () => request<{ files: GeoFileStatus[] }>('GET', '/api/geo'),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。
|
* 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 「纯文本 + 编辑铅笔」的行内编辑:平时只显示值,点铅笔换成输入框,
|
||||||
|
* Enter 或失焦提交,Esc 放弃。设置页里一行一个值的场合都用它,
|
||||||
|
* 免得整页铺满常驻的输入框。
|
||||||
|
*
|
||||||
|
* 校验交给 validate:Enter 提交没过就地标红不退出;失焦没过直接放弃改动
|
||||||
|
* ——失焦是「我走了」,不该把人拽回来。
|
||||||
|
*/
|
||||||
|
import { nextTick, ref } from 'vue'
|
||||||
|
import IconEdit from '~icons/ri/pencil-line'
|
||||||
|
import IconReset from '~icons/ri/arrow-go-back-line'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 当前值的原始文本,空串表示未设置 */
|
||||||
|
value: string
|
||||||
|
/** 空值时显示的说明(「未启用」「内核默认」) */
|
||||||
|
placeholder?: string
|
||||||
|
/** 值后面的单位 */
|
||||||
|
unit?: string
|
||||||
|
mono?: boolean
|
||||||
|
/** 显示「恢复默认」按钮 */
|
||||||
|
canReset?: boolean
|
||||||
|
/** 提交进行中,禁用编辑入口 */
|
||||||
|
busy?: boolean
|
||||||
|
validate?: (value: string) => boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ submit: [value: string]; reset: [] }>()
|
||||||
|
|
||||||
|
const editing = ref(false)
|
||||||
|
const draft = ref('')
|
||||||
|
const bad = ref(false)
|
||||||
|
const inputEl = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
async function open() {
|
||||||
|
draft.value = props.value
|
||||||
|
bad.value = false
|
||||||
|
editing.value = true
|
||||||
|
await nextTick()
|
||||||
|
inputEl.value?.focus()
|
||||||
|
inputEl.value?.select()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** lenient 是失焦路径:无效就放弃,不纠缠。 */
|
||||||
|
function finish(lenient: boolean) {
|
||||||
|
if (!editing.value) return
|
||||||
|
const next = draft.value.trim()
|
||||||
|
if (props.validate && !props.validate(next)) {
|
||||||
|
if (lenient) editing.value = false
|
||||||
|
else bad.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
editing.value = false
|
||||||
|
if (next !== props.value) emit('submit', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
editing.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="ie">
|
||||||
|
<template v-if="!editing">
|
||||||
|
<span :class="['val', mono && 'mono', !value && 'faint']" :title="value || undefined">
|
||||||
|
{{ value || placeholder }}
|
||||||
|
</span>
|
||||||
|
<span v-if="unit && value" class="unit">{{ unit }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canReset"
|
||||||
|
type="button"
|
||||||
|
class="ico"
|
||||||
|
title="恢复默认"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="emit('reset')"
|
||||||
|
>
|
||||||
|
<IconReset />
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ico" title="编辑" :disabled="busy" @click="open">
|
||||||
|
<IconEdit />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<input
|
||||||
|
ref="inputEl"
|
||||||
|
v-model="draft"
|
||||||
|
:class="['input', mono && 'mono', bad && 'is-bad']"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
@keydown.enter.prevent="finish(false)"
|
||||||
|
@keydown.esc.prevent="cancel"
|
||||||
|
@blur="finish(true)"
|
||||||
|
/>
|
||||||
|
<span v-if="unit" class="unit">{{ unit }}</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ie {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.val {
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
max-width: var(--ie-max, 320px);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faint {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit {
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: var(--ie-width, 220px);
|
||||||
|
max-width: 100%;
|
||||||
|
padding: var(--s-1) var(--s-2);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+429
-210
@@ -2,16 +2,18 @@
|
|||||||
/**
|
/**
|
||||||
* 设置页。分两类,反馈方式不同:
|
* 设置页。分两类,反馈方式不同:
|
||||||
* - 本地偏好(主题、测速地址)点了就生效,没有等待
|
* - 本地偏好(主题、测速地址)点了就生效,没有等待
|
||||||
* - 动配置文件的(内核参数、上传替换)走服务端完整的保护链路,
|
* - 动配置文件的(内核参数、GEO 下载地址、上传恢复/导入)走服务端完整的
|
||||||
* 有校验、备份、重载和失败回退,反馈方式照路由页的 commit()
|
* 保护链路,有校验、备份、重载和失败回退,反馈方式照路由页的 commit()
|
||||||
*
|
*
|
||||||
* 备份列表不再摆出来:自动备份是落盘链路的安全网,不是日常操作对象。
|
* 单值编辑统一用 InlineEdit:平时纯文本,点铅笔才出输入框,Enter 或失焦提交。
|
||||||
* 「配置文件」一组提供下载和上传替换;上传时用配置里的 Patchbay 标记
|
* 备份列表不摆出来:自动备份是落盘链路的安全网,不是日常操作对象;
|
||||||
* 区分「还原自己导出的」和「导入外来的」,后者要多确认一步。
|
* 「配置文件」一组给下载和上传,上传按 Patchbay 标记分「恢复」和「导入」两个入口。
|
||||||
*/
|
*/
|
||||||
import { computed, ref, watch } from 'vue'
|
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { parse } from 'yaml'
|
import { parse } from 'yaml'
|
||||||
import { hasValidMarker } from '@shared/config-marker.ts'
|
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 type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||||
import { ApiError, api } from '../api'
|
import { ApiError, api } from '../api'
|
||||||
import { DEFAULT_TEST_URL, testUrl, theme } from '../prefs'
|
import { DEFAULT_TEST_URL, testUrl, theme } from '../prefs'
|
||||||
@@ -19,11 +21,14 @@ import type { Theme } from '../prefs'
|
|||||||
import { kernel } from '../runtime'
|
import { kernel } from '../runtime'
|
||||||
import { DISCLAIMER, DISCLAIMER_TITLE } from '../legal'
|
import { DISCLAIMER, DISCLAIMER_TITLE } from '../legal'
|
||||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
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 IconCloseTip from '~icons/ri/close-line'
|
||||||
import IconRight from '~icons/ri/arrow-right-s-line'
|
import IconRight from '~icons/ri/arrow-right-s-line'
|
||||||
import IconBack from '~icons/ri/arrow-left-s-line'
|
import IconBack from '~icons/ri/arrow-left-s-line'
|
||||||
import IconDownload from '~icons/ri/download-2-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 }>()
|
const props = defineProps<{ data: ConfigObject; revision: string }>()
|
||||||
|
|
||||||
@@ -43,18 +48,19 @@ function pickTheme(value: Theme) {
|
|||||||
theme.value = value
|
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 saving = ref(false)
|
||||||
const failure = ref<{ message: string; hint: string } | null>(null)
|
const failure = ref<{ message: string; hint: string } | null>(null)
|
||||||
const done = ref('')
|
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) {
|
function fail(error: unknown) {
|
||||||
failure.value =
|
failure.value =
|
||||||
error instanceof ApiError
|
error instanceof ApiError
|
||||||
@@ -62,73 +68,17 @@ function fail(error: unknown) {
|
|||||||
: { message: String(error), hint: '' }
|
: { message: String(error), hint: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 内核参数 ─────────────────────────────────── */
|
/** 所有改配置文件的动作都从这里走。 */
|
||||||
|
async function commitConfig(next: ConfigObject, success: string) {
|
||||||
/** 老式顶层端口,键名用配置原词。空表示没启用(配置里不写这个键)。 */
|
if (saving.value) return
|
||||||
const PORT_KEYS = ['mixed-port', 'port', 'socks-port', 'redir-port', 'tproxy-port'] as const
|
|
||||||
|
|
||||||
const ports = ref<Record<string, string>>({})
|
|
||||||
const keepAlive = ref('')
|
|
||||||
|
|
||||||
// 配置重读后回填表单。保存成功后 data 换新,这里跟着对齐
|
|
||||||
watch(
|
|
||||||
() => props.data,
|
|
||||||
() => {
|
|
||||||
const next: Record<string, string> = {}
|
|
||||||
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
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
failure.value = null
|
failure.value = null
|
||||||
done.value = ''
|
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 {
|
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) {
|
} catch (error) {
|
||||||
fail(error)
|
fail(error)
|
||||||
} finally {
|
} 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<GeoFileStatus[] | null>(null)
|
||||||
const geoBusy = ref(false)
|
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() {
|
async function updateGeo() {
|
||||||
if (geoBusy.value || !kernel.value.online) return
|
if (geoBusy.value || !kernel.value.online) return
|
||||||
geoBusy.value = true
|
geoBusy.value = true
|
||||||
@@ -149,6 +142,7 @@ async function updateGeo() {
|
|||||||
try {
|
try {
|
||||||
await api.updateGeo()
|
await api.updateGeo()
|
||||||
done.value = 'GEO 数据库已更新'
|
done.value = 'GEO 数据库已更新'
|
||||||
|
await loadGeo()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(error)
|
fail(error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -156,6 +150,46 @@ async function updateGeo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 正在编辑下载地址的那一行(configKey),一次只开一行 */
|
||||||
|
const geoEditing = ref<string | null>(null)
|
||||||
|
const geoDraft = ref('')
|
||||||
|
const geoBad = ref(false)
|
||||||
|
const geoInput = ref<HTMLInputElement | null>(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() {
|
async function downloadConfig() {
|
||||||
@@ -173,12 +207,81 @@ async function downloadConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 待确认的上传。foreign 表示没有 Patchbay 标记,要用更重的措辞确认 */
|
type UploadKind = 'restore' | 'import'
|
||||||
const pendingImport = ref<{ content: string; foreign: boolean } | null>(null)
|
|
||||||
|
|
||||||
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<HTMLInputElement | null>(null)
|
||||||
|
const importInput = ref<HTMLInputElement | null>(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
|
failure.value = null
|
||||||
done.value = ''
|
done.value = ''
|
||||||
|
const content = await file.text()
|
||||||
let parsed: unknown
|
let parsed: unknown
|
||||||
try {
|
try {
|
||||||
parsed = parse(content)
|
parsed = parse(content)
|
||||||
@@ -190,18 +293,39 @@ function onUpload(_name: string, content: string) {
|
|||||||
failure.value = { message: '这份文件不是一份 mihomo 配置。', hint: '' }
|
failure.value = { message: '这份文件不是一份 mihomo 配置。', hint: '' }
|
||||||
return
|
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() {
|
function uploadMessage(): string {
|
||||||
const pending = pendingImport.value
|
const kind = pendingUpload.value?.kind
|
||||||
pendingImport.value = null
|
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
|
if (!pending || saving.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
failure.value = null
|
failure.value = null
|
||||||
done.value = ''
|
done.value = ''
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
fail(error)
|
fail(error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -279,81 +403,148 @@ const backend = location.origin
|
|||||||
<h2 class="title">测速</h2>
|
<h2 class="title">测速</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="key">测速地址</span>
|
<span class="key">测速地址</span>
|
||||||
<input
|
<InlineEdit
|
||||||
v-model="testUrl"
|
:value="testUrl"
|
||||||
class="input mono"
|
mono
|
||||||
type="text"
|
:can-reset="testUrl !== DEFAULT_TEST_URL"
|
||||||
:placeholder="DEFAULT_TEST_URL"
|
:validate="isUrlOrEmpty"
|
||||||
autocomplete="off"
|
style="--ie-width: 320px"
|
||||||
spellcheck="false"
|
@submit="saveTestUrl"
|
||||||
|
@reset="testUrl = DEFAULT_TEST_URL"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="group">
|
<section class="group">
|
||||||
<h2 class="title">内核参数</h2>
|
<h2 class="title">内核参数</h2>
|
||||||
<div class="ports">
|
<div v-for="key in PORT_KEYS" :key="key" class="row">
|
||||||
<label v-for="key in PORT_KEYS" :key="key" class="pfield">
|
<span class="key mono">{{ key }}</span>
|
||||||
<span class="pk mono">{{ key }}</span>
|
<InlineEdit
|
||||||
<input
|
:value="numberText(key)"
|
||||||
v-model="ports[key]"
|
placeholder="未启用"
|
||||||
:class="['input mono', portInvalid(ports[key] ?? '') && 'is-bad']"
|
mono
|
||||||
type="text"
|
:busy="saving"
|
||||||
inputmode="numeric"
|
:validate="isPortOrEmpty"
|
||||||
placeholder="未启用"
|
style="--ie-width: 120px"
|
||||||
autocomplete="off"
|
@submit="(v) => saveNumberKey(key, v, `${key} 已保存`)"
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="key">TCP 保持活动间隔</span>
|
<span class="key">TCP 保持活动间隔</span>
|
||||||
<span class="with-unit">
|
<InlineEdit
|
||||||
<input
|
:value="numberText('keep-alive-interval')"
|
||||||
v-model="keepAlive"
|
placeholder="内核默认"
|
||||||
:class="['input mono', keepAliveInvalid && 'is-bad']"
|
unit="秒"
|
||||||
type="text"
|
mono
|
||||||
inputmode="numeric"
|
:busy="saving"
|
||||||
placeholder="内核默认"
|
:validate="isPosIntOrEmpty"
|
||||||
autocomplete="off"
|
style="--ie-width: 120px"
|
||||||
/>
|
@submit="(v) => saveNumberKey('keep-alive-interval', v, 'TCP 保持活动间隔已保存')"
|
||||||
<span class="unit">秒</span>
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn"
|
|
||||||
:disabled="saving || kernelParamsInvalid || !kernelParamsDirty"
|
|
||||||
@click="saveKernelParams"
|
|
||||||
>
|
|
||||||
{{ saving ? '保存中' : '保存内核参数' }}
|
|
||||||
</button>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="group">
|
<section class="group">
|
||||||
<h2 class="title">资源</h2>
|
<h2 class="title">资源</h2>
|
||||||
<div class="row">
|
<p v-if="geoFiles === null" class="faint">载入中</p>
|
||||||
<span class="key">GEO 数据库(GEOIP、GEOSITE、MMDB、ASN)</span>
|
<p v-else-if="geoFiles.length === 0" class="faint">读不到 GEO 文件的状态。</p>
|
||||||
<button
|
<template v-else>
|
||||||
type="button"
|
<div v-for="g in geoFiles" :key="g.key" class="geo">
|
||||||
class="btn"
|
<div class="row" :title="g.url">
|
||||||
:disabled="geoBusy || !kernel.online"
|
<span class="key mono">{{ g.key }}</span>
|
||||||
:title="kernel.online ? undefined : '内核未连接'"
|
<span class="g-right">
|
||||||
@click="updateGeo"
|
<span :class="['g-meta', g.size === null && 'faint']">{{ geoMeta(g) }}</span>
|
||||||
>
|
<button
|
||||||
{{ geoBusy ? '更新中' : '更新 GEO 数据库' }}
|
type="button"
|
||||||
</button>
|
class="ico"
|
||||||
</div>
|
title="编辑下载地址"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="openGeoEdit(g)"
|
||||||
|
>
|
||||||
|
<IconEdit />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ico"
|
||||||
|
:title="kernel.online ? '更新(有变化的 GEO 文件会一并更新)' : '内核未连接'"
|
||||||
|
:disabled="geoBusy || !kernel.online"
|
||||||
|
@click="updateGeo"
|
||||||
|
>
|
||||||
|
<IconSync :class="geoBusy && 'spin'" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-if="geoEditing === g.configKey"
|
||||||
|
:ref="setGeoInput"
|
||||||
|
v-model="geoDraft"
|
||||||
|
:class="['g-input mono', geoBad && 'is-bad']"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
:placeholder="g.url"
|
||||||
|
@keydown.enter.prevent="finishGeoEdit(g, false)"
|
||||||
|
@keydown.esc.prevent="geoEditing = null"
|
||||||
|
@blur="finishGeoEdit(g, true)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="group">
|
<section class="group">
|
||||||
<h2 class="title">配置文件</h2>
|
<h2 class="title">配置文件</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="key">当前配置</span>
|
<span class="key">备份到本地</span>
|
||||||
<button type="button" class="btn with-ico" @click="downloadConfig">
|
<button type="button" class="btn with-ico" @click="downloadConfig">
|
||||||
<IconDownload /> 下载
|
<IconDownload /> 下载文件
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<DropFile accept=".yaml,.yml" @load="onUpload" />
|
|
||||||
|
<!-- 拖着文件进窗口时,两个上传行整行变成投放区 -->
|
||||||
|
<div
|
||||||
|
v-if="dragging"
|
||||||
|
class="drop"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop.prevent="onDrop('restore', $event)"
|
||||||
|
>
|
||||||
|
拖拽到此处,恢复配置
|
||||||
|
</div>
|
||||||
|
<div v-else class="row">
|
||||||
|
<span class="key">通过文件恢复配置</span>
|
||||||
|
<button type="button" class="btn" :disabled="saving" @click="restoreInput?.click()">
|
||||||
|
选择文件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="dragging" class="drop" @dragover.prevent @drop.prevent="onDrop('import', $event)">
|
||||||
|
拖拽到此处,导入配置文件
|
||||||
|
</div>
|
||||||
|
<div v-else class="row">
|
||||||
|
<span class="key">导入配置文件</span>
|
||||||
|
<button type="button" class="btn" :disabled="saving" @click="importInput?.click()">
|
||||||
|
选择文件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="key">恢复策略</span>
|
||||||
|
<SelectBox v-model="strategy" :options="STRATEGY_OPTIONS" class="strategy" aria-label="恢复策略" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref="restoreInput"
|
||||||
|
type="file"
|
||||||
|
accept=".yaml,.yml"
|
||||||
|
hidden
|
||||||
|
@change="onPick('restore', $event)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="importInput"
|
||||||
|
type="file"
|
||||||
|
accept=".yaml,.yml"
|
||||||
|
hidden
|
||||||
|
@change="onPick('import', $event)"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="group">
|
<section class="group">
|
||||||
@@ -381,15 +572,11 @@ const backend = location.origin
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="pendingImport !== null"
|
:open="pendingUpload !== null"
|
||||||
:message="
|
:message="uploadMessage()"
|
||||||
pendingImport?.foreign
|
:action="pendingUpload?.kind === 'import' ? '导入' : '恢复'"
|
||||||
? '这份文件没有 Patchbay 标记,可能不是 Patchbay 管理过的配置。仍然导入会整份替换当前配置(当前配置会先自动备份),之后 Patchbay 开始接管其中的段落。'
|
@close="pendingUpload = null"
|
||||||
: '用上传的文件整份替换当前配置?当前配置会先自动备份。'
|
@confirm="uploadConfirmed"
|
||||||
"
|
|
||||||
:action="pendingImport?.foreign ? '仍然导入' : '替换'"
|
|
||||||
@close="pendingImport = null"
|
|
||||||
@confirm="importConfirmed"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -430,6 +617,11 @@ const backend = location.origin
|
|||||||
font-size: var(--t-sm);
|
font-size: var(--t-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.faint {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.seg {
|
.seg {
|
||||||
display: flex;
|
display: flex;
|
||||||
border: 1px solid var(--c-border-strong);
|
border: 1px solid var(--c-border-strong);
|
||||||
@@ -460,58 +652,6 @@ const backend = location.origin
|
|||||||
outline-offset: -2px;
|
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 {
|
.btn {
|
||||||
padding: var(--s-2) var(--s-4);
|
padding: var(--s-2) var(--s-4);
|
||||||
border: 1px solid var(--c-border-strong);
|
border: 1px solid var(--c-border-strong);
|
||||||
@@ -534,26 +674,105 @@ const backend = location.origin
|
|||||||
gap: var(--s-2);
|
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 {
|
.more {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--s-3) var(--s-2);
|
padding: var(--s-2) 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--r-sm);
|
|
||||||
background: none;
|
background: none;
|
||||||
font-size: var(--t-base);
|
font-size: var(--t-sm);
|
||||||
color: var(--c-text);
|
color: var(--c-text-soft);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.more:hover {
|
.more svg {
|
||||||
background: var(--c-surface-sunken);
|
color: var(--c-text-faint);
|
||||||
}
|
}
|
||||||
.more + .more {
|
.more:hover,
|
||||||
border-top: 1px solid var(--c-border);
|
.more:hover svg {
|
||||||
border-radius: 0;
|
color: var(--c-accent-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 子页 ────────────────────────────────────── */
|
/* ── 子页 ────────────────────────────────────── */
|
||||||
|
|||||||
Reference in New Issue
Block a user