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