feat: add storage cleanup mechanism with soft delete and auto scheduler

- Add soft delete (deleted_at column) to preserve task records for statistics
- Implement cleanup service to delete old files while keeping DB records
- Add automatic cleanup scheduler (configurable interval, default 24h)
- Add admin endpoints: storage stats, cleanup trigger, scheduler status
- Update task service with admin views (include deleted/files_deleted)
- Add frontend storage management UI in admin dashboard
- Add i18n translations for storage management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-14 12:41:01 +08:00
parent 81a0a3ab0f
commit 73112db055
23 changed files with 1359 additions and 634 deletions

View File

@@ -7,7 +7,7 @@ import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { apiClientV2 } from '@/services/apiV2'
import type { SystemStats, UserWithStats, TopUser, TranslationStats } from '@/types/apiV2'
import type { SystemStats, UserWithStats, TopUser, TranslationStats, StorageStats } from '@/types/apiV2'
import {
Users,
ClipboardList,
@@ -21,6 +21,8 @@ import {
Loader2,
Languages,
Coins,
HardDrive,
Trash2,
} from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -41,6 +43,8 @@ export default function AdminDashboardPage() {
const [users, setUsers] = useState<UserWithStats[]>([])
const [topUsers, setTopUsers] = useState<TopUser[]>([])
const [translationStats, setTranslationStats] = useState<TranslationStats | null>(null)
const [storageStats, setStorageStats] = useState<StorageStats | null>(null)
const [cleanupLoading, setCleanupLoading] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -50,17 +54,19 @@ export default function AdminDashboardPage() {
setLoading(true)
setError('')
const [statsData, usersData, topUsersData, translationStatsData] = await Promise.all([
const [statsData, usersData, topUsersData, translationStatsData, storageStatsData] = await Promise.all([
apiClientV2.getSystemStats(),
apiClientV2.listUsers({ page: 1, page_size: 10 }),
apiClientV2.getTopUsers({ metric: 'tasks', limit: 5 }),
apiClientV2.getTranslationStats(),
apiClientV2.getStorageStats(),
])
setStats(statsData)
setUsers(usersData.users)
setTopUsers(topUsersData)
setTranslationStats(translationStatsData)
setStorageStats(storageStatsData)
} catch (err: any) {
console.error('Failed to fetch admin data:', err)
setError(err.response?.data?.detail || t('admin.loadFailed'))
@@ -80,6 +86,27 @@ export default function AdminDashboardPage() {
return date.toLocaleString(i18n.language === 'zh-TW' ? 'zh-TW' : 'en-US')
}
// Handle cleanup trigger
const handleCleanup = async () => {
try {
setCleanupLoading(true)
const result = await apiClientV2.triggerCleanup()
alert(t('admin.storage.cleanupResult', {
users: result.users_processed,
files: result.total_files_deleted,
mb: (result.total_bytes_freed / 1024 / 1024).toFixed(2)
}))
// Refresh storage stats
const newStorageStats = await apiClientV2.getStorageStats()
setStorageStats(newStorageStats)
} catch (err: any) {
console.error('Cleanup failed:', err)
alert(t('admin.storage.cleanupFailed'))
} finally {
setCleanupLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
@@ -329,6 +356,104 @@ export default function AdminDashboardPage() {
</Card>
)}
{/* Storage Management */}
{storageStats && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<HardDrive className="w-5 h-5" />
{t('admin.storage.title')}
</CardTitle>
<CardDescription>{t('admin.storage.description')}</CardDescription>
</div>
<Button
onClick={handleCleanup}
disabled={cleanupLoading}
variant="outline"
className="gap-2"
>
{cleanupLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
{t('admin.storage.triggerCleanup')}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="p-4 bg-blue-50 rounded-lg">
<div className="flex items-center gap-2 text-blue-600 mb-1">
<ClipboardList className="w-4 h-4" />
<span className="text-sm font-medium">{t('admin.storage.totalTasks')}</span>
</div>
<div className="text-2xl font-bold text-blue-700">
{storageStats.total_tasks.toLocaleString()}
</div>
</div>
<div className="p-4 bg-green-50 rounded-lg">
<div className="flex items-center gap-2 text-green-600 mb-1">
<CheckCircle2 className="w-4 h-4" />
<span className="text-sm font-medium">{t('admin.storage.tasksWithFiles')}</span>
</div>
<div className="text-2xl font-bold text-green-700">
{storageStats.tasks_with_files.toLocaleString()}
</div>
</div>
<div className="p-4 bg-amber-50 rounded-lg">
<div className="flex items-center gap-2 text-amber-600 mb-1">
<Trash2 className="w-4 h-4" />
<span className="text-sm font-medium">{t('admin.storage.filesDeleted')}</span>
</div>
<div className="text-2xl font-bold text-amber-700">
{storageStats.tasks_files_deleted.toLocaleString()}
</div>
</div>
<div className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2 text-gray-600 mb-1">
<XCircle className="w-4 h-4" />
<span className="text-sm font-medium">{t('admin.storage.softDeleted')}</span>
</div>
<div className="text-2xl font-bold text-gray-700">
{storageStats.soft_deleted_tasks.toLocaleString()}
</div>
</div>
</div>
{/* Disk Usage */}
<div className="border rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('admin.storage.diskUsage')}</h4>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-lg font-semibold text-blue-600">
{storageStats.disk_usage.uploads_mb} MB
</div>
<div className="text-xs text-gray-500">{t('admin.storage.uploadsSize')}</div>
</div>
<div>
<div className="text-lg font-semibold text-green-600">
{storageStats.disk_usage.results_mb} MB
</div>
<div className="text-xs text-gray-500">{t('admin.storage.resultsSize')}</div>
</div>
<div>
<div className="text-lg font-semibold text-purple-600">
{storageStats.disk_usage.total_mb} MB
</div>
<div className="text-xs text-gray-500">{t('admin.storage.totalSize')}</div>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Top Users */}
{topUsers.length > 0 && (
<Card>