feat: add desktop storage cleanup controls

This commit is contained in:
2026-06-18 23:50:56 +08:00
parent 889a575188
commit d8195bc935
7 changed files with 976 additions and 1 deletions
+40
View File
@@ -20,6 +20,7 @@ declare global {
interface DesktopAppSettings {
openAtLogin: boolean
keepRunningInBackground: boolean
autoCleanStorageWeekly: boolean
}
interface DesktopLoginCredentials {
@@ -27,6 +28,43 @@ declare global {
password: string
}
interface DesktopStorageEntry {
id: string
label: string
kind: 'app-cache' | 'bound-account' | 'unbound-session'
partition: string | null
accountId: string | null
sizeBytes: number
cleanableBytes: number
lastModifiedAt: number | null
cleanable: boolean
active: boolean
}
interface DesktopStorageSnapshot {
generatedAt: number
totalBytes: number
cleanableBytes: number
appCacheBytes: number
boundAccountBytes: number
boundAccountCleanableBytes: number
unboundSessionBytes: number
unboundSessionCount: number
boundAccountCount: number
entries: DesktopStorageEntry[]
autoCleanStorageWeekly: boolean
lastCleanupAt: number | null
nextCleanupAt: number | null
}
interface DesktopStorageCleanupResult {
cleanedAt: number
cleanedBytes: number
removedEntries: number
before: DesktopStorageSnapshot
after: DesktopStorageSnapshot
}
interface WorkbenchNavigationState {
canGoBack: boolean
canGoForward: boolean
@@ -90,6 +128,8 @@ declare global {
saveLoginCredentials(credentials: DesktopLoginCredentials): Promise<DesktopLoginCredentials>
clearLoginCredentials(): Promise<null>
setAppSetting(key: keyof DesktopAppSettings, value: boolean): Promise<DesktopAppSettings>
storageSnapshot(): Promise<DesktopStorageSnapshot>
cleanStorage(): Promise<DesktopStorageCleanupResult>
syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise<null>
rotateRuntimeClientToken(): Promise<DesktopClientRotateResponse>
releaseRuntimeSession(revoke?: boolean): Promise<null>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import {
ClearOutlined,
DownloadOutlined,
InfoCircleOutlined,
LoginOutlined,
@@ -46,6 +47,7 @@ type SectionKey =
const sections = [
{ key: 'general' as const, title: '通用', icon: SlidersOutlined },
{ key: 'login' as const, title: '登录设置', icon: LoginOutlined },
{ key: 'storage' as const, title: '存储清理', icon: ClearOutlined },
{ key: 'diagnostics' as const, title: '诊断', icon: ToolOutlined },
{ key: 'about' as const, title: '关于GEO', icon: InfoCircleOutlined },
]
@@ -54,6 +56,7 @@ const activeSection = ref<SectionKey>('general')
const appSettings = ref<DesktopAppSettings>({
openAtLogin: false,
keepRunningInBackground: true,
autoCleanStorageWeekly: false,
})
const serverBaseURL = ref(apiBaseURL.value)
const settingsLoaded = ref(false)
@@ -61,6 +64,11 @@ const settingsError = ref<string | null>(null)
const serverSettingsMessage = ref<string | null>(null)
const serverSettingsError = ref<string | null>(null)
const savingSetting = ref<keyof DesktopAppSettings | null>(null)
const storageSnapshot = ref<DesktopStorageSnapshot | null>(null)
const storageLoading = ref(false)
const storageCleaning = ref(false)
const storageError = ref<string | null>(null)
const storageMessage = ref<string | null>(null)
const activeTitle = computed(
() => sections.find((item) => item.key === activeSection.value)?.title ?? '设置',
@@ -158,6 +166,53 @@ const generalRows = computed(() => [
},
])
const visibleStorageEntries = computed(() =>
(storageSnapshot.value?.entries ?? []).filter(
(entry) => entry.cleanable && entry.cleanableBytes > 0,
),
)
const storageMetricRows = computed(() => [
{
key: 'total',
label: '可清理总量',
value: formatBytes(storageSnapshot.value?.cleanableBytes ?? 0),
},
{
key: 'app',
label: '应用临时缓存',
value: formatBytes(storageSnapshot.value?.appCacheBytes ?? 0),
},
{
key: 'unbound',
label: '未绑定缓存',
value: `${storageSnapshot.value?.unboundSessionCount ?? 0} 项 · ${formatBytes(
storageSnapshot.value?.unboundSessionBytes ?? 0,
)}`,
},
])
const storageUpdatedAtLabel = computed(() =>
storageSnapshot.value ? formatDateTime(storageSnapshot.value.generatedAt) : '',
)
const lastStorageCleanupLabel = computed(() =>
storageSnapshot.value?.lastCleanupAt
? formatDateTime(storageSnapshot.value.lastCleanupAt)
: '尚未清理',
)
const nextStorageCleanupLabel = computed(() =>
storageSnapshot.value?.nextCleanupAt
? formatDateTime(storageSnapshot.value.nextCleanupAt)
: '未开启',
)
const runtimeAccountLabels = computed(() => {
const labels = new Map<string, string>()
for (const account of snapshot.value?.accounts ?? []) {
labels.set(account.id, `${account.displayName || account.platformUid || account.id}`)
}
return labels
})
const copied = ref(false)
function copyLogs() {
if (!snapshot.value) return
@@ -224,6 +279,79 @@ function resetServerSettings() {
saveServerSettings()
}
function formatBytes(value: number): string {
if (!Number.isFinite(value) || value <= 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let next = value
let unitIndex = 0
while (next >= 1024 && unitIndex < units.length - 1) {
next /= 1024
unitIndex += 1
}
return `${next >= 10 || unitIndex === 0 ? Math.round(next) : next.toFixed(1)} ${units[unitIndex]}`
}
function formatDateTime(value: number): string {
return new Date(value).toLocaleString('zh-CN', {
hour12: false,
})
}
function storageEntryKindLabel(kind: DesktopStorageEntry['kind']): string {
switch (kind) {
case 'app-cache':
return '应用缓存'
case 'bound-account':
return '账号缓存'
case 'unbound-session':
return '未绑定缓存'
default:
return '缓存'
}
}
function storageEntryLabel(entry: DesktopStorageEntry): string {
if (entry.accountId) {
return runtimeAccountLabels.value.get(entry.accountId) ?? entry.label
}
return entry.label
}
async function loadStorageSnapshot() {
storageLoading.value = true
storageError.value = null
try {
storageSnapshot.value = await window.desktopBridge.app.storageSnapshot()
} catch (error) {
storageError.value = error instanceof Error ? error.message : '缓存统计失败'
} finally {
storageLoading.value = false
}
}
async function cleanStorageNow() {
if (storageCleaning.value) {
return
}
storageCleaning.value = true
storageError.value = null
storageMessage.value = null
try {
const result = await window.desktopBridge.app.cleanStorage()
storageSnapshot.value = result.after
storageMessage.value = `已清理 ${formatBytes(result.cleanedBytes)},移除 ${result.removedEntries} 项未绑定缓存`
} catch (error) {
storageError.value = error instanceof Error ? error.message : '缓存清理失败'
} finally {
storageCleaning.value = false
}
}
async function loadAppSettings() {
settingsError.value = null
try {
@@ -248,6 +376,9 @@ async function toggleAppSetting(key: keyof DesktopAppSettings) {
try {
appSettings.value = await window.desktopBridge.app.setAppSetting(key, value)
if (key === 'autoCleanStorageWeekly') {
await loadStorageSnapshot()
}
} catch (error) {
appSettings.value = previous
settingsError.value = error instanceof Error ? error.message : '设置保存失败'
@@ -258,6 +389,7 @@ async function toggleAppSetting(key: keyof DesktopAppSettings) {
onMounted(() => {
void loadAppSettings()
void loadStorageSnapshot()
void checkClientRelease('silent')
})
@@ -433,6 +565,98 @@ watch(apiBaseURL, (next) => {
</p>
</section>
<section v-else-if="activeSection === 'storage'" class="settings-form-page">
<div class="storage-panel">
<div class="storage-summary-grid">
<div v-for="metric in storageMetricRows" :key="metric.key" class="storage-metric">
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
</div>
</div>
<div class="storage-auto-card">
<div
class="settings-row storage-auto-row"
:class="{
'is-disabled':
!settingsLoaded ||
(savingSetting !== null && savingSetting !== 'autoCleanStorageWeekly'),
}"
role="switch"
:aria-checked="appSettings.autoCleanStorageWeekly"
tabindex="0"
@click="toggleAppSetting('autoCleanStorageWeekly')"
@keydown.enter.prevent="toggleAppSetting('autoCleanStorageWeekly')"
@keydown.space.prevent="toggleAppSetting('autoCleanStorageWeekly')"
>
<span>每周自动清理未绑定缓存和无用缓存</span>
<a-switch
class="row-switch"
:checked="appSettings.autoCleanStorageWeekly"
:loading="savingSetting === 'autoCleanStorageWeekly'"
:disabled="!settingsLoaded"
/>
</div>
</div>
<div class="storage-actions-row">
<div class="storage-status-copy">
<strong>存储清理</strong>
<span>上次清理: {{ lastStorageCleanupLabel }}</span>
<span>下次自动清理: {{ nextStorageCleanupLabel }}</span>
</div>
<div class="storage-actions">
<button
type="button"
class="server-button"
:disabled="storageLoading || storageCleaning"
@click="loadStorageSnapshot"
>
<SyncOutlined />
{{ storageLoading ? '统计中' : '刷新统计' }}
</button>
<button
type="button"
class="server-button server-button--primary"
:disabled="storageCleaning || storageLoading || !storageSnapshot?.cleanableBytes"
@click="cleanStorageNow"
>
<ClearOutlined />
{{ storageCleaning ? '清理中' : '立即清理' }}
</button>
</div>
</div>
<div class="storage-detail-list">
<div
v-for="entry in visibleStorageEntries"
:key="entry.id"
class="storage-detail-row"
>
<div class="storage-detail-copy">
<strong>{{ storageEntryKindLabel(entry.kind) }}</strong>
<span>{{ storageEntryLabel(entry) }}</span>
<span v-if="entry.lastModifiedAt">
最近更新 {{ formatDateTime(entry.lastModifiedAt) }}
</span>
</div>
<div class="storage-detail-size">
<strong>{{ formatBytes(entry.sizeBytes) }}</strong>
<span>可清理 {{ formatBytes(entry.cleanableBytes) }}</span>
</div>
</div>
<div v-if="!visibleStorageEntries.length" class="storage-empty-row">
<span>{{ storageLoading ? '正在统计缓存' : '暂无可清理缓存' }}</span>
</div>
</div>
</div>
<p v-if="storageUpdatedAtLabel" class="settings-subtle">
统计时间 {{ storageUpdatedAtLabel }}
</p>
<p v-if="storageError" class="settings-error">{{ storageError }}</p>
<p v-else-if="storageMessage" class="settings-success">{{ storageMessage }}</p>
</section>
<section v-else-if="activeSection === 'general'" class="settings-form-page">
<div class="settings-list-card">
<div
@@ -864,6 +1088,144 @@ watch(apiBaseURL, (next) => {
background: #0f6bea;
}
.storage-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.storage-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.storage-metric {
display: flex;
min-height: 92px;
min-width: 0;
flex-direction: column;
justify-content: center;
gap: 8px;
padding: 0 22px;
border-radius: 8px;
background: #ffffff;
}
.storage-metric span {
color: #777777;
font-size: 13px;
font-weight: 700;
}
.storage-metric strong {
overflow: hidden;
color: #050505;
font-size: 22px;
font-weight: 900;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.storage-actions-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 20px 24px;
border-radius: 8px;
background: #ffffff;
}
.storage-status-copy,
.storage-detail-copy,
.storage-detail-size {
display: flex;
min-width: 0;
flex-direction: column;
}
.storage-status-copy {
gap: 5px;
}
.storage-status-copy strong {
color: #080808;
font-size: 15px;
font-weight: 850;
}
.storage-status-copy span,
.storage-detail-copy span,
.storage-detail-size span {
overflow: hidden;
color: #777777;
font-size: 13px;
font-weight: 600;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.storage-actions {
display: inline-flex;
flex: 0 0 auto;
gap: 8px;
}
.storage-detail-list {
overflow: hidden;
border-radius: 8px;
background: #ffffff;
}
.storage-detail-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(120px, auto);
gap: 18px;
align-items: center;
min-height: 68px;
padding: 14px 24px;
border-bottom: 1px solid #eeeeef;
}
.storage-detail-row:last-child {
border-bottom: 0;
}
.storage-detail-copy {
gap: 4px;
}
.storage-detail-copy strong {
color: #111111;
font-size: 14px;
font-weight: 800;
}
.storage-detail-size {
align-items: flex-end;
gap: 5px;
text-align: right;
}
.storage-detail-size strong {
color: #111111;
font-size: 15px;
font-weight: 850;
}
.storage-empty-row {
display: flex;
min-height: 64px;
align-items: center;
padding: 0 24px;
color: #777777;
font-size: 13px;
font-weight: 700;
}
.card-title-row {
display: flex;
align-items: center;
@@ -997,6 +1359,13 @@ watch(apiBaseURL, (next) => {
font-weight: 600;
}
.settings-subtle {
margin: 12px 4px 0;
color: #777777;
font-size: 13px;
font-weight: 600;
}
@media (max-width: 820px) {
.settings-shell {
grid-template-columns: 190px minmax(0, 1fr);
@@ -1026,5 +1395,29 @@ watch(apiBaseURL, (next) => {
.release-check-actions {
justify-content: flex-start;
}
.storage-summary-grid {
grid-template-columns: 1fr;
}
.storage-actions-row,
.storage-detail-row {
grid-template-columns: 1fr;
}
.storage-actions-row {
align-items: flex-start;
flex-direction: column;
}
.storage-actions {
width: 100%;
flex-wrap: wrap;
}
.storage-detail-size {
align-items: flex-start;
text-align: left;
}
}
</style>