feat(settings): show categorized storage usage on demand
This commit is contained in:
1 parent
ac10de0466
commit
56145dba72
14 files changed
+440
-5
No files matched your search
@@ -14,6 +14,7 @@ import { SETTINGS_SECTIONS, searchSettings, type SettingSearchResult } from './s
|
||||
import ShortcutSettings from './ShortcutSettings.vue'
|
||||
import TokensPanel from './TokensPanel.vue'
|
||||
import TrashPanel from './TrashPanel.vue'
|
||||
import StoragePanel from './StoragePanel.vue'
|
||||
import { toast, ws, type SettingsSection } from './workspace'
|
||||
|
||||
const buildVersion = import.meta.env.DEV ? '开发版' : document.querySelector<HTMLMetaElement>('meta[name="rawmark-version"]')?.content
|
||||
@@ -204,6 +205,7 @@ async function changePassword() {
|
||||
<ShortcutSettings v-else-if="section === 'shortcuts'" />
|
||||
<TokensPanel v-else-if="section === 'tokens'" />
|
||||
<TrashPanel v-else-if="section === 'trash'" />
|
||||
<StoragePanel v-else-if="section === 'application'" />
|
||||
<template v-else-if="section === 'about'">
|
||||
<p class="rm-share-note">RawMark · 纯 Markdown 笔记编辑器,文件即真源。</p>
|
||||
<SettingRow id="version"><code>{{ buildVersion }}</code></SettingRow>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { api, type StorageUsage } from './api'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const usage = ref<StorageUsage | null>(null)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
let mounted = true
|
||||
onBeforeUnmount(() => { mounted = false })
|
||||
|
||||
const names: Record<StorageUsage['categories'][number]['id'], string> = { vault: '笔记库', databases: '数据库', fonts: '字体', themes: '主题', plugins: '插件', other: '其他' }
|
||||
const colors = ['var(--rm-accent)', '#56a89c', '#bd9364', '#a88ac2', '#d57e8d', '#8d99a8']
|
||||
const segments = computed(() => {
|
||||
let offset = 0
|
||||
return (usage.value?.categories ?? []).map((category, index) => {
|
||||
const percent = usage.value!.totalSize ? category.size / usage.value!.totalSize * 100 : 0
|
||||
const segment = { ...category, percent, offset, color: colors[index] }
|
||||
offset += percent
|
||||
return segment
|
||||
})
|
||||
})
|
||||
|
||||
function size(value: number): string {
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||
const unit = value > 0 ? Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1) : 0
|
||||
return `${(value / 1024 ** unit).toLocaleString('zh-CN', { maximumFractionDigits: unit ? 2 : 0 })} ${units[unit]}`
|
||||
}
|
||||
const updated = computed(() => usage.value ? new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'medium' }).format(usage.value.calculatedAt) : '')
|
||||
async function scan() {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const next = await api.storage()
|
||||
if (mounted) usage.value = next
|
||||
} catch (cause) {
|
||||
if (mounted) error.value = '统计失败:' + String(cause)
|
||||
} finally { if (mounted) busy.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SettingRow id="storage"><button class="rm-btn" :disabled="busy" @click="scan">{{ busy ? '统计中…' : usage ? '刷新' : '查看' }}</button></SettingRow>
|
||||
<p class="rm-share-note">统计普通文件实际字节;附件与回收站已包含在笔记库内。不跟随内部符号链接,不包含数据目录外的运维备份。</p>
|
||||
<p v-if="error" class="rm-auth-error" role="alert">{{ error }}</p>
|
||||
<div v-if="usage" class="rm-storage" :aria-busy="busy">
|
||||
<div class="rm-storage-summary">
|
||||
<svg viewBox="0 0 120 120" role="img" aria-label="各类存储空间占比">
|
||||
<circle cx="60" cy="60" r="46" fill="none" stroke="var(--rm-divider)" stroke-width="14" />
|
||||
<circle v-for="segment in segments" :key="segment.id" cx="60" cy="60" r="46" pathLength="100" fill="none" :stroke="segment.color" stroke-width="14" :stroke-dasharray="`${segment.percent} ${100 - segment.percent}`" :stroke-dashoffset="-segment.offset" transform="rotate(-90 60 60)" />
|
||||
</svg>
|
||||
<div><span>总占用</span><strong :title="`${usage.totalSize} 字节`">{{ size(usage.totalSize) }}</strong><small>统计时间:{{ updated }}</small></div>
|
||||
</div>
|
||||
<dl class="rm-storage-list">
|
||||
<template v-for="segment in segments" :key="segment.id">
|
||||
<div><dt><i :style="{ background: segment.color }" />{{ names[segment.id] }}</dt><dd :title="`${segment.size} 字节`">{{ size(segment.size) }} <small>{{ segment.percent.toFixed(1) }}%</small></dd></div>
|
||||
<template v-if="segment.id === 'vault'">
|
||||
<div class="rm-storage-sub"><dt>附件</dt><dd :title="`${usage.assetsSize} 字节`">{{ size(usage.assetsSize) }} <small>{{ (usage.totalSize ? usage.assetsSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small></dd></div>
|
||||
<div class="rm-storage-sub"><dt>回收站</dt><dd :title="`${usage.trashSize} 字节`">{{ size(usage.trashSize) }} <small>{{ (usage.totalSize ? usage.trashSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small></dd></div>
|
||||
</template>
|
||||
</template>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rm-storage-summary { display: flex; align-items: center; gap: 20px; margin: 20px 0; }
|
||||
.rm-storage-summary svg { width: 128px; height: 128px; flex-shrink: 0; }
|
||||
.rm-storage-summary strong { display: block; font-size: var(--rm-ui-font-heading); margin: 6px 0; }
|
||||
.rm-storage-summary small { display: block; color: var(--rm-text-muted); }
|
||||
.rm-storage-list { margin: 0; }
|
||||
.rm-storage-list > div { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; }
|
||||
.rm-storage-list dt { display: flex; align-items: center; gap: 8px; }
|
||||
.rm-storage-list i { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.rm-storage-list dd { margin: 0; text-align: right; }
|
||||
.rm-storage-list small { display: inline-block; min-width: 52px; color: var(--rm-text-muted); }
|
||||
.rm-storage-list .rm-storage-sub { padding-left: 18px; color: var(--rm-text-muted); }
|
||||
@media (max-width: 480px) { .rm-storage-summary { gap: 12px; flex-wrap: wrap; } .rm-storage-summary svg { width: 100px; height: 100px; } }
|
||||
</style>
|
||||
@@ -57,6 +57,14 @@ export interface TrashItem {
|
||||
deletedAt: number
|
||||
}
|
||||
|
||||
export interface StorageUsage {
|
||||
totalSize: number
|
||||
categories: { id: 'vault' | 'databases' | 'fonts' | 'themes' | 'plugins' | 'other'; size: number }[]
|
||||
assetsSize: number
|
||||
trashSize: number
|
||||
calculatedAt: number
|
||||
}
|
||||
|
||||
export interface TrashPurgeResult {
|
||||
deleted: string[]
|
||||
failed: { id: string; error: string }[]
|
||||
@@ -221,6 +229,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)),
|
||||
|
||||
storage: () => json<StorageUsage>(request('GET', '/api/v1/storage')),
|
||||
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`)),
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('设置查找与重置', () => {
|
||||
expect(allCommands().map(command => command.id)).not.toContain('app.shares')
|
||||
ws.settings = 'appearance'
|
||||
mount(SettingsDialog)
|
||||
expect(host!.querySelectorAll('.rm-settings-tab')).toHaveLength(8)
|
||||
expect(host!.querySelectorAll('.rm-settings-tab')).toHaveLength(9)
|
||||
expect(host!.textContent).not.toContain('分享')
|
||||
})
|
||||
|
||||
@@ -320,7 +320,28 @@ describe('快捷键查找', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('回收站', () => {
|
||||
describe('应用存储与回收站', () => {
|
||||
const usage = { totalSize: 1024, categories: [{ id: 'vault' as const, size: 1024 }], assetsSize: 512, trashSize: 256, calculatedAt: 1700000000000 }
|
||||
it('按需统计,防重复请求,刷新失败保留上次结果并展示错误', async () => {
|
||||
let resolve!: (value: typeof usage) => void
|
||||
const storage = vi.spyOn(api, 'storage').mockImplementationOnce(() => new Promise(done => { resolve = done }))
|
||||
ws.settings = 'application'
|
||||
mount(SettingsDialog)
|
||||
expect(storage).not.toHaveBeenCalled()
|
||||
const button = host!.querySelector<HTMLButtonElement>('#rm-setting-storage button')!
|
||||
button.click()
|
||||
await nextTick()
|
||||
expect(button.disabled).toBe(true)
|
||||
button.click()
|
||||
expect(storage).toHaveBeenCalledTimes(1)
|
||||
resolve(usage)
|
||||
await vi.waitFor(() => expect(host!.textContent).toContain('1 KiB'))
|
||||
expect(host!.textContent).toContain('附件')
|
||||
storage.mockRejectedValueOnce(new Error('磁盘读取错误'))
|
||||
button.click()
|
||||
await vi.waitFor(() => expect(host!.querySelector('[role="alert"]')?.textContent).toContain('磁盘读取错误'))
|
||||
expect(host!.textContent).toContain('1 KiB')
|
||||
})
|
||||
it('清空必须确认;防止重复操作,部分失败后重读并保留错误详情', async () => {
|
||||
const item = { id: 'one', path: 'broken.md', dir: false, deletedAt: Date.now() }
|
||||
vi.spyOn(api, 'trash').mockResolvedValue([item])
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { IconName } from './Icon.vue'
|
||||
import type { SettingsSection } from './workspace'
|
||||
|
||||
export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: IconName; description: string }[] = [
|
||||
{ id: 'application', title: '应用', icon: 'grid', description: '查看存储空间及各类文件占用' },
|
||||
{ id: 'appearance', title: '外观', icon: 'palette', description: '界面字号、主题、明暗模式与 CSS 片段' },
|
||||
{ id: 'editor', title: '编辑器', icon: 'edit', description: '正文排版、代码字体与编辑行为' },
|
||||
{ id: 'shortcuts', title: '快捷键', icon: 'command', description: '查找命令,添加、移除或重置快捷键' },
|
||||
@@ -13,6 +14,7 @@ export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: Icon
|
||||
]
|
||||
|
||||
export const SETTING_ITEMS = {
|
||||
'storage': { section: 'application', 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' },
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface Tab {
|
||||
conflictRev?: string
|
||||
}
|
||||
|
||||
export type SettingsSection = 'appearance' | 'editor' | 'account' | 'shortcuts' | 'tokens' | 'trash' | 'plugins' | 'about'
|
||||
export type SettingsSection = 'appearance' | 'editor' | 'account' | 'shortcuts' | 'tokens' | 'trash' | 'plugins' | 'about' | 'application'
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
|
||||
Reference in new issue
Block a user