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

@@ -12,3 +12,5 @@ export { groupsApi } from './groups'
export type { GroupQueryParams, GroupDetail, GroupMember } from './groups'
export { dashboardApi } from './dashboard'
export type { DashboardStatsData, ActivityTrendData, ContentStatsData, PendingContent } from './dashboard'
export { subjectsApi, materialsApi } from './materials'
export type { MaterialSubject, MaterialFile, MaterialFileType, SubjectUpsertRequest, MaterialCreateRequest, MaterialUpdateRequest, MaterialQueryParams } from './materials'

135
src/api/materials.ts Normal file
View File

@@ -0,0 +1,135 @@
import apiClient from './client'
import type { ApiResponse, PaginatedResponse } from '@/types'
// =====================================================
// 类型定义
// =====================================================
// 文件类型
export type MaterialFileType = 'pdf' | 'word' | 'ppt'
// 学科
export interface MaterialSubject {
id: string
name: string
icon: string
color: string
description: string
sort_order: number
is_active: boolean
material_count: number
created_at: string
updated_at: string
}
// 学习资料
export interface MaterialFile {
id: string
subject_id: string
title: string
description: string
file_type: MaterialFileType
file_size: number
file_url: string
file_name: string
download_count: number
author_id: string
tags: string[]
status: 'active' | 'inactive'
created_at: string
updated_at: string
subject?: MaterialSubject
}
// 学科创建/更新请求
export interface SubjectUpsertRequest {
name: string
icon?: string
color?: string
description?: string
sort_order?: number
is_active?: boolean
}
// 资料创建请求
export interface MaterialCreateRequest {
subject_id: string
title: string
description?: string
file_type: MaterialFileType
file_size?: number
file_url: string
file_name?: string
tags?: string[]
}
// 资料更新请求
export interface MaterialUpdateRequest {
subject_id?: string
title?: string
description?: string
file_type?: MaterialFileType
file_size?: number
file_url?: string
file_name?: string
tags?: string[]
status?: 'active' | 'inactive'
}
// 资料查询参数
export interface MaterialQueryParams {
page?: number
page_size?: number
subject_id?: string
file_type?: MaterialFileType
status?: string
keyword?: string
}
// =====================================================
// API 接口
// =====================================================
// 学科管理 API
export const subjectsApi = {
// 获取学科列表
getSubjects: () =>
apiClient.get<ApiResponse<MaterialSubject[]>>('/admin/materials/subjects'),
// 创建学科
createSubject: (data: SubjectUpsertRequest) =>
apiClient.post<ApiResponse<MaterialSubject>>('/admin/materials/subjects', data),
// 更新学科
updateSubject: (id: string, data: SubjectUpsertRequest) =>
apiClient.put<ApiResponse<MaterialSubject>>(`/admin/materials/subjects/${id}`, data),
// 删除学科
deleteSubject: (id: string) =>
apiClient.delete<ApiResponse<void>>(`/admin/materials/subjects/${id}`),
}
// 学习资料管理 API
export const materialsApi = {
// 获取资料列表
getMaterials: (params: MaterialQueryParams) =>
apiClient.get<ApiResponse<PaginatedResponse<MaterialFile>>>('/admin/materials', { params }),
// 获取资料详情
getMaterialById: (id: string) =>
apiClient.get<ApiResponse<MaterialFile>>(`/admin/materials/${id}`),
// 创建资料
createMaterial: (data: MaterialCreateRequest) =>
apiClient.post<ApiResponse<MaterialFile>>('/admin/materials', data),
// 更新资料
updateMaterial: (id: string, data: MaterialUpdateRequest) =>
apiClient.put<ApiResponse<MaterialFile>>(`/admin/materials/${id}`, data),
// 删除资料
deleteMaterial: (id: string) =>
apiClient.delete<ApiResponse<void>>(`/admin/materials/${id}`),
}
export default materialsApi

View File

@@ -10,15 +10,27 @@ import {
Users as UsersIcon,
ChevronLeft,
ChevronRight,
FolderOpen,
BookOpen,
ChevronDown,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useState } from 'react'
interface SidebarProps {
collapsed: boolean
onCollapse: (collapsed: boolean) => void
}
const menuItems = [
interface MenuItem {
title: string
path?: string
icon: React.ComponentType<{ className?: string }>
roles: string[]
children?: MenuItem[]
}
const menuItems: MenuItem[] = [
{
title: 'Dashboard',
path: '/dashboard',
@@ -55,23 +67,112 @@ const menuItems = [
icon: UsersIcon,
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MODERATOR],
},
{
title: '学习资料',
icon: FolderOpen,
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN],
children: [
{
title: '学科管理',
path: '/materials/subjects',
icon: BookOpen,
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN],
},
{
title: '资料管理',
path: '/materials',
icon: FileText,
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN],
},
],
},
]
export default function Sidebar({ collapsed, onCollapse }: SidebarProps) {
const { hasAnyRole, roles } = useAuthStore()
const [expandedItems, setExpandedItems] = useState<string[]>(['学习资料'])
// DEBUG: 输出角色信息
console.log('[Sidebar DEBUG] roles:', roles)
console.log('[Sidebar DEBUG] ROLES constants:', ROLES)
// 切换展开状态
const toggleExpand = (title: string) => {
setExpandedItems((prev) =>
prev.includes(title) ? prev.filter((t) => t !== title) : [...prev, title]
)
}
// 过滤出当前用户有权访问的菜单
const visibleMenuItems = menuItems.filter(item => {
const hasAccess = hasAnyRole(item.roles)
console.log(`[Sidebar DEBUG] Menu "${item.title}" - required roles:`, item.roles, '- hasAccess:', hasAccess)
return hasAccess
})
const filterMenuItems = (items: MenuItem[]): MenuItem[] => {
return items
.filter((item) => hasAnyRole(item.roles))
.map((item) => ({
...item,
children: item.children ? filterMenuItems(item.children) : undefined,
}))
.filter((item) => item.path || (item.children && item.children.length > 0))
}
console.log('[Sidebar DEBUG] visibleMenuItems count:', visibleMenuItems.length)
const visibleMenuItems = filterMenuItems(menuItems)
// 渲染单个菜单项
const renderMenuItem = (item: MenuItem) => {
const Icon = item.icon
// 如果有子菜单
if (item.children && item.children.length > 0) {
const isExpanded = expandedItems.includes(item.title)
return (
<div key={item.title}>
<button
onClick={() => toggleExpand(item.title)}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors cursor-pointer',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
collapsed && 'justify-center px-2'
)}
title={collapsed ? item.title : undefined}
>
<Icon className="h-5 w-5 shrink-0" />
{!collapsed && (
<>
<span className="flex-1 text-left">{item.title}</span>
<ChevronDown
className={cn(
'h-4 w-4 transition-transform',
isExpanded && 'rotate-180'
)}
/>
</>
)}
</button>
{!collapsed && isExpanded && (
<div className="ml-6 mt-1 space-y-1 border-l-2 border-muted pl-3">
{item.children.map((child) => renderMenuItem(child))}
</div>
)}
</div>
)
}
// 普通菜单项
return (
<NavLink
key={item.path}
to={item.path!}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
collapsed && 'justify-center px-2'
)
}
title={collapsed ? item.title : undefined}
>
<Icon className="h-5 w-5 shrink-0" />
{!collapsed && <span>{item.title}</span>}
</NavLink>
)
}
return (
<aside
@@ -101,25 +202,7 @@ export default function Sidebar({ collapsed, onCollapse }: SidebarProps) {
{/* 导航菜单 */}
<nav className="flex-1 space-y-1 p-2">
{visibleMenuItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
collapsed && 'justify-center px-2'
)
}
title={collapsed ? item.title : undefined}
>
<item.icon className="h-5 w-5 shrink-0" />
{!collapsed && <span>{item.title}</span>}
</NavLink>
))}
{visibleMenuItems.map((item) => renderMenuItem(item))}
</nav>
{/* 底部版本信息 */}

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

View File

@@ -103,6 +103,8 @@ const Roles = lazy(() => import('@/pages/Roles'))
const Posts = lazy(() => import('@/pages/Posts'))
const Comments = lazy(() => import('@/pages/Comments'))
const Groups = lazy(() => import('@/pages/Groups'))
const MaterialSubjects = lazy(() => import('@/pages/MaterialSubjects'))
const Materials = lazy(() => import('@/pages/Materials'))
const Forbidden = lazy(() => import('@/pages/Forbidden'))
const NotFound = lazy(() => import('@/pages/NotFound'))
@@ -191,6 +193,26 @@ export const router = createBrowserRouter([
</ModeratorGuard>
),
},
{
path: 'materials/subjects',
element: (
<AdminGuard>
<Suspense fallback={<PageLoader />}>
<MaterialSubjects />
</Suspense>
</AdminGuard>
),
},
{
path: 'materials',
element: (
<AdminGuard>
<Suspense fallback={<PageLoader />}>
<Materials />
</Suspense>
</AdminGuard>
),
},
],
},
{

View File

@@ -5,7 +5,7 @@
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"types": ["vite/client", "react", "react-dom"],
"skipLibCheck": true,
/* Bundler mode */