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:
420
src/pages/MaterialSubjects.tsx
Normal file
420
src/pages/MaterialSubjects.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { subjectsApi, type MaterialSubject, type SubjectUpsertRequest } 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 {
|
||||
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, RefreshCw, BookOpen } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
// 颜色预设选项
|
||||
const COLOR_PRESETS = [
|
||||
{ name: '红色', value: '#ef4444' },
|
||||
{ name: '橙色', value: '#f97316' },
|
||||
{ name: '黄色', value: '#eab308' },
|
||||
{ name: '绿色', value: '#22c55e' },
|
||||
{ name: '蓝色', value: '#3b82f6' },
|
||||
{ name: '紫色', value: '#a855f7' },
|
||||
{ name: '粉色', value: '#ec4899' },
|
||||
{ name: '青色', value: '#06b6d4' },
|
||||
]
|
||||
|
||||
// 图标预设选项
|
||||
const ICON_PRESETS = [
|
||||
'📚', '📖', '📐', '📏', '🔢', '🔤', '🌍', '⚗️',
|
||||
'🧪', '🧬', '💻', '📊', '📈', '🎨', '🎵', '⚽',
|
||||
]
|
||||
|
||||
export default function MaterialSubjects() {
|
||||
const [subjects, setSubjects] = useState<MaterialSubject[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = 10
|
||||
|
||||
// 创建/编辑对话框
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingSubject, setEditingSubject] = useState<MaterialSubject | null>(null)
|
||||
const [formData, setFormData] = useState<SubjectUpsertRequest>({
|
||||
name: '',
|
||||
icon: '📚',
|
||||
color: '#3b82f6',
|
||||
description: '',
|
||||
sort_order: 0,
|
||||
is_active: true,
|
||||
})
|
||||
const [dialogLoading, setDialogLoading] = useState(false)
|
||||
|
||||
// 删除确认对话框
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deletingSubject, setDeletingSubject] = useState<MaterialSubject | null>(null)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
|
||||
// 加载学科列表
|
||||
const loadSubjects = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await subjectsApi.getSubjects()
|
||||
setSubjects(response.data.data || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load subjects:', error)
|
||||
setSubjects([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSubjects()
|
||||
}, [loadSubjects])
|
||||
|
||||
// 打开创建对话框
|
||||
const handleOpenCreate = () => {
|
||||
setEditingSubject(null)
|
||||
setFormData({
|
||||
name: '',
|
||||
icon: '📚',
|
||||
color: '#3b82f6',
|
||||
description: '',
|
||||
sort_order: subjects.length,
|
||||
is_active: true,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
const handleOpenEdit = (subject: MaterialSubject) => {
|
||||
setEditingSubject(subject)
|
||||
setFormData({
|
||||
name: subject.name,
|
||||
icon: subject.icon,
|
||||
color: subject.color,
|
||||
description: subject.description,
|
||||
sort_order: subject.sort_order,
|
||||
is_active: subject.is_active,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 保存学科
|
||||
const handleSave = async () => {
|
||||
if (!formData.name?.trim()) return
|
||||
setDialogLoading(true)
|
||||
try {
|
||||
if (editingSubject) {
|
||||
await subjectsApi.updateSubject(editingSubject.id, formData)
|
||||
} else {
|
||||
await subjectsApi.createSubject(formData)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
loadSubjects()
|
||||
} catch (error) {
|
||||
console.error('Failed to save subject:', error)
|
||||
} finally {
|
||||
setDialogLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开删除确认
|
||||
const handleOpenDelete = (subject: MaterialSubject) => {
|
||||
setDeletingSubject(subject)
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingSubject) return
|
||||
setDeleteLoading(true)
|
||||
try {
|
||||
await subjectsApi.deleteSubject(deletingSubject.id)
|
||||
setDeleteOpen(false)
|
||||
loadSubjects()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete subject:', error)
|
||||
alert(error?.response?.data?.message || '删除失败,该学科下可能存在资料')
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
return format(new Date(dateStr), 'yyyy-MM-dd HH:mm')
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
// 分页后的数据
|
||||
const paginatedSubjects = subjects.slice((page - 1) * pageSize, page * pageSize)
|
||||
const totalPages = Math.ceil(subjects.length / pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">学科管理</h1>
|
||||
<p className="text-muted-foreground">管理学习资料的学科分类</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>学科列表</CardTitle>
|
||||
<CardDescription>共 {subjects.length} 个学科</CardDescription>
|
||||
</div>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加学科
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[80px]">排序</TableHead>
|
||||
<TableHead className="w-[80px]">图标</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>颜色</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead className="w-[100px]">资料数</TableHead>
|
||||
<TableHead className="w-[100px]">状态</TableHead>
|
||||
<TableHead className="w-[150px]">创建时间</TableHead>
|
||||
<TableHead className="w-[120px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} 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>
|
||||
) : paginatedSubjects.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedSubjects.map((subject) => (
|
||||
<TableRow key={subject.id}>
|
||||
<TableCell>{subject.sort_order}</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-2xl">{subject.icon}</span>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{subject.name}</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
className="w-6 h-6 rounded"
|
||||
style={{ backgroundColor: subject.color }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{subject.description || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{subject.material_count}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={subject.is_active ? 'default' : 'outline'}>
|
||||
{subject.is_active ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatDate(subject.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleOpenEdit(subject)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleOpenDelete(subject)}
|
||||
>
|
||||
<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-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingSubject ? '编辑学科' : '添加学科'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingSubject ? '修改学科信息' : '创建新的学科分类'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">学科名称 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name || ''}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="如:高等数学"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>图标</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ICON_PRESETS.map((icon) => (
|
||||
<button
|
||||
key={icon}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, icon })}
|
||||
className={`w-10 h-10 text-xl rounded border-2 transition-colors ${
|
||||
formData.icon === icon
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-muted hover:border-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>颜色</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLOR_PRESETS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, color: color.value })}
|
||||
className={`w-8 h-8 rounded-full border-2 transition-colors ${
|
||||
formData.color === color.value
|
||||
? 'border-primary scale-110'
|
||||
: 'border-transparent hover:scale-105'
|
||||
}`}
|
||||
style={{ backgroundColor: color.value }}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</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="space-y-2">
|
||||
<Label htmlFor="sort_order">排序值</Label>
|
||||
<Input
|
||||
id="sort_order"
|
||||
type="number"
|
||||
value={formData.sort_order || 0}
|
||||
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">数值越小排序越靠前</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="is_active"
|
||||
type="checkbox"
|
||||
checked={formData.is_active ?? true}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="is_active">启用此学科</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={dialogLoading || !formData.name?.trim()}>
|
||||
{dialogLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{editingSubject ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除学科「{deletingSubject?.name}」吗?
|
||||
{deletingSubject && deletingSubject.material_count > 0 && (
|
||||
<span className="block mt-2 text-destructive">
|
||||
该学科下有 {deletingSubject.material_count} 个资料,无法删除。
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={deleteLoading || (deletingSubject && deletingSubject.material_count > 0)}
|
||||
>
|
||||
{deleteLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
665
src/pages/Materials.tsx
Normal file
665
src/pages/Materials.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user