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:
@@ -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
135
src/api/materials.ts
Normal 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
|
||||
@@ -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()
|
||||
|
||||
// DEBUG: 输出角色信息
|
||||
console.log('[Sidebar DEBUG] roles:', roles)
|
||||
console.log('[Sidebar DEBUG] ROLES constants:', ROLES)
|
||||
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>(['学习资料'])
|
||||
|
||||
// 切换展开状态
|
||||
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
|
||||
})
|
||||
|
||||
console.log('[Sidebar DEBUG] visibleMenuItems count:', visibleMenuItems.length)
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
@@ -98,30 +199,12 @@ export default function Sidebar({ collapsed, onCollapse }: SidebarProps) {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<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>
|
||||
|
||||
|
||||
{/* 底部版本信息 */}
|
||||
{!collapsed && (
|
||||
<div className="border-t p-4 text-center text-xs text-muted-foreground">
|
||||
@@ -130,4 +213,4 @@ export default function Sidebar({ collapsed, onCollapse }: SidebarProps) {
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user