Files
mihomo-patchbay/web/views/SettingsPage.vue
T
im0o 8eb98e2e9a 设置页重做:配置文件下载上传、内核参数、资源、免责声明
配置每次落盘都打顶层 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。

退出登录挪到侧栏底部;密度设置从设置页撤下(侧栏按钮还在);
免责声明和关于做成子页(左上角返回),登录页输入框下加同一份
免责声明的弹层入口。
2026-08-12 01:01:33 +08:00

638 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 设置页。分两类,反馈方式不同:
* - 本地偏好(主题、测速地址)点了就生效,没有等待
* - 动配置文件的(内核参数、上传替换)走服务端完整的保护链路,
* 有校验、备份、重载和失败回退,反馈方式照路由页的 commit()
*
* 备份列表不再摆出来:自动备份是落盘链路的安全网,不是日常操作对象。
* 「配置文件」一组提供下载和上传替换;上传时用配置里的 Patchbay 标记
* 区分「还原自己导出的」和「导入外来的」,后者要多确认一步。
*/
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'
const props = defineProps<{ data: ConfigObject; revision: string }>()
/** 动了配置文件,外壳需要重读整份配置。 */
const emit = defineEmits<{ saved: [] }>()
/* ── 外观与测速 ───────────────────────────────── */
const themeOptions: { value: Theme; label: string }[] = [
{ value: 'system', label: '跟随系统' },
{ value: 'light', label: '浅色' },
{ value: 'dark', label: '深色' },
]
// 模板里直接给 import 进来的 ref 赋值编译结果不可靠,走显式的赋值函数
function pickTheme(value: Theme) {
theme.value = value
}
/* ── 状态与反馈 ───────────────────────────────── */
const saving = ref(false)
const failure = ref<{ message: string; hint: string } | null>(null)
const done = ref('')
/** 走配置文件保护链路的动作共用的收尾。 */
function settle(result: { reloaded: boolean; reloadMessage: string }, success: string) {
done.value = result.reloaded ? success : `${success}(${result.reloadMessage})`
emit('saved')
}
function fail(error: unknown) {
failure.value =
error instanceof ApiError
? { 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 {
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 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>
<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>
<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">
<button
v-for="o in themeOptions"
:key="o.value"
type="button"
:class="['seg-item', theme === o.value && 'is-on']"
@click="pickTheme(o.value)"
>
{{ o.label }}
</button>
</div>
</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">
<span class="key">后端地址</span>
<span class="mono">{{ backend }}</span>
</div>
<div class="row">
<span class="key">内核状态</span>
<span>{{ kernel.online ? '已连接' : '内核未连接' }}</span>
</div>
</section>
<section class="group">
<h2 class="title">其他</h2>
<button type="button" class="more" @click="sub = 'disclaimer'">
<span>{{ DISCLAIMER_TITLE }}</span>
<IconRight aria-hidden="true" />
</button>
<button type="button" class="more" @click="sub = 'about'">
<span>关于</span>
<IconRight aria-hidden="true" />
</button>
</section>
<ConfirmDialog
:open="pendingImport !== null"
:message="
pendingImport?.foreign
? '这份文件没有 Patchbay 标记,可能不是 Patchbay 管理过的配置。仍然导入会整份替换当前配置(当前配置会先自动备份),之后 Patchbay 开始接管其中的段落。'
: '用上传的文件整份替换当前配置?当前配置会先自动备份。'
"
:action="pendingImport?.foreign ? '仍然导入' : '替换'"
@close="pendingImport = null"
@confirm="importConfirmed"
/>
</div>
</template>
<style scoped>
/* 内容少的页面收窄居中,段落之间用分割线而不是卡片——一屏最多两层容器 */
.page {
max-width: 640px;
margin-inline: auto;
}
.group {
padding: var(--s-5) 0;
border-top: 1px solid var(--c-border);
}
.group:first-of-type {
border-top: none;
padding-top: 0;
}
.title {
font-size: var(--t-lg);
font-weight: 600;
margin-bottom: var(--s-3);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-4);
padding: var(--s-2) 0;
}
.key {
color: var(--c-text-soft);
font-size: var(--t-sm);
}
.seg {
display: flex;
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
}
.seg-item {
padding: var(--s-1) var(--s-3);
border: none;
background: none;
color: var(--c-text-soft);
font-size: var(--t-sm);
cursor: pointer;
}
.seg-item + .seg-item {
border-left: 1px solid var(--c-border);
}
.seg-item.is-on {
background: var(--c-accent-soft);
color: var(--c-accent-text);
}
/* 焦点圈画在里面,外面的会被相邻边框和圆角裁掉 */
.seg-item:focus-visible {
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);
border-radius: var(--r-sm);
background: var(--c-surface);
cursor: pointer;
}
.btn:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.btn:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
.with-ico {
display: inline-flex;
align-items: center;
gap: 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;
}
/* ── 子页 ────────────────────────────────────── */
.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;
gap: var(--s-3);
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
border-radius: var(--r-md);
background: var(--c-danger-soft);
padding: var(--s-3) var(--s-4);
margin-bottom: var(--s-3);
font-size: var(--t-sm);
}
.failure-text {
display: flex;
flex-direction: column;
gap: var(--s-1);
flex: 1;
min-width: 0;
}
.failure-x {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-danger);
cursor: pointer;
}
.failure-x:hover {
background: color-mix(in srgb, var(--c-danger) 12%, transparent);
}
.done {
color: var(--c-ok);
font-size: var(--t-sm);
margin-bottom: var(--s-3);
}
</style>