Initial commit
This commit is contained in:
283
src/modules/recycle-bin/RecycleBinPage.tsx
Normal file
283
src/modules/recycle-bin/RecycleBinPage.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
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 p-6">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user