Files
mihomo-patchbay/web/views/SettingsPage.vue
T
im0o fb1dd401b2 设置页:外观、连接、关于、备份还原
主题用 <html> 的 data-theme 标记显式选择,tokens.css 新增两组选择器块,
权重压过 prefers-color-scheme;选「跟随系统」则不标记,交回媒体查询,
系统主题切换时界面仍跟着变。
顺带把 InboundForm 和 TokenGate 里硬编码的 #fff 换成 var(--c-bg):
深色主题的强调色是浅青,白字压在上面对比度不够,深色下会看不清。
2026-08-11 18:35:04 +08:00

310 lines
8.1 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">
/**
* 设置页:Patchbay 自己的设置,不含 mihomo 的任何设置——那些属于配置页。
*
* 四组里只有备份还原会等、会失败:还原走服务端完整的保护链路(先备份当前文件,
* 再写入、重载内核,失败自动回退),反馈方式照路由页的 commit()。其余三组是本地
* 偏好或只读信息,点了就生效,没有等待。
*/
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 { kernel } from '../runtime'
import ConfirmDialog from '../components/ConfirmDialog.vue'
/** 还原备份改的是配置文件,外壳需要重读整份配置(同路由页的 saved)。 */
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: '浅色' },
{ value: 'dark', label: '深色' },
]
// 模板里直接给 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 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})`
emit('saved')
// 还原本身也走写入链路,会留下一份新备份,列表要重拉
await loadBackups()
} catch (error) {
failure.value =
error instanceof ApiError
? { message: error.message, hint: error.hint }
: { message: String(error), hint: '' }
} finally {
restoring.value = false
}
}
</script>
<template>
<div class="page">
<section class="group">
<h2 class="title">外观</h2>
<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 }}
</button>
</div>
</div>
<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>
<span class="mono">{{ backend }}</span>
</div>
<div class="row">
<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">
<strong>{{ failure.message }}</strong>
<span v-if="failure.hint">{{ failure.hint }}</span>
</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>
</li>
</ul>
</section>
<ConfirmDialog
:open="asking !== null"
:message="
asking ? `把配置还原到 ${backupTime(asking) ?? asking}?当前配置会先自动备份。` : ''
"
action="还原"
@close="asking = null"
@confirm="restoreConfirmed"
/>
</div>
</template>
<style scoped>
/* 内容少的页面收窄成一列,段落之间用分割线而不是卡片——一屏最多两层容器 */
.page {
max-width: 560px;
}
.group {
padding: var(--s-5) 0;
border-top: 1px solid var(--c-border);
}
.group:first-child {
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;
}
.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;
}
.logout {
margin-top: var(--s-2);
}
.about {
margin-bottom: var(--s-2);
}
.faint {
color: var(--c-text-faint);
font-size: var(--t-sm);
}
.failure {
display: flex;
flex-direction: column;
gap: var(--s-1);
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);
}
.done {
color: var(--c-ok);
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>