配置每次落盘都打顶层 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。 退出登录挪到侧栏底部;密度设置从设置页撤下(侧栏按钮还在); 免责声明和关于做成子页(左上角返回),登录页输入框下加同一份 免责声明的弹层入口。
638 lines
18 KiB
Vue
638 lines
18 KiB
Vue
<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>
|