文件导入走两步:内容先经新端点 POST /api/provider-files 写进内核工作 目录的 providers/ 下(路径白名单校验,只认两个路径助手生成的形状), 成了再写 file 类型的声明——顺序反了内核重载会去读不存在的文件。 file 类型没有地址和自动更新(内核靠文件监视跟进),详情页照此展示, 编辑按钮禁用。ini 导入的取文件按钮换成同一个拖放模块。 订阅的更新间隔改成开关语义:内核只在 interval > 0 时才起定时拉取 (component/resource/fetcher.go:226-228),关掉开关就是不写这个键。 配置页加「更新所有订阅」,详情的「立刻更新」改叫「更新订阅」。 规则表从零开始写入时自动垫上内网直连——保留地址不直连的话第一条 代理规则一生效,访问局域网的流量就全进了代理;按钮改成「补全内网 直连」,只在有缺口时出现。 所有表单对话框点遮罩关闭;失败横幅右侧加关闭按钮。
341 lines
8.7 KiB
Vue
341 lines
8.7 KiB
Vue
<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>
|