反馈改右上角轻提示;侧栏加撤回重做等配置动作

设置页的横幅换成 toast(右滑入、几秒后上移淡出,web/toast.ts 单例):
动作结果看一眼就行,不值得在页面里占一块常驻位置。表单校验类的错误
仍留在各自表单里。

撤回栈就是备份链:撤回把最新备份应用回去并消费掉它,撤回前的状态进
重做栈(.patchbay-redo,同样留 10 份);重做反向;新的正常保存清空
重做栈,不留分叉历史。这两条路复用 applyConfig 的保护链路,用
restoreFrom 参数跳过自建备份——否则备份链会被撤回本身污染,撤两次
等于原地打转。侧栏左下角一排图标:撤回、重做、历史记录(备份列表
弹窗)、更新配置(POST /api/kernel/reload,给 SSH 手改后用)、重启
内核;收拢时容器变窄自动折成一列。

设置页顺手修三处:GEO 行的编辑按钮在输入框开着时再点会闪(blur 关了
click 又开),mousedown.prevent 保焦点、click 显式开关;上传按钮改
「选择或拖放文件」,投放区拖入高亮;恢复策略挪到恢复行下面。
This commit is contained in:
2026-08-12 01:59:22 +08:00
parent 2580c03bfa
commit f761d13579
9 changed files with 798 additions and 106 deletions
+64 -100
View File
@@ -15,7 +15,8 @@ import { hasValidMarker } from '@shared/config-marker.ts'
import { formatAgo, formatBytes } from '@shared/format.ts'
import type { GeoFileStatus } from '@shared/geo.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import { ApiError, api } from '../api'
import { api } from '../api'
import { toast, toastError } from '../toast'
import { DEFAULT_TEST_URL, testUrl, theme } from '../prefs'
import type { Theme } from '../prefs'
import { kernel } from '../runtime'
@@ -23,7 +24,6 @@ import { DISCLAIMER, DISCLAIMER_TITLE } from '../legal'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import InlineEdit from '../components/InlineEdit.vue'
import SelectBox from '../components/SelectBox.vue'
import IconCloseTip from '~icons/ri/close-line'
import IconRight from '~icons/ri/arrow-right-s-line'
import IconBack from '~icons/ri/arrow-left-s-line'
import IconDownload from '~icons/ri/download-2-line'
@@ -56,31 +56,21 @@ function saveTestUrl(value: string) {
}
/* ── 状态与反馈 ───────────────────────────────── */
/* 动作结果一律走右上角的轻提示(web/toast.ts),页面里不再放常驻的横幅 */
const saving = ref(false)
const failure = ref<{ message: string; hint: string } | null>(null)
const done = ref('')
function fail(error: unknown) {
failure.value =
error instanceof ApiError
? { message: error.message, hint: error.hint }
: { message: String(error), hint: '' }
}
/** 所有改配置文件的动作都从这里走。 */
async function commitConfig(next: ConfigObject, success: string) {
if (saving.value) return
saving.value = true
failure.value = null
done.value = ''
try {
const result = await api.applyConfig(next, props.revision)
// 内核不在线时只写了文件没重载,如实说,不能只报成功
done.value = result.reloaded ? success : `${success}(${result.reloadMessage})`
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
emit('saved')
} catch (error) {
fail(error)
toastError(error)
} finally {
saving.value = false
}
@@ -137,14 +127,12 @@ function geoMeta(g: GeoFileStatus): string {
async function updateGeo() {
if (geoBusy.value || !kernel.value.online) return
geoBusy.value = true
failure.value = null
done.value = ''
try {
await api.updateGeo()
done.value = 'GEO 数据库已更新'
toast('GEO 数据库已更新')
await loadGeo()
} catch (error) {
fail(error)
toastError(error)
} finally {
geoBusy.value = false
}
@@ -170,6 +158,16 @@ async function openGeoEdit(g: GeoFileStatus) {
geoInput.value?.select()
}
/**
* 编辑按钮在输入框打开期间还留在行里。直接点它会先触发输入框的 blur(关闭)
* 再触发 click(重新打开),肉眼看就是闪一下。按钮上 mousedown.prevent 保住
* 焦点不掉,click 再显式决定开还是关。
*/
function toggleGeoEdit(g: GeoFileStatus) {
if (geoEditing.value === g.configKey) finishGeoEdit(g, true)
else void openGeoEdit(g)
}
/** lenient 是失焦路径:无效就放弃改动。空值 = 回到内核默认(配置里删掉这个键)。 */
function finishGeoEdit(g: GeoFileStatus, lenient: boolean) {
if (geoEditing.value !== g.configKey) return
@@ -193,7 +191,6 @@ function finishGeoEdit(g: GeoFileStatus, lenient: boolean) {
/* ── 配置文件 ─────────────────────────────────── */
async function downloadConfig() {
failure.value = null
try {
const text = await api.configRaw()
const url = URL.createObjectURL(new Blob([text], { type: 'text/yaml' }))
@@ -203,7 +200,7 @@ async function downloadConfig() {
link.click()
URL.revokeObjectURL(url)
} catch (error) {
fail(error)
toastError(error)
}
}
@@ -217,6 +214,8 @@ const STRATEGY_OPTIONS = [
/** 窗口里正拖着文件。为真时两个上传行整行变成投放区。 */
const dragging = ref(false)
/** 悬在哪个投放区上,给高亮用 */
const overZone = ref<UploadKind | null>(null)
let dragDepth = 0
function hasFiles(event: DragEvent): boolean {
@@ -232,7 +231,10 @@ function onWindowEnter(event: DragEvent) {
function onWindowLeave(event: DragEvent) {
if (!hasFiles(event)) return
dragDepth = Math.max(0, dragDepth - 1)
if (dragDepth === 0) dragging.value = false
if (dragDepth === 0) {
dragging.value = false
overZone.value = null
}
}
// dragover 不阻止默认行为浏览器不允许 drop;窗口级兜底 drop 防止误放导致页面跳走
@@ -244,6 +246,7 @@ function onWindowDrop(event: DragEvent) {
event.preventDefault()
dragDepth = 0
dragging.value = false
overZone.value = null
}
onMounted(() => {
@@ -279,26 +282,24 @@ function onDrop(kind: UploadKind, event: DragEvent) {
const pendingUpload = ref<{ content: string; kind: UploadKind } | null>(null)
async function readUpload(kind: UploadKind, file: File) {
failure.value = null
done.value = ''
const content = await file.text()
let parsed: unknown
try {
parsed = parse(content)
} catch (error) {
failure.value = { message: `这份文件不是合法的 YAML:${String(error)}`, hint: '' }
toast(`这份文件不是合法的 YAML:${String(error)}`, { kind: 'error' })
return
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
failure.value = { message: '这份文件不是一份 mihomo 配置。', hint: '' }
toast('这份文件不是一份 mihomo 配置。', { kind: 'error' })
return
}
// 恢复入口只收 Patchbay 自己下载的文件,认标记。别家的配置走导入
if (kind === 'restore' && !hasValidMarker(parsed as ConfigObject)) {
failure.value = {
message: '这份文件没有 Patchbay 标记,不像是从 Patchbay 下载的配置。',
toast('这份文件没有 Patchbay 标记,不像是从 Patchbay 下载的配置。', {
kind: 'error',
hint: '如果确实要用它,走「导入配置文件」。',
}
})
return
}
pendingUpload.value = { content, kind }
@@ -319,15 +320,13 @@ async function uploadConfirmed() {
pendingUpload.value = null
if (!pending || saving.value) return
saving.value = true
failure.value = null
done.value = ''
try {
const result = await api.importConfig(pending.content, strategy.value)
const success = pending.kind === 'restore' ? '已从文件恢复配置' : '已导入配置文件'
done.value = result.reloaded ? success : `${success}(${result.reloadMessage})`
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
emit('saved')
} catch (error) {
fail(error)
toastError(error)
} finally {
saving.value = false
}
@@ -370,17 +369,6 @@ const backend = location.origin
</div>
<div v-else class="page">
<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>
<section class="group">
<h2 class="title">外观</h2>
<div class="row">
@@ -459,7 +447,8 @@ const backend = location.origin
class="ico"
title="编辑下载地址"
:disabled="saving"
@click="openGeoEdit(g)"
@mousedown.prevent
@click="toggleGeoEdit(g)"
>
<IconEdit />
</button>
@@ -503,7 +492,9 @@ const backend = location.origin
<!-- 拖着文件进窗口时,两个上传行整行变成投放区 -->
<div
v-if="dragging"
class="drop"
:class="['drop', overZone === 'restore' && 'is-over']"
@dragenter="overZone = 'restore'"
@dragleave="overZone = null"
@dragover.prevent
@drop.prevent="onDrop('restore', $event)"
>
@@ -512,17 +503,7 @@ const backend = location.origin
<div v-else class="row">
<span class="key">通过文件恢复配置</span>
<button type="button" class="btn" :disabled="saving" @click="restoreInput?.click()">
选择文件
</button>
</div>
<div v-if="dragging" class="drop" @dragover.prevent @drop.prevent="onDrop('import', $event)">
拖拽到此处,导入配置文件
</div>
<div v-else class="row">
<span class="key">导入配置文件</span>
<button type="button" class="btn" :disabled="saving" @click="importInput?.click()">
选择文件
选择或拖放文件
</button>
</div>
@@ -531,6 +512,23 @@ const backend = location.origin
<SelectBox v-model="strategy" :options="STRATEGY_OPTIONS" class="strategy" aria-label="恢复策略" />
</div>
<div
v-if="dragging"
:class="['drop', overZone === 'import' && 'is-over']"
@dragenter="overZone = 'import'"
@dragleave="overZone = null"
@dragover.prevent
@drop.prevent="onDrop('import', $event)"
>
拖拽到此处,导入配置文件
</div>
<div v-else class="row">
<span class="key">导入配置文件</span>
<button type="button" class="btn" :disabled="saving" @click="importInput?.click()">
选择或拖放文件
</button>
</div>
<input
ref="restoreInput"
type="file"
@@ -747,6 +745,15 @@ const backend = location.origin
border-radius: var(--r-md);
color: var(--c-text-soft);
font-size: var(--t-sm);
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}
.drop.is-over {
border-color: var(--c-accent);
background: var(--c-accent-soft);
color: var(--c-accent-text);
}
.strategy {
@@ -810,47 +817,4 @@ const backend = location.origin
margin-top: var(--s-3);
}
/* ── 反馈 ────────────────────────────────────── */
.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);
}
</style>