284 lines
8.8 KiB
TypeScript
284 lines
8.8 KiB
TypeScript
import React, { useEffect, useState, useRef } from 'react'
|
|
import { Trash2, FileText, Folder, Loader2 } from 'lucide-react'
|
|
import { useWallpaper } from '@/stores'
|
|
import { fetchRecycleBin, restoreItem, permanentlyDeleteItem, emptyRecycleBin } from './api'
|
|
import type { RecycleBinGroupDTO, RecycleBinItemDTO } from '@shared/types'
|
|
import { ContextMenu } from '@/components/common/ContextMenu'
|
|
import { DeleteConfirmDialog } from '@/components/dialogs/DeleteConfirmDialog'
|
|
|
|
type NotebookEvent = {
|
|
event: string
|
|
path?: string
|
|
}
|
|
|
|
const formatDate = (dateStr: string): string => {
|
|
if (dateStr.length !== 8) return dateStr
|
|
const year = dateStr.substring(0, 4)
|
|
const month = dateStr.substring(4, 6)
|
|
const day = dateStr.substring(6, 8)
|
|
return `${year}年${month}月${day}日`
|
|
}
|
|
|
|
export const RecycleBinPage: React.FC = () => {
|
|
const { opacity } = useWallpaper()
|
|
const [groups, setGroups] = useState<RecycleBinGroupDTO[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [contextMenu, setContextMenu] = useState<{
|
|
isOpen: boolean
|
|
position: { x: number; y: number }
|
|
item: RecycleBinItemDTO | null
|
|
}>({
|
|
isOpen: false,
|
|
position: { x: 0, y: 0 },
|
|
item: null
|
|
})
|
|
const [deleteDialog, setDeleteDialog] = useState<{
|
|
isOpen: boolean
|
|
item: RecycleBinItemDTO | null
|
|
}>({
|
|
isOpen: false,
|
|
item: null
|
|
})
|
|
const [emptyDialog, setEmptyDialog] = useState(false)
|
|
|
|
const loadRecycleBinRef = useRef<(showLoading?: boolean) => Promise<void>>()
|
|
|
|
const loadRecycleBin = async (showLoading = true) => {
|
|
if (showLoading) {
|
|
setLoading(true)
|
|
}
|
|
setError(null)
|
|
try {
|
|
const data = await fetchRecycleBin()
|
|
setGroups(data.groups)
|
|
} catch (err) {
|
|
console.error('Failed to load recycle bin:', err)
|
|
setError('加载回收站失败')
|
|
} finally {
|
|
if (showLoading) {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
loadRecycleBinRef.current = loadRecycleBin
|
|
|
|
useEffect(() => {
|
|
loadRecycleBin()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const eventSource = new EventSource('/api/events')
|
|
|
|
eventSource.onmessage = (event) => {
|
|
const data = JSON.parse(event.data) as NotebookEvent
|
|
if (
|
|
data.event === 'change' ||
|
|
data.event === 'add' ||
|
|
data.event === 'unlink' ||
|
|
data.event === 'addDir' ||
|
|
data.event === 'unlinkDir'
|
|
) {
|
|
if (data.path && data.path.startsWith('RB/')) {
|
|
loadRecycleBinRef.current?.(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
eventSource.onerror = () => {
|
|
eventSource.close()
|
|
}
|
|
|
|
return () => {
|
|
eventSource.close()
|
|
}
|
|
}, [])
|
|
|
|
const handleContextMenu = (e: React.MouseEvent, item: RecycleBinItemDTO) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setContextMenu({
|
|
isOpen: true,
|
|
position: { x: e.clientX, y: e.clientY },
|
|
item
|
|
})
|
|
}
|
|
|
|
const handleCloseContextMenu = () => {
|
|
setContextMenu(prev => ({ ...prev, isOpen: false }))
|
|
}
|
|
|
|
const handleRestore = async () => {
|
|
if (!contextMenu.item) return
|
|
try {
|
|
await restoreItem(contextMenu.item.path, contextMenu.item.type)
|
|
handleCloseContextMenu()
|
|
loadRecycleBin(false)
|
|
} catch (err) {
|
|
console.error('Failed to restore:', err)
|
|
alert('恢复失败')
|
|
}
|
|
}
|
|
|
|
const handlePermanentDeleteClick = () => {
|
|
if (!contextMenu.item) return
|
|
handleCloseContextMenu()
|
|
setDeleteDialog({
|
|
isOpen: true,
|
|
item: contextMenu.item
|
|
})
|
|
}
|
|
|
|
const handlePermanentDeleteConfirm = async () => {
|
|
if (!deleteDialog.item) return
|
|
try {
|
|
await permanentlyDeleteItem(deleteDialog.item.path, deleteDialog.item.type)
|
|
setDeleteDialog({ isOpen: false, item: null })
|
|
loadRecycleBin(false)
|
|
} catch (err) {
|
|
console.error('Failed to delete:', err)
|
|
alert('删除失败')
|
|
}
|
|
}
|
|
|
|
const handleDeleteDialogCancel = () => {
|
|
setDeleteDialog({ isOpen: false, item: null })
|
|
}
|
|
|
|
const handleEmptyClick = () => {
|
|
setEmptyDialog(true)
|
|
}
|
|
|
|
const handleEmptyConfirm = async () => {
|
|
try {
|
|
await emptyRecycleBin()
|
|
setEmptyDialog(false)
|
|
loadRecycleBin(false)
|
|
} catch (err) {
|
|
console.error('Failed to empty recycle bin:', err)
|
|
alert('清空回收站失败')
|
|
}
|
|
}
|
|
|
|
const handleEmptyDialogCancel = () => {
|
|
setEmptyDialog(false)
|
|
}
|
|
|
|
const contextMenuItems = [
|
|
{ label: '恢复', onClick: handleRestore },
|
|
{ label: '彻底删除', onClick: handlePermanentDeleteClick }
|
|
]
|
|
|
|
const deleteItem = deleteDialog.item
|
|
const deleteExpectedText = deleteItem?.type === 'dir' ? 'DESTROY FOLDER' : 'DESTROY FILE'
|
|
const deleteTitle = deleteItem?.type === 'dir' ? '彻底删除文件夹' : '彻底删除文件'
|
|
const deleteMessage = deleteItem?.type === 'dir'
|
|
? `确定要彻底删除文件夹 "${deleteItem?.originalName}" 吗?此操作不可撤销,文件夹中的所有内容都将被永久删除。`
|
|
: `确定要彻底删除文件 "${deleteItem?.originalName}" 吗?此操作不可撤销,文件将被永久删除。`
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto w-full px-14 py-12 pb-40">
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div className="flex items-center gap-3">
|
|
<Trash2 size={28} className="text-gray-600 dark:text-gray-300" />
|
|
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200">回收站</h1>
|
|
</div>
|
|
{groups.length > 0 && (
|
|
<button
|
|
onClick={handleEmptyClick}
|
|
className="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg transition-colors"
|
|
>
|
|
清空
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{loading && (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 size={32} className="animate-spin text-gray-400" />
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && groups.length === 0 && (
|
|
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
|
<Trash2 size={48} className="mx-auto mb-4 opacity-30" />
|
|
<p>回收站是空的</p>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && groups.length > 0 && (
|
|
<div className="space-y-6">
|
|
{groups.map((group) => (
|
|
<div
|
|
key={group.date}
|
|
className="bg-white/60 dark:bg-gray-900/60 backdrop-blur-md rounded-xl border border-gray-200/50 dark:border-gray-700/60 shadow-sm overflow-hidden"
|
|
>
|
|
<div
|
|
className="px-4 py-3 border-b border-gray-200/50 dark:border-gray-700/60 font-medium text-gray-700 dark:text-gray-200"
|
|
style={{
|
|
backgroundColor: `rgba(var(--app-content-bg-rgb), ${opacity * 0.8})`
|
|
}}
|
|
>
|
|
{formatDate(group.date)}
|
|
</div>
|
|
<div className="divide-y divide-gray-200/50 dark:divide-gray-700/60">
|
|
{group.items.map((item) => (
|
|
<div
|
|
key={item.path}
|
|
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-100/50 dark:hover:bg-gray-800/50 transition-colors cursor-pointer"
|
|
onContextMenu={(e) => handleContextMenu(e, item)}
|
|
>
|
|
{item.type === 'dir' ? (
|
|
<Folder size={20} className="text-gray-500 dark:text-gray-400 shrink-0" />
|
|
) : (
|
|
<FileText size={20} className="text-gray-500 dark:text-gray-400 shrink-0" />
|
|
)}
|
|
<span className="text-gray-700 dark:text-gray-200 truncate">
|
|
{item.originalName}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<ContextMenu
|
|
isOpen={contextMenu.isOpen}
|
|
position={contextMenu.position}
|
|
items={contextMenuItems}
|
|
onClose={handleCloseContextMenu}
|
|
/>
|
|
|
|
<DeleteConfirmDialog
|
|
isOpen={deleteDialog.isOpen}
|
|
title={deleteTitle}
|
|
message={deleteMessage}
|
|
expectedText={deleteExpectedText}
|
|
confirmText="彻底删除"
|
|
onConfirm={handlePermanentDeleteConfirm}
|
|
onCancel={handleDeleteDialogCancel}
|
|
/>
|
|
|
|
<DeleteConfirmDialog
|
|
isOpen={emptyDialog}
|
|
title="清空回收站"
|
|
message="确定要清空回收站吗?此操作将永久删除回收站中的所有文件和文件夹,且不可撤销。"
|
|
expectedText="DESTROY EVERYTHING"
|
|
confirmText="清空"
|
|
buttonVariant="primary"
|
|
onConfirm={handleEmptyConfirm}
|
|
onCancel={handleEmptyDialogCancel}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|