设置页重做:配置文件下载上传、内核参数、资源、免责声明
配置每次落盘都打顶层 patchbay: 标记(盐加版本的哈希)。内核忽略未知 顶层键(yaml.Unmarshal 没开严格模式,config/config.go:612)。刻意不对 内容做哈希:本项目只接管部分段落,手改其余段落是正常用法,内容哈希 会把合法手改判成无效。上传配置时靠标记分流:有标记的确认后替换, 没有的用更重的措辞确认导入;两者都走 config-store 完整保护链路 (新端点 POST /api/config/import)。自动备份列表从界面撤下,改成 下载当前配置 + 拖放上传。 内核参数一组编辑五个老式顶层端口和 keep-alive-interval(config.go:444, 整数秒),走带基准指纹的保存链路。资源一组接 POST /configs/geo (同步接口,透传给了 300 秒超时)。测速地址成为本地偏好,延迟测试 读它;prefs 模块顶层的浏览器全局加了存在性守卫——api.ts 开始引用 prefs,而 bun test 环境没有 localStorage。 退出登录挪到侧栏底部;密度设置从设置页撤下(侧栏按钮还在); 免责声明和关于做成子页(左上角返回),登录页输入框下加同一份 免责声明的弹层入口。
This commit is contained in:
@@ -82,8 +82,13 @@ describe('落盘保护链路(内核在线)', () => {
|
|||||||
|
|
||||||
it('内容没变直接返回,不备份也不重载', async () => {
|
it('内容没变直接返回,不备份也不重载', async () => {
|
||||||
const env = makeEnv()
|
const env = makeEnv()
|
||||||
|
// 先落一次盘让文件带上 Patchbay 标记——没有标记的文件第一次保存必有
|
||||||
|
// 「补标记」这一处内容变化,短路只发生在稳态
|
||||||
|
const first = await readConfig(env)
|
||||||
|
await applyConfig(env, first.data, first.revision)
|
||||||
const { data, revision } = await readConfig(env)
|
const { data, revision } = await readConfig(env)
|
||||||
const before = reloadCount
|
const before = reloadCount
|
||||||
|
const backupsBefore = (await listBackups(env)).length
|
||||||
|
|
||||||
const result = await applyConfig(env, data, revision)
|
const result = await applyConfig(env, data, revision)
|
||||||
|
|
||||||
@@ -93,7 +98,7 @@ describe('落盘保护链路(内核在线)', () => {
|
|||||||
expect(result.reloadMessage).toBe('内容没有变化')
|
expect(result.reloadMessage).toBe('内容没有变化')
|
||||||
}
|
}
|
||||||
expect(reloadCount).toBe(before)
|
expect(reloadCount).toBe(before)
|
||||||
expect(await listBackups(env)).toHaveLength(0)
|
expect((await listBackups(env)).length).toBe(backupsBefore)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('自检拦下断引用:入站的 proxy 指向不存在的出站', async () => {
|
it('自检拦下断引用:入站的 proxy 指向不存在的出站', async () => {
|
||||||
@@ -137,7 +142,11 @@ describe('落盘保护链路(内核在线)', () => {
|
|||||||
const result = await restoreBackup(env, backup!)
|
const result = await restoreBackup(env, backup!)
|
||||||
|
|
||||||
expect(result.ok).toBe(true)
|
expect(result.ok).toBe(true)
|
||||||
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
|
// 还原也走落盘链路,所以会重新打上 Patchbay 标记——内容回去了,但不逐字节等于旧文件
|
||||||
|
const body = readFileSync(env.configPath, 'utf8')
|
||||||
|
expect(body).toContain('mixed-port: 7890')
|
||||||
|
expect(body).not.toContain('7891')
|
||||||
|
expect(body).toContain('patchbay:')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('恢复只认列表里的名字,路径穿越进不来', async () => {
|
it('恢复只认列表里的名字,路径穿越进不来', async () => {
|
||||||
|
|||||||
+38
-1
@@ -30,6 +30,7 @@ import {
|
|||||||
} from '../vendor/config-editor/index.ts'
|
} from '../vendor/config-editor/index.ts'
|
||||||
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
||||||
import { restoreInlineCommentAlignment } from '../shared/yaml-cosmetics.ts'
|
import { restoreInlineCommentAlignment } from '../shared/yaml-cosmetics.ts'
|
||||||
|
import { withMarker } from '../shared/config-marker.ts'
|
||||||
import { kernelVersion, validateConfigFile, reloadConfig } from './kernel.ts'
|
import { kernelVersion, validateConfigFile, reloadConfig } from './kernel.ts'
|
||||||
import type { Env } from './env.ts'
|
import type { Env } from './env.ts'
|
||||||
|
|
||||||
@@ -133,7 +134,10 @@ export async function applyConfig(
|
|||||||
return `${stage} ${String(ms)}ms`
|
return `${stage} ${String(ms)}ms`
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextYaml = rewriteYaml(current.yaml, nextData)
|
// 每次落盘都打上 Patchbay 标记(顶层 patchbay: 键,内核忽略未知键)。
|
||||||
|
// 上传配置时靠它认「这是 Patchbay 管理过的配置」。标记是幂等的,
|
||||||
|
// 已带标记的配置重打一遍内容不变,不影响下面的「内容没变就短路」
|
||||||
|
const nextYaml = rewriteYaml(current.yaml, withMarker(nextData))
|
||||||
|
|
||||||
// 内容没变就不要走后面那一串——不必要的备份和重载只会制造噪音。
|
// 内容没变就不要走后面那一串——不必要的备份和重载只会制造噪音。
|
||||||
if (nextYaml === current.yaml) {
|
if (nextYaml === current.yaml) {
|
||||||
@@ -282,6 +286,39 @@ 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(() => {})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用上传的 YAML 原文整份替换当前配置。
|
||||||
|
*
|
||||||
|
* 解析用和读配置同一个引擎,之后走 applyConfig 完整的保护链路(自检、备份、
|
||||||
|
* 重载、失败回退)。没有 baseRevision——整份替换本来就是覆盖语义,同还原备份。
|
||||||
|
*/
|
||||||
|
export async function importConfig(env: Env, content: string): Promise<ApplyOutcome> {
|
||||||
|
let data: ConfigObject
|
||||||
|
try {
|
||||||
|
const doc = openDocument(content)
|
||||||
|
const broken = doc.diagnostics.filter((d) => d.severity === 'error')
|
||||||
|
if (broken.length > 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
stage: 'self-check',
|
||||||
|
message: broken.map((p) => `${p.path.join('.')}:${p.message}`).join('\n'),
|
||||||
|
hint: '上传的文件解析不过,当前配置没有被改动。',
|
||||||
|
restored: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data = doc.data
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
stage: 'self-check',
|
||||||
|
message: `上传的文件不是合法的 YAML:${String(error)}`,
|
||||||
|
hint: '当前配置没有被改动。',
|
||||||
|
restored: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return applyConfig(env, data)
|
||||||
|
}
|
||||||
|
|
||||||
/** 把某个备份恢复成当前配置。走的是和普通改动同一条保护链路。 */
|
/** 把某个备份恢复成当前配置。走的是和普通改动同一条保护链路。 */
|
||||||
export async function restoreBackup(env: Env, name: string): Promise<ApplyOutcome> {
|
export async function restoreBackup(env: Env, name: string): Promise<ApplyOutcome> {
|
||||||
const dir = await backupDir(env)
|
const dir = await backupDir(env)
|
||||||
|
|||||||
@@ -160,6 +160,23 @@ export async function restartKernel(env: Env): Promise<ValidateResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 让内核更新 GEO 数据库(POST /configs/geo,内核源码 hub/route/configs.go:30,442-452)。
|
||||||
|
* 同步接口,下载完才回话,超时要给足——GEO 文件几十兆、走的还是外网。
|
||||||
|
*/
|
||||||
|
export async function updateGeoDatabases(env: Env): Promise<ValidateResult> {
|
||||||
|
try {
|
||||||
|
const res = await callApi(env, '/configs/geo', {
|
||||||
|
method: 'POST',
|
||||||
|
signal: AbortSignal.timeout(300_000),
|
||||||
|
})
|
||||||
|
if (res.ok) return { ok: true, message: '' }
|
||||||
|
return { ok: false, message: (await res.text()) || `管理接口返回 ${String(res.status)}` }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, message: `连不上管理接口:${String(error)}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 透传一个管理接口的 GET 请求。
|
* 透传一个管理接口的 GET 请求。
|
||||||
*
|
*
|
||||||
|
|||||||
+38
-2
@@ -12,9 +12,16 @@
|
|||||||
import { resolve, sep } from 'node:path'
|
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, listBackups, readConfig, restoreBackup } from './config-store.ts'
|
import { applyConfig, importConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
|
||||||
import { hostInfo, probeExit } from './host.ts'
|
import { hostInfo, probeExit } from './host.ts'
|
||||||
import { kernelVersion, proxyGet, proxyWrite, restartKernel, selectProxy } from './kernel.ts'
|
import {
|
||||||
|
kernelVersion,
|
||||||
|
proxyGet,
|
||||||
|
proxyWrite,
|
||||||
|
restartKernel,
|
||||||
|
selectProxy,
|
||||||
|
updateGeoDatabases,
|
||||||
|
} from './kernel.ts'
|
||||||
import { tokenFromRequest, tokenMatches } from './auth.ts'
|
import { tokenFromRequest, tokenMatches } from './auth.ts'
|
||||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||||
import type { Env } from './env.ts'
|
import type { Env } from './env.ts'
|
||||||
@@ -176,6 +183,27 @@ function makeRoute(env: Env, token: string) {
|
|||||||
return json({ backups: await listBackups(env) })
|
return json({ backups: await listBackups(env) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 配置文件原文,给「下载当前配置」用。带 attachment 让浏览器按下载处理
|
||||||
|
if (path === '/api/config/raw' && req.method === 'GET') {
|
||||||
|
const file = Bun.file(env.configPath)
|
||||||
|
return new Response(file, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/yaml; charset=utf-8',
|
||||||
|
'Content-Disposition': 'attachment; filename="config.yaml"',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传的 YAML 整份替换当前配置。走 config-store 完整的保护链路
|
||||||
|
if (path === '/api/config/import' && req.method === 'POST') {
|
||||||
|
const body = (await req.json()) as { content?: string }
|
||||||
|
if (typeof body.content !== 'string' || body.content.trim() === '') {
|
||||||
|
return json({ message: '请求缺少 content 字段' }, 400)
|
||||||
|
}
|
||||||
|
const result = await importConfig(env, body.content)
|
||||||
|
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||||
|
}
|
||||||
|
|
||||||
const restoreMatch = /^\/api\/config\/backups\/([^/]+)\/restore$/.exec(path)
|
const restoreMatch = /^\/api\/config\/backups\/([^/]+)\/restore$/.exec(path)
|
||||||
if (restoreMatch && req.method === 'POST') {
|
if (restoreMatch && req.method === 'POST') {
|
||||||
const name = decodeURIComponent(restoreMatch[1]!)
|
const name = decodeURIComponent(restoreMatch[1]!)
|
||||||
@@ -236,6 +264,14 @@ function makeRoute(env: Env, token: string) {
|
|||||||
: json({ message: upstream.message || '内核拒绝了这次切换' }, 422)
|
: json({ message: upstream.message || '内核拒绝了这次切换' }, 422)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新 GEO 数据库。运行时动作;同步接口,透传层给了长超时
|
||||||
|
if (path === '/api/kernel/geo' && req.method === 'POST') {
|
||||||
|
const upstream = await updateGeoDatabases(env)
|
||||||
|
return upstream.ok
|
||||||
|
? json({ ok: true })
|
||||||
|
: json({ message: upstream.message || '内核更新 GEO 数据库失败' }, 502)
|
||||||
|
}
|
||||||
|
|
||||||
// 重启内核进程。运行时动作,不碰配置文件;和其它写操作一样单独点名,不开通用口子
|
// 重启内核进程。运行时动作,不碰配置文件;和其它写操作一样单独点名,不开通用口子
|
||||||
if (path === '/api/kernel/restart' && req.method === 'POST') {
|
if (path === '/api/kernel/restart' && req.method === 'POST') {
|
||||||
const upstream = await restartKernel(env)
|
const upstream = await restartKernel(env)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { MARKER_KEY, hasValidMarker, withMarker } from './config-marker.ts'
|
||||||
|
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||||
|
|
||||||
|
describe('配置标记', () => {
|
||||||
|
test('打上标记后校验通过,其余键不动', () => {
|
||||||
|
const data: ConfigObject = { 'mixed-port': 7890 }
|
||||||
|
const marked = withMarker(data)
|
||||||
|
expect(hasValidMarker(marked)).toBe(true)
|
||||||
|
expect(marked['mixed-port']).toBe(7890)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('没有标记、标记形状不对、sig 对不上都算无效', () => {
|
||||||
|
expect(hasValidMarker({})).toBe(false)
|
||||||
|
expect(hasValidMarker({ [MARKER_KEY]: 'yes' })).toBe(false)
|
||||||
|
expect(hasValidMarker({ [MARKER_KEY]: { v: 1, sig: 'deadbeef' } })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('打两次标记结果一样(落盘链路的内容不变检查靠它短路)', () => {
|
||||||
|
const once = withMarker({})
|
||||||
|
expect(withMarker(once)).toEqual(once)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 配置文件里的 Patchbay 标记。
|
||||||
|
*
|
||||||
|
* 每次落盘都在顶层写一个 `patchbay:` 键(内核对未知顶层键不报错:解析用的
|
||||||
|
* `yaml.Unmarshal` 没开严格模式,config/config.go:612)。上传配置文件时靠它
|
||||||
|
* 区分「Patchbay 管理过的配置」和随便一份 mihomo 配置——前者直接还原,
|
||||||
|
* 后者提示走导入。
|
||||||
|
*
|
||||||
|
* sig 是盐加版本号的哈希,回答的是「这个标记是不是 Patchbay 写的」,不是防伪
|
||||||
|
* ——在用户自己的机器上没有对手。刻意不对配置内容做哈希:这个项目只接管部分
|
||||||
|
* 段落,手改其余段落是正常用法(决策记录 0003),内容哈希会把合法的手改
|
||||||
|
* 判成无效。
|
||||||
|
*/
|
||||||
|
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||||
|
|
||||||
|
export const MARKER_KEY = 'patchbay'
|
||||||
|
|
||||||
|
const VERSION = 1
|
||||||
|
const SALT = 'patchbay-config-salt'
|
||||||
|
|
||||||
|
/** FNV-1a。只做标记指纹,不是安全用途。 */
|
||||||
|
function fnv(text: string): string {
|
||||||
|
let h = 0x811c9dc5
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
h ^= text.charCodeAt(i)
|
||||||
|
h = Math.imul(h, 0x01000193)
|
||||||
|
}
|
||||||
|
return (h >>> 0).toString(16).padStart(8, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sigOf = (version: number): string => fnv(`${SALT}:${String(version)}`)
|
||||||
|
|
||||||
|
/** 给一份配置打上标记。已有的旧标记会被换成当前版本的。 */
|
||||||
|
export function withMarker(data: ConfigObject): ConfigObject {
|
||||||
|
return { ...data, [MARKER_KEY]: { v: VERSION, sig: sigOf(VERSION) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这份配置带不带有效的 Patchbay 标记。 */
|
||||||
|
export function hasValidMarker(data: ConfigObject): boolean {
|
||||||
|
const marker = data[MARKER_KEY]
|
||||||
|
if (typeof marker !== 'object' || marker === null || Array.isArray(marker)) return false
|
||||||
|
const { v, sig } = marker as Record<string, unknown>
|
||||||
|
return typeof v === 'number' && sig === sigOf(v)
|
||||||
|
}
|
||||||
+22
-1
@@ -17,6 +17,7 @@ import SettingsPage from './views/SettingsPage.vue'
|
|||||||
import TokenGate from './views/TokenGate.vue'
|
import TokenGate from './views/TokenGate.vue'
|
||||||
import IconFold from '~icons/ri/sidebar-fold-line'
|
import IconFold from '~icons/ri/sidebar-fold-line'
|
||||||
import IconUnfold from '~icons/ri/sidebar-unfold-line'
|
import IconUnfold from '~icons/ri/sidebar-unfold-line'
|
||||||
|
import IconLogout from '~icons/ri/logout-box-r-line'
|
||||||
|
|
||||||
const snapshot = ref<ConfigSnapshot | null>(null)
|
const snapshot = ref<ConfigSnapshot | null>(null)
|
||||||
const needsToken = ref(!getToken())
|
const needsToken = ref(!getToken())
|
||||||
@@ -70,6 +71,11 @@ function submitToken(value: string) {
|
|||||||
void load()
|
void load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
clearToken()
|
||||||
|
location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!needsToken.value) void load()
|
if (!needsToken.value) void load()
|
||||||
// 流量、每路吞吐和日志的历史跨页面留存,所以收集挂在外壳上而不是各页里。
|
// 流量、每路吞吐和日志的历史跨页面留存,所以收集挂在外壳上而不是各页里。
|
||||||
@@ -126,6 +132,11 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<button type="button" class="nav-item off" title="退出登录" @click="logout">
|
||||||
|
<IconLogout class="nav-icon" />
|
||||||
|
<span class="nav-label">退出登录</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="side-foot">
|
<div class="side-foot">
|
||||||
<div class="kernel" :title="kernel.online ? kernel.version : '内核未连接'">
|
<div class="kernel" :title="kernel.online ? kernel.version : '内核未连接'">
|
||||||
<span :class="['dot', kernel.online ? 'dot-ok' : 'dot-off']" aria-hidden="true"></span>
|
<span :class="['dot', kernel.online ? 'dot-ok' : 'dot-off']" aria-hidden="true"></span>
|
||||||
@@ -195,7 +206,12 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
|||||||
|
|
||||||
<ConnectionsPage v-else-if="active === 'connections'" />
|
<ConnectionsPage v-else-if="active === 'connections'" />
|
||||||
|
|
||||||
<SettingsPage v-else-if="active === 'settings'" @saved="load" />
|
<SettingsPage
|
||||||
|
v-else-if="active === 'settings'"
|
||||||
|
:data="snapshot.data"
|
||||||
|
:revision="snapshot.revision"
|
||||||
|
@saved="load"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-else class="note">
|
<div v-else class="note">
|
||||||
<component :is="current?.icon" class="note-icon" />
|
<component :is="current?.icon" class="note-icon" />
|
||||||
@@ -358,6 +374,11 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
|||||||
height: 18px;
|
height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 退出登录:形态同导航项,但不参与「当前页」的高亮,收拢时同样缩成方块 */
|
||||||
|
.off {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
.side-foot {
|
.side-foot {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+30
-4
@@ -7,12 +7,10 @@
|
|||||||
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 { DEFAULT_TEST_URL, testUrl } from './prefs'
|
||||||
|
|
||||||
const TOKEN_KEY = 'patchbay.token'
|
const TOKEN_KEY = 'patchbay.token'
|
||||||
|
|
||||||
/** 测延迟用的目标。204 空响应,体积最小,全球都有部署。 */
|
|
||||||
const TEST_URL = 'https://www.gstatic.com/generate_204'
|
|
||||||
|
|
||||||
export function getToken(): string {
|
export function getToken(): string {
|
||||||
return localStorage.getItem(TOKEN_KEY) ?? ''
|
return localStorage.getItem(TOKEN_KEY) ?? ''
|
||||||
}
|
}
|
||||||
@@ -144,6 +142,26 @@ export const api = {
|
|||||||
|
|
||||||
listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'),
|
listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'),
|
||||||
|
|
||||||
|
/** 配置文件的原文,给「下载当前配置」用。 */
|
||||||
|
configRaw: async (): Promise<string> => {
|
||||||
|
const res = await fetch('/api/config/raw', {
|
||||||
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
const payload = text ? (JSON.parse(text) as Record<string, unknown>) : {}
|
||||||
|
throw new ApiError(res.status, payload)
|
||||||
|
}
|
||||||
|
return res.text()
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用上传的 YAML 原文整份替换当前配置。走服务端完整的保护链路
|
||||||
|
* (校验、备份、重载、失败回退),没有 baseRevision——整份替换本来就是覆盖语义。
|
||||||
|
*/
|
||||||
|
importConfig: (content: string) =>
|
||||||
|
request<ApplySuccess>('POST', '/api/config/import', { content }),
|
||||||
|
|
||||||
restoreBackup: (name: string) =>
|
restoreBackup: (name: string) =>
|
||||||
request<ApplySuccess>('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`),
|
request<ApplySuccess>('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`),
|
||||||
|
|
||||||
@@ -170,6 +188,12 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
restartKernel: () => request<{ ok: true }>('POST', '/api/kernel/restart'),
|
restartKernel: () => request<{ ok: true }>('POST', '/api/kernel/restart'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 让内核更新 GEO 数据库(GEOIP、GEOSITE、MMDB、ASN)。
|
||||||
|
* 同步接口,下载完才回话,可能要等上几十秒。
|
||||||
|
*/
|
||||||
|
updateGeo: () => request<{ ok: true }>('POST', '/api/kernel/geo'),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。
|
* 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。
|
||||||
*
|
*
|
||||||
@@ -222,7 +246,9 @@ export const api = {
|
|||||||
* 订阅线路不在表里(那正是它们无法被入站直接引用的原因),只能从所属订阅进去测。
|
* 订阅线路不在表里(那正是它们无法被入站直接引用的原因),只能从所属订阅进去测。
|
||||||
*/
|
*/
|
||||||
testLine: (name: string, provider?: string, timeout = 5000) => {
|
testLine: (name: string, provider?: string, timeout = 5000) => {
|
||||||
const q = `url=${encodeURIComponent(TEST_URL)}&timeout=${String(timeout)}`
|
// 测速地址是设置页里的偏好,空值退回默认
|
||||||
|
const target = testUrl.value.trim() || DEFAULT_TEST_URL
|
||||||
|
const q = `url=${encodeURIComponent(target)}&timeout=${String(timeout)}`
|
||||||
const path = provider
|
const path = provider
|
||||||
? `/api/kernel/providers/proxies/${encodeURIComponent(provider)}/${encodeURIComponent(name)}/healthcheck?${q}`
|
? `/api/kernel/providers/proxies/${encodeURIComponent(provider)}/${encodeURIComponent(name)}/healthcheck?${q}`
|
||||||
: `/api/kernel/proxies/${encodeURIComponent(name)}/delay?${q}`
|
: `/api/kernel/proxies/${encodeURIComponent(name)}/delay?${q}`
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* 免责声明的文案。设置页的子页和登录页的弹层共用同一份,不出现两个版本。
|
||||||
|
*/
|
||||||
|
export const DISCLAIMER_TITLE = '免责声明'
|
||||||
|
|
||||||
|
export const DISCLAIMER = [
|
||||||
|
'Patchbay 是编辑 mihomo 配置文件的本地网页工具,按现状提供,不提供任何形式的担保。因配置错误、软件缺陷或网络中断造成的服务不可用、数据丢失,作者不承担责任;重要改动前请自行留存配置文件副本。',
|
||||||
|
'代理技术本身是中性的。使用者应遵守所在国家和地区的法律法规,因使用本工具产生的一切后果由使用者自行承担。',
|
||||||
|
'配置文件与备份中可能包含密钥、订阅地址等敏感信息,访问令牌等同于密码,请妥善保管,不要在公开场合分享。',
|
||||||
|
'继续使用 Patchbay 即视为已阅读并同意以上条款。',
|
||||||
|
]
|
||||||
+24
-5
@@ -3,32 +3,50 @@
|
|||||||
*
|
*
|
||||||
* 密度:工具栏和导航是只显示图标,还是图标加文字。
|
* 密度:工具栏和导航是只显示图标,还是图标加文字。
|
||||||
* 默认是「完整」——这个工具是隔一阵子才动一次的,纯图标对偶尔来一次的人不友好;
|
* 默认是「完整」——这个工具是隔一阵子才动一次的,纯图标对偶尔来一次的人不友好;
|
||||||
* 想要紧凑的人自己去设置里切。
|
* 想收拢的人用侧栏底部的按钮切。
|
||||||
*
|
*
|
||||||
* 主题:跟随系统、浅色、深色。默认跟随系统。
|
* 主题:跟随系统、浅色、深色。默认跟随系统。
|
||||||
|
*
|
||||||
|
* localStorage 和 document 都加了存在性守卫:api.ts 引用这里(测速地址),
|
||||||
|
* 而 api.ts 被 bun test 环境里跑的模块间接引用——那里没有浏览器全局,
|
||||||
|
* 模块顶层直接碰它们会把整个测试文件炸掉。
|
||||||
*/
|
*/
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const storage = typeof localStorage === 'undefined' ? null : localStorage
|
||||||
|
|
||||||
export type Density = 'full' | 'compact'
|
export type Density = 'full' | 'compact'
|
||||||
|
|
||||||
const KEY = 'patchbay.density'
|
const KEY = 'patchbay.density'
|
||||||
|
|
||||||
const stored = localStorage.getItem(KEY)
|
const stored = storage?.getItem(KEY)
|
||||||
export const density = ref<Density>(stored === 'compact' ? 'compact' : 'full')
|
export const density = ref<Density>(stored === 'compact' ? 'compact' : 'full')
|
||||||
|
|
||||||
watch(density, (value) => {
|
watch(density, (value) => {
|
||||||
localStorage.setItem(KEY, value)
|
storage?.setItem(KEY, value)
|
||||||
})
|
})
|
||||||
|
|
||||||
export function toggleDensity(): void {
|
export function toggleDensity(): void {
|
||||||
density.value = density.value === 'full' ? 'compact' : 'full'
|
density.value = density.value === 'full' ? 'compact' : 'full'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 测延迟用的目标地址。204 空响应的端点最合适,默认值全球都有部署。 */
|
||||||
|
export const DEFAULT_TEST_URL = 'https://www.gstatic.com/generate_204'
|
||||||
|
|
||||||
|
const TEST_URL_KEY = 'patchbay.test-url'
|
||||||
|
|
||||||
|
export const testUrl = ref(storage?.getItem(TEST_URL_KEY) || DEFAULT_TEST_URL)
|
||||||
|
|
||||||
|
watch(testUrl, (value) => {
|
||||||
|
if (value.trim() === '' || value === DEFAULT_TEST_URL) storage?.removeItem(TEST_URL_KEY)
|
||||||
|
else storage?.setItem(TEST_URL_KEY, value.trim())
|
||||||
|
})
|
||||||
|
|
||||||
export type Theme = 'system' | 'light' | 'dark'
|
export type Theme = 'system' | 'light' | 'dark'
|
||||||
|
|
||||||
const THEME_KEY = 'patchbay.theme'
|
const THEME_KEY = 'patchbay.theme'
|
||||||
|
|
||||||
const storedTheme = localStorage.getItem(THEME_KEY)
|
const storedTheme = storage?.getItem(THEME_KEY)
|
||||||
export const theme = ref<Theme>(
|
export const theme = ref<Theme>(
|
||||||
storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : 'system',
|
storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : 'system',
|
||||||
)
|
)
|
||||||
@@ -39,7 +57,8 @@ export const theme = ref<Theme>(
|
|||||||
watch(
|
watch(
|
||||||
theme,
|
theme,
|
||||||
(value) => {
|
(value) => {
|
||||||
localStorage.setItem(THEME_KEY, value)
|
storage?.setItem(THEME_KEY, value)
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
if (value === 'system') document.documentElement.removeAttribute('data-theme')
|
if (value === 'system') document.documentElement.removeAttribute('data-theme')
|
||||||
else document.documentElement.setAttribute('data-theme', value)
|
else document.documentElement.setAttribute('data-theme', value)
|
||||||
},
|
},
|
||||||
|
|||||||
+429
-132
@@ -1,27 +1,37 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 设置页:Patchbay 自己的设置,不含 mihomo 的任何设置——那些属于配置页。
|
* 设置页。分两类,反馈方式不同:
|
||||||
|
* - 本地偏好(主题、测速地址)点了就生效,没有等待
|
||||||
|
* - 动配置文件的(内核参数、上传替换)走服务端完整的保护链路,
|
||||||
|
* 有校验、备份、重载和失败回退,反馈方式照路由页的 commit()
|
||||||
*
|
*
|
||||||
* 四组里只有备份还原会等、会失败:还原走服务端完整的保护链路(先备份当前文件,
|
* 备份列表不再摆出来:自动备份是落盘链路的安全网,不是日常操作对象。
|
||||||
* 再写入、重载内核,失败自动回退),反馈方式照路由页的 commit()。其余三组是本地
|
* 「配置文件」一组提供下载和上传替换;上传时用配置里的 Patchbay 标记
|
||||||
* 偏好或只读信息,点了就生效,没有等待。
|
* 区分「还原自己导出的」和「导入外来的」,后者要多确认一步。
|
||||||
*/
|
*/
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { backupTime } from '@shared/backups.ts'
|
import { parse } from 'yaml'
|
||||||
import { ApiError, api, clearToken } from '../api'
|
import { hasValidMarker } from '@shared/config-marker.ts'
|
||||||
import { density, theme } from '../prefs'
|
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||||
import type { Density, Theme } from '../prefs'
|
import { ApiError, api } from '../api'
|
||||||
|
import { DEFAULT_TEST_URL, testUrl, theme } from '../prefs'
|
||||||
|
import type { Theme } from '../prefs'
|
||||||
import { kernel } from '../runtime'
|
import { kernel } from '../runtime'
|
||||||
|
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 IconCloseTip from '~icons/ri/close-line'
|
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'
|
||||||
|
|
||||||
/** 还原备份改的是配置文件,外壳需要重读整份配置(同路由页的 saved)。 */
|
const props = defineProps<{ data: ConfigObject; revision: string }>()
|
||||||
|
|
||||||
|
/** 动了配置文件,外壳需要重读整份配置。 */
|
||||||
const emit = defineEmits<{ saved: [] }>()
|
const emit = defineEmits<{ saved: [] }>()
|
||||||
|
|
||||||
const densityOptions: { value: Density; label: string }[] = [
|
/* ── 外观与测速 ───────────────────────────────── */
|
||||||
{ value: 'full', label: '完整' },
|
|
||||||
{ value: 'compact', label: '紧凑' },
|
|
||||||
]
|
|
||||||
const themeOptions: { value: Theme; label: string }[] = [
|
const themeOptions: { value: Theme; label: string }[] = [
|
||||||
{ value: 'system', label: '跟随系统' },
|
{ value: 'system', label: '跟随系统' },
|
||||||
{ value: 'light', label: '浅色' },
|
{ value: 'light', label: '浅色' },
|
||||||
@@ -29,83 +39,226 @@ const themeOptions: { value: Theme; label: string }[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
// 模板里直接给 import 进来的 ref 赋值编译结果不可靠,走显式的赋值函数
|
// 模板里直接给 import 进来的 ref 赋值编译结果不可靠,走显式的赋值函数
|
||||||
function pickDensity(value: Density) {
|
|
||||||
density.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickTheme(value: Theme) {
|
function pickTheme(value: Theme) {
|
||||||
theme.value = value
|
theme.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 接口请求都发往当前源(开发时由 vite 转发给后端),这就是后端地址。 */
|
/* ── 状态与反馈 ───────────────────────────────── */
|
||||||
const backend = location.origin
|
|
||||||
|
|
||||||
function logout() {
|
const saving = ref(false)
|
||||||
clearToken()
|
|
||||||
location.reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
const backups = ref<string[] | null>(null)
|
|
||||||
const restoring = 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('')
|
||||||
/** 待确认还原的那份备份。非 null 时确认对话框打开着 */
|
|
||||||
const asking = ref<string | null>(null)
|
|
||||||
|
|
||||||
async function loadBackups() {
|
/** 走配置文件保护链路的动作共用的收尾。 */
|
||||||
try {
|
function settle(result: { reloaded: boolean; reloadMessage: string }, success: string) {
|
||||||
backups.value = (await api.listBackups()).backups
|
done.value = result.reloaded ? success : `${success}(${result.reloadMessage})`
|
||||||
} catch (error) {
|
emit('saved')
|
||||||
failure.value = { message: error instanceof ApiError ? error.message : String(error), hint: '' }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadBackups)
|
function fail(error: unknown) {
|
||||||
|
|
||||||
async function restoreConfirmed() {
|
|
||||||
const name = asking.value
|
|
||||||
asking.value = null
|
|
||||||
if (!name || restoring.value) return
|
|
||||||
restoring.value = true
|
|
||||||
failure.value = null
|
|
||||||
done.value = ''
|
|
||||||
try {
|
|
||||||
const result = await api.restoreBackup(name)
|
|
||||||
const time = backupTime(name) ?? name
|
|
||||||
// 内核不在线时只写了文件没重载,如实说,不能只报成功(同路由页 commit())
|
|
||||||
done.value = result.reloaded ? `已还原到 ${time}` : `已还原到 ${time}(${result.reloadMessage})`
|
|
||||||
emit('saved')
|
|
||||||
// 还原本身也走写入链路,会留下一份新备份,列表要重拉
|
|
||||||
await loadBackups()
|
|
||||||
} catch (error) {
|
|
||||||
failure.value =
|
failure.value =
|
||||||
error instanceof ApiError
|
error instanceof ApiError
|
||||||
? { message: error.message, hint: error.hint }
|
? { message: error.message, hint: error.hint }
|
||||||
: { message: String(error), hint: '' }
|
: { message: String(error), hint: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 内核参数 ─────────────────────────────────── */
|
||||||
|
|
||||||
|
/** 老式顶层端口,键名用配置原词。空表示没启用(配置里不写这个键)。 */
|
||||||
|
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
|
||||||
|
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), '内核参数已保存')
|
||||||
|
} catch (error) {
|
||||||
|
fail(error)
|
||||||
} finally {
|
} finally {
|
||||||
restoring.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 资源 ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
const geoBusy = ref(false)
|
||||||
|
|
||||||
|
/** GEO 更新是运行时动作,不写配置文件。同步接口,下载完才回话。 */
|
||||||
|
async function updateGeo() {
|
||||||
|
if (geoBusy.value || !kernel.value.online) return
|
||||||
|
geoBusy.value = true
|
||||||
|
failure.value = null
|
||||||
|
done.value = ''
|
||||||
|
try {
|
||||||
|
await api.updateGeo()
|
||||||
|
done.value = 'GEO 数据库已更新'
|
||||||
|
} catch (error) {
|
||||||
|
fail(error)
|
||||||
|
} finally {
|
||||||
|
geoBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 配置文件 ─────────────────────────────────── */
|
||||||
|
|
||||||
|
async function downloadConfig() {
|
||||||
|
failure.value = null
|
||||||
|
try {
|
||||||
|
const text = await api.configRaw()
|
||||||
|
const url = URL.createObjectURL(new Blob([text], { type: 'text/yaml' }))
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = 'config.yaml'
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch (error) {
|
||||||
|
fail(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 待确认的上传。foreign 表示没有 Patchbay 标记,要用更重的措辞确认 */
|
||||||
|
const pendingImport = ref<{ content: string; foreign: boolean } | null>(null)
|
||||||
|
|
||||||
|
function onUpload(_name: string, content: string) {
|
||||||
|
failure.value = null
|
||||||
|
done.value = ''
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = parse(content)
|
||||||
|
} catch (error) {
|
||||||
|
failure.value = { message: `这份文件不是合法的 YAML:${String(error)}`, hint: '' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||||
|
failure.value = { message: '这份文件不是一份 mihomo 配置。', hint: '' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingImport.value = { content, foreign: !hasValidMarker(parsed as ConfigObject) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importConfirmed() {
|
||||||
|
const pending = pendingImport.value
|
||||||
|
pendingImport.value = null
|
||||||
|
if (!pending || saving.value) return
|
||||||
|
saving.value = true
|
||||||
|
failure.value = null
|
||||||
|
done.value = ''
|
||||||
|
try {
|
||||||
|
settle(await api.importConfig(pending.content), '已替换为上传的配置')
|
||||||
|
} catch (error) {
|
||||||
|
fail(error)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 子页 ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
const sub = ref<null | 'disclaimer' | 'about'>(null)
|
||||||
|
|
||||||
|
/** 接口请求都发往当前源(开发时由 vite 转发给后端),这就是后端地址。 */
|
||||||
|
const backend = location.origin
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<!-- 子页:左上角「< 设置」返回,标题加内容 -->
|
||||||
<section class="group">
|
<div v-if="sub" class="page">
|
||||||
<h2 class="title">外观</h2>
|
<button type="button" class="back" @click="sub = null"><IconBack /> 设置</button>
|
||||||
|
|
||||||
|
<template v-if="sub === 'disclaimer'">
|
||||||
|
<h1 class="sub-title">{{ DISCLAIMER_TITLE }}</h1>
|
||||||
|
<p v-for="(para, i) in DISCLAIMER" :key="i" class="para">{{ para }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<h1 class="sub-title">关于</h1>
|
||||||
|
<p class="para">编辑 mihomo 配置文件的网页界面:把每一路入站接到它自己的出站上。</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="key">密度</span>
|
<span class="key">适配内核</span>
|
||||||
<div class="seg">
|
<span class="mono">mihomo v1.19.29</span>
|
||||||
<button
|
</div>
|
||||||
v-for="o in densityOptions"
|
<div class="row">
|
||||||
:key="o.value"
|
<span class="key">当前内核</span>
|
||||||
type="button"
|
<span class="mono">{{ kernel.online && kernel.version ? kernel.version : '内核未连接' }}</span>
|
||||||
:class="['seg-item', density === o.value && 'is-on']"
|
</div>
|
||||||
@click="pickDensity(o.value)"
|
<div class="row">
|
||||||
>
|
<span class="key">后端地址</span>
|
||||||
{{ o.label }}
|
<span class="mono">{{ backend }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="page">
|
||||||
|
<div v-if="failure" class="failure">
|
||||||
|
<div class="failure-text">
|
||||||
|
<strong>{{ failure.message }}</strong>
|
||||||
|
<span v-if="failure.hint">{{ failure.hint }}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="failure-x" title="关闭提示" @click="failure = null">
|
||||||
|
<IconCloseTip />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p v-else-if="done" class="done">{{ done }}</p>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<h2 class="title">外观</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="key">主题</span>
|
<span class="key">主题</span>
|
||||||
<div class="seg">
|
<div class="seg">
|
||||||
@@ -122,6 +275,87 @@ async function restoreConfirmed() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<h2 class="title">测速</h2>
|
||||||
|
<div class="row">
|
||||||
|
<span class="key">测速地址</span>
|
||||||
|
<input
|
||||||
|
v-model="testUrl"
|
||||||
|
class="input mono"
|
||||||
|
type="text"
|
||||||
|
:placeholder="DEFAULT_TEST_URL"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<h2 class="title">内核参数</h2>
|
||||||
|
<div class="ports">
|
||||||
|
<label v-for="key in PORT_KEYS" :key="key" class="pfield">
|
||||||
|
<span class="pk mono">{{ key }}</span>
|
||||||
|
<input
|
||||||
|
v-model="ports[key]"
|
||||||
|
:class="['input mono', portInvalid(ports[key] ?? '') && 'is-bad']"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="未启用"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="key">TCP 保持活动间隔</span>
|
||||||
|
<span class="with-unit">
|
||||||
|
<input
|
||||||
|
v-model="keepAlive"
|
||||||
|
:class="['input mono', keepAliveInvalid && 'is-bad']"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="内核默认"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<span class="unit">秒</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn"
|
||||||
|
:disabled="saving || kernelParamsInvalid || !kernelParamsDirty"
|
||||||
|
@click="saveKernelParams"
|
||||||
|
>
|
||||||
|
{{ saving ? '保存中' : '保存内核参数' }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<h2 class="title">资源</h2>
|
||||||
|
<div class="row">
|
||||||
|
<span class="key">GEO 数据库(GEOIP、GEOSITE、MMDB、ASN)</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn"
|
||||||
|
:disabled="geoBusy || !kernel.online"
|
||||||
|
:title="kernel.online ? undefined : '内核未连接'"
|
||||||
|
@click="updateGeo"
|
||||||
|
>
|
||||||
|
{{ geoBusy ? '更新中' : '更新 GEO 数据库' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<h2 class="title">配置文件</h2>
|
||||||
|
<div class="row">
|
||||||
|
<span class="key">当前配置</span>
|
||||||
|
<button type="button" class="btn with-ico" @click="downloadConfig">
|
||||||
|
<IconDownload /> 下载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DropFile accept=".yaml,.yml" @load="onUpload" />
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="group">
|
<section class="group">
|
||||||
<h2 class="title">连接</h2>
|
<h2 class="title">连接</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -132,60 +366,39 @@ async function restoreConfirmed() {
|
|||||||
<span class="key">内核状态</span>
|
<span class="key">内核状态</span>
|
||||||
<span>{{ kernel.online ? '已连接' : '内核未连接' }}</span>
|
<span>{{ kernel.online ? '已连接' : '内核未连接' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn logout" @click="logout">退出登录</button>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="group">
|
<section class="group">
|
||||||
<h2 class="title">关于</h2>
|
<h2 class="title">其他</h2>
|
||||||
<p class="about">编辑 mihomo 配置文件的网页界面:把每一路入站接到它自己的出站上。</p>
|
<button type="button" class="more" @click="sub = 'disclaimer'">
|
||||||
<div class="row">
|
<span>{{ DISCLAIMER_TITLE }}</span>
|
||||||
<span class="key">内核版本</span>
|
<IconRight aria-hidden="true" />
|
||||||
<span class="mono">{{ kernel.online && kernel.version ? kernel.version : '内核未连接' }}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="group">
|
|
||||||
<h2 class="title">备份还原</h2>
|
|
||||||
|
|
||||||
<div v-if="failure" class="failure">
|
|
||||||
<div class="failure-text">
|
|
||||||
<strong>{{ failure.message }}</strong>
|
|
||||||
<span v-if="failure.hint">{{ failure.hint }}</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="failure-x" title="关闭提示" @click="failure = null">
|
|
||||||
<IconCloseTip />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button type="button" class="more" @click="sub = 'about'">
|
||||||
<p v-else-if="done" class="done">{{ done }}</p>
|
<span>关于</span>
|
||||||
|
<IconRight aria-hidden="true" />
|
||||||
<p v-if="backups === null" class="faint">载入中</p>
|
|
||||||
<p v-else-if="backups.length === 0" class="faint">没有备份。</p>
|
|
||||||
<ul v-else class="backups">
|
|
||||||
<li v-for="name in backups" :key="name" class="backup" :title="name">
|
|
||||||
<span class="mono">{{ backupTime(name) ?? name }}</span>
|
|
||||||
<button type="button" class="btn" :disabled="restoring" @click="asking = name">
|
|
||||||
还原
|
|
||||||
</button>
|
</button>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="asking !== null"
|
:open="pendingImport !== null"
|
||||||
:message="
|
:message="
|
||||||
asking ? `把配置还原到 ${backupTime(asking) ?? asking}?当前配置会先自动备份。` : ''
|
pendingImport?.foreign
|
||||||
|
? '这份文件没有 Patchbay 标记,可能不是 Patchbay 管理过的配置。仍然导入会整份替换当前配置(当前配置会先自动备份),之后 Patchbay 开始接管其中的段落。'
|
||||||
|
: '用上传的文件整份替换当前配置?当前配置会先自动备份。'
|
||||||
"
|
"
|
||||||
action="还原"
|
:action="pendingImport?.foreign ? '仍然导入' : '替换'"
|
||||||
@close="asking = null"
|
@close="pendingImport = null"
|
||||||
@confirm="restoreConfirmed"
|
@confirm="importConfirmed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 内容少的页面收窄成一列,段落之间用分割线而不是卡片——一屏最多两层容器 */
|
/* 内容少的页面收窄居中,段落之间用分割线而不是卡片——一屏最多两层容器 */
|
||||||
.page {
|
.page {
|
||||||
max-width: 560px;
|
max-width: 640px;
|
||||||
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group {
|
.group {
|
||||||
@@ -193,7 +406,7 @@ async function restoreConfirmed() {
|
|||||||
border-top: 1px solid var(--c-border);
|
border-top: 1px solid var(--c-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.group:first-child {
|
.group:first-of-type {
|
||||||
border-top: none;
|
border-top: none;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
@@ -217,7 +430,6 @@ async function restoreConfirmed() {
|
|||||||
font-size: var(--t-sm);
|
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);
|
||||||
@@ -248,6 +460,58 @@ async function restoreConfirmed() {
|
|||||||
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);
|
||||||
@@ -264,19 +528,70 @@ async function restoreConfirmed() {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout {
|
.with-ico {
|
||||||
margin-top: var(--s-2);
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.about {
|
/* 「其他」里的整行入口:左标题右箭头,整行点击 */
|
||||||
margin-bottom: var(--s-2);
|
.more {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s-3) var(--s-2);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: none;
|
||||||
|
font-size: var(--t-base);
|
||||||
|
color: var(--c-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.more:hover {
|
||||||
|
background: var(--c-surface-sunken);
|
||||||
|
}
|
||||||
|
.more + .more {
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faint {
|
/* ── 子页 ────────────────────────────────────── */
|
||||||
color: var(--c-text-faint);
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1);
|
||||||
|
padding: var(--s-1) var(--s-2);
|
||||||
|
margin-bottom: var(--s-4);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--c-text-soft);
|
||||||
font-size: var(--t-sm);
|
font-size: var(--t-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.back:hover {
|
||||||
|
background: var(--c-surface-sunken);
|
||||||
|
color: var(--c-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sub-title {
|
||||||
|
font-size: var(--t-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--s-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.para {
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
line-height: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.para + .para {
|
||||||
|
margin-top: var(--s-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 反馈 ────────────────────────────────────── */
|
||||||
.failure {
|
.failure {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -319,22 +634,4 @@ async function restoreConfirmed() {
|
|||||||
font-size: var(--t-sm);
|
font-size: var(--t-sm);
|
||||||
margin-bottom: var(--s-3);
|
margin-bottom: var(--s-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.backups {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--s-4);
|
|
||||||
padding: var(--s-2) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup + .backup {
|
|
||||||
border-top: 1px solid var(--c-border);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+106
-1
@@ -1,8 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
import { DISCLAIMER, DISCLAIMER_TITLE } from '../legal'
|
||||||
|
import IconClose from '~icons/ri/close-line'
|
||||||
|
|
||||||
const emit = defineEmits<{ submit: [token: string] }>()
|
const emit = defineEmits<{ submit: [token: string] }>()
|
||||||
const value = ref('')
|
const value = ref('')
|
||||||
|
|
||||||
|
const legalOpen = ref(false)
|
||||||
|
const legalDialog = ref<HTMLDialogElement | null>(null)
|
||||||
|
|
||||||
|
watch(legalOpen, (open) => {
|
||||||
|
if (open) legalDialog.value?.showModal()
|
||||||
|
else legalDialog.value?.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 点在遮罩上就关闭。内容区的点击 target 是里面的元素,到不了 dialog 本身。 */
|
||||||
|
function onBackdrop(event: MouseEvent) {
|
||||||
|
if (event.target === legalDialog.value) legalOpen.value = false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -23,7 +38,26 @@ const value = ref('')
|
|||||||
<p class="where">
|
<p class="where">
|
||||||
令牌位于服务端配置文件的同级目录 <code>.patchbay-token</code>,启动时也会输出到控制台。
|
令牌位于服务端配置文件的同级目录 <code>.patchbay-token</code>,启动时也会输出到控制台。
|
||||||
</p>
|
</p>
|
||||||
|
<button type="button" class="legal" @click="legalOpen = true">
|
||||||
|
使用 Patchbay 即视为同意《免责声明》
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
ref="legalDialog"
|
||||||
|
class="dlg"
|
||||||
|
@click="onBackdrop"
|
||||||
|
@close="legalOpen = false"
|
||||||
|
@cancel.prevent="legalOpen = false"
|
||||||
|
>
|
||||||
|
<div class="dlg-body">
|
||||||
|
<header class="dlg-head">
|
||||||
|
<h2>{{ DISCLAIMER_TITLE }}</h2>
|
||||||
|
<button type="button" class="x" @click="legalOpen = false"><IconClose /></button>
|
||||||
|
</header>
|
||||||
|
<p v-for="(para, i) in DISCLAIMER" :key="i" class="para">{{ para }}</p>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -85,4 +119,75 @@ h1 {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin-top: var(--s-2);
|
margin-top: var(--s-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.legal {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
.legal:hover {
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlg {
|
||||||
|
width: min(480px, calc(100vw - 2 * var(--s-5)));
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--c-border-strong);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
background: var(--c-surface);
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlg::backdrop {
|
||||||
|
background: rgb(0 0 0 / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlg-body {
|
||||||
|
padding: var(--s-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlg-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--s-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlg-head h2 {
|
||||||
|
font-size: var(--t-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.x {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.x:hover {
|
||||||
|
background: var(--c-surface-sunken);
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.para {
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
line-height: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.para + .para {
|
||||||
|
margin-top: var(--s-3);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user