diff --git a/src/api/index.ts b/src/api/index.ts index feb97f0..8598609 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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' diff --git a/src/api/materials.ts b/src/api/materials.ts new file mode 100644 index 0000000..3726485 --- /dev/null +++ b/src/api/materials.ts @@ -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>('/admin/materials/subjects'), + + // 创建学科 + createSubject: (data: SubjectUpsertRequest) => + apiClient.post>('/admin/materials/subjects', data), + + // 更新学科 + updateSubject: (id: string, data: SubjectUpsertRequest) => + apiClient.put>(`/admin/materials/subjects/${id}`, data), + + // 删除学科 + deleteSubject: (id: string) => + apiClient.delete>(`/admin/materials/subjects/${id}`), +} + +// 学习资料管理 API +export const materialsApi = { + // 获取资料列表 + getMaterials: (params: MaterialQueryParams) => + apiClient.get>>('/admin/materials', { params }), + + // 获取资料详情 + getMaterialById: (id: string) => + apiClient.get>(`/admin/materials/${id}`), + + // 创建资料 + createMaterial: (data: MaterialCreateRequest) => + apiClient.post>('/admin/materials', data), + + // 更新资料 + updateMaterial: (id: string, data: MaterialUpdateRequest) => + apiClient.put>(`/admin/materials/${id}`, data), + + // 删除资料 + deleteMaterial: (id: string) => + apiClient.delete>(`/admin/materials/${id}`), +} + +export default materialsApi \ No newline at end of file diff --git a/src/components/Layout/Sidebar.tsx b/src/components/Layout/Sidebar.tsx index 9692000..7ad97a0 100644 --- a/src/components/Layout/Sidebar.tsx +++ b/src/components/Layout/Sidebar.tsx @@ -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(['学习资料']) + + // 切换展开状态 + 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 ( +
+ + {!collapsed && isExpanded && ( +
+ {item.children.map((child) => renderMenuItem(child))} +
+ )} +
+ ) + } + + // 普通菜单项 + return ( + + 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} + > + + {!collapsed && {item.title}} + + ) + } return ( ) -} +} \ No newline at end of file diff --git a/src/pages/MaterialSubjects.tsx b/src/pages/MaterialSubjects.tsx new file mode 100644 index 0000000..8a00075 --- /dev/null +++ b/src/pages/MaterialSubjects.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const pageSize = 10 + + // 创建/编辑对话框 + const [dialogOpen, setDialogOpen] = useState(false) + const [editingSubject, setEditingSubject] = useState(null) + const [formData, setFormData] = useState({ + 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(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 ( +
+
+

学科管理

+

管理学习资料的学科分类

+
+ + + +
+ 学科列表 + 共 {subjects.length} 个学科 +
+ +
+ + + + + 排序 + 图标 + 名称 + 颜色 + 描述 + 资料数 + 状态 + 创建时间 + 操作 + + + + {loading ? ( + + + +

加载中...

+
+
+ ) : paginatedSubjects.length === 0 ? ( + + + 暂无数据 + + + ) : ( + paginatedSubjects.map((subject) => ( + + {subject.sort_order} + + {subject.icon} + + {subject.name} + +
+ + + {subject.description || '-'} + + + {subject.material_count} + + + + {subject.is_active ? '启用' : '禁用'} + + + + {formatDate(subject.created_at)} + + +
+ + +
+
+ + )) + )} + +
+ + {totalPages > 1 && ( +
+ +
+ )} +
+
+ + {/* 创建/编辑对话框 */} + + + + + {editingSubject ? '编辑学科' : '添加学科'} + + + {editingSubject ? '修改学科信息' : '创建新的学科分类'} + + +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="如:高等数学" + /> +
+ +
+ +
+ {ICON_PRESETS.map((icon) => ( + + ))} +
+
+ +
+ +
+ {COLOR_PRESETS.map((color) => ( +
+
+ +
+ +