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

View 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
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>
)
}