diff --git a/frontend/react-shadcn/pc/src/contexts/AuthContext.tsx b/frontend/react-shadcn/pc/src/contexts/AuthContext.tsx index 32adb1b..b73cb33 100644 --- a/frontend/react-shadcn/pc/src/contexts/AuthContext.tsx +++ b/frontend/react-shadcn/pc/src/contexts/AuthContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, useEffect, type ReactNode } from 'react' +import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react' import type { User, MenuItem, UserOrgInfo } from '@/types' import { apiClient } from '@/services/api' @@ -7,6 +7,7 @@ interface AuthContextType { token: string | null isAuthenticated: boolean isLoading: boolean + menusLoaded: boolean login: (account: string, password: string) => Promise loginWithToken: (token: string) => void logout: () => void @@ -26,6 +27,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [currentOrg, setCurrentOrg] = useState<{ id: number; name: string } | null>(null) const [userOrgs, setUserOrgs] = useState([]) const [userMenus, setUserMenus] = useState([]) + const [menusLoaded, setMenusLoaded] = useState(false) useEffect(() => { const storedToken = localStorage.getItem('token') @@ -57,6 +59,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUserMenus(data.list || []) } catch (e) { console.error('Failed to fetch menus:', e) + } finally { + setMenusLoaded(true) } } @@ -159,13 +163,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(userData) localStorage.setItem('user', JSON.stringify(userData)) } + } else { + throw new Error(response.message || '登录失败,请检查用户名和密码') } } catch (error) { throw error } } - const loginWithToken = (ssoToken: string) => { + const loginWithToken = useCallback((ssoToken: string) => { localStorage.setItem('token', ssoToken) setToken(ssoToken) @@ -194,7 +200,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(userData) localStorage.setItem('user', JSON.stringify(userData)) } - } + }, []) const logout = () => { apiClient.logout() @@ -203,6 +209,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setCurrentOrg(null) setUserOrgs([]) setUserMenus([]) + setMenusLoaded(false) localStorage.removeItem('user') localStorage.removeItem('currentOrg') } @@ -212,6 +219,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { token, isAuthenticated: !!token, isLoading, + menusLoaded, login, loginWithToken, logout, diff --git a/frontend/react-shadcn/pc/src/pages/FileManagementPage.tsx b/frontend/react-shadcn/pc/src/pages/FileManagementPage.tsx new file mode 100644 index 0000000..50df62b --- /dev/null +++ b/frontend/react-shadcn/pc/src/pages/FileManagementPage.tsx @@ -0,0 +1,531 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { Upload, Search, Eye, Edit2, Trash2, Download, Image, Film, FileText, File as FileIcon, ChevronLeft, ChevronRight } from 'lucide-react' +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card' +import { Modal } from '@/components/ui/Modal' +import { + Table, + TableHeader, + TableBody, + TableRow, + TableHead, + TableCell, +} from '@/components/ui/Table' +import type { FileInfo, UpdateFileRequest } from '@/types' +import { apiClient } from '@/services/api' +import { useAuth } from '@/contexts/AuthContext' + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8888/api/v1' + +const CATEGORY_OPTIONS = [ + { value: '', label: '全部分类' }, + { value: 'default', label: '默认' }, + { value: 'avatar', label: '头像' }, + { value: 'document', label: '文档' }, + { value: 'media', label: '媒体' }, +] + +const MIME_FILTER_OPTIONS = [ + { value: '', label: '全部类型' }, + { value: 'image', label: '图片' }, + { value: 'video', label: '视频' }, + { value: 'application/pdf', label: 'PDF' }, +] + +function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] +} + +function getFileIcon(mimeType: string) { + if (mimeType.startsWith('image/')) return + if (mimeType.startsWith('video/')) return + if (mimeType === 'application/pdf') return + return +} + +function getPreviewUrl(file: FileInfo): string { + const token = localStorage.getItem('token') + const base = `${API_BASE_URL}/file/${file.id}/url` + return token ? `${base}?token=${token}` : base +} + +export function FileManagementPage() { + const { user: currentUser } = useAuth() + const [files, setFiles] = useState([]) + const [total, setTotal] = useState(0) + const [isLoading, setIsLoading] = useState(true) + const [searchQuery, setSearchQuery] = useState('') + const [categoryFilter, setCategoryFilter] = useState('') + const [mimeFilter, setMimeFilter] = useState('') + const [page, setPage] = useState(1) + const pageSize = 20 + const [error, setError] = useState(null) + + // Upload + const [isUploading, setIsUploading] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const [uploadCategory, setUploadCategory] = useState('default') + const [uploadIsPublic, setUploadIsPublic] = useState(false) + const fileInputRef = useRef(null) + + // Edit + const [editModalOpen, setEditModalOpen] = useState(false) + const [editingFile, setEditingFile] = useState(null) + const [editForm, setEditForm] = useState({}) + + // Preview + const [previewFile, setPreviewFile] = useState(null) + + // Delete + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) + const [fileToDelete, setFileToDelete] = useState(null) + const [isDeleting, setIsDeleting] = useState(false) + + const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'super_admin' + + const fetchFiles = useCallback(async () => { + try { + setIsLoading(true) + setError(null) + const response = await apiClient.getFiles({ + page, + pageSize, + keyword: searchQuery || undefined, + category: categoryFilter || undefined, + mimeType: mimeFilter || undefined, + }) + if (response.success && response.data) { + setFiles(response.data.list || []) + setTotal(response.data.total || 0) + } else { + setFiles([]) + setTotal(0) + } + } catch { + setError('获取文件列表失败') + setFiles([]) + } finally { + setIsLoading(false) + } + }, [page, searchQuery, categoryFilter, mimeFilter]) + + useEffect(() => { + fetchFiles() + }, [fetchFiles]) + + // Upload + const handleUpload = async (fileList: FileList | null) => { + if (!fileList || fileList.length === 0) return + setIsUploading(true) + try { + for (const f of Array.from(fileList)) { + await apiClient.uploadFile(f, uploadCategory, uploadIsPublic) + } + await fetchFiles() + } catch { + alert('上传失败') + } finally { + setIsUploading(false) + if (fileInputRef.current) fileInputRef.current.value = '' + } + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + handleUpload(e.dataTransfer.files) + } + + // Preview + const handlePreview = (file: FileInfo) => { + setPreviewFile(file) + } + + // Edit + const openEditModal = (file: FileInfo) => { + setEditingFile(file) + setEditForm({ name: file.name, category: file.category, isPublic: file.isPublic }) + setEditModalOpen(true) + } + + const handleUpdate = async () => { + if (!editingFile) return + try { + await apiClient.updateFile(editingFile.id, editForm) + setEditModalOpen(false) + setEditingFile(null) + await fetchFiles() + } catch { + alert('更新失败') + } + } + + // Delete + const handleDelete = async () => { + if (!fileToDelete) return + try { + setIsDeleting(true) + await apiClient.deleteFile(fileToDelete.id) + setDeleteConfirmOpen(false) + setFileToDelete(null) + await fetchFiles() + } catch { + alert('删除失败') + } finally { + setIsDeleting(false) + } + } + + const totalPages = Math.ceil(total / pageSize) + + return ( +
+ {/* Upload Area */} + + +
{ e.preventDefault(); setIsDragging(true) }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + > +
+ +
+

+ {isUploading ? '正在上传...' : '拖拽文件到此处上传'} +

+

支持图片、视频、PDF 等文件格式

+
+ + +
+ + handleUpload(e.target.files)} + /> +
+
+
+ + {/* Filters */} + + +
+
+ { setSearchQuery(e.target.value); setPage(1) }} + leftIcon={} + /> +
+ + +
+
+
+ + {/* Error */} + {error && ( +
+ {error} + +
+ )} + + {/* File Table */} + + + 文件列表 ({total}) + + +
+ + + + 文件名 + 分类 + 大小 + 类型 + 状态 + 上传时间 + 操作 + + + + {isLoading ? ( + 加载中... + ) : files.length === 0 ? ( + 暂无文件 + ) : ( + files.map((file) => ( + + +
+ {getFileIcon(file.mimeType)} + + {file.name} + +
+
+ + + {file.category} + + + {formatFileSize(file.size)} + {file.mimeType.split('/')[1] || file.mimeType} + + + + {file.isPublic ? '公开' : '私有'} + + + + {new Date(file.createdAt).toLocaleDateString('zh-CN')} + + +
+ + + {isAdmin && ( + + )} +
+
+
+ )) + )} +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ 共 {total} 个文件 +

+
+ + + {page} / {totalPages} + + +
+
+ )} +
+
+ + {/* Preview Modal */} + setPreviewFile(null)} + title={previewFile?.name || '文件预览'} + size="lg" + > + {previewFile && ( +
+ {previewFile.mimeType.startsWith('image/') && ( + {previewFile.name} + )} + {previewFile.mimeType.startsWith('video/') && ( + + )} + {previewFile.mimeType === 'application/pdf' && ( +