Browse Source
1. FileManagementPage: hidden input 从 display:none 改为 absolute+opacity:0, Button 添加 type="button" 防止表单提交 2. AuthContext: login() 在 response.success 为 false 时抛出错误, 使 LoginPage 能正确显示错误提示 3. 更新 file-management.e2e.test.ts,8/8 测试通过master
3 changed files with 724 additions and 3 deletions
@ -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 <Image className="h-4 w-4 text-emerald-400" /> |
||||
|
if (mimeType.startsWith('video/')) return <Film className="h-4 w-4 text-violet-400" /> |
||||
|
if (mimeType === 'application/pdf') return <FileText className="h-4 w-4 text-rose-400" /> |
||||
|
return <FileIcon className="h-4 w-4 text-text-secondary" /> |
||||
|
} |
||||
|
|
||||
|
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<FileInfo[]>([]) |
||||
|
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<string | null>(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<HTMLInputElement>(null) |
||||
|
|
||||
|
// Edit
|
||||
|
const [editModalOpen, setEditModalOpen] = useState(false) |
||||
|
const [editingFile, setEditingFile] = useState<FileInfo | null>(null) |
||||
|
const [editForm, setEditForm] = useState<UpdateFileRequest>({}) |
||||
|
|
||||
|
// Preview
|
||||
|
const [previewFile, setPreviewFile] = useState<FileInfo | null>(null) |
||||
|
|
||||
|
// Delete
|
||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) |
||||
|
const [fileToDelete, setFileToDelete] = useState<FileInfo | null>(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 ( |
||||
|
<div className="space-y-6 animate-fade-in"> |
||||
|
{/* Upload Area */} |
||||
|
<Card> |
||||
|
<CardContent className="p-6"> |
||||
|
<div |
||||
|
className={`relative border-2 border-dashed rounded-xl p-10 text-center transition-all duration-300 ${ |
||||
|
isDragging |
||||
|
? 'border-sky-400 bg-sky-500/10 shadow-[0_0_30px_rgba(14,165,233,0.15)]' |
||||
|
: 'border-border-secondary/60 hover:border-border-secondary' |
||||
|
}`}
|
||||
|
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }} |
||||
|
onDragLeave={() => setIsDragging(false)} |
||||
|
onDrop={handleDrop} |
||||
|
> |
||||
|
<div className={`inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-4 transition-all duration-300 ${ |
||||
|
isDragging ? 'bg-sky-500/20 scale-110' : 'bg-card' |
||||
|
}`}>
|
||||
|
<Upload className={`h-6 w-6 transition-colors ${isDragging ? 'text-sky-400' : 'text-text-muted'}`} /> |
||||
|
</div> |
||||
|
<p className="text-foreground mb-1 font-medium"> |
||||
|
{isUploading ? '正在上传...' : '拖拽文件到此处上传'} |
||||
|
</p> |
||||
|
<p className="text-text-muted text-sm mb-5">支持图片、视频、PDF 等文件格式</p> |
||||
|
<div className="flex items-center justify-center gap-4 mb-5"> |
||||
|
<select |
||||
|
className="rounded-lg border border-border bg-muted px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-sky-500" |
||||
|
value={uploadCategory} |
||||
|
onChange={(e) => setUploadCategory(e.target.value)} |
||||
|
> |
||||
|
{CATEGORY_OPTIONS.filter(o => o.value).map(opt => ( |
||||
|
<option key={opt.value} value={opt.value} className="bg-card">{opt.label}</option> |
||||
|
))} |
||||
|
</select> |
||||
|
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer select-none"> |
||||
|
<input |
||||
|
type="checkbox" |
||||
|
checked={uploadIsPublic} |
||||
|
onChange={(e) => setUploadIsPublic(e.target.checked)} |
||||
|
className="rounded border-border-secondary bg-card text-sky-500 focus:ring-sky-500" |
||||
|
/> |
||||
|
公开 |
||||
|
</label> |
||||
|
</div> |
||||
|
<Button type="button" onClick={() => fileInputRef.current?.click()} disabled={isUploading}> |
||||
|
<Upload className="h-4 w-4" /> |
||||
|
{isUploading ? '上传中...' : '选择文件'} |
||||
|
</Button> |
||||
|
<input |
||||
|
ref={fileInputRef} |
||||
|
type="file" |
||||
|
multiple |
||||
|
className="absolute w-0 h-0 overflow-hidden opacity-0" |
||||
|
tabIndex={-1} |
||||
|
aria-hidden="true" |
||||
|
onChange={(e) => handleUpload(e.target.files)} |
||||
|
/> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
{/* Filters */} |
||||
|
<Card> |
||||
|
<CardContent className="p-6"> |
||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4"> |
||||
|
<div className="flex-1 w-full sm:max-w-md"> |
||||
|
<Input |
||||
|
placeholder="搜索文件名..." |
||||
|
value={searchQuery} |
||||
|
onChange={(e) => { setSearchQuery(e.target.value); setPage(1) }} |
||||
|
leftIcon={<Search className="h-4 w-4" />} |
||||
|
/> |
||||
|
</div> |
||||
|
<select |
||||
|
className="rounded-lg border border-border bg-muted px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-sky-500" |
||||
|
value={categoryFilter} |
||||
|
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1) }} |
||||
|
> |
||||
|
{CATEGORY_OPTIONS.map(opt => ( |
||||
|
<option key={opt.value} value={opt.value} className="bg-card">{opt.label}</option> |
||||
|
))} |
||||
|
</select> |
||||
|
<select |
||||
|
className="rounded-lg border border-border bg-muted px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-sky-500" |
||||
|
value={mimeFilter} |
||||
|
onChange={(e) => { setMimeFilter(e.target.value); setPage(1) }} |
||||
|
> |
||||
|
{MIME_FILTER_OPTIONS.map(opt => ( |
||||
|
<option key={opt.value} value={opt.value} className="bg-card">{opt.label}</option> |
||||
|
))} |
||||
|
</select> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
{/* Error */} |
||||
|
{error && ( |
||||
|
<div className="p-4 bg-red-500/10 text-red-400 rounded-lg flex justify-between items-center"> |
||||
|
<span>{error}</span> |
||||
|
<button onClick={fetchFiles} className="underline hover:text-red-300">重试</button> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* File Table */} |
||||
|
<Card> |
||||
|
<CardHeader> |
||||
|
<CardTitle>文件列表 ({total})</CardTitle> |
||||
|
</CardHeader> |
||||
|
<CardContent> |
||||
|
<div className="overflow-x-auto"> |
||||
|
<Table> |
||||
|
<TableHeader> |
||||
|
<TableRow> |
||||
|
<TableHead>文件名</TableHead> |
||||
|
<TableHead>分类</TableHead> |
||||
|
<TableHead>大小</TableHead> |
||||
|
<TableHead>类型</TableHead> |
||||
|
<TableHead>状态</TableHead> |
||||
|
<TableHead>上传时间</TableHead> |
||||
|
<TableHead className="text-right">操作</TableHead> |
||||
|
</TableRow> |
||||
|
</TableHeader> |
||||
|
<TableBody> |
||||
|
{isLoading ? ( |
||||
|
<TableRow><TableCell colSpan={7} className="text-center text-text-muted py-12">加载中...</TableCell></TableRow> |
||||
|
) : files.length === 0 ? ( |
||||
|
<TableRow><TableCell colSpan={7} className="text-center text-text-muted py-12">暂无文件</TableCell></TableRow> |
||||
|
) : ( |
||||
|
files.map((file) => ( |
||||
|
<TableRow key={file.id}> |
||||
|
<TableCell> |
||||
|
<div className="flex items-center gap-2.5"> |
||||
|
{getFileIcon(file.mimeType)} |
||||
|
<span className="font-medium text-foreground truncate max-w-[220px]" title={file.name}> |
||||
|
{file.name} |
||||
|
</span> |
||||
|
</div> |
||||
|
</TableCell> |
||||
|
<TableCell> |
||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-500/15 text-sky-400 border border-sky-500/25"> |
||||
|
{file.category} |
||||
|
</span> |
||||
|
</TableCell> |
||||
|
<TableCell className="text-text-secondary tabular-nums">{formatFileSize(file.size)}</TableCell> |
||||
|
<TableCell className="text-text-muted text-xs font-mono">{file.mimeType.split('/')[1] || file.mimeType}</TableCell> |
||||
|
<TableCell> |
||||
|
<span className={`inline-flex items-center gap-1 text-xs ${file.isPublic ? 'text-emerald-400' : 'text-text-muted'}`}> |
||||
|
<span className={`w-1.5 h-1.5 rounded-full ${file.isPublic ? 'bg-emerald-400' : 'bg-gray-600'}`} /> |
||||
|
{file.isPublic ? '公开' : '私有'} |
||||
|
</span> |
||||
|
</TableCell> |
||||
|
<TableCell className="text-text-muted text-sm"> |
||||
|
{new Date(file.createdAt).toLocaleDateString('zh-CN')} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-right"> |
||||
|
<div className="flex items-center justify-end gap-1"> |
||||
|
<Button variant="ghost" size="sm" onClick={() => handlePreview(file)} title="预览"> |
||||
|
<Eye className="h-4 w-4" /> |
||||
|
</Button> |
||||
|
<Button variant="ghost" size="sm" onClick={() => openEditModal(file)} title="编辑"> |
||||
|
<Edit2 className="h-4 w-4" /> |
||||
|
</Button> |
||||
|
{isAdmin && ( |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="sm" |
||||
|
onClick={() => { setFileToDelete(file); setDeleteConfirmOpen(true) }} |
||||
|
className="text-red-400 hover:text-red-300" |
||||
|
title="删除" |
||||
|
> |
||||
|
<Trash2 className="h-4 w-4" /> |
||||
|
</Button> |
||||
|
)} |
||||
|
</div> |
||||
|
</TableCell> |
||||
|
</TableRow> |
||||
|
)) |
||||
|
)} |
||||
|
</TableBody> |
||||
|
</Table> |
||||
|
</div> |
||||
|
|
||||
|
{/* Pagination */} |
||||
|
{totalPages > 1 && ( |
||||
|
<div className="flex items-center justify-between mt-6 pt-4 border-t border-border"> |
||||
|
<p className="text-sm text-text-muted"> |
||||
|
共 {total} 个文件 |
||||
|
</p> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
disabled={page <= 1} |
||||
|
onClick={() => setPage(p => p - 1)} |
||||
|
> |
||||
|
<ChevronLeft className="h-4 w-4" /> |
||||
|
</Button> |
||||
|
<span className="px-3 py-1.5 text-sm text-text-secondary tabular-nums"> |
||||
|
{page} / {totalPages} |
||||
|
</span> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
disabled={page >= totalPages} |
||||
|
onClick={() => setPage(p => p + 1)} |
||||
|
> |
||||
|
<ChevronRight className="h-4 w-4" /> |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
{/* Preview Modal */} |
||||
|
<Modal |
||||
|
isOpen={!!previewFile} |
||||
|
onClose={() => setPreviewFile(null)} |
||||
|
title={previewFile?.name || '文件预览'} |
||||
|
size="lg" |
||||
|
> |
||||
|
{previewFile && ( |
||||
|
<div className="py-4"> |
||||
|
{previewFile.mimeType.startsWith('image/') && ( |
||||
|
<img |
||||
|
src={getPreviewUrl(previewFile)} |
||||
|
alt={previewFile.name} |
||||
|
className="max-w-full max-h-[65vh] mx-auto rounded-lg shadow-2xl" |
||||
|
crossOrigin="anonymous" |
||||
|
/> |
||||
|
)} |
||||
|
{previewFile.mimeType.startsWith('video/') && ( |
||||
|
<video |
||||
|
src={getPreviewUrl(previewFile)} |
||||
|
controls |
||||
|
className="max-w-full max-h-[65vh] mx-auto rounded-lg" |
||||
|
> |
||||
|
您的浏览器不支持视频播放 |
||||
|
</video> |
||||
|
)} |
||||
|
{previewFile.mimeType === 'application/pdf' && ( |
||||
|
<iframe |
||||
|
src={getPreviewUrl(previewFile)} |
||||
|
className="w-full h-[65vh] rounded-lg border border-border-secondary" |
||||
|
title={previewFile.name} |
||||
|
/> |
||||
|
)} |
||||
|
{!previewFile.mimeType.startsWith('image/') && |
||||
|
!previewFile.mimeType.startsWith('video/') && |
||||
|
previewFile.mimeType !== 'application/pdf' && ( |
||||
|
<div className="text-center py-12"> |
||||
|
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-card mb-5"> |
||||
|
<FileIcon className="h-10 w-10 text-text-muted" /> |
||||
|
</div> |
||||
|
<p className="text-foreground font-medium mb-1">{previewFile.name}</p> |
||||
|
<p className="text-sm text-text-muted mb-6"> |
||||
|
{formatFileSize(previewFile.size)} · {previewFile.mimeType} |
||||
|
</p> |
||||
|
<a |
||||
|
href={getPreviewUrl(previewFile)} |
||||
|
download={previewFile.name} |
||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 bg-sky-500 text-foreground rounded-lg hover:bg-sky-600 transition font-medium text-sm" |
||||
|
> |
||||
|
<Download className="h-4 w-4" /> |
||||
|
下载文件 |
||||
|
</a> |
||||
|
</div> |
||||
|
)} |
||||
|
<div className="mt-4 pt-4 border-t border-border grid grid-cols-2 gap-3 text-sm"> |
||||
|
<div><span className="text-text-muted">大小:</span><span className="text-foreground">{formatFileSize(previewFile.size)}</span></div> |
||||
|
<div><span className="text-text-muted">类型:</span><span className="text-foreground">{previewFile.mimeType}</span></div> |
||||
|
<div><span className="text-text-muted">分类:</span><span className="text-foreground">{previewFile.category}</span></div> |
||||
|
<div><span className="text-text-muted">上传:</span><span className="text-foreground">{previewFile.createdAt}</span></div> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
</Modal> |
||||
|
|
||||
|
{/* Edit Modal */} |
||||
|
<Modal |
||||
|
isOpen={editModalOpen} |
||||
|
onClose={() => { setEditModalOpen(false); setEditingFile(null) }} |
||||
|
title="编辑文件信息" |
||||
|
size="md" |
||||
|
footer={ |
||||
|
<> |
||||
|
<Button variant="outline" onClick={() => { setEditModalOpen(false); setEditingFile(null) }}>取消</Button> |
||||
|
<Button onClick={handleUpdate}>保存</Button> |
||||
|
</> |
||||
|
} |
||||
|
> |
||||
|
<div className="space-y-4"> |
||||
|
<Input |
||||
|
label="文件名" |
||||
|
value={editForm.name || ''} |
||||
|
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} |
||||
|
/> |
||||
|
<div> |
||||
|
<label className="block text-sm font-medium text-foreground mb-1.5">分类</label> |
||||
|
<select |
||||
|
className="w-full rounded-lg border border-border bg-muted px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-sky-500" |
||||
|
value={editForm.category || ''} |
||||
|
onChange={(e) => setEditForm({ ...editForm, category: e.target.value })} |
||||
|
> |
||||
|
{CATEGORY_OPTIONS.filter(o => o.value).map(opt => ( |
||||
|
<option key={opt.value} value={opt.value} className="bg-card">{opt.label}</option> |
||||
|
))} |
||||
|
</select> |
||||
|
</div> |
||||
|
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer"> |
||||
|
<input |
||||
|
type="checkbox" |
||||
|
checked={editForm.isPublic || false} |
||||
|
onChange={(e) => setEditForm({ ...editForm, isPublic: e.target.checked })} |
||||
|
className="rounded border-border-secondary bg-card text-sky-500 focus:ring-sky-500" |
||||
|
/> |
||||
|
公开文件 |
||||
|
</label> |
||||
|
</div> |
||||
|
</Modal> |
||||
|
|
||||
|
{/* Delete Confirm Modal */} |
||||
|
<Modal |
||||
|
isOpen={deleteConfirmOpen} |
||||
|
onClose={() => { setDeleteConfirmOpen(false); setFileToDelete(null) }} |
||||
|
title="确认删除" |
||||
|
size="sm" |
||||
|
footer={ |
||||
|
<> |
||||
|
<Button variant="outline" onClick={() => { setDeleteConfirmOpen(false); setFileToDelete(null) }} disabled={isDeleting}>取消</Button> |
||||
|
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}> |
||||
|
{isDeleting ? '删除中...' : '确认删除'} |
||||
|
</Button> |
||||
|
</> |
||||
|
} |
||||
|
> |
||||
|
<div className="py-4"> |
||||
|
<p className="text-foreground"> |
||||
|
确定要删除文件 <span className="font-medium text-foreground">{fileToDelete?.name}</span> 吗? |
||||
|
</p> |
||||
|
<p className="text-sm text-text-muted mt-2">文件将从存储中删除,此操作不可恢复。</p> |
||||
|
</div> |
||||
|
</Modal> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,182 @@ |
|||||
|
import { TEST_CONFIG, ROUTES } from './config' |
||||
|
|
||||
|
/** |
||||
|
* 文件管理 E2E 测试(Playwright MCP 交互式) |
||||
|
* |
||||
|
* Tests the file management module: |
||||
|
* 1. Super admin login |
||||
|
* 2. Navigate to file management page |
||||
|
* 3. Verify page initial state (upload area, filters, empty table) |
||||
|
* 4. Upload file via "选择文件" button |
||||
|
* 5. Edit file metadata (rename, change category) |
||||
|
* 6. Preview file (shows details + download link) |
||||
|
* 7. Delete file with confirmation dialog |
||||
|
* 8. Verify file list is empty after delete |
||||
|
* |
||||
|
* Prerequisites: |
||||
|
* - Backend running at localhost:8888 (with file storage configured) |
||||
|
* - Frontend running at localhost:5173 |
||||
|
* - Admin user seeded (admin / admin123) |
||||
|
* |
||||
|
* How to run (via Playwright MCP): |
||||
|
* 1. Navigate to login page |
||||
|
* 2. Login as admin / admin123 |
||||
|
* 3. Navigate to /files |
||||
|
* 4. Execute each test step in order |
||||
|
* |
||||
|
* Bug fixes verified: |
||||
|
* - "选择文件" button was unresponsive due to hidden input using display:none |
||||
|
* Fix: Changed to absolute positioning with opacity:0, added type="button" |
||||
|
* - Login page did not show error for wrong password |
||||
|
* Fix: AuthContext.login() now throws when response.success is false |
||||
|
*/ |
||||
|
|
||||
|
export const fileManagementE2ETests = { |
||||
|
name: '文件管理 E2E 测试', |
||||
|
totalSteps: 8, |
||||
|
|
||||
|
/** |
||||
|
* Step 1: Super admin login |
||||
|
* Action: Navigate to /login, enter admin/admin123, submit |
||||
|
* Verify: Redirected to authenticated page, sidebar shows admin |
||||
|
*/ |
||||
|
step1_login: { |
||||
|
action: 'Navigate to /login, fill admin/admin123, click login button', |
||||
|
verify: 'Redirected to authenticated page, sidebar shows admin with super_admin role', |
||||
|
status: 'PASS' as const, |
||||
|
detail: 'Logged in as admin, sidebar shows "admin" with role "super_admin"', |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Step 2: Navigate to file management page |
||||
|
* Action: Click "文件管理" in sidebar |
||||
|
* Verify: URL = /files, file management page loaded |
||||
|
*/ |
||||
|
step2_navigateToFiles: { |
||||
|
action: 'Click "文件管理" link in sidebar', |
||||
|
verify: 'URL is /files, page title shows "文件管理"', |
||||
|
status: 'PASS' as const, |
||||
|
detail: 'Navigated to /files, page heading "文件管理" and subtitle "上传与管理系统文件" visible', |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Step 3: Verify page initial state |
||||
|
* Action: Snapshot the file management page |
||||
|
* Verify: Upload area, filters, table with correct columns, empty state |
||||
|
*/ |
||||
|
step3_verifyInitialState: { |
||||
|
action: 'Capture page snapshot', |
||||
|
verify: 'Upload area with drag-drop zone, category selector, "公开" checkbox, "选择文件" button, search input, category/type filters, table columns: 文件名/分类/大小/类型/状态/上传时间/操作, empty table shows "暂无文件"', |
||||
|
status: 'PASS' as const, |
||||
|
detail: 'All UI elements present: upload zone, category combobox (默认/头像/文档/媒体), 公开 checkbox, 选择文件 button, search textbox, category filter (全部分类/默认/头像/文档/媒体), type filter (全部类型/图片/视频/PDF), table with 7 columns, "暂无文件" shown in empty state', |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Step 4: Upload file via "选择文件" button |
||||
|
* Action: Click "选择文件", select test-upload.txt via file chooser |
||||
|
* Verify: File appears in table with correct metadata |
||||
|
* |
||||
|
* Bug fix verified: Only 1 file chooser opens (was 4 before fix) |
||||
|
* Fix: input changed from className="hidden" to absolute positioning with opacity:0 |
||||
|
* Button added type="button" to prevent form submission |
||||
|
*/ |
||||
|
step4_uploadFile: { |
||||
|
action: 'Click "选择文件" button, select test-upload.txt file', |
||||
|
verify: 'File chooser opens (exactly 1), file uploaded, appears in table', |
||||
|
status: 'PASS' as const, |
||||
|
detail: 'Clicked "选择文件" → 1 file chooser opened (bug fixed). Uploaded test-upload.txt (44 B). Table shows: 文件列表 (1), row: test-upload.txt | default | 44 B | plain | 私有 | 2026/2/14', |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Step 5: Edit file metadata |
||||
|
* Action: Click "编辑" button, change name and category, save |
||||
|
* Verify: Modal opens with file info, changes saved, table updated |
||||
|
*/ |
||||
|
step5_editFile: { |
||||
|
action: 'Click "编辑" button → change filename to "test-upload-edited.txt", category to "文档" → click "保存"', |
||||
|
verify: 'Edit modal opens with current file info, changes saved, table row updated', |
||||
|
status: 'PASS' as const, |
||||
|
detail: 'Edit modal: title "编辑文件信息", textbox "文件名"=test-upload.txt, combobox "分类"=默认, checkbox "公开文件". Changed name to "test-upload-edited.txt", category to "文档". After save: table shows test-upload-edited.txt | document | 44 B | plain | 私有', |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Step 6: Preview file |
||||
|
* Action: Click "预览" button on the file row |
||||
|
* Verify: Preview modal shows file details and download link |
||||
|
*/ |
||||
|
step6_previewFile: { |
||||
|
action: 'Click "预览" button on test-upload-edited.txt row', |
||||
|
verify: 'Preview modal shows file icon, name, size, type, download link, and metadata grid', |
||||
|
status: 'PASS' as const, |
||||
|
detail: 'Preview modal: title "test-upload-edited.txt", file icon, name, "44 B · text/plain", "下载文件" link with token URL, metadata grid: 大小=44 B, 类型=text/plain, 分类=document, 上传=2026-02-14 17:06:15', |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Step 7: Delete file with confirmation |
||||
|
* Action: Click "删除" button, confirm in dialog |
||||
|
* Verify: Confirmation dialog appears, file removed after confirm |
||||
|
*/ |
||||
|
step7_deleteFile: { |
||||
|
action: 'Click "删除" button → confirm in deletion dialog', |
||||
|
verify: 'Confirmation dialog shows file name and warning, file removed after confirm', |
||||
|
status: 'PASS' as const, |
||||
|
detail: 'Delete dialog: title "确认删除", message "确定要删除文件 test-upload-edited.txt 吗?", warning "文件将从存储中删除,此操作不可恢复。", buttons "取消"/"确认删除". After confirm: file removed', |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Step 8: Verify empty state after delete |
||||
|
* Action: Check table state after deletion |
||||
|
* Verify: Table shows "暂无文件", file count is 0 |
||||
|
*/ |
||||
|
step8_verifyEmptyAfterDelete: { |
||||
|
action: 'Verify table state after file deletion', |
||||
|
verify: 'File list count is 0, table shows "暂无文件"', |
||||
|
status: 'PASS' as const, |
||||
|
detail: 'After deletion: heading shows "文件列表 (0)", table body shows single row "暂无文件"', |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Login Error Display E2E Test (bonus - tested alongside file management) |
||||
|
* |
||||
|
* Bug: Login page did not show error message when entering wrong password. |
||||
|
* Root cause: AuthContext.login() didn't throw when response.success was false. |
||||
|
* Fix: Added else branch to throw new Error(response.message || '登录失败') |
||||
|
* |
||||
|
* Verification: |
||||
|
* - Entered admin / wrongpassword on login page |
||||
|
* - Red alert banner displayed: "用户不存在或密码错误" |
||||
|
* - User stays on login page (not redirected) |
||||
|
* |
||||
|
* Status: PASS |
||||
|
*/ |
||||
|
export const loginErrorDisplayTest = { |
||||
|
name: '登录错误提示测试', |
||||
|
status: 'PASS' as const, |
||||
|
detail: 'Entered wrong password "wrongpassword" → red alert with role="alert" shows "用户不存在或密码错误", user stays on /login page', |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Test Summary: |
||||
|
* |
||||
|
* File Management: |
||||
|
* Step 1: Super admin login ......................... PASS |
||||
|
* Step 2: Navigate to file management ............... PASS |
||||
|
* Step 3: Verify page initial state ................. PASS |
||||
|
* Step 4: Upload file via button (bug fix) .......... PASS |
||||
|
* Step 5: Edit file metadata ........................ PASS |
||||
|
* Step 6: Preview file .............................. PASS |
||||
|
* Step 7: Delete file with confirmation ............. PASS |
||||
|
* Step 8: Verify empty state after delete ........... PASS |
||||
|
* |
||||
|
* Login Error Display (bonus): |
||||
|
* Wrong password error message ...................... PASS |
||||
|
* |
||||
|
* Result: 8/8 PASS (+ 1 bonus PASS) |
||||
|
* |
||||
|
* Bug Fixes Verified: |
||||
|
* 1. "选择文件" button unresponsive → Fixed: hidden input changed from display:none |
||||
|
* to absolute+opacity:0, Button added type="button" (FileManagementPage.tsx) |
||||
|
* 2. Login wrong password no error → Fixed: AuthContext.login() throws on |
||||
|
* response.success=false (AuthContext.tsx) |
||||
|
*/ |
||||
Loading…
Reference in new issue