Files
admin/src/pages/Materials.tsx

665 lines
24 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useCallback } from 'react'
import { materialsApi, subjectsApi, type MaterialFile, type MaterialQueryParams, type MaterialSubject, type MaterialCreateRequest, type MaterialUpdateRequest } from '@/api'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { PaginationNavigator } from '@/components/ui/pagination'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Loader2, Plus, Pencil, Trash2, Search, FileText, Download, Eye } from 'lucide-react'
import { format } from 'date-fns'
// 文件类型配置
const FILE_TYPE_CONFIG = {
pdf: { icon: '📄', color: 'bg-red-100 text-red-800', label: 'PDF' },
word: { icon: '📝', color: 'bg-blue-100 text-blue-800', label: 'Word' },
ppt: { icon: '📊', color: 'bg-orange-100 text-orange-800', label: 'PPT' },
} as const
// 格式化文件大小
const formatFileSize = (bytes: number) => {
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(2)) + ' ' + sizes[i]
}
export default function Materials() {
const [materials, setMaterials] = useState<MaterialFile[]>([])
const [subjects, setSubjects] = useState<MaterialSubject[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const pageSize = 10
// 筛选
const [subjectFilter, setSubjectFilter] = useState<string>('all')
const [fileTypeFilter, setFileTypeFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [keyword, setKeyword] = useState('')
// 创建/编辑对话框
const [dialogOpen, setDialogOpen] = useState(false)
const [editingMaterial, setEditingMaterial] = useState<MaterialFile | null>(null)
const [formData, setFormData] = useState<MaterialCreateRequest>({
subject_id: '',
title: '',
description: '',
file_type: 'pdf',
file_size: 0,
file_url: '',
file_name: '',
tags: [],
})
const [dialogLoading, setDialogLoading] = useState(false)
// 删除确认对话框
const [deleteOpen, setDeleteOpen] = useState(false)
const [deletingMaterial, setDeletingMaterial] = useState<MaterialFile | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
// 详情对话框
const [detailOpen, setDetailOpen] = useState(false)
const [selectedMaterial, setSelectedMaterial] = useState<MaterialFile | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
// 加载资料列表
const loadMaterials = useCallback(async () => {
setLoading(true)
try {
const params: MaterialQueryParams = {
page,
page_size: pageSize,
keyword: keyword || undefined,
subject_id: subjectFilter !== 'all' ? subjectFilter : undefined,
file_type: fileTypeFilter !== 'all' ? fileTypeFilter as any : undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
}
const response = await materialsApi.getMaterials(params)
const data = response.data.data
setMaterials(data.list || [])
setTotal(data.total || 0)
} catch (error) {
console.error('Failed to load materials:', error)
setMaterials([])
setTotal(0)
} finally {
setLoading(false)
}
}, [page, pageSize, keyword, subjectFilter, fileTypeFilter, statusFilter])
// 加载学科列表(用于筛选和表单)
const loadSubjects = useCallback(async () => {
try {
const response = await subjectsApi.getSubjects()
setSubjects(response.data.data || [])
} catch (error) {
console.error('Failed to load subjects:', error)
}
}, [])
useEffect(() => {
loadSubjects()
}, [loadSubjects])
useEffect(() => {
loadMaterials()
}, [loadMaterials])
// 搜索
const handleSearch = () => {
setPage(1)
loadMaterials()
}
// 打开创建对话框
const handleOpenCreate = () => {
setEditingMaterial(null)
setFormData({
subject_id: subjects[0]?.id || '',
title: '',
description: '',
file_type: 'pdf',
file_size: 0,
file_url: '',
file_name: '',
tags: [],
})
setDialogOpen(true)
}
// 打开编辑对话框
const handleOpenEdit = (material: MaterialFile) => {
setEditingMaterial(material)
setFormData({
subject_id: material.subject_id,
title: material.title,
description: material.description || '',
file_type: material.file_type,
file_size: material.file_size,
file_url: material.file_url,
file_name: material.file_name || '',
tags: material.tags || [],
})
setDialogOpen(true)
}
// 保存资料
const handleSave = async () => {
if (!formData.subject_id || !formData.title?.trim() || !formData.file_url?.trim()) return
setDialogLoading(true)
try {
if (editingMaterial) {
const updateData: MaterialUpdateRequest = { ...formData }
await materialsApi.updateMaterial(editingMaterial.id, updateData)
} else {
await materialsApi.createMaterial(formData)
}
setDialogOpen(false)
loadMaterials()
} catch (error) {
console.error('Failed to save material:', error)
} finally {
setDialogLoading(false)
}
}
// 打开删除确认
const handleOpenDelete = (material: MaterialFile) => {
setDeletingMaterial(material)
setDeleteOpen(true)
}
// 确认删除
const handleConfirmDelete = async () => {
if (!deletingMaterial) return
setDeleteLoading(true)
try {
await materialsApi.deleteMaterial(deletingMaterial.id)
setDeleteOpen(false)
loadMaterials()
} catch (error) {
console.error('Failed to delete material:', error)
} finally {
setDeleteLoading(false)
}
}
// 查看详情
const handleViewDetail = async (material: MaterialFile) => {
setDetailLoading(true)
setDetailOpen(true)
try {
const response = await materialsApi.getMaterialById(material.id)
setSelectedMaterial(response.data.data)
} catch (error) {
console.error('Failed to load material detail:', error)
setSelectedMaterial(null)
} finally {
setDetailLoading(false)
}
}
// 格式化日期
const formatDate = (dateStr: string) => {
try {
return format(new Date(dateStr), 'yyyy-MM-dd HH:mm')
} catch {
return dateStr
}
}
const totalPages = Math.ceil(total / pageSize)
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
<Card>
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription> {total} </CardDescription>
</div>
<Button onClick={handleOpenCreate}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</CardHeader>
<CardContent>
{/* 筛选栏 */}
<div className="flex flex-wrap gap-4 mb-4">
<Input
placeholder="搜索资料标题..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="w-[200px]"
/>
<Select value={subjectFilter} onValueChange={(v) => { setSubjectFilter(v); setPage(1); }}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="学科筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.icon} {s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={fileTypeFilter} onValueChange={(v) => { setFileTypeFilter(v); setPage(1); }}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="文件类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="word">Word</SelectItem>
<SelectItem value="ppt">PPT</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1); }}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="active"></SelectItem>
<SelectItem value="inactive"></SelectItem>
</SelectContent>
</Select>
<Button variant="outline" onClick={handleSearch}>
<Search className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={loadMaterials}>
<Loader2 className="h-4 w-4 mr-2" />
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[80px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
<p className="text-muted-foreground mt-2">...</p>
</TableCell>
</TableRow>
) : materials.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
) : (
materials.map((material) => {
const typeConfig = FILE_TYPE_CONFIG[material.file_type] || FILE_TYPE_CONFIG.pdf
const subject = subjects.find((s) => s.id === material.subject_id)
return (
<TableRow key={material.id}>
<TableCell>
<Badge className={typeConfig.color}>
{typeConfig.icon} {typeConfig.label}
</Badge>
</TableCell>
<TableCell>
<div className="max-w-[300px]">
<p className="font-medium truncate">{material.title}</p>
<p className="text-xs text-muted-foreground truncate">
{material.file_name || '未命名文件'}
</p>
</div>
</TableCell>
<TableCell>
{subject ? (
<span>{subject.icon} {subject.name}</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>{formatFileSize(material.file_size)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Download className="h-3 w-3 text-muted-foreground" />
{material.download_count}
</div>
</TableCell>
<TableCell>
<Badge variant={material.status === 'active' ? 'default' : 'outline'}>
{material.status === 'active' ? '启用' : '禁用'}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(material.created_at)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleViewDetail(material)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleOpenEdit(material)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleOpenDelete(material)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="mt-4">
<PaginationNavigator
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
)}
</CardContent>
</Card>
{/* 创建/编辑对话框 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
{editingMaterial ? '编辑资料' : '添加资料'}
</DialogTitle>
<DialogDescription>
{editingMaterial ? '修改资料信息' : '创建新的学习资料'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto">
<div className="space-y-2">
<Label htmlFor="subject_id"> *</Label>
<Select
value={formData.subject_id}
onValueChange={(v) => setFormData({ ...formData, subject_id: v })}
>
<SelectTrigger>
<SelectValue placeholder="选择学科" />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.icon} {s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="title"> *</Label>
<Input
id="title"
value={formData.title || ''}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="如:高等数学期末复习资料"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="资料描述(可选)"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="file_type"> *</Label>
<Select
value={formData.file_type}
onValueChange={(v) => setFormData({ ...formData, file_type: v as any })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pdf">📄 PDF</SelectItem>
<SelectItem value="word">📝 Word</SelectItem>
<SelectItem value="ppt">📊 PPT</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="file_size"> (bytes)</Label>
<Input
id="file_size"
type="number"
value={formData.file_size || 0}
onChange={(e) => setFormData({ ...formData, file_size: parseInt(e.target.value) || 0 })}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="file_name"></Label>
<Input
id="file_name"
value={formData.file_name || ''}
onChange={(e) => setFormData({ ...formData, file_name: e.target.value })}
placeholder="如:高等数学-期末复习-2024.pdf"
/>
</div>
<div className="space-y-2">
<Label htmlFor="file_url"> *</Label>
<Input
id="file_url"
value={formData.file_url || ''}
onChange={(e) => setFormData({ ...formData, file_url: e.target.value })}
placeholder="https://example.com/path/to/file.pdf"
/>
</div>
{editingMaterial && (
<div className="flex items-center gap-2">
<input
id="status"
type="checkbox"
checked={formData.status === 'active' || formData.status === undefined}
onChange={(e) => setFormData({ ...formData, status: e.target.checked ? 'active' : 'inactive' })}
className="rounded"
/>
<Label htmlFor="status"></Label>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
</Button>
<Button
onClick={handleSave}
disabled={dialogLoading || !formData.subject_id || !formData.title?.trim() || !formData.file_url?.trim()}
>
{dialogLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{editingMaterial ? '保存' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{deletingMaterial?.title}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteOpen(false)}>
</Button>
<Button variant="destructive" onClick={handleConfirmDelete}>
{deleteLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 详情对话框 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : selectedMaterial ? (
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="text-4xl">
{FILE_TYPE_CONFIG[selectedMaterial.file_type]?.icon || '📄'}
</div>
<div>
<h3 className="font-semibold text-lg">{selectedMaterial.title}</h3>
<p className="text-sm text-muted-foreground">{selectedMaterial.file_name}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground"></span>
<span>
{subjects.find((s) => s.id === selectedMaterial.subject_id)?.name || '-'}
</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<span>{FILE_TYPE_CONFIG[selectedMaterial.file_type]?.label || selectedMaterial.file_type}</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<span>{formatFileSize(selectedMaterial.file_size)}</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<span>{selectedMaterial.download_count}</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<Badge variant={selectedMaterial.status === 'active' ? 'default' : 'outline'}>
{selectedMaterial.status === 'active' ? '启用' : '禁用'}
</Badge>
</div>
<div>
<span className="text-muted-foreground"></span>
<span>{formatDate(selectedMaterial.created_at)}</span>
</div>
</div>
{selectedMaterial.description && (
<div>
<span className="text-muted-foreground"></span>
<p className="mt-1 text-sm">{selectedMaterial.description}</p>
</div>
)}
{selectedMaterial.tags && selectedMaterial.tags.length > 0 && (
<div>
<span className="text-muted-foreground"></span>
<div className="flex flex-wrap gap-1 mt-1">
{selectedMaterial.tags.map((tag, i) => (
<Badge key={i} variant="secondary">{tag}</Badge>
))}
</div>
</div>
)}
<div className="pt-4">
<Label className="text-muted-foreground"></Label>
<a
href={selectedMaterial.file_url}
target="_blank"
rel="noopener noreferrer"
className="block mt-1 text-sm text-blue-600 hover:underline break-all"
>
{selectedMaterial.file_url}
</a>
</div>
</div>
) : (
<p className="text-center text-muted-foreground py-8"></p>
)}
</DialogContent>
</Dialog>
</div>
)
}