feat(trash): add bulk purge and automatic retention

This commit is contained in:
eezd committed 2026-10-25 11:56:28 +08:00
1 parent 2b8f18f0db
commit ac10de0466
16 files changed
+528 -26

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ const dialog = ref<HTMLElement>()
const content = ref<HTMLElement>()
const heading = ref<HTMLElement>()
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
const preferenceSection = computed(() => ['appearance', 'editor', 'shortcuts'].includes(section.value))
const preferenceSection = computed(() => ['appearance', 'editor', 'shortcuts', 'trash'].includes(section.value))
const saveLabel = computed(() => settingsSave.pending ? '保存中…' : settingsSave.result === 'error' ? '保存失败' : settingsSave.result === 'saved' ? '已保存' : '修改自动保存')
async function open(id: SettingsSection) {
+58 -18
View File
@@ -2,33 +2,67 @@
import { onMounted, ref } from 'vue'
import { api, type TrashItem } from './api'
import Icon from './Icon.vue'
import SettingRow from './SettingRow.vue'
import { isImage } from './paths'
import { DEFAULTS, loadSettings, settings, SettingsAppearanceError, updateSettings } from './settings'
import { restore, toast } from './workspace'
const items = ref<TrashItem[] | null>(null)
const failed = ref(false)
const error = ref('')
const failures = ref<{ id: string; error: string }[]>([])
const busy = ref(false)
const saving = ref(false)
async function load() {
try {
items.value = await api.trash()
} catch {
failed.value = true
error.value = ''
} catch (cause) {
error.value = '读取回收站失败:' + String(cause)
}
}
async function doRestore(item: TrashItem) {
items.value = items.value!.filter((i) => i.id !== item.id)
await restore(item.id)
async function retention(event: Event) {
const input = event.target as HTMLInputElement
const days = input.valueAsNumber
if (!Number.isSafeInteger(days) || days < 0) {
input.value = String(settings.trashRetentionDays)
toast('请输入非负整数天数,0 表示永不自动删除')
return
}
if (days === settings.trashRetentionDays) return
saving.value = true
try { await updateSettings({ trashRetentionDays: days }) }
catch (cause) {
toast('保存自动删除期限失败:' + String(cause))
if (!(cause instanceof SettingsAppearanceError)) await loadSettings().catch(cause => toast('重新读取设置失败:' + String(cause)))
} finally { saving.value = false }
}
async function purge(item: TrashItem) {
if (!confirm(`彻底删除“${item.path}”?此操作无法撤销。`)) return
items.value = items.value!.filter((i) => i.id !== item.id)
async function doRestore(item: TrashItem) {
if (busy.value) return
busy.value = true
try { await restore(item.id); await load() }
finally { busy.value = false }
}
async function purge(item?: TrashItem) {
if (busy.value) return
if (!confirm(item ? `彻底删除“${item.path}”?此操作无法撤销。` : '清空回收站全部条目?此操作无法撤销。')) return
busy.value = true
failures.value = []
try {
await api.purge(item.id)
} catch {
toast('删除失败')
void load()
if (item) await api.purge(item.id)
else {
const result = await api.clearTrash()
failures.value = result.failed
toast(`已永久删除 ${result.deleted.length} 项${result.failed.length ? `,${result.failed.length} 项失败` : ''}`)
}
} catch (cause) {
toast('删除失败:' + String(cause))
} finally {
await load()
busy.value = false
}
}
@@ -38,17 +72,23 @@ onMounted(load)
<template>
<div>
<p v-if="failed" class="rm-tree-empty">读取回收站失败</p>
<p v-else-if="items && !items.length" class="rm-tree-empty">回收站是空的</p>
<ul v-else class="rm-trash-list">
<SettingRow id="trash-retention" control="rm-trash-retention">
<label class="rm-font-input"><input id="rm-trash-retention" type="number" min="0" step="1" :placeholder="String(DEFAULTS.trashRetentionDays)" :value="settings.trashRetentionDays" :disabled="saving" @change="retention" /> 天</label>
</SettingRow>
<SettingRow id="trash-clear"><button class="rm-btn rm-btn-danger" :disabled="busy || items?.length === 0" @click="purge()">清空全部</button></SettingRow>
<p v-if="error" role="alert" class="rm-auth-error">{{ error }} <button class="rm-btn" :disabled="busy" @click="load">重试</button></p>
<ul v-if="failures.length" role="alert"><li v-for="failure in failures" :key="failure.id">{{ items?.find(item => item.id === failure.id)?.path || failure.id }}:{{ failure.error }}</li></ul>
<p v-if="items && !items.length" class="rm-tree-empty">回收站是空的</p>
<p v-else-if="items === null && !error" class="rm-tree-empty" role="status">读取中…</p>
<ul v-else class="rm-trash-list" :aria-busy="busy">
<li v-for="item in items ?? []" :key="item.id">
<Icon :name="item.dir ? 'folder' : isImage(item.path) ? 'image' : 'note'" />
<div class="rm-trash-info">
<span class="rm-trash-path">{{ item.path.replace(/\.md$/, '') }}</span>
<span class="rm-trash-time">{{ fmt.format(item.deletedAt) }}</span>
</div>
<button class="rm-btn" @click="doRestore(item)">恢复</button>
<button class="rm-btn rm-btn-danger" @click="purge(item)">彻底删除</button>
<button class="rm-btn" :disabled="busy" @click="doRestore(item)">恢复</button>
<button class="rm-btn rm-btn-danger" :disabled="busy" @click="purge(item)">彻底删除</button>
</li>
</ul>
</div>
+6
View File
@@ -57,6 +57,11 @@ export interface TrashItem {
deletedAt: number
}
export interface TrashPurgeResult {
deleted: string[]
failed: { id: string; error: string }[]
}
export interface SearchResult {
path: string
// 第一处匹配所在行(从 1 开始)及其摘录;只有路径匹配时 line 为 0
@@ -216,6 +221,7 @@ export const api = {
json<{ token: string; info: ApiToken }>(request('POST', '/api/v1/tokens', { json: { name, scope } })),
deleteToken: (id: string) => request('DELETE', '/api/v1/tokens/' + encodeURIComponent(id)),
clearTrash: () => json<TrashPurgeResult>(request('DELETE', '/api/v1/trash')),
trash: async () => (await json<{ items: TrashItem[] }>(request('GET', '/api/v1/trash'))).items,
restore: (id: string) => json<TrashItem>(request('POST', `/api/v1/trash/${id}/restore`)),
purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`),
+46
View File
@@ -29,6 +29,7 @@ afterEach(() => {
cleanup.splice(0).forEach(fn => fn())
ws.settings = null
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function mount(component: typeof SettingsDialog | typeof ShortcutSettings) {
host = document.createElement('div')
@@ -318,3 +319,48 @@ describe('快捷键查找', () => {
expect(host!.textContent).toContain('没有匹配的快捷键')
})
})
describe('回收站', () => {
it('清空必须确认;防止重复操作,部分失败后重读并保留错误详情', async () => {
const item = { id: 'one', path: 'broken.md', dir: false, deletedAt: Date.now() }
vi.spyOn(api, 'trash').mockResolvedValue([item])
let finish!: (value: { deleted: string[]; failed: { id: string; error: string }[] }) => void
const clear = vi.spyOn(api, 'clearTrash').mockImplementation(() => new Promise(done => { finish = done }))
const confirm = vi.fn().mockReturnValueOnce(false).mockReturnValue(true)
vi.stubGlobal('confirm', confirm)
ws.settings = 'trash'
mount(SettingsDialog)
await vi.waitFor(() => expect(host!.textContent).toContain('broken'))
const button = host!.querySelector<HTMLButtonElement>('#rm-setting-trash-clear button')!
button.click()
expect(clear).not.toHaveBeenCalled()
button.click()
await nextTick()
expect(confirm).toHaveBeenCalledTimes(2)
expect(button.disabled).toBe(true)
expect([...host!.querySelectorAll<HTMLButtonElement>('.rm-trash-list button')].every(b => b.disabled)).toBe(true)
button.click()
expect(clear).toHaveBeenCalledTimes(1)
finish({ deleted: [], failed: [{ id: item.id, error: '元数据损坏' }] })
await vi.waitFor(() => expect(host!.querySelector('[role="alert"]')?.textContent).toContain('元数据损坏'))
expect(api.trash).toHaveBeenCalledTimes(2)
expect(button.disabled).toBe(false)
})
it('保留天数支持0,拒绝负数和小数,不提交无效值', async () => {
vi.spyOn(api, 'trash').mockResolvedValue([])
ws.settings = 'trash'
mount(SettingsDialog)
const input = host!.querySelector<HTMLInputElement>('#rm-trash-retention')!
expect(input.value).toBe('30')
for (const value of ['-1', '1.5', '']) {
input.value = value
input.dispatchEvent(new Event('change', { bubbles: true }))
expect(api.patchSettings).not.toHaveBeenCalled()
expect(input.value).toBe('30')
}
input.value = '0'
input.dispatchEvent(new Event('change', { bubbles: true }))
await vi.waitFor(() => expect(api.patchSettings).toHaveBeenCalledWith({ trashRetentionDays: 0 }))
expect(settings.trashRetentionDays).toBe(0)
})
})
+2 -1
View File
@@ -18,7 +18,7 @@ export const CONTENT_WIDTHS = [
export const SIDEBAR_WIDTH = { min: 200, max: 480 }
export const DEFAULTS = { hiddenFonts: [] as string[], selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
export const DEFAULTS = { trashRetentionDays: 30, hiddenFonts: [] as string[], selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]> }
const CACHE_KEY = 'rawmark.settings'
@@ -39,6 +39,7 @@ function sanitize(raw: Record<string, unknown>): Settings {
const hiddenFonts = Array.isArray(raw.hiddenFonts) ? raw.hiddenFonts.filter((id): id is string => typeof id === 'string' && id !== 'maple' && Object.hasOwn(FONT_PRESETS, id)) : []
return {
hiddenFonts,
trashRetentionDays: Number.isSafeInteger(raw.trashRetentionDays) && (raw.trashRetentionDays as number) >= 0 ? raw.trashRetentionDays as number : DEFAULTS.trashRetentionDays,
...sanitizeDisplaySettings(raw, customFonts.value.map(font => font.id), hiddenFonts),
selectionToolbar: typeof raw.selectionToolbar === 'boolean' ? raw.selectionToolbar : true,
shortcuts: sanitizeShortcuts(raw.shortcuts),
+2
View File
@@ -13,6 +13,8 @@ export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: Icon
]
export const SETTING_ITEMS = {
'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' },
'ui-font-size': { section: 'appearance', title: '界面字号', description: '调整菜单、侧栏与按钮文字,不影响正文和代码字号。', keywords: 'UI 大小' },
'theme-select': { section: 'appearance', title: '主题', description: '使用内置主题,或从服务器数据目录的 themes 文件夹添加主题。', keywords: '白色 黑色 自定义' },