设置页重做:配置文件下载上传、内核参数、资源、免责声明

配置每次落盘都打顶层 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:
2026-08-12 01:01:33 +08:00
parent 013f537eaa
commit 8eb98e2e9a
12 changed files with 792 additions and 147 deletions
+11 -2
View File
@@ -82,8 +82,13 @@ describe('落盘保护链路(内核在线)', () => {
it('内容没变直接返回,不备份也不重载', async () => {
const env = makeEnv()
// 先落一次盘让文件带上 Patchbay 标记——没有标记的文件第一次保存必有
// 「补标记」这一处内容变化,短路只发生在稳态
const first = await readConfig(env)
await applyConfig(env, first.data, first.revision)
const { data, revision } = await readConfig(env)
const before = reloadCount
const backupsBefore = (await listBackups(env)).length
const result = await applyConfig(env, data, revision)
@@ -93,7 +98,7 @@ describe('落盘保护链路(内核在线)', () => {
expect(result.reloadMessage).toBe('内容没有变化')
}
expect(reloadCount).toBe(before)
expect(await listBackups(env)).toHaveLength(0)
expect((await listBackups(env)).length).toBe(backupsBefore)
})
it('自检拦下断引用:入站的 proxy 指向不存在的出站', async () => {
@@ -137,7 +142,11 @@ describe('落盘保护链路(内核在线)', () => {
const result = await restoreBackup(env, backup!)
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 () => {
+38 -1
View File
@@ -30,6 +30,7 @@ import {
} from '../vendor/config-editor/index.ts'
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
import { restoreInlineCommentAlignment } from '../shared/yaml-cosmetics.ts'
import { withMarker } from '../shared/config-marker.ts'
import { kernelVersion, validateConfigFile, reloadConfig } from './kernel.ts'
import type { Env } from './env.ts'
@@ -133,7 +134,10 @@ export async function applyConfig(
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) {
@@ -282,6 +286,39 @@ async function pruneBackups(env: Env): Promise<void> {
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> {
const dir = await backupDir(env)
+17
View File
@@ -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 请求。
*
+38 -2
View File
@@ -12,9 +12,16 @@
import { resolve, sep } from 'node:path'
import listenerFields from '../shared/listener-fields.json' with { type: 'json' }
import { isProviderFilePath } from '../shared/provider-files.ts'
import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
import { applyConfig, importConfig, listBackups, readConfig, restoreBackup } from './config-store.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 type { ConfigObject } from '../vendor/config-editor/index.ts'
import type { Env } from './env.ts'
@@ -176,6 +183,27 @@ function makeRoute(env: Env, token: string) {
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)
if (restoreMatch && req.method === 'POST') {
const name = decodeURIComponent(restoreMatch[1]!)
@@ -236,6 +264,14 @@ function makeRoute(env: Env, token: string) {
: 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') {
const upstream = await restartKernel(env)
+23
View File
@@ -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)
})
})
+44
View File
@@ -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
View File
@@ -17,6 +17,7 @@ import SettingsPage from './views/SettingsPage.vue'
import TokenGate from './views/TokenGate.vue'
import IconFold from '~icons/ri/sidebar-fold-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 needsToken = ref(!getToken())
@@ -70,6 +71,11 @@ function submitToken(value: string) {
void load()
}
function logout() {
clearToken()
location.reload()
}
onMounted(() => {
if (!needsToken.value) void load()
// 流量、每路吞吐和日志的历史跨页面留存,所以收集挂在外壳上而不是各页里。
@@ -126,6 +132,11 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
</li>
</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="kernel" :title="kernel.online ? kernel.version : '内核未连接'">
<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'" />
<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">
<component :is="current?.icon" class="note-icon" />
@@ -358,6 +374,11 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
height: 18px;
}
/* 退出登录:形态同导航项,但不参与「当前页」的高亮,收拢时同样缩成方块 */
.off {
color: var(--c-text-faint);
}
.side-foot {
display: flex;
align-items: center;
+30 -4
View File
@@ -7,12 +7,10 @@
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
import type { ConnectionSnapshot } from '@shared/connections.ts'
import type { ExitProbe, HostInfo } from '@shared/host-info.ts'
import { DEFAULT_TEST_URL, testUrl } from './prefs'
const TOKEN_KEY = 'patchbay.token'
/** 测延迟用的目标。204 空响应,体积最小,全球都有部署。 */
const TEST_URL = 'https://www.gstatic.com/generate_204'
export function getToken(): string {
return localStorage.getItem(TOKEN_KEY) ?? ''
}
@@ -144,6 +142,26 @@ export const api = {
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) =>
request<ApplySuccess>('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`),
@@ -170,6 +188,12 @@ export const api = {
*/
restartKernel: () => request<{ ok: true }>('POST', '/api/kernel/restart'),
/**
* 让内核更新 GEO 数据库(GEOIP、GEOSITE、MMDB、ASN)。
* 同步接口,下载完才回话,可能要等上几十秒。
*/
updateGeo: () => request<{ ok: true }>('POST', '/api/kernel/geo'),
/**
* 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。
*
@@ -222,7 +246,9 @@ export const api = {
* 订阅线路不在表里(那正是它们无法被入站直接引用的原因),只能从所属订阅进去测。
*/
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
? `/api/kernel/providers/proxies/${encodeURIComponent(provider)}/${encodeURIComponent(name)}/healthcheck?${q}`
: `/api/kernel/proxies/${encodeURIComponent(name)}/delay?${q}`
+11
View File
@@ -0,0 +1,11 @@
/**
* 免责声明的文案。设置页的子页和登录页的弹层共用同一份,不出现两个版本。
*/
export const DISCLAIMER_TITLE = '免责声明'
export const DISCLAIMER = [
'Patchbay 是编辑 mihomo 配置文件的本地网页工具,按现状提供,不提供任何形式的担保。因配置错误、软件缺陷或网络中断造成的服务不可用、数据丢失,作者不承担责任;重要改动前请自行留存配置文件副本。',
'代理技术本身是中性的。使用者应遵守所在国家和地区的法律法规,因使用本工具产生的一切后果由使用者自行承担。',
'配置文件与备份中可能包含密钥、订阅地址等敏感信息,访问令牌等同于密码,请妥善保管,不要在公开场合分享。',
'继续使用 Patchbay 即视为已阅读并同意以上条款。',
]
+24 -5
View File
@@ -3,32 +3,50 @@
*
* 密度:工具栏和导航是只显示图标,还是图标加文字。
* 默认是「完整」——这个工具是隔一阵子才动一次的,纯图标对偶尔来一次的人不友好;
* 想要紧凑的人自己去设置里切。
* 想收拢的人用侧栏底部的按钮切。
*
* 主题:跟随系统、浅色、深色。默认跟随系统。
*
* localStorage 和 document 都加了存在性守卫:api.ts 引用这里(测速地址),
* 而 api.ts 被 bun test 环境里跑的模块间接引用——那里没有浏览器全局,
* 模块顶层直接碰它们会把整个测试文件炸掉。
*/
import { ref, watch } from 'vue'
const storage = typeof localStorage === 'undefined' ? null : localStorage
export type Density = 'full' | 'compact'
const KEY = 'patchbay.density'
const stored = localStorage.getItem(KEY)
const stored = storage?.getItem(KEY)
export const density = ref<Density>(stored === 'compact' ? 'compact' : 'full')
watch(density, (value) => {
localStorage.setItem(KEY, value)
storage?.setItem(KEY, value)
})
export function toggleDensity(): void {
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'
const THEME_KEY = 'patchbay.theme'
const storedTheme = localStorage.getItem(THEME_KEY)
const storedTheme = storage?.getItem(THEME_KEY)
export const theme = ref<Theme>(
storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : 'system',
)
@@ -39,7 +57,8 @@ export const theme = ref<Theme>(
watch(
theme,
(value) => {
localStorage.setItem(THEME_KEY, value)
storage?.setItem(THEME_KEY, value)
if (typeof document === 'undefined') return
if (value === 'system') document.documentElement.removeAttribute('data-theme')
else document.documentElement.setAttribute('data-theme', value)
},
+430 -133
View File
@@ -1,27 +1,37 @@
<script setup lang="ts">
/**
* 设置页:Patchbay 自己的设置,不含 mihomo 的任何设置——那些属于配置页。
* 设置页。分两类,反馈方式不同:
* - 本地偏好(主题、测速地址)点了就生效,没有等待
* - 动配置文件的(内核参数、上传替换)走服务端完整的保护链路,
* 有校验、备份、重载和失败回退,反馈方式照路由页的 commit()
*
* 四组里只有备份还原会等、会失败:还原走服务端完整的保护链路(先备份当前文件,
* 再写入、重载内核,失败自动回退),反馈方式照路由页的 commit()。其余三组是本地
* 偏好或只读信息,点了就生效,没有等待。
* 备份列表不再摆出来:自动备份是落盘链路的安全网,不是日常操作对象。
* 「配置文件」一组提供下载和上传替换;上传时用配置里的 Patchbay 标记
* 区分「还原自己导出的」和「导入外来的」,后者要多确认一步。
*/
import { onMounted, ref } from 'vue'
import { backupTime } from '@shared/backups.ts'
import { ApiError, api, clearToken } from '../api'
import { density, theme } from '../prefs'
import type { Density, Theme } from '../prefs'
import { computed, ref, watch } from 'vue'
import { parse } from 'yaml'
import { hasValidMarker } from '@shared/config-marker.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import { ApiError, api } from '../api'
import { DEFAULT_TEST_URL, testUrl, theme } from '../prefs'
import type { Theme } from '../prefs'
import { kernel } from '../runtime'
import { DISCLAIMER, DISCLAIMER_TITLE } from '../legal'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import DropFile from '../components/DropFile.vue'
import 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 densityOptions: { value: Density; label: string }[] = [
{ value: 'full', label: '完整' },
{ value: 'compact', label: '紧凑' },
]
/* ── 外观与测速 ───────────────────────────────── */
const themeOptions: { value: Theme; label: string }[] = [
{ value: 'system', label: '跟随系统' },
{ value: 'light', label: '浅色' },
@@ -29,83 +39,226 @@ const themeOptions: { value: Theme; label: string }[] = [
]
// 模板里直接给 import 进来的 ref 赋值编译结果不可靠,走显式的赋值函数
function pickDensity(value: Density) {
density.value = value
}
function pickTheme(value: Theme) {
theme.value = value
}
/** 接口请求都发往当前源(开发时由 vite 转发给后端),这就是后端地址。 */
const backend = location.origin
/* ── 状态与反馈 ───────────────────────────────── */
function logout() {
clearToken()
location.reload()
}
const backups = ref<string[] | null>(null)
const restoring = ref(false)
const saving = ref(false)
const failure = ref<{ message: string; hint: string } | null>(null)
const done = ref('')
/** 待确认还原的那份备份。非 null 时确认对话框打开着 */
const asking = ref<string | null>(null)
async function loadBackups() {
try {
backups.value = (await api.listBackups()).backups
} catch (error) {
failure.value = { message: error instanceof ApiError ? error.message : String(error), hint: '' }
}
}
onMounted(loadBackups)
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})`
/** 走配置文件保护链路的动作共用的收尾。 */
function settle(result: { reloaded: boolean; reloadMessage: string }, success: string) {
done.value = result.reloaded ? success : `${success}(${result.reloadMessage})`
emit('saved')
// 还原本身也走写入链路,会留下一份新备份,列表要重拉
await loadBackups()
} catch (error) {
}
function fail(error: unknown) {
failure.value =
error instanceof ApiError
? { message: error.message, hint: 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 {
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>
<template>
<div class="page">
<section class="group">
<h2 class="title">外观</h2>
<!-- 子页:左上角「< 设置」返回,标题加内容 -->
<div v-if="sub" class="page">
<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">
<span class="key">密度</span>
<div class="seg">
<button
v-for="o in densityOptions"
:key="o.value"
type="button"
:class="['seg-item', density === o.value && 'is-on']"
@click="pickDensity(o.value)"
>
{{ o.label }}
<span class="key">适配内核</span>
<span class="mono">mihomo v1.19.29</span>
</div>
<div class="row">
<span class="key">当前内核</span>
<span class="mono">{{ kernel.online && kernel.version ? kernel.version : '内核未连接' }}</span>
</div>
<div class="row">
<span class="key">后端地址</span>
<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>
</div>
</div>
<p v-else-if="done" class="done">{{ done }}</p>
<section class="group">
<h2 class="title">外观</h2>
<div class="row">
<span class="key">主题</span>
<div class="seg">
@@ -122,6 +275,87 @@ async function restoreConfirmed() {
</div>
</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">
<h2 class="title">连接</h2>
<div class="row">
@@ -132,60 +366,39 @@ async function restoreConfirmed() {
<span class="key">内核状态</span>
<span>{{ kernel.online ? '已连接' : '内核未连接' }}</span>
</div>
<button type="button" class="btn logout" @click="logout">退出登录</button>
</section>
<section class="group">
<h2 class="title">关于</h2>
<p class="about">编辑 mihomo 配置文件的网页界面:把每一路入站接到它自己的出站上。</p>
<div class="row">
<span class="key">内核版本</span>
<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 />
<h2 class="title">其他</h2>
<button type="button" class="more" @click="sub = 'disclaimer'">
<span>{{ DISCLAIMER_TITLE }}</span>
<IconRight aria-hidden="true" />
</button>
</div>
<p v-else-if="done" class="done">{{ done }}</p>
<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 type="button" class="more" @click="sub = 'about'">
<span>关于</span>
<IconRight aria-hidden="true" />
</button>
</li>
</ul>
</section>
<ConfirmDialog
:open="asking !== null"
:open="pendingImport !== null"
:message="
asking ? `把配置还原到 ${backupTime(asking) ?? asking}?当前配置会先自动备份。` : ''
pendingImport?.foreign
? '这份文件没有 Patchbay 标记,可能不是 Patchbay 管理过的配置。仍然导入会整份替换当前配置(当前配置会先自动备份),之后 Patchbay 开始接管其中的段落。'
: '用上传的文件整份替换当前配置?当前配置会先自动备份。'
"
action="还原"
@close="asking = null"
@confirm="restoreConfirmed"
:action="pendingImport?.foreign ? '仍然导入' : '替换'"
@close="pendingImport = null"
@confirm="importConfirmed"
/>
</div>
</template>
<style scoped>
/* 内容少的页面收窄成一列,段落之间用分割线而不是卡片——一屏最多两层容器 */
/* 内容少的页面收窄居中,段落之间用分割线而不是卡片——一屏最多两层容器 */
.page {
max-width: 560px;
max-width: 640px;
margin-inline: auto;
}
.group {
@@ -193,7 +406,7 @@ async function restoreConfirmed() {
border-top: 1px solid var(--c-border);
}
.group:first-child {
.group:first-of-type {
border-top: none;
padding-top: 0;
}
@@ -217,7 +430,6 @@ async function restoreConfirmed() {
font-size: var(--t-sm);
}
/* 分段选择器:两三个互斥选项,当前项用强调色淡底(同导航的选中态) */
.seg {
display: flex;
border: 1px solid var(--c-border-strong);
@@ -248,6 +460,58 @@ async function restoreConfirmed() {
outline-offset: -2px;
}
.input {
width: 260px;
max-width: 100%;
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-sm);
}
.input.is-bad {
border-color: var(--c-danger);
}
.with-unit {
display: flex;
align-items: center;
gap: var(--s-2);
}
.with-unit .input {
width: 120px;
}
.unit {
color: var(--c-text-soft);
font-size: var(--t-sm);
}
/* 端口一组:键名原词在上,输入框在下,窄格子排开 */
.ports {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--s-3);
padding: var(--s-2) 0;
}
.pfield {
display: flex;
flex-direction: column;
gap: var(--s-1);
}
.pfield .input {
width: 100%;
}
.pk {
color: var(--c-text-faint);
font-size: var(--t-xs);
}
.btn {
padding: var(--s-2) var(--s-4);
border: 1px solid var(--c-border-strong);
@@ -264,19 +528,70 @@ async function restoreConfirmed() {
cursor: not-allowed;
}
.logout {
margin-top: var(--s-2);
.with-ico {
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);
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 {
display: flex;
align-items: flex-start;
@@ -319,22 +634,4 @@ async function restoreConfirmed() {
font-size: var(--t-sm);
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>
+106 -1
View File
@@ -1,8 +1,23 @@
<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 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>
<template>
@@ -23,7 +38,26 @@ const value = ref('')
<p class="where">
令牌位于服务端配置文件的同级目录 <code>.patchbay-token</code>,启动时也会输出到控制台。
</p>
<button type="button" class="legal" @click="legalOpen = true">
使用 Patchbay 即视为同意《免责声明》
</button>
</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>
</template>
@@ -85,4 +119,75 @@ h1 {
line-height: 1.6;
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>