Enhance Sidebar with new menu items for materials management and update TypeScript configuration. Add subjects and materials APIs to the API index. Introduce new routes for materials and subjects with appropriate guards.

This commit is contained in:
lafay
2026-03-25 20:44:27 +08:00
parent 890c33f510
commit 2910dd6ccf
7 changed files with 1363 additions and 36 deletions

665
src/pages/Materials.tsx Normal file
View File

@@ -0,0 +1,665 @@
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>
)
}