反馈改右上角轻提示;侧栏加撤回重做等配置动作
设置页的横幅换成 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:
@@ -2,7 +2,16 @@ import { afterAll, describe, expect, it } from 'bun:test'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { applyConfig, importConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
|
||||
import {
|
||||
applyConfig,
|
||||
importConfig,
|
||||
listBackups,
|
||||
listRedo,
|
||||
readConfig,
|
||||
redoConfig,
|
||||
restoreBackup,
|
||||
undoConfig,
|
||||
} from './config-store.ts'
|
||||
import type { Env } from './env.ts'
|
||||
|
||||
/**
|
||||
@@ -184,6 +193,48 @@ describe('落盘保护链路(内核在线)', () => {
|
||||
if (!result.ok) expect(result.message).toContain('没有这个备份')
|
||||
})
|
||||
|
||||
it('撤回回到改动前,重做拿回改动,备份链不被撤回污染', async () => {
|
||||
const env = makeEnv()
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
|
||||
|
||||
const undone = await undoConfig(env)
|
||||
expect(undone.ok).toBe(true)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
|
||||
// 备份被消费、当前状态进了重做栈
|
||||
expect(await listBackups(env)).toHaveLength(0)
|
||||
expect(await listRedo(env)).toHaveLength(1)
|
||||
|
||||
const redone = await redoConfig(env)
|
||||
expect(redone.ok).toBe(true)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7891')
|
||||
expect(await listRedo(env)).toHaveLength(0)
|
||||
expect(await listBackups(env)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('栈空时撤回和重做被拦住,文件不动', async () => {
|
||||
const env = makeEnv()
|
||||
const undone = await undoConfig(env)
|
||||
expect(undone.ok).toBe(false)
|
||||
if (!undone.ok) expect(undone.message).toContain('没有可以撤回')
|
||||
|
||||
const redone = await redoConfig(env)
|
||||
expect(redone.ok).toBe(false)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
|
||||
})
|
||||
|
||||
it('新的保存清空重做栈', async () => {
|
||||
const env = makeEnv()
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
|
||||
await undoConfig(env)
|
||||
expect(await listRedo(env)).toHaveLength(1)
|
||||
|
||||
const { data, revision } = await readConfig(env)
|
||||
await applyConfig(env, { ...data, 'mixed-port': 7892 }, revision)
|
||||
expect(await listRedo(env)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('备份最多留 10 份,旧的被清掉', async () => {
|
||||
const env = makeEnv()
|
||||
const dir = join(env.configPath, '..', '.patchbay-backups')
|
||||
|
||||
+102
-2
@@ -106,11 +106,16 @@ export function rewriteYaml(currentYaml: string, nextData: ConfigObject): string
|
||||
*
|
||||
* `baseRevision` 是客户端读到这份配置时拿到的指纹。传 undefined 表示
|
||||
* 「不管别人改没改,以我为准」,仅用于还原备份这类明确要覆盖的场景。
|
||||
*
|
||||
* `opts.restoreFrom`:撤回/重做专用。给了它就不再新建备份(否则备份链会被
|
||||
* 撤回本身污染,撤两次等于原地打转),重载失败时用它当还原源;同时不清空
|
||||
* 重做栈——只有「正常的新改动」才让已撤回的未来失效。
|
||||
*/
|
||||
export async function applyConfig(
|
||||
env: Env,
|
||||
nextData: ConfigObject,
|
||||
baseRevision?: string,
|
||||
opts?: { restoreFrom?: string },
|
||||
): Promise<ApplyOutcome> {
|
||||
const current = await readDocument(env)
|
||||
|
||||
@@ -185,8 +190,8 @@ export async function applyConfig(
|
||||
}
|
||||
|
||||
// 校验过了才动真文件。备份放在写入之前。
|
||||
const backupPath = await createBackup(env)
|
||||
const backupTiming = lap('备份')
|
||||
const backupPath = opts?.restoreFrom ?? (await createBackup(env))
|
||||
const backupTiming = opts?.restoreFrom ? '' : lap('备份')
|
||||
try {
|
||||
await writeFile(env.configPath, nextYaml, 'utf8')
|
||||
} catch (error) {
|
||||
@@ -204,6 +209,7 @@ export async function applyConfig(
|
||||
if (!kernel.online) {
|
||||
// mihomo -t 已经把过关,文件留给内核下次启动时读。
|
||||
await pruneBackups(env)
|
||||
if (!opts?.restoreFrom) await clearRedo(env)
|
||||
console.log(
|
||||
`[apply] 共 ${String(Date.now() - clock.start)}ms(${checkTiming},${backupTiming},${writeTiming},未重载)`,
|
||||
)
|
||||
@@ -240,6 +246,7 @@ export async function applyConfig(
|
||||
}
|
||||
|
||||
await pruneBackups(env)
|
||||
if (!opts?.restoreFrom) await clearRedo(env)
|
||||
const timings = [checkTiming, backupTiming, writeTiming, reloadTiming].filter(Boolean).join(',')
|
||||
console.log(`[apply] 共 ${String(Date.now() - clock.start)}ms(${timings})`)
|
||||
return {
|
||||
@@ -343,6 +350,99 @@ export async function importConfig(
|
||||
return applyConfig(env, merged)
|
||||
}
|
||||
|
||||
/* ── 撤回 / 重做 ──────────────────────────────────
|
||||
*
|
||||
* 撤回栈就是备份链:每次成功落盘都留了「改动前」的备份。撤回把最新备份应用
|
||||
* 回去并**消费掉它**(从链里删除),同时把撤回前的状态推进重做栈;重做反向。
|
||||
* 任何正常的新改动清空重做栈——和编辑器撤回的通行语义一致,不留分叉历史。
|
||||
*/
|
||||
|
||||
async function redoDir(env: Env): Promise<string> {
|
||||
const dir = join(dirname(env.configPath), '.patchbay-redo')
|
||||
await mkdir(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
/** 重做条目按名字排序就是按时间排序。带毫秒:连续快速撤回不能互相覆盖。 */
|
||||
function redoName(configPath: string, at: Date): string {
|
||||
const p = (n: number, w = 2) => String(n).padStart(w, '0')
|
||||
const stamp =
|
||||
`${String(at.getFullYear())}${p(at.getMonth() + 1)}${p(at.getDate())}` +
|
||||
`-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}${p(at.getMilliseconds(), 3)}`
|
||||
return `${basename(configPath)}.redo-${stamp}`
|
||||
}
|
||||
|
||||
/** 新的在前,同 listBackups。 */
|
||||
export async function listRedo(env: Env): Promise<string[]> {
|
||||
const dir = await redoDir(env)
|
||||
const prefix = `${basename(env.configPath)}.redo-`
|
||||
const entries = await readdir(dir)
|
||||
return entries.filter((name) => name.startsWith(prefix)).sort().reverse()
|
||||
}
|
||||
|
||||
async function clearRedo(env: Env): Promise<void> {
|
||||
const dir = await redoDir(env)
|
||||
const entries = await listRedo(env)
|
||||
await Promise.all(entries.map((name) => unlink(join(dir, name)).catch(() => {})))
|
||||
}
|
||||
|
||||
/** 当前文件进重做栈,返回落点路径。 */
|
||||
async function pushRedo(env: Env): Promise<string> {
|
||||
const dir = await redoDir(env)
|
||||
const path = join(dir, redoName(env.configPath, new Date()))
|
||||
await copyFile(env.configPath, path)
|
||||
// 和备份同一个上限。裁掉的是最「未来」的状态,同备份链裁掉最远的过去
|
||||
const stale = (await listRedo(env)).slice(KEEP_BACKUPS)
|
||||
await Promise.all(stale.map((name) => unlink(join(dir, name)).catch(() => {})))
|
||||
return path
|
||||
}
|
||||
|
||||
/** 撤回最近一次改动:应用最新备份并消费掉它,撤回前的状态进重做栈。 */
|
||||
export async function undoConfig(env: Env): Promise<ApplyOutcome> {
|
||||
const [target] = await listBackups(env)
|
||||
if (!target) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'self-check',
|
||||
message: '没有可以撤回的改动',
|
||||
hint: '每次保存都会把改动前的状态留进撤回栈,现在栈是空的。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
const dir = await backupDir(env)
|
||||
const targetYaml = await readFile(join(dir, target), 'utf8')
|
||||
const redoPath = await pushRedo(env)
|
||||
const result = await applyConfig(env, openDocument(targetYaml).data, undefined, {
|
||||
restoreFrom: redoPath,
|
||||
})
|
||||
if (result.ok) await unlink(join(dir, target)).catch(() => {})
|
||||
else await unlink(redoPath).catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
/** 重做最近一次被撤回的改动。当前状态回到撤回栈,重做条目被消费。 */
|
||||
export async function redoConfig(env: Env): Promise<ApplyOutcome> {
|
||||
const [target] = await listRedo(env)
|
||||
if (!target) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'self-check',
|
||||
message: '没有可以重做的改动',
|
||||
hint: '只有撤回过的改动才能重做,新的保存会清空重做栈。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
const dir = await redoDir(env)
|
||||
const targetYaml = await readFile(join(dir, target), 'utf8')
|
||||
const backupPath = await createBackup(env)
|
||||
const result = await applyConfig(env, openDocument(targetYaml).data, undefined, {
|
||||
restoreFrom: backupPath,
|
||||
})
|
||||
if (result.ok) await unlink(join(dir, target)).catch(() => {})
|
||||
else await unlink(backupPath).catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
/** 把某个备份恢复成当前配置。走的是和普通改动同一条保护链路。 */
|
||||
export async function restoreBackup(env: Env, name: string): Promise<ApplyOutcome> {
|
||||
const dir = await backupDir(env)
|
||||
|
||||
+32
-2
@@ -12,13 +12,23 @@
|
||||
import { resolve, sep } from 'node:path'
|
||||
import listenerFields from '../shared/listener-fields.json' with { type: 'json' }
|
||||
import { isProviderFilePath } from '../shared/provider-files.ts'
|
||||
import { applyConfig, importConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
|
||||
import {
|
||||
applyConfig,
|
||||
importConfig,
|
||||
listBackups,
|
||||
listRedo,
|
||||
readConfig,
|
||||
redoConfig,
|
||||
restoreBackup,
|
||||
undoConfig,
|
||||
} from './config-store.ts'
|
||||
import { geoStatus } from './geo.ts'
|
||||
import { hostInfo, probeExit } from './host.ts'
|
||||
import {
|
||||
kernelVersion,
|
||||
proxyGet,
|
||||
proxyWrite,
|
||||
reloadConfig,
|
||||
restartKernel,
|
||||
selectProxy,
|
||||
updateGeoDatabases,
|
||||
@@ -181,7 +191,19 @@ function makeRoute(env: Env, token: string) {
|
||||
}
|
||||
|
||||
if (path === '/api/config/backups' && req.method === 'GET') {
|
||||
return json({ backups: await listBackups(env) })
|
||||
// redo 是重做栈深度,侧栏用它决定重做按钮亮不亮
|
||||
return json({ backups: await listBackups(env), redo: (await listRedo(env)).length })
|
||||
}
|
||||
|
||||
// 撤回/重做。走 config-store 的保护链路,备份链的进出由它们自己管
|
||||
if (path === '/api/config/undo' && req.method === 'POST') {
|
||||
const result = await undoConfig(env)
|
||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||
}
|
||||
|
||||
if (path === '/api/config/redo' && req.method === 'POST') {
|
||||
const result = await redoConfig(env)
|
||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||
}
|
||||
|
||||
// 配置文件原文,给「下载当前配置」用。带 attachment 让浏览器按下载处理
|
||||
@@ -269,6 +291,14 @@ function makeRoute(env: Env, token: string) {
|
||||
: json({ message: upstream.message || '内核拒绝了这次切换' }, 422)
|
||||
}
|
||||
|
||||
// 让内核重新加载磁盘上的配置文件。给 SSH 手改过配置的场景用;运行时动作
|
||||
if (path === '/api/kernel/reload' && req.method === 'POST') {
|
||||
const upstream = await reloadConfig(env, env.configPath)
|
||||
return upstream.ok
|
||||
? json({ ok: true })
|
||||
: json({ message: upstream.message || '内核重载失败' }, 502)
|
||||
}
|
||||
|
||||
// GEO 数据库文件的本地状态(大小、更新时间)和生效中的下载地址
|
||||
if (path === '/api/geo' && req.method === 'GET') {
|
||||
const snapshot = await readConfig(env)
|
||||
|
||||
+192
@@ -15,9 +15,18 @@ import RouterPage from './views/RouterPage.vue'
|
||||
import RulesPage from './views/RulesPage.vue'
|
||||
import SettingsPage from './views/SettingsPage.vue'
|
||||
import TokenGate from './views/TokenGate.vue'
|
||||
import ToastHost from './components/ToastHost.vue'
|
||||
import HistoryDialog from './components/HistoryDialog.vue'
|
||||
import ConfirmDialog from './components/ConfirmDialog.vue'
|
||||
import { toast, toastError } from './toast'
|
||||
import IconFold from '~icons/ri/sidebar-fold-line'
|
||||
import IconUnfold from '~icons/ri/sidebar-unfold-line'
|
||||
import IconLogout from '~icons/ri/logout-box-r-line'
|
||||
import IconUndo from '~icons/ri/arrow-go-back-line'
|
||||
import IconRedo from '~icons/ri/arrow-go-forward-line'
|
||||
import IconHistory from '~icons/ri/history-line'
|
||||
import IconReload from '~icons/ri/refresh-line'
|
||||
import IconRestart from '~icons/ri/restart-line'
|
||||
|
||||
const snapshot = ref<ConfigSnapshot | null>(null)
|
||||
const needsToken = ref(!getToken())
|
||||
@@ -53,6 +62,8 @@ async function load() {
|
||||
await reloadRuntime()
|
||||
snapshot.value = await api.readConfig()
|
||||
needsToken.value = false
|
||||
// 撤回/重做按钮的可用性跟着每次重读走
|
||||
void refreshHistory()
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.unauthorized) {
|
||||
clearToken()
|
||||
@@ -76,6 +87,91 @@ function logout() {
|
||||
location.reload()
|
||||
}
|
||||
|
||||
/* ── 侧栏左下角的配置动作 ─────────────────────── */
|
||||
|
||||
/** 撤回栈(备份链)和重做栈的深度,决定按钮亮不亮。每次 load() 时刷新。 */
|
||||
const history = ref<{ backups: string[]; redo: number }>({ backups: [], redo: 0 })
|
||||
|
||||
async function refreshHistory() {
|
||||
history.value = await api.listBackups().catch(() => ({ backups: [], redo: 0 }))
|
||||
}
|
||||
|
||||
const busyAct = ref(false)
|
||||
const historyOpen = ref(false)
|
||||
const askRestart = ref(false)
|
||||
|
||||
async function undo() {
|
||||
if (busyAct.value) return
|
||||
busyAct.value = true
|
||||
try {
|
||||
const result = await api.undoConfig()
|
||||
toast(result.reloaded ? '已撤回上一次改动' : `已撤回上一次改动(${result.reloadMessage})`)
|
||||
await load()
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
busyAct.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function redo() {
|
||||
if (busyAct.value) return
|
||||
busyAct.value = true
|
||||
try {
|
||||
const result = await api.redoConfig()
|
||||
toast(result.reloaded ? '已重做' : `已重做(${result.reloadMessage})`)
|
||||
await load()
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
busyAct.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 让内核重读磁盘上的配置文件,界面跟着重读。给 SSH 手改过配置的场景用。 */
|
||||
async function reloadFromDisk() {
|
||||
if (busyAct.value) return
|
||||
busyAct.value = true
|
||||
try {
|
||||
await api.reloadKernelConfig()
|
||||
toast('内核已重新加载配置文件')
|
||||
await load()
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
busyAct.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 重启后每 2 秒探一次、最多 30 秒;心跳兜底是 10 秒一次,这里只是快一点。 */
|
||||
let restartTimer = 0
|
||||
onBeforeUnmount(() => window.clearInterval(restartTimer))
|
||||
|
||||
async function restartConfirmed() {
|
||||
askRestart.value = false
|
||||
try {
|
||||
await api.restartKernel()
|
||||
kernel.value = { online: false }
|
||||
toast('已发出重启指令,等待内核回来')
|
||||
const started = Date.now()
|
||||
window.clearInterval(restartTimer)
|
||||
restartTimer = window.setInterval(() => {
|
||||
void refreshKernel().then(() => {
|
||||
if (kernel.value.online) {
|
||||
window.clearInterval(restartTimer)
|
||||
void reloadRuntime()
|
||||
toast('内核已恢复在线')
|
||||
} else if (Date.now() - started > 30_000) {
|
||||
window.clearInterval(restartTimer)
|
||||
toast('内核 30 秒内没有回来', { kind: 'error', hint: '去日志页看看它在说什么。' })
|
||||
}
|
||||
})
|
||||
}, 2_000)
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!needsToken.value) void load()
|
||||
// 流量、每路吞吐和日志的历史跨页面留存,所以收集挂在外壳上而不是各页里。
|
||||
@@ -137,6 +233,55 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
<span class="nav-label">退出登录</span>
|
||||
</button>
|
||||
|
||||
<!-- 配置动作一排。收拢时容器变窄,图标自动折行成一列 -->
|
||||
<div class="acts">
|
||||
<button
|
||||
type="button"
|
||||
class="act"
|
||||
title="撤回"
|
||||
:disabled="busyAct || !snapshot || history.backups.length === 0"
|
||||
@click="undo"
|
||||
>
|
||||
<IconUndo />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="act"
|
||||
title="重做"
|
||||
:disabled="busyAct || !snapshot || history.redo === 0"
|
||||
@click="redo"
|
||||
>
|
||||
<IconRedo />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="act"
|
||||
title="历史记录"
|
||||
:disabled="!snapshot"
|
||||
@click="historyOpen = true"
|
||||
>
|
||||
<IconHistory />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="act"
|
||||
:title="kernel.online ? '更新配置(让内核重读配置文件)' : '内核未连接'"
|
||||
:disabled="busyAct || !snapshot || !kernel.online"
|
||||
@click="reloadFromDisk"
|
||||
>
|
||||
<IconReload />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="act"
|
||||
:title="kernel.online ? '重启内核' : '内核未连接'"
|
||||
:disabled="!kernel.online"
|
||||
@click="askRestart = true"
|
||||
>
|
||||
<IconRestart />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="side-foot">
|
||||
<div class="kernel" :title="kernel.online ? kernel.version : '内核未连接'">
|
||||
<span :class="['dot', kernel.online ? 'dot-ok' : 'dot-off']" aria-hidden="true"></span>
|
||||
@@ -219,7 +364,19 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<HistoryDialog :open="historyOpen" @close="historyOpen = false" @restored="load" />
|
||||
|
||||
<ConfirmDialog
|
||||
:open="askRestart"
|
||||
message="重启内核进程?所有连接会断开,几秒后自动恢复;策略组的选中依赖 profile.store-selected 找回。"
|
||||
action="重启"
|
||||
@close="askRestart = false"
|
||||
@confirm="restartConfirmed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ToastHost />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -379,6 +536,41 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
/* 配置动作排:展开时一行五个,收拢时容器随 --side-w 变窄、flex 自动折成一列 */
|
||||
.acts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-1);
|
||||
padding: 0 var(--s-2);
|
||||
width: calc(var(--side-w) - 2 * var(--s-3));
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.act {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.act:hover:not(:disabled) {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.act:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.act svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.side-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+11
-1
@@ -141,7 +141,14 @@ export const api = {
|
||||
applyConfig: (data: ConfigObject, baseRevision: string) =>
|
||||
request<ApplySuccess>('PUT', '/api/config', { data, baseRevision }),
|
||||
|
||||
listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'),
|
||||
/** 备份链(撤回栈)和重做栈深度。侧栏用它决定撤回/重做按钮亮不亮。 */
|
||||
listBackups: () => request<{ backups: string[]; redo: number }>('GET', '/api/config/backups'),
|
||||
|
||||
/** 撤回最近一次改动:应用最新备份并消费掉它,撤回前的状态进重做栈。 */
|
||||
undoConfig: () => request<ApplySuccess>('POST', '/api/config/undo'),
|
||||
|
||||
/** 重做最近一次被撤回的改动。新的正常保存会清空重做栈。 */
|
||||
redoConfig: () => request<ApplySuccess>('POST', '/api/config/redo'),
|
||||
|
||||
/** 配置文件的原文,给「下载当前配置」用。 */
|
||||
configRaw: async (): Promise<string> => {
|
||||
@@ -189,6 +196,9 @@ export const api = {
|
||||
*/
|
||||
restartKernel: () => request<{ ok: true }>('POST', '/api/kernel/restart'),
|
||||
|
||||
/** 让内核重新加载磁盘上的配置文件。给 SSH 手改过配置的场景用。 */
|
||||
reloadKernelConfig: () => request<{ ok: true }>('POST', '/api/kernel/reload'),
|
||||
|
||||
/**
|
||||
* 让内核更新 GEO 数据库。同步接口,下载完才回话,可能要等上几十秒。
|
||||
* 内核只有全量更新这一个口子,没变化的文件靠内容哈希跳过重写。
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 右上角的轻提示(成就弹窗式):从右滑入一行字,几秒后上移淡出。
|
||||
*
|
||||
* 模块级单例,谁都能发;渲染在外壳的 ToastHost 里。适合「动作已完成/失败」
|
||||
* 这类看一眼就行的反馈;需要用户处理的内容(表单校验、带重试的错误)
|
||||
* 仍然留在页面里,不进这里。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { ApiError } from './api'
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
kind: 'ok' | 'error'
|
||||
message: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
export const toasts = ref<Toast[]>([])
|
||||
|
||||
let nextId = 1
|
||||
|
||||
export function toast(message: string, opts?: { kind?: 'ok' | 'error'; hint?: string }): void {
|
||||
const item: Toast = {
|
||||
id: nextId++,
|
||||
kind: opts?.kind ?? 'ok',
|
||||
message,
|
||||
hint: opts?.hint ?? '',
|
||||
}
|
||||
toasts.value = [...toasts.value, item]
|
||||
// 错误多留一会儿,看清 hint 里的下一步
|
||||
const ttl = item.kind === 'error' ? 8000 : 4000
|
||||
setTimeout(() => dismiss(item.id), ttl)
|
||||
}
|
||||
|
||||
export function dismiss(id: number): void {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||
}
|
||||
|
||||
/** 接口报错的统一出口:message 说发生了什么,hint 说下一步。 */
|
||||
export function toastError(error: unknown): void {
|
||||
if (error instanceof ApiError) toast(error.message, { kind: 'error', hint: error.hint })
|
||||
else toast(String(error), { kind: 'error' })
|
||||
}
|
||||
+64
-100
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user