feat(storage): expose backup restoration and maintenance tools
This commit is contained in:
1 parent
88492fdcde
commit
9449a1aa65
18 files changed
+2194
-61
No files matched your search
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { api, type BackupKind, type MaintenanceStatus } from './api'
|
||||
import { flushAll, readyToReload, ws } from './workspace'
|
||||
import { flushSettings, loadSettings, reloadTheme } from './settings'
|
||||
import { refreshPlugins } from '../plugins/host'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const busy = ref(false)
|
||||
const progress = ref('')
|
||||
const error = ref('')
|
||||
const message = ref('')
|
||||
const last = ref<MaintenanceStatus | null>(null)
|
||||
const fullInput = ref<HTMLInputElement>()
|
||||
const settingsInput = ref<HTMLInputElement>()
|
||||
let mounted = true
|
||||
onBeforeUnmount(() => { mounted = false })
|
||||
onMounted(async () => {
|
||||
try { const status = await api.maintenanceStatus(); if (mounted) last.value = status }
|
||||
catch (cause) { if (mounted) error.value = '读取维护状态失败:' + String(cause) }
|
||||
})
|
||||
async function prepare() {
|
||||
await Promise.all([flushAll(), flushSettings()])
|
||||
if (!readyToReload()) throw new Error('仍有未保存内容或正在上传、操作文件,请完成后再试')
|
||||
}
|
||||
async function perform(label: string, action: () => Promise<void>) {
|
||||
if (busy.value) return
|
||||
busy.value = true; progress.value = label; error.value = ''; message.value = ''
|
||||
try {
|
||||
await prepare()
|
||||
await action()
|
||||
if (mounted) { emit('changed'); last.value = await api.maintenanceStatus() }
|
||||
} catch (cause) { if (mounted) error.value = String(cause) }
|
||||
finally { if (mounted) { busy.value = false; progress.value = '' } }
|
||||
}
|
||||
async function download(response: Response, fallback: string) {
|
||||
const blob = await response.blob()
|
||||
const header = response.headers.get('Content-Disposition') ?? ''
|
||||
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header)?.[1]
|
||||
const plain = /filename="([^"]+)"/i.exec(header)?.[1]
|
||||
const name = encoded ? decodeURIComponent(encoded) : plain ?? fallback
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = Object.assign(document.createElement('a'), { href: url, download: name })
|
||||
link.click();setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
message.value = '导出文件已生成'
|
||||
}
|
||||
async function exportPackage(kind: BackupKind) { await perform('正在导出…', async () => download(await api.exportBackup(kind), `rawmark-${kind}.zip`)) }
|
||||
async function exportMarkdown() { await perform('正在导出笔记…', async () => download(await api.exportNotes(), 'rawmark-notes.zip')) }
|
||||
async function exportSystemLogs() { await perform('正在导出日志…', async () => download(await api.exportLogs(), 'rawmark-system-logs.zip')) }
|
||||
async function syncPreferences() { await loadSettings(); await reloadTheme(); await refreshPlugins() }
|
||||
async function reset() {
|
||||
if (!confirm('恢复全部 RawMark 偏好?快捷键恢复默认,回收站期限恢复为 30 天,插件停用。保留笔记、账号、已安装资源、插件存储和本机标签页/布局。操作前会备份设置。')) return
|
||||
await perform('正在重置设置…', async () => { await api.resetSettings(); await syncPreferences(); message.value = '设置已重置,原设置已备份' })
|
||||
}
|
||||
async function restore(kind: BackupKind, event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]; input.value = ''
|
||||
if (!file) return
|
||||
await perform('正在上传…', async () => {
|
||||
if (file.size <= 0 || file.size > 2 * 1024 ** 3) throw new Error('备份压缩大小须在 0–2 GiB 之间')
|
||||
const { id } = await api.startBackupUpload(kind, file.size)
|
||||
let submitted = false
|
||||
try {
|
||||
let offset = 0
|
||||
while (offset < file.size) {
|
||||
if (!mounted) throw new Error('已取消上传')
|
||||
const chunk = file.slice(offset, offset + 8 * 1024 ** 2)
|
||||
const result = await api.uploadBackupChunk(id, offset, chunk)
|
||||
if (result.offset !== offset + chunk.size) throw new Error('服务端返回的上传进度不一致')
|
||||
offset = result.offset; progress.value = `正在上传… ${Math.round(offset / file.size * 100)}%`
|
||||
}
|
||||
progress.value = '正在校验备份…'
|
||||
const summary = await api.validateBackup(id)
|
||||
if (!mounted) throw new Error('已取消导入')
|
||||
const description = kind === 'full'
|
||||
? '将替换全部实例数据,恢复备份中的密码与 API 令牌,短暂维护后需要重新登录。'
|
||||
: '将替换全部偏好和插件配置,合并字体、主题和插件,同 ID 资源以导入包为准。'
|
||||
if (!confirm(`备份时间:${new Date(summary.createdAt).toLocaleString()}\n条目:${summary.entries},展开大小:${(summary.size / 1024 ** 2).toFixed(2)} MiB\n${description}\n操作前会备份当前内容。确认继续?`)) return
|
||||
await prepare()
|
||||
progress.value = kind === 'full' ? '正在恢复实例,请勿关闭页面…' : '正在导入设置…'
|
||||
if (kind === 'settings') {
|
||||
await api.importSettings(id);submitted = true
|
||||
await syncPreferences();message.value = '设置与资源已导入,原设置已备份'
|
||||
} else {
|
||||
await api.restoreBackup(id);submitted = true
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
try { if (!(await api.authState()).authenticated) { location.reload(); return } }
|
||||
catch { /* 服务关闭和重新监听期间继续等待,最终超时会明确提示。 */ }
|
||||
}
|
||||
throw new Error('恢复请求已提交,服务仍在维护,请稍后重新打开页面核对结果')
|
||||
}
|
||||
} finally {
|
||||
if (!submitted) {
|
||||
try { await api.cancelBackupUpload(id) }
|
||||
catch (cause) { if (mounted) error.value = '清理上传暂存失败:' + String(cause) }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
async function index(operation: 'optimize' | 'rebuild') {
|
||||
await perform(operation === 'optimize' ? '正在优化索引…' : '正在重建索引…', async () => {
|
||||
await api.maintainIndex(operation);message.value = operation === 'optimize' ? '索引已优化' : '索引已重建'
|
||||
})
|
||||
}
|
||||
async function clearTemp() {
|
||||
await perform('正在清理临时文件…', async () => {
|
||||
const result = await api.clearTemp()
|
||||
message.value = `已清理 ${result.files} 个文件,释放 ${(result.bytes / 1024 ** 2).toFixed(2)} MiB`
|
||||
if (result.failed.length) error.value = '部分清理失败:\n' + result.failed.join('\n')
|
||||
})
|
||||
}
|
||||
function importNotes() { if (!busy.value) { ws.settings = null; ws.importing = '' } }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-maintenance" :aria-busy="busy">
|
||||
<h3 class="rm-settings-group-title">备份与迁移</h3>
|
||||
<SettingRow id="backup-full"><button class="rm-btn" :disabled="busy" @click="exportPackage('full')">导出</button><button class="rm-btn" :disabled="busy" @click="fullInput!.click()">恢复</button><input ref="fullInput" type="file" accept=".zip" hidden @change="restore('full', $event)" /></SettingRow>
|
||||
<SettingRow id="backup-notes"><button class="rm-btn" :disabled="busy" @click="exportMarkdown">导出</button><button class="rm-btn" :disabled="busy" @click="importNotes">导入</button></SettingRow>
|
||||
<SettingRow id="backup-settings"><button class="rm-btn" :disabled="busy" @click="exportPackage('settings')">导出</button><button class="rm-btn" :disabled="busy" @click="settingsInput!.click()">导入</button><input ref="settingsInput" type="file" accept=".zip" hidden @change="restore('settings', $event)" /></SettingRow>
|
||||
<h3 class="rm-settings-group-title">维护工具</h3>
|
||||
<SettingRow id="index-optimize"><button class="rm-btn" :disabled="busy" @click="index('optimize')">优化</button></SettingRow>
|
||||
<SettingRow id="index-rebuild"><button class="rm-btn" :disabled="busy" @click="index('rebuild')">重建</button></SettingRow>
|
||||
<SettingRow id="temp-clear"><button class="rm-btn" :disabled="busy" @click="clearTemp">清理</button></SettingRow>
|
||||
<SettingRow id="settings-reset"><button class="rm-btn" :disabled="busy" @click="reset">重置</button></SettingRow>
|
||||
<SettingRow id="logs-export"><button class="rm-btn" :disabled="busy" @click="exportSystemLogs">导出</button></SettingRow>
|
||||
<p v-if="busy" role="status" aria-live="polite">{{ progress }}</p>
|
||||
<p v-if="message" role="status">{{ message }}</p>
|
||||
<p v-if="error" class="rm-auth-error" role="alert">{{ error }}</p>
|
||||
<p v-if="last?.error" class="rm-auth-error" role="alert">上次维护失败:{{ last.error }}</p>
|
||||
<p v-if="last?.backup" class="rm-share-note">操作前备份:backups/{{ last.backup }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rm-maintenance [role="alert"] { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { api, type StorageUsage } from './api'
|
||||
import MaintenancePanel from './MaintenancePanel.vue'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const usage = ref<StorageUsage | null>(null)
|
||||
@@ -69,6 +70,7 @@ onMounted(scan)
|
||||
</template>
|
||||
</dl>
|
||||
</div>
|
||||
<MaintenancePanel @changed="scan" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -67,6 +67,10 @@ export interface StorageUsage {
|
||||
calculatedAt: number
|
||||
}
|
||||
|
||||
export interface MaintenanceStatus { busy: boolean; operation: string; error: string; finishedAt: number; backup?: string }
|
||||
export interface BackupSummary { kind: 'full' | 'settings'; createdAt: number; entries: number; size: number }
|
||||
export type BackupKind = 'full' | 'settings'
|
||||
|
||||
export interface TrashPurgeResult {
|
||||
deleted: string[]
|
||||
failed: { id: string; error: string }[]
|
||||
@@ -143,6 +147,20 @@ export const api = {
|
||||
|
||||
changePassword: (current: string, next: string) => request('POST', '/api/v1/auth/password', { json: { current, next } }),
|
||||
|
||||
maintenanceStatus: () => json<MaintenanceStatus>(request('GET', '/api/v1/maintenance/status')),
|
||||
exportBackup: (kind: BackupKind) => request('POST', '/api/v1/maintenance/export/' + kind),
|
||||
exportNotes: () => request('POST', '/api/v1/maintenance/notes/export'),
|
||||
startBackupUpload: (kind: BackupKind, size: number) => json<{ id: string }>(request('POST', '/api/v1/maintenance/uploads', { json: { kind, size } })),
|
||||
uploadBackupChunk: (id: string, offset: number, body: Blob) => json<{ offset: number }>(request('PUT', '/api/v1/maintenance/uploads/' + id, { body, headers: { 'Upload-Offset': String(offset) } })),
|
||||
validateBackup: (id: string) => json<BackupSummary>(request('POST', '/api/v1/maintenance/uploads/' + id + '/validate')),
|
||||
cancelBackupUpload: (id: string) => request('DELETE', '/api/v1/maintenance/uploads/' + id),
|
||||
importSettings: (id: string) => request('POST', '/api/v1/maintenance/settings/import', { json: { id, confirmed: true } }),
|
||||
restoreBackup: (id: string) => request('POST', '/api/v1/maintenance/restore', { json: { id, confirmed: true } }),
|
||||
resetSettings: () => request('POST', '/api/v1/maintenance/settings/reset', { json: { confirmed: true } }),
|
||||
maintainIndex: (operation: 'optimize' | 'rebuild') => request('POST', '/api/v1/maintenance/index/' + operation),
|
||||
clearTemp: () => json<{ files: number; bytes: number; failed: string[] }>(request('POST', '/api/v1/maintenance/temp/clear')),
|
||||
exportLogs: () => request('POST', '/api/v1/maintenance/logs/export'),
|
||||
|
||||
settings: () => json<Record<string, unknown>>(request('GET', '/api/v1/settings')),
|
||||
fonts: async () => (await json<{ fonts: CustomFont[] }>(request('GET', '/api/v1/fonts'))).fonts,
|
||||
importFont: (file: File) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ beforeEach(() => {
|
||||
vi.spyOn(api, 'patchSettings').mockResolvedValue({})
|
||||
vi.spyOn(api, 'settings').mockResolvedValue({})
|
||||
vi.spyOn(api, 'fonts').mockResolvedValue([])
|
||||
vi.spyOn(api, 'maintenanceStatus').mockResolvedValue({ busy: false, operation: '', error: '', finishedAt: 0 })
|
||||
vi.spyOn(api, 'themes').mockResolvedValue([{ id: 'default', name: '默认', version: '1' }])
|
||||
})
|
||||
afterEach(() => {
|
||||
@@ -428,6 +429,18 @@ describe('应用存储与回收站', () => {
|
||||
expect(host!.textContent).toContain('1 KiB')
|
||||
})
|
||||
|
||||
it('维护重置取消时不发送请求,确认时等待保存并保留真实失败', async () => {
|
||||
vi.spyOn(api, 'storage').mockResolvedValue(usage)
|
||||
const reset = vi.spyOn(api, 'resetSettings').mockRejectedValue(new Error('backup disk full'))
|
||||
vi.stubGlobal('confirm', vi.fn().mockReturnValueOnce(false).mockReturnValue(true))
|
||||
ws.settings = 'storage'; mount(SettingsDialog)
|
||||
const button = host!.querySelector<HTMLButtonElement>('#rm-setting-settings-reset button')!
|
||||
button.click(); expect(reset).not.toHaveBeenCalled()
|
||||
button.click()
|
||||
await vi.waitFor(() => expect(host!.textContent).toContain('backup disk full'))
|
||||
expect(reset).toHaveBeenCalledTimes(1)
|
||||
expect(button.disabled).toBe(false)
|
||||
})
|
||||
it('清空必须确认;防止重复操作,部分失败后重读并保留错误详情', async () => {
|
||||
const item = { id: 'one', path: 'broken.md', dir: false, deletedAt: Date.now() }
|
||||
vi.spyOn(api, 'trash').mockResolvedValue([item])
|
||||
|
||||
@@ -143,3 +143,9 @@ function saveSettings(patch: Partial<Settings>) {
|
||||
saveQueue = pending.catch(() => {})
|
||||
return pending
|
||||
}
|
||||
|
||||
/** 维护前等待现有设置保存;失败时不得继续替换配置。 */
|
||||
export async function flushSettings() {
|
||||
await saveQueue
|
||||
if (settingsSave.result === 'error') throw new Error('设置保存失败:' + settingsSave.error)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: Icon
|
||||
{ id: 'editor', title: '编辑器', icon: 'edit', description: '正文排版、代码字体与编辑行为' },
|
||||
{ id: 'shortcuts', title: '快捷键', icon: 'command', description: '查找命令,添加、移除或重置快捷键' },
|
||||
{ id: 'plugins', title: '插件', icon: 'grid', description: '安装、启停和管理插件及其设置页' },
|
||||
{ id: 'storage', title: '存储空间', icon: 'grid', description: '笔记库、数据库、字体、主题与插件占用' },
|
||||
{ id: 'storage', title: '存储空间', icon: 'grid', description: '空间占用、备份迁移及维护工具' },
|
||||
{ id: 'trash', title: '回收站', icon: 'trash', description: '恢复或永久删除已移入回收站的文件' },
|
||||
{ id: 'account', title: '账户与安全', icon: 'user', description: '登录密码、API 令牌与退出登录' },
|
||||
{ id: 'about', title: '关于', icon: 'info', description: '应用版本、安装应用与 API 文档' },
|
||||
@@ -14,6 +14,14 @@ export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: Icon
|
||||
|
||||
export const SETTING_ITEMS = {
|
||||
'storage': { section: 'storage', title: '空间占用', description: '自动统计笔记库、数据库、字体、主题、插件及其他文件;字体不包含默认 Maple。', keywords: '容量 占用 大小 附件 图片 回收站' },
|
||||
'backup-full': { section: 'storage', title: '完整服务备份', description: '包含笔记、回收站、登录信息、设置和资源,用于恢复实例;恢复前备份当前数据,完成后重新登录。请妥善保管导出包。', keywords: 'Data 导入 导出 备份 恢复 迁移' },
|
||||
'backup-notes': { section: 'storage', title: '笔记数据', description: '导出普通 Markdown 与附件 ZIP,保留目录和相对链接,缺失附件另附清单;导入到新文件夹,不覆盖现有笔记。', keywords: 'Data MD Markdown 导入 导出 迁移' },
|
||||
'backup-settings': { section: 'storage', title: '设置与资源', description: '备份偏好、快捷键、插件数据及字体、主题、插件;不包含笔记或登录信息。导入前备份,同 ID 资源替换。', keywords: '设置 导入 导出 字体 主题 插件' },
|
||||
'index-optimize': { section: 'storage', title: '优化索引', description: '整理全文索引并回收索引数据库空闲空间,不修改笔记。', keywords: '搜索 数据库 整理 VACUUM' },
|
||||
'index-rebuild': { section: 'storage', title: '重建索引', description: '从当前 Markdown 笔记重新生成全文搜索和元数据索引。', keywords: '搜索 数据库 重建 修复' },
|
||||
'temp-clear': { section: 'storage', title: '清理临时文件', description: '清理本程序不再使用的临时文件;保留活动上传、恢复备份与用户文件。', keywords: '临时 缓存 空间 清理' },
|
||||
'settings-reset': { section: 'storage', title: '重置设置', description: '恢复全部 RawMark 偏好并停用插件,保留笔记、账户、已安装资源、插件存储和本机标签页/布局。', keywords: '重置 默认 恢复' },
|
||||
'logs-export': { section: 'storage', title: '导出系统日志', description: '导出有限轮转的应用日志和运行环境摘要,不包含笔记正文及认证信息。历史未保存的日志无法补回。', keywords: '日志 系统 错误 排查' },
|
||||
'trash-retention': { section: 'trash', title: '自动删除期限', description: '按原删除时间计算,默认 30 天;0 表示永不自动删除。修改期限会影响已有条目,服务启动及每小时清理一次。', keywords: '回收站 天数 自动 清理 保留' },
|
||||
'trash-clear': { section: 'trash', title: '清空全部', description: '永久删除回收站中的全部条目,无法撤销。', keywords: '回收站 清理 全部 删除' },
|
||||
'ui-font': { section: 'appearance', title: '界面字体', description: '用于菜单、侧栏与按钮;可导入或删除字体,Maple 为不可删除的默认字体。', keywords: 'UI 字体 导入 删除 TTF OTF WOFF' },
|
||||
|
||||
Reference in new issue
Block a user