设置页:外观、连接、关于、备份还原
主题用 <html> 的 data-theme 标记显式选择,tokens.css 新增两组选择器块, 权重压过 prefers-color-scheme;选「跟随系统」则不标记,交回媒体查询, 系统主题切换时界面仍跟着变。 顺带把 InboundForm 和 TokenGate 里硬编码的 #fff 换成 var(--c-bg): 深色主题的强调色是浅青,白字压在上面对比度不够,深色下会看不清。
This commit is contained in:
+25
-1
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -310,7 +310,7 @@ select {
|
||||
.btn-go {
|
||||
background: var(--c-accent);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
color: var(--c-bg);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user