设置页:外观、连接、关于、备份还原

主题用 <html> 的 data-theme 标记显式选择,tokens.css 新增两组选择器块,
权重压过 prefers-color-scheme;选「跟随系统」则不标记,交回媒体查询,
系统主题切换时界面仍跟着变。
顺带把 InboundForm 和 TokenGate 里硬编码的 #fff 换成 var(--c-bg):
深色主题的强调色是浅青,白字压在上面对比度不够,深色下会看不清。
This commit is contained in:
2026-08-11 18:35:04 +08:00
parent 6c8a1c4e0e
commit fb1dd401b2
7 changed files with 427 additions and 3 deletions
+25 -1
View File
@@ -1,9 +1,11 @@
/**
* 界面偏好,存在 localStorage 里。
*
* 目前只有一项:工具栏和导航是只显示图标,还是图标加文字。
* 密度:工具栏和导航是只显示图标,还是图标加文字。
* 默认是「完整」——这个工具是隔一阵子才动一次的,纯图标对偶尔来一次的人不友好;
* 想要紧凑的人自己去设置里切。
*
* 主题:跟随系统、浅色、深色。默认跟随系统。
*/
import { ref, watch } from 'vue'
@@ -21,3 +23,25 @@ watch(density, (value) => {
export function toggleDensity(): void {
density.value = density.value === 'full' ? 'compact' : 'full'
}
export type Theme = 'system' | 'light' | 'dark'
const THEME_KEY = 'patchbay.theme'
const storedTheme = localStorage.getItem(THEME_KEY)
export const theme = ref<Theme>(
storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : 'system',
)
// 显式选了浅色或深色就在 <html> 上标出来:tokens.css 里 :root[data-theme=…] 的权重
// 高于 :root 和媒体查询里的 :root,压过系统偏好。跟随系统则不加标记,交回给媒体查询,
// 这样系统主题切换时界面仍然跟着变。
watch(
theme,
(value) => {
localStorage.setItem(THEME_KEY, value)
if (value === 'system') document.documentElement.removeAttribute('data-theme')
else document.documentElement.setAttribute('data-theme', value)
},
{ immediate: true },
)
+60
View File
@@ -71,6 +71,66 @@
--line: 1.7;
}
/*
* 显式选择的主题(web/prefs.ts 在 <html> 上写 data-theme)。两个方向都要写:
* 深色系统上选浅色也要生效。选择器权重高于 :root 和上面媒体查询里的 :root,
* 显式选择压过系统偏好;不标 data-theme 时这两块不命中,媒体查询说了算。
* 只覆盖颜色变量,间距、字号与主题无关。
*/
:root[data-theme='light'] {
--c-bg: #fbfbfc;
--c-surface: #ffffff;
--c-surface-sunken: #f4f5f7;
--c-border: #e4e6ea;
--c-border-strong: #d0d3d9;
--c-text: #1c1e21;
--c-text-soft: #5c6169;
--c-text-faint: #8b9098;
--c-accent: #2b7a78;
--c-accent-soft: #e6f2f1;
--c-accent-text: #1f5c5a;
--c-chain: #5145cd;
--c-chain-soft: #eeedfb;
--c-chain-text: #4038a8;
--c-danger: #b3261e;
--c-danger-soft: #fdedec;
--c-warn: #8a6100;
--c-warn-soft: #fdf5e3;
--c-ok: #1f6b3a;
--c-ok-soft: #e9f5ed;
}
:root[data-theme='dark'] {
--c-bg: #16181c;
--c-surface: #1d2025;
--c-surface-sunken: #121417;
--c-border: #2b2f36;
--c-border-strong: #3a3f48;
--c-text: #e6e8eb;
--c-text-soft: #9ba1a9;
--c-text-faint: #6c727a;
--c-accent: #4db6ac;
--c-accent-soft: #1b3330;
--c-accent-text: #7fd4cb;
--c-chain: #8b83f0;
--c-chain-soft: #241f4a;
--c-chain-text: #a9a2f7;
--c-danger: #f2b8b5;
--c-danger-soft: #3a1f1e;
--c-warn: #e8c37a;
--c-warn-soft: #33290f;
--c-ok: #86d6a0;
--c-ok-soft: #16301f;
}
@media (prefers-color-scheme: dark) {
:root {
--c-bg: #16181c;
+1 -1
View File
@@ -310,7 +310,7 @@ select {
.btn-go {
background: var(--c-accent);
border-color: transparent;
color: #fff;
color: var(--c-bg);
font-weight: 500;
}
+309
View File
@@ -0,0 +1,309 @@
<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>
+1 -1
View File
@@ -67,7 +67,7 @@ h1 {
border: 1px solid transparent;
border-radius: var(--r-sm);
background: var(--c-accent);
color: #fff;
color: var(--c-bg);
font-weight: 500;
cursor: pointer;
}