Files
mihomo-patchbay/web/views/SettingsPage.vue
T
im0o f761d13579 反馈改右上角轻提示;侧栏加撤回重做等配置动作
设置页的横幅换成 toast(右滑入、几秒后上移淡出,web/toast.ts 单例):
动作结果看一眼就行,不值得在页面里占一块常驻位置。表单校验类的错误
仍留在各自表单里。

撤回栈就是备份链:撤回把最新备份应用回去并消费掉它,撤回前的状态进
重做栈(.patchbay-redo,同样留 10 份);重做反向;新的正常保存清空
重做栈,不留分叉历史。这两条路复用 applyConfig 的保护链路,用
restoreFrom 参数跳过自建备份——否则备份链会被撤回本身污染,撤两次
等于原地打转。侧栏左下角一排图标:撤回、重做、历史记录(备份列表
弹窗)、更新配置(POST /api/kernel/reload,给 SSH 手改后用)、重启
内核;收拢时容器变窄自动折成一列。

设置页顺手修三处:GEO 行的编辑按钮在输入框开着时再点会闪(blur 关了
click 又开),mousedown.prevent 保焦点、click 显式开关;上传按钮改
「选择或拖放文件」,投放区拖入高亮;恢复策略挪到恢复行下面。
2026-08-12 01:59:22 +08:00

821 lines
24 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">
/**
* 设置页。分两类,反馈方式不同:
* - 本地偏好(主题、测速地址)点了就生效,没有等待
* - 动配置文件的(内核参数、GEO 下载地址、上传恢复/导入)走服务端完整的
* 保护链路,有校验、备份、重载和失败回退,反馈方式照路由页的 commit()
*
* 单值编辑统一用 InlineEdit:平时纯文本,点铅笔才出输入框,Enter 或失焦提交。
* 备份列表不摆出来:自动备份是落盘链路的安全网,不是日常操作对象;
* 「配置文件」一组给下载和上传,上传按 Patchbay 标记分「恢复」和「导入」两个入口。
*/
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { parse } from 'yaml'
import { hasValidMarker } from '@shared/config-marker.ts'
import { formatAgo, formatBytes } from '@shared/format.ts'
import type { GeoFileStatus } from '@shared/geo.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import { api } from '../api'
import { toast, toastError } from '../toast'
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 InlineEdit from '../components/InlineEdit.vue'
import SelectBox from '../components/SelectBox.vue'
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'
import IconEdit from '~icons/ri/pencil-line'
import IconSync from '~icons/ri/refresh-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 isUrlOrEmpty = (value: string) => value === '' || /^https?:\/\//.test(value)
/** 本地偏好,立刻生效。清空等于回到默认。 */
function saveTestUrl(value: string) {
testUrl.value = value || DEFAULT_TEST_URL
}
/* ── 状态与反馈 ───────────────────────────────── */
/* 动作结果一律走右上角的轻提示(web/toast.ts),页面里不再放常驻的横幅 */
const saving = ref(false)
/** 所有改配置文件的动作都从这里走。 */
async function commitConfig(next: ConfigObject, success: string) {
if (saving.value) return
saving.value = true
try {
const result = await api.applyConfig(next, props.revision)
// 内核不在线时只写了文件没重载,如实说,不能只报成功
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
emit('saved')
} catch (error) {
toastError(error)
} finally {
saving.value = false
}
}
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
/* ── 内核参数 ─────────────────────────────────── */
/** 老式顶层端口,键名用配置原词。空表示没启用(配置里不写这个键)。 */
const PORT_KEYS = ['mixed-port', 'port', 'socks-port', 'redir-port', 'tproxy-port'] as const
function numberText(key: string): string {
const value = props.data[key]
return typeof value === 'number' ? String(value) : ''
}
const isPortOrEmpty = (value: string) =>
value === '' || (/^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= 65535)
const isPosIntOrEmpty = (value: string) => value === '' || (/^\d+$/.test(value) && Number(value) >= 1)
/** 单个数字键的保存:空值删键,其余写数字。端口和 keep-alive 共用。 */
function saveNumberKey(key: string, value: string, success: string) {
const next = clone()
if (value === '') delete next[key]
else next[key] = Number(value)
void commitConfig(next, success)
}
/* ── 资源(GEO 数据库) ───────────────────────── */
const geoFiles = ref<GeoFileStatus[] | null>(null)
const geoBusy = ref(false)
async function loadGeo() {
// 失败给空数组而不是停在「载入中」,那是个出不去的死状态
geoFiles.value = await api
.geoStatus()
.then((r) => r.files)
.catch(() => [])
}
onMounted(() => void loadGeo())
// 保存过配置(比如改了下载地址)后重新问一遍生效中的地址
watch(() => props.data, () => void loadGeo())
function geoMeta(g: GeoFileStatus): string {
if (g.size === null || !g.updatedAt) return '未下载'
return `${formatBytes(g.size)} · ${formatAgo(new Date(g.updatedAt), new Date())}`
}
/** GEO 更新是运行时动作,不写配置文件。内核只有全量更新的口子,四行共用一个忙态。 */
async function updateGeo() {
if (geoBusy.value || !kernel.value.online) return
geoBusy.value = true
try {
await api.updateGeo()
toast('GEO 数据库已更新')
await loadGeo()
} catch (error) {
toastError(error)
} finally {
geoBusy.value = false
}
}
/** 正在编辑下载地址的那一行(configKey),一次只开一行 */
const geoEditing = ref<string | null>(null)
const geoDraft = ref('')
const geoBad = ref(false)
const geoInput = ref<HTMLInputElement | null>(null)
// 输入框在 v-for 里,字符串 ref 会被 Vue 收成数组;同时只开一行,函数 ref 存单个就够
function setGeoInput(el: unknown) {
geoInput.value = el instanceof HTMLInputElement ? el : null
}
async function openGeoEdit(g: GeoFileStatus) {
geoDraft.value = g.url
geoBad.value = false
geoEditing.value = g.configKey
await nextTick()
geoInput.value?.focus()
geoInput.value?.select()
}
/**
* 编辑按钮在输入框打开期间还留在行里。直接点它会先触发输入框的 blur(关闭)
* 再触发 click(重新打开),肉眼看就是闪一下。按钮上 mousedown.prevent 保住
* 焦点不掉,click 再显式决定开还是关。
*/
function toggleGeoEdit(g: GeoFileStatus) {
if (geoEditing.value === g.configKey) finishGeoEdit(g, true)
else void openGeoEdit(g)
}
/** lenient 是失焦路径:无效就放弃改动。空值 = 回到内核默认(配置里删掉这个键)。 */
function finishGeoEdit(g: GeoFileStatus, lenient: boolean) {
if (geoEditing.value !== g.configKey) return
const next = geoDraft.value.trim()
if (!isUrlOrEmpty(next)) {
if (lenient) geoEditing.value = null
else geoBad.value = true
return
}
geoEditing.value = null
if (next === g.url) return
const data = clone()
const geox: ConfigObject = { ...((data['geox-url'] ?? {}) as ConfigObject) }
if (next === '') delete geox[g.configKey]
else geox[g.configKey] = next
if (Object.keys(geox).length === 0) delete data['geox-url']
else data['geox-url'] = geox
void commitConfig(data, `${g.key} 的下载地址已保存`)
}
/* ── 配置文件 ─────────────────────────────────── */
async function downloadConfig() {
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) {
toastError(error)
}
}
type UploadKind = 'restore' | 'import'
const strategy = ref<'compatible' | 'override'>('compatible')
const STRATEGY_OPTIONS = [
{ value: 'compatible', label: '兼容' },
{ value: 'override', label: '覆盖' },
]
/** 窗口里正拖着文件。为真时两个上传行整行变成投放区。 */
const dragging = ref(false)
/** 悬在哪个投放区上,给高亮用 */
const overZone = ref<UploadKind | null>(null)
let dragDepth = 0
function hasFiles(event: DragEvent): boolean {
return Array.from(event.dataTransfer?.types ?? []).includes('Files')
}
function onWindowEnter(event: DragEvent) {
if (!hasFiles(event)) return
dragDepth += 1
dragging.value = true
}
function onWindowLeave(event: DragEvent) {
if (!hasFiles(event)) return
dragDepth = Math.max(0, dragDepth - 1)
if (dragDepth === 0) {
dragging.value = false
overZone.value = null
}
}
// dragover 不阻止默认行为浏览器不允许 drop;窗口级兜底 drop 防止误放导致页面跳走
function onWindowOver(event: DragEvent) {
event.preventDefault()
}
function onWindowDrop(event: DragEvent) {
event.preventDefault()
dragDepth = 0
dragging.value = false
overZone.value = null
}
onMounted(() => {
window.addEventListener('dragenter', onWindowEnter)
window.addEventListener('dragleave', onWindowLeave)
window.addEventListener('dragover', onWindowOver)
window.addEventListener('drop', onWindowDrop)
})
onUnmounted(() => {
window.removeEventListener('dragenter', onWindowEnter)
window.removeEventListener('dragleave', onWindowLeave)
window.removeEventListener('dragover', onWindowOver)
window.removeEventListener('drop', onWindowDrop)
})
const restoreInput = ref<HTMLInputElement | null>(null)
const importInput = ref<HTMLInputElement | null>(null)
function onPick(kind: UploadKind, event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (file) void readUpload(kind, file)
}
function onDrop(kind: UploadKind, event: DragEvent) {
const file = event.dataTransfer?.files[0]
if (file) void readUpload(kind, file)
}
/** 待确认的上传 */
const pendingUpload = ref<{ content: string; kind: UploadKind } | null>(null)
async function readUpload(kind: UploadKind, file: File) {
const content = await file.text()
let parsed: unknown
try {
parsed = parse(content)
} catch (error) {
toast(`这份文件不是合法的 YAML:${String(error)}`, { kind: 'error' })
return
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
toast('这份文件不是一份 mihomo 配置。', { kind: 'error' })
return
}
// 恢复入口只收 Patchbay 自己下载的文件,认标记。别家的配置走导入
if (kind === 'restore' && !hasValidMarker(parsed as ConfigObject)) {
toast('这份文件没有 Patchbay 标记,不像是从 Patchbay 下载的配置。', {
kind: 'error',
hint: '如果确实要用它,走「导入配置文件」。',
})
return
}
pendingUpload.value = { content, kind }
}
function uploadMessage(): string {
const kind = pendingUpload.value?.kind
const head = kind === 'import' ? '导入这份配置文件?' : '从这份文件恢复配置?'
const note =
strategy.value === 'compatible'
? '策略为兼容:只替换 Patchbay 接管的段落(入站、专属组、规则、订阅、规则组),其余段落维持当前配置。'
: '策略为覆盖:整份替换当前配置。'
return `${head}${note}当前配置会先自动备份。`
}
async function uploadConfirmed() {
const pending = pendingUpload.value
pendingUpload.value = null
if (!pending || saving.value) return
saving.value = true
try {
const result = await api.importConfig(pending.content, strategy.value)
const success = pending.kind === 'restore' ? '已从文件恢复配置' : '已导入配置文件'
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
emit('saved')
} catch (error) {
toastError(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">
<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>
<InlineEdit
:value="testUrl"
mono
:can-reset="testUrl !== DEFAULT_TEST_URL"
:validate="isUrlOrEmpty"
style="--ie-width: 320px"
@submit="saveTestUrl"
@reset="testUrl = DEFAULT_TEST_URL"
/>
</div>
</section>
<section class="group">
<h2 class="title">内核参数</h2>
<div v-for="key in PORT_KEYS" :key="key" class="row">
<span class="key mono">{{ key }}</span>
<InlineEdit
:value="numberText(key)"
placeholder="未启用"
mono
:busy="saving"
:validate="isPortOrEmpty"
style="--ie-width: 120px"
@submit="(v) => saveNumberKey(key, v, `${key} 已保存`)"
/>
</div>
<div class="row">
<span class="key">TCP 保持活动间隔</span>
<InlineEdit
:value="numberText('keep-alive-interval')"
placeholder="内核默认"
unit="秒"
mono
:busy="saving"
:validate="isPosIntOrEmpty"
style="--ie-width: 120px"
@submit="(v) => saveNumberKey('keep-alive-interval', v, 'TCP 保持活动间隔已保存')"
/>
</div>
</section>
<section class="group">
<h2 class="title">资源</h2>
<p v-if="geoFiles === null" class="faint">载入中</p>
<p v-else-if="geoFiles.length === 0" class="faint">读不到 GEO 文件的状态。</p>
<template v-else>
<div v-for="g in geoFiles" :key="g.key" class="geo">
<div class="row" :title="g.url">
<span class="key mono">{{ g.key }}</span>
<span class="g-right">
<span :class="['g-meta', g.size === null && 'faint']">{{ geoMeta(g) }}</span>
<button
type="button"
class="ico"
title="编辑下载地址"
:disabled="saving"
@mousedown.prevent
@click="toggleGeoEdit(g)"
>
<IconEdit />
</button>
<button
type="button"
class="ico"
:title="kernel.online ? '更新(有变化的 GEO 文件会一并更新)' : '内核未连接'"
:disabled="geoBusy || !kernel.online"
@click="updateGeo"
>
<IconSync :class="geoBusy && 'spin'" />
</button>
</span>
</div>
<input
v-if="geoEditing === g.configKey"
:ref="setGeoInput"
v-model="geoDraft"
:class="['g-input mono', geoBad && 'is-bad']"
type="text"
autocomplete="off"
spellcheck="false"
:placeholder="g.url"
@keydown.enter.prevent="finishGeoEdit(g, false)"
@keydown.esc.prevent="geoEditing = null"
@blur="finishGeoEdit(g, true)"
/>
</div>
</template>
</section>
<section 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>
<!-- 拖着文件进窗口时,两个上传行整行变成投放区 -->
<div
v-if="dragging"
:class="['drop', overZone === 'restore' && 'is-over']"
@dragenter="overZone = 'restore'"
@dragleave="overZone = null"
@dragover.prevent
@drop.prevent="onDrop('restore', $event)"
>
拖拽到此处,恢复配置
</div>
<div v-else class="row">
<span class="key">通过文件恢复配置</span>
<button type="button" class="btn" :disabled="saving" @click="restoreInput?.click()">
选择或拖放文件
</button>
</div>
<div class="row">
<span class="key">恢复策略</span>
<SelectBox v-model="strategy" :options="STRATEGY_OPTIONS" class="strategy" aria-label="恢复策略" />
</div>
<div
v-if="dragging"
:class="['drop', overZone === 'import' && 'is-over']"
@dragenter="overZone = 'import'"
@dragleave="overZone = null"
@dragover.prevent
@drop.prevent="onDrop('import', $event)"
>
拖拽到此处,导入配置文件
</div>
<div v-else class="row">
<span class="key">导入配置文件</span>
<button type="button" class="btn" :disabled="saving" @click="importInput?.click()">
选择或拖放文件
</button>
</div>
<input
ref="restoreInput"
type="file"
accept=".yaml,.yml"
hidden
@change="onPick('restore', $event)"
/>
<input
ref="importInput"
type="file"
accept=".yaml,.yml"
hidden
@change="onPick('import', $event)"
/>
</section>
<section 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="pendingUpload !== null"
:message="uploadMessage()"
:action="pendingUpload?.kind === 'import' ? '导入' : '恢复'"
@close="pendingUpload = null"
@confirm="uploadConfirmed"
/>
</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);
}
.faint {
color: var(--c-text-faint);
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;
}
.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);
}
/* ── 资源 ────────────────────────────────────── */
.g-right {
display: inline-flex;
align-items: center;
gap: var(--s-2);
}
.g-meta {
font-size: var(--t-sm);
color: var(--c-text);
margin-right: var(--s-2);
}
.ico {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.ico:hover:not(:disabled) {
background: var(--c-surface-sunken);
color: var(--c-text);
}
.ico:disabled {
cursor: not-allowed;
}
.ico svg {
width: 15px;
height: 15px;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 下载地址很长,编辑框独占一行放在行下面 */
.g-input {
width: 100%;
margin: 0 0 var(--s-2);
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-sm);
}
.g-input.is-bad {
border-color: var(--c-danger);
}
/* ── 配置文件 ────────────────────────────────── */
.drop {
display: flex;
align-items: center;
justify-content: center;
padding: var(--s-3) 0;
margin: var(--s-1) 0;
border: 1px dashed var(--c-border-strong);
border-radius: var(--r-md);
color: var(--c-text-soft);
font-size: var(--t-sm);
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}
.drop.is-over {
border-color: var(--c-accent);
background: var(--c-accent-soft);
color: var(--c-accent-text);
}
.strategy {
width: 140px;
--select-bg: var(--c-surface);
}
/* ── 其他:整行入口,形态与信息行一致,右侧箭头示意可进入 ── */
.more {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--s-2) 0;
border: none;
background: none;
font-size: var(--t-sm);
color: var(--c-text-soft);
cursor: pointer;
}
.more svg {
color: var(--c-text-faint);
}
.more:hover,
.more:hover svg {
color: var(--c-accent-text);
}
/* ── 子页 ────────────────────────────────────── */
.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);
}
</style>