设置页:外观、连接、关于、备份还原
主题用 <html> 的 data-theme 标记显式选择,tokens.css 新增两组选择器块, 权重压过 prefers-color-scheme;选「跟随系统」则不标记,交回媒体查询, 系统主题切换时界面仍跟着变。 顺带把 InboundForm 和 TokenGate 里硬编码的 #fff 换成 var(--c-bg): 深色主题的强调色是浅青,白字压在上面对比度不够,深色下会看不清。
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
import { expect, test } from 'bun:test'
|
||||||
|
import { backupTime } from './backups.ts'
|
||||||
|
|
||||||
|
test('标准备份名排成可读时间', () => {
|
||||||
|
expect(backupTime('config.yaml.bak-20260811-031000')).toBe('2026-08-11 03:10:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('配置文件名不同也能解析,只看结尾的时间戳', () => {
|
||||||
|
expect(backupTime('mihomo.yml.bak-20260101-235959')).toBe('2026-01-01 23:59:59')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('不合预期的名字返回 null', () => {
|
||||||
|
expect(backupTime('config.yaml.bak')).toBeNull()
|
||||||
|
expect(backupTime('config.yaml.bak-2026081-031000')).toBeNull()
|
||||||
|
expect(backupTime('notes.txt')).toBeNull()
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 备份文件名里的时间戳。
|
||||||
|
*
|
||||||
|
* 名字由服务端生成(server/config-store.ts 的 backupName),形如
|
||||||
|
* `config.yaml.bak-20260811-031000`,时间戳是服务端的本地时间。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STAMP = /\.bak-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/
|
||||||
|
|
||||||
|
/** 把备份文件名排成可读时间。名字不合预期时返回 null,调用方退回显示原名。 */
|
||||||
|
export function backupTime(name: string): string | null {
|
||||||
|
const m = STAMP.exec(name)
|
||||||
|
if (!m) return null
|
||||||
|
return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}`
|
||||||
|
}
|
||||||
+25
-1
@@ -1,9 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* 界面偏好,存在 localStorage 里。
|
* 界面偏好,存在 localStorage 里。
|
||||||
*
|
*
|
||||||
* 目前只有一项:工具栏和导航是只显示图标,还是图标加文字。
|
* 密度:工具栏和导航是只显示图标,还是图标加文字。
|
||||||
* 默认是「完整」——这个工具是隔一阵子才动一次的,纯图标对偶尔来一次的人不友好;
|
* 默认是「完整」——这个工具是隔一阵子才动一次的,纯图标对偶尔来一次的人不友好;
|
||||||
* 想要紧凑的人自己去设置里切。
|
* 想要紧凑的人自己去设置里切。
|
||||||
|
*
|
||||||
|
* 主题:跟随系统、浅色、深色。默认跟随系统。
|
||||||
*/
|
*/
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
@@ -21,3 +23,25 @@ watch(density, (value) => {
|
|||||||
export function toggleDensity(): void {
|
export function toggleDensity(): void {
|
||||||
density.value = density.value === 'full' ? 'compact' : 'full'
|
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;
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--c-bg: #16181c;
|
--c-bg: #16181c;
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ select {
|
|||||||
.btn-go {
|
.btn-go {
|
||||||
background: var(--c-accent);
|
background: var(--c-accent);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
color: #fff;
|
color: var(--c-bg);
|
||||||
font-weight: 500;
|
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: 1px solid transparent;
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
background: var(--c-accent);
|
background: var(--c-accent);
|
||||||
color: #fff;
|
color: var(--c-bg);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user