Files
mihomo-patchbay/web/views/SettingsPage.vue
T
im0o 013f537eaa 订阅和规则组支持从文件导入,配置页与表单一批打磨
文件导入走两步:内容先经新端点 POST /api/provider-files 写进内核工作
目录的 providers/ 下(路径白名单校验,只认两个路径助手生成的形状),
成了再写 file 类型的声明——顺序反了内核重载会去读不存在的文件。
file 类型没有地址和自动更新(内核靠文件监视跟进),详情页照此展示,
编辑按钮禁用。ini 导入的取文件按钮换成同一个拖放模块。

订阅的更新间隔改成开关语义:内核只在 interval > 0 时才起定时拉取
(component/resource/fetcher.go:226-228),关掉开关就是不写这个键。
配置页加「更新所有订阅」,详情的「立刻更新」改叫「更新订阅」。

规则表从零开始写入时自动垫上内网直连——保留地址不直连的话第一条
代理规则一生效,访问局域网的流量就全进了代理;按钮改成「补全内网
直连」,只在有缺口时出现。

所有表单对话框点遮罩关闭;失败横幅右侧加关闭按钮。
2026-08-12 00:46:17 +08:00

341 lines
8.7 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'
import IconCloseTip from '~icons/ri/close-line'
/** 还原备份改的是配置文件,外壳需要重读整份配置(同路由页的 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">
<div class="failure-text">
<strong>{{ failure.message }}</strong>
<span v-if="failure.hint">{{ failure.hint }}</span>
</div>
<button type="button" class="failure-x" title="关闭提示" @click="failure = null">
<IconCloseTip />
</button>
</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;
align-items: flex-start;
gap: var(--s-3);
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);
}
.failure-text {
display: flex;
flex-direction: column;
gap: var(--s-1);
flex: 1;
min-width: 0;
}
.failure-x {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-danger);
cursor: pointer;
}
.failure-x:hover {
background: color-mix(in srgb, var(--c-danger) 12%, transparent);
}
.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>