665 lines
24 KiB
TypeScript
665 lines
24 KiB
TypeScript
|
|
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>
|
|||
|
|
)
|
|||
|
|
}
|