反馈改右上角轻提示;侧栏加撤回重做等配置动作
设置页的横幅换成 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:
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 备份链(历史记录)的弹窗:列出每次改动前留下的备份,点还原直接跳回去。
|
||||
* 还原走服务端同一条保护链路,当前状态会先自动备份,所以不需要再套一层确认。
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { backupTime } from '@shared/backups.ts'
|
||||
import { api } from '../api'
|
||||
import { toast, toastError } from '../toast'
|
||||
import IconClose from '~icons/ri/close-line'
|
||||
|
||||
const props = defineProps<{ open: boolean }>()
|
||||
|
||||
/** restored:还原成功,外壳该重读配置了。 */
|
||||
const emit = defineEmits<{ close: []; restored: [] }>()
|
||||
|
||||
const dialog = ref<HTMLDialogElement | null>(null)
|
||||
const backups = ref<string[] | null>(null)
|
||||
const busy = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
dialog.value?.showModal()
|
||||
backups.value = null
|
||||
void api
|
||||
.listBackups()
|
||||
.then((r) => (backups.value = r.backups))
|
||||
.catch(() => (backups.value = []))
|
||||
} else {
|
||||
dialog.value?.close()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function restore(name: string) {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await api.restoreBackup(name)
|
||||
const time = backupTime(name) ?? name
|
||||
toast(result.reloaded ? `已还原到 ${time}` : `已还原到 ${time}(${result.reloadMessage})`)
|
||||
emit('restored')
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 点在遮罩上就关闭。内容区的点击 target 是里面的元素,到不了 dialog 本身。 */
|
||||
function onBackdrop(event: MouseEvent) {
|
||||
if (event.target === dialog.value) emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialog"
|
||||
class="dlg"
|
||||
@click="onBackdrop"
|
||||
@close="emit('close')"
|
||||
@cancel.prevent="emit('close')"
|
||||
>
|
||||
<div class="body">
|
||||
<header class="head">
|
||||
<h2>历史记录</h2>
|
||||
<button type="button" class="x" title="关闭" @click="emit('close')"><IconClose /></button>
|
||||
</header>
|
||||
|
||||
<p v-if="backups === null" class="faint">载入中</p>
|
||||
<p v-else-if="backups.length === 0" class="faint">
|
||||
没有备份。每次保存都会把改动前的状态留在这里,最多 10 份。
|
||||
</p>
|
||||
<ul v-else class="list">
|
||||
<li v-for="name in backups" :key="name" class="item" :title="name">
|
||||
<span class="mono time">{{ backupTime(name) ?? name }}</span>
|
||||
<button type="button" class="btn" :disabled="busy" @click="restore(name)">还原</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dlg {
|
||||
width: min(420px, calc(100vw - 2 * var(--s-5)));
|
||||
padding: 0;
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.dlg::backdrop {
|
||||
background: rgb(0 0 0 / 0.45);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: var(--s-5);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
|
||||
.head h2 {
|
||||
font-size: var(--t-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.x {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.x:hover {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.faint {
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-strong) transparent;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-4);
|
||||
padding: var(--s-2) 0;
|
||||
}
|
||||
|
||||
.item + .item {
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--s-1) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
font-size: var(--t-sm);
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 轻提示的渲染宿主,挂在外壳根部。进场从右滑入,退场上移淡出;
|
||||
* 点一下立刻消掉。内容来自 web/toast.ts 的单例队列。
|
||||
*/
|
||||
import { dismiss, toasts } from '../toast'
|
||||
import IconOk from '~icons/ri/checkbox-circle-line'
|
||||
import IconErr from '~icons/ri/error-warning-line'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="host" aria-live="polite">
|
||||
<TransitionGroup name="tst">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
:class="['tst', t.kind === 'error' && 'is-error']"
|
||||
@click="dismiss(t.id)"
|
||||
>
|
||||
<IconErr v-if="t.kind === 'error'" class="tico" />
|
||||
<IconOk v-else class="tico" />
|
||||
<div class="tbody">
|
||||
<strong class="tmsg">{{ t.message }}</strong>
|
||||
<span v-if="t.hint" class="thint">{{ t.hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.host {
|
||||
position: fixed;
|
||||
top: var(--s-4);
|
||||
right: var(--s-4);
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--s-2);
|
||||
/* 宿主自己不挡点击,字条才挡 */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tst {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s-3);
|
||||
min-width: 240px;
|
||||
max-width: 380px;
|
||||
padding: var(--s-3) var(--s-4);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
box-shadow: 0 6px 24px rgb(0 0 0 / 0.16);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tst.is-error {
|
||||
border-color: color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
|
||||
}
|
||||
|
||||
.tico {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 1px;
|
||||
color: var(--c-ok);
|
||||
}
|
||||
|
||||
.is-error .tico {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.tbody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tmsg {
|
||||
font-size: var(--t-sm);
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.thint {
|
||||
font-size: var(--t-xs);
|
||||
color: var(--c-text-soft);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 进场:从右滑入;退场:上移淡出;队列压缩时其余字条平滑归位 */
|
||||
.tst-enter-active {
|
||||
transition:
|
||||
transform 0.28s cubic-bezier(0.21, 0.8, 0.35, 1),
|
||||
opacity 0.28s ease;
|
||||
}
|
||||
.tst-enter-from {
|
||||
transform: translateX(120%);
|
||||
opacity: 0;
|
||||
}
|
||||
.tst-leave-active {
|
||||
transition:
|
||||
transform 0.4s ease,
|
||||
opacity 0.4s ease;
|
||||
/* 退场中脱离布局,后面的字条立刻开始归位 */
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
.tst-leave-to {
|
||||
transform: translateY(-14px);
|
||||
opacity: 0;
|
||||
}
|
||||
.tst-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user