Add channels management to Sidebar and API index; update MaterialSubjects for icon handling
This commit is contained in:
2
.env.development
Normal file
2
.env.development
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# 开发环境 API 地址
|
||||||
|
VITE_API_BASE_URL=https://bbs.littlelan.cn/api/v1
|
||||||
44
src/api/channels.ts
Normal file
44
src/api/channels.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
import type { ApiResponse } from '@/types'
|
||||||
|
|
||||||
|
// 频道信息
|
||||||
|
export interface Channel {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
description: string
|
||||||
|
sort_order: number
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建/更新频道请求
|
||||||
|
export interface ChannelUpsertRequest {
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
description?: string
|
||||||
|
sort_order?: number
|
||||||
|
is_active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 频道管理 API
|
||||||
|
export const channelsApi = {
|
||||||
|
// 获取频道列表(全部)
|
||||||
|
getChannels: () =>
|
||||||
|
apiClient.get<ApiResponse<Channel[]>>('/admin/channels'),
|
||||||
|
|
||||||
|
// 创建频道
|
||||||
|
createChannel: (data: ChannelUpsertRequest) =>
|
||||||
|
apiClient.post<ApiResponse<Channel>>('/admin/channels', data),
|
||||||
|
|
||||||
|
// 更新频道
|
||||||
|
updateChannel: (id: string, data: ChannelUpsertRequest) =>
|
||||||
|
apiClient.put<ApiResponse<Channel>>(`/admin/channels/${id}`, data),
|
||||||
|
|
||||||
|
// 删除频道
|
||||||
|
deleteChannel: (id: string) =>
|
||||||
|
apiClient.delete<ApiResponse<void>>(`/admin/channels/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
export default channelsApi
|
||||||
@@ -10,6 +10,8 @@ export { commentsApi } from './comments'
|
|||||||
export type { CommentQueryParams, CommentDetail } from './comments'
|
export type { CommentQueryParams, CommentDetail } from './comments'
|
||||||
export { groupsApi } from './groups'
|
export { groupsApi } from './groups'
|
||||||
export type { GroupQueryParams, GroupDetail, GroupMember } from './groups'
|
export type { GroupQueryParams, GroupDetail, GroupMember } from './groups'
|
||||||
|
export { channelsApi } from './channels'
|
||||||
|
export type { Channel, ChannelUpsertRequest } from './channels'
|
||||||
export { dashboardApi } from './dashboard'
|
export { dashboardApi } from './dashboard'
|
||||||
export type { DashboardStatsData, ActivityTrendData, ContentStatsData, PendingContent } from './dashboard'
|
export type { DashboardStatsData, ActivityTrendData, ContentStatsData, PendingContent } from './dashboard'
|
||||||
export { subjectsApi, materialsApi } from './materials'
|
export { subjectsApi, materialsApi } from './materials'
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Layers,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
@@ -67,6 +68,12 @@ const menuItems: MenuItem[] = [
|
|||||||
icon: UsersIcon,
|
icon: UsersIcon,
|
||||||
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MODERATOR],
|
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MODERATOR],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '频道管理',
|
||||||
|
path: '/channels',
|
||||||
|
icon: Layers,
|
||||||
|
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '学习资料',
|
title: '学习资料',
|
||||||
icon: FolderOpen,
|
icon: FolderOpen,
|
||||||
|
|||||||
394
src/pages/Channels.tsx
Normal file
394
src/pages/Channels.tsx
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { channelsApi, type Channel, type ChannelUpsertRequest } from '@/api/channels'
|
||||||
|
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 { Switch } from '@/components/ui/switch'
|
||||||
|
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, RefreshCw, Layers } from 'lucide-react'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
|
export default function Channels() {
|
||||||
|
const [channels, setChannels] = useState<Channel[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const pageSize = 10
|
||||||
|
|
||||||
|
// 创建/编辑对话框
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editingChannel, setEditingChannel] = useState<Channel | null>(null)
|
||||||
|
const [formData, setFormData] = useState<ChannelUpsertRequest>({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
sort_order: 0,
|
||||||
|
is_active: true,
|
||||||
|
})
|
||||||
|
const [dialogLoading, setDialogLoading] = useState(false)
|
||||||
|
|
||||||
|
// 删除确认对话框
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const [deletingChannel, setDeletingChannel] = useState<Channel | null>(null)
|
||||||
|
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||||
|
|
||||||
|
// 加载频道列表
|
||||||
|
const loadChannels = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await channelsApi.getChannels()
|
||||||
|
const list = response.data.data
|
||||||
|
// 按 sort_order 排序
|
||||||
|
list.sort((a: Channel, b: Channel) => a.sort_order - b.sort_order)
|
||||||
|
setChannels(list)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load channels:', error)
|
||||||
|
setChannels([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadChannels()
|
||||||
|
}, [loadChannels])
|
||||||
|
|
||||||
|
// 打开创建对话框
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingChannel(null)
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
sort_order: channels.length,
|
||||||
|
is_active: true,
|
||||||
|
})
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开编辑对话框
|
||||||
|
const handleOpenEdit = (channel: Channel) => {
|
||||||
|
setEditingChannel(channel)
|
||||||
|
setFormData({
|
||||||
|
name: channel.name,
|
||||||
|
slug: channel.slug,
|
||||||
|
description: channel.description,
|
||||||
|
sort_order: channel.sort_order,
|
||||||
|
is_active: channel.is_active,
|
||||||
|
})
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 slug(根据名称)
|
||||||
|
const generateSlug = (name: string) => {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\u4e00-\u9fa5]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 名称改变时自动生成 slug
|
||||||
|
const handleNameChange = (name: string) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
name,
|
||||||
|
slug: editingChannel ? formData.slug : generateSlug(name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存频道
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.name?.trim() || !formData.slug?.trim()) return
|
||||||
|
setDialogLoading(true)
|
||||||
|
try {
|
||||||
|
if (editingChannel) {
|
||||||
|
await channelsApi.updateChannel(editingChannel.id, formData)
|
||||||
|
} else {
|
||||||
|
await channelsApi.createChannel(formData)
|
||||||
|
}
|
||||||
|
setDialogOpen(false)
|
||||||
|
loadChannels()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to save channel:', error)
|
||||||
|
alert(error?.response?.data?.message || '保存失败')
|
||||||
|
} finally {
|
||||||
|
setDialogLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开删除确认
|
||||||
|
const handleOpenDelete = (channel: Channel) => {
|
||||||
|
setDeletingChannel(channel)
|
||||||
|
setDeleteOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认删除
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!deletingChannel) return
|
||||||
|
setDeleteLoading(true)
|
||||||
|
try {
|
||||||
|
await channelsApi.deleteChannel(deletingChannel.id)
|
||||||
|
setDeleteOpen(false)
|
||||||
|
loadChannels()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to delete channel:', 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 paginatedChannels = channels.slice((page - 1) * pageSize, page * pageSize)
|
||||||
|
const totalPages = Math.ceil(channels.length / pageSize)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">频道管理</h1>
|
||||||
|
<p className="text-muted-foreground">管理帖子的分类频道</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={loadChannels} disabled={loading}>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleOpenCreate}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
添加频道
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Layers className="h-5 w-5" />
|
||||||
|
频道列表
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>共 {channels.length} 个频道</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[80px]">排序</TableHead>
|
||||||
|
<TableHead>频道名称</TableHead>
|
||||||
|
<TableHead>Slug</TableHead>
|
||||||
|
<TableHead>描述</TableHead>
|
||||||
|
<TableHead className="w-[100px]">状态</TableHead>
|
||||||
|
<TableHead className="w-[150px]">创建时间</TableHead>
|
||||||
|
<TableHead className="w-[120px]">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} 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>
|
||||||
|
) : paginatedChannels.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||||
|
暂无数据,点击「添加频道」创建第一个频道
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
paginatedChannels.map((channel) => (
|
||||||
|
<TableRow key={channel.id}>
|
||||||
|
<TableCell>{channel.sort_order}</TableCell>
|
||||||
|
<TableCell className="font-medium">{channel.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code className="bg-muted px-2 py-1 rounded text-sm">
|
||||||
|
{channel.slug}
|
||||||
|
</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate">
|
||||||
|
{channel.description || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={channel.is_active ? 'default' : 'outline'}>
|
||||||
|
{channel.is_active ? '启用' : '禁用'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{formatDate(channel.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleOpenEdit(channel)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleOpenDelete(channel)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<PaginationNavigator
|
||||||
|
currentPage={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 创建/编辑对话框 */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingChannel ? '编辑频道' : '添加频道'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editingChannel ? '修改频道信息' : '创建新的帖子分类频道'}
|
||||||
|
</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) => handleNameChange(e.target.value)}
|
||||||
|
placeholder="如:校园生活"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="slug">Slug(标识符)*</Label>
|
||||||
|
<Input
|
||||||
|
id="slug"
|
||||||
|
value={formData.slug || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||||
|
placeholder="如:campus-life"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
用于 URL 和 API 调用,建议使用英文小写、数字和连字符
|
||||||
|
</p>
|
||||||
|
</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">
|
||||||
|
<Switch
|
||||||
|
id="is_active"
|
||||||
|
checked={formData.is_active ?? true}
|
||||||
|
onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="is_active">启用此频道</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={dialogLoading || !formData.name?.trim() || !formData.slug?.trim()}
|
||||||
|
>
|
||||||
|
{dialogLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
{editingChannel ? '保存' : '创建'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 删除确认对话框 */}
|
||||||
|
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
确定要删除频道「{deletingChannel?.name}」吗?此操作不可撤销。
|
||||||
|
<br />
|
||||||
|
<span className="text-destructive">
|
||||||
|
注意:删除频道后,该频道下的帖子将变为无频道状态。
|
||||||
|
</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
disabled={deleteLoading}
|
||||||
|
>
|
||||||
|
{deleteLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,7 +23,8 @@ import {
|
|||||||
import { PaginationNavigator } from '@/components/ui/pagination'
|
import { PaginationNavigator } from '@/components/ui/pagination'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Loader2, Plus, Pencil, Trash2, Search, RefreshCw, BookOpen } from 'lucide-react'
|
import { Loader2, Plus, Pencil, Trash2 } from 'lucide-react'
|
||||||
|
import * as Icons from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
// 颜色预设选项
|
// 颜色预设选项
|
||||||
@@ -38,12 +39,61 @@ const COLOR_PRESETS = [
|
|||||||
{ name: '青色', value: '#06b6d4' },
|
{ name: '青色', value: '#06b6d4' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// 图标预设选项
|
// 图标预设选项(使用 lucide 图标名称)
|
||||||
const ICON_PRESETS = [
|
const ICON_PRESETS = [
|
||||||
'📚', '📖', '📐', '📏', '🔢', '🔤', '🌍', '⚗️',
|
'BookOpen', 'Book', 'Ruler', 'Calculator', 'Type', 'Languages', 'Globe', 'FlaskConical',
|
||||||
'🧪', '🧬', '💻', '📊', '📈', '🎨', '🎵', '⚽',
|
'TestTube', 'Dna', 'Laptop', 'BarChart3', 'TrendingUp', 'Palette', 'Music', 'Gamepad2',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 图标名称映射(兼容 MaterialCommunityIcons -> Lucide)
|
||||||
|
const ICON_NAME_MAPPING: Record<string, string> = {
|
||||||
|
// 常见 MaterialCommunityIcons 到 Lucide 的映射
|
||||||
|
'book-open': 'BookOpen',
|
||||||
|
'book': 'Book',
|
||||||
|
'book-open-variant': 'BookOpen',
|
||||||
|
'calculator': 'Calculator',
|
||||||
|
'ruler': 'Ruler',
|
||||||
|
'pencil-ruler': 'Ruler',
|
||||||
|
'type': 'Type',
|
||||||
|
'format-text': 'Type',
|
||||||
|
'translate': 'Languages',
|
||||||
|
'web': 'Globe',
|
||||||
|
'earth': 'Globe',
|
||||||
|
'flask': 'FlaskConical',
|
||||||
|
'flask-outline': 'FlaskConical',
|
||||||
|
'test-tube': 'TestTube',
|
||||||
|
'dna': 'Dna',
|
||||||
|
'laptop': 'Laptop',
|
||||||
|
'chart-bar': 'BarChart3',
|
||||||
|
'chart-line': 'TrendingUp',
|
||||||
|
'palette': 'Palette',
|
||||||
|
'music': 'Music',
|
||||||
|
'soccer': 'Gamepad2',
|
||||||
|
'basketball': 'Gamepad2',
|
||||||
|
'school': 'GraduationCap',
|
||||||
|
'math-compass': 'Compass',
|
||||||
|
'history': 'Clock',
|
||||||
|
'atom': 'Atom',
|
||||||
|
'microscope': 'Microscope',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取图标组件
|
||||||
|
const getIconComponent = (iconName: string): React.ComponentType<{ className?: string; style?: React.CSSProperties }> => {
|
||||||
|
// 尝试直接获取
|
||||||
|
const icons = Icons as unknown as Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>>
|
||||||
|
const IconComponent = icons[iconName]
|
||||||
|
if (IconComponent) return IconComponent
|
||||||
|
|
||||||
|
// 尝试映射后的名称
|
||||||
|
const mappedName = ICON_NAME_MAPPING[iconName]
|
||||||
|
if (mappedName) {
|
||||||
|
return icons[mappedName] || Icons.BookOpen
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认返回 BookOpen
|
||||||
|
return Icons.BookOpen
|
||||||
|
}
|
||||||
|
|
||||||
export default function MaterialSubjects() {
|
export default function MaterialSubjects() {
|
||||||
const [subjects, setSubjects] = useState<MaterialSubject[]>([])
|
const [subjects, setSubjects] = useState<MaterialSubject[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -55,7 +105,7 @@ export default function MaterialSubjects() {
|
|||||||
const [editingSubject, setEditingSubject] = useState<MaterialSubject | null>(null)
|
const [editingSubject, setEditingSubject] = useState<MaterialSubject | null>(null)
|
||||||
const [formData, setFormData] = useState<SubjectUpsertRequest>({
|
const [formData, setFormData] = useState<SubjectUpsertRequest>({
|
||||||
name: '',
|
name: '',
|
||||||
icon: '📚',
|
icon: 'BookOpen',
|
||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
description: '',
|
description: '',
|
||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
@@ -73,7 +123,8 @@ export default function MaterialSubjects() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const response = await subjectsApi.getSubjects()
|
const response = await subjectsApi.getSubjects()
|
||||||
setSubjects(response.data.data || [])
|
const list = response.data.data
|
||||||
|
setSubjects(list)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load subjects:', error)
|
console.error('Failed to load subjects:', error)
|
||||||
setSubjects([])
|
setSubjects([])
|
||||||
@@ -91,7 +142,7 @@ export default function MaterialSubjects() {
|
|||||||
setEditingSubject(null)
|
setEditingSubject(null)
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
icon: '📚',
|
icon: 'BookOpen',
|
||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
description: '',
|
description: '',
|
||||||
sort_order: subjects.length,
|
sort_order: subjects.length,
|
||||||
@@ -216,13 +267,15 @@ export default function MaterialSubjects() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
paginatedSubjects.map((subject) => (
|
paginatedSubjects.map((subject) => {
|
||||||
<TableRow key={subject.id}>
|
const IconComponent = getIconComponent(subject.icon)
|
||||||
<TableCell>{subject.sort_order}</TableCell>
|
return (
|
||||||
<TableCell>
|
<TableRow key={subject.id}>
|
||||||
<span className="text-2xl">{subject.icon}</span>
|
<TableCell>{subject.sort_order}</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell className="font-medium">{subject.name}</TableCell>
|
<IconComponent className="h-6 w-6" style={{ color: subject.color }} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">{subject.name}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div
|
<div
|
||||||
className="w-6 h-6 rounded"
|
className="w-6 h-6 rounded"
|
||||||
@@ -261,8 +314,9 @@ export default function MaterialSubjects() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
)
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -270,7 +324,7 @@ export default function MaterialSubjects() {
|
|||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<PaginationNavigator
|
<PaginationNavigator
|
||||||
page={page}
|
currentPage={page}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
@@ -304,20 +358,23 @@ export default function MaterialSubjects() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>图标</Label>
|
<Label>图标</Label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{ICON_PRESETS.map((icon) => (
|
{ICON_PRESETS.map((iconName) => {
|
||||||
<button
|
const IconComponent = getIconComponent(iconName)
|
||||||
key={icon}
|
return (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setFormData({ ...formData, icon })}
|
key={iconName}
|
||||||
className={`w-10 h-10 text-xl rounded border-2 transition-colors ${
|
type="button"
|
||||||
formData.icon === icon
|
onClick={() => setFormData({ ...formData, icon: iconName })}
|
||||||
? 'border-primary bg-primary/10'
|
className={`w-10 h-10 rounded border-2 transition-colors flex items-center justify-center ${
|
||||||
: 'border-muted hover:border-muted-foreground'
|
formData.icon === iconName
|
||||||
}`}
|
? 'border-primary bg-primary/10'
|
||||||
>
|
: 'border-muted hover:border-muted-foreground'
|
||||||
{icon}
|
}`}
|
||||||
</button>
|
>
|
||||||
))}
|
<IconComponent className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -407,7 +464,7 @@ export default function MaterialSubjects() {
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleConfirmDelete}
|
onClick={handleConfirmDelete}
|
||||||
disabled={deleteLoading || (deletingSubject && deletingSubject.material_count > 0)}
|
disabled={deleteLoading || (deletingSubject?.material_count ?? 0) > 0}
|
||||||
>
|
>
|
||||||
{deleteLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
{deleteLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
删除
|
删除
|
||||||
|
|||||||
@@ -117,7 +117,10 @@ export default function Materials() {
|
|||||||
const loadSubjects = useCallback(async () => {
|
const loadSubjects = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await subjectsApi.getSubjects()
|
const response = await subjectsApi.getSubjects()
|
||||||
setSubjects(response.data.data || [])
|
const data = response.data.data
|
||||||
|
// 后端返回 { list: [...] } 结构
|
||||||
|
const list = Array.isArray(data) ? data : (data?.list || [])
|
||||||
|
setSubjects(list)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load subjects:', error)
|
console.error('Failed to load subjects:', error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ const Roles = lazy(() => import('@/pages/Roles'))
|
|||||||
const Posts = lazy(() => import('@/pages/Posts'))
|
const Posts = lazy(() => import('@/pages/Posts'))
|
||||||
const Comments = lazy(() => import('@/pages/Comments'))
|
const Comments = lazy(() => import('@/pages/Comments'))
|
||||||
const Groups = lazy(() => import('@/pages/Groups'))
|
const Groups = lazy(() => import('@/pages/Groups'))
|
||||||
|
const Channels = lazy(() => import('@/pages/Channels'))
|
||||||
const MaterialSubjects = lazy(() => import('@/pages/MaterialSubjects'))
|
const MaterialSubjects = lazy(() => import('@/pages/MaterialSubjects'))
|
||||||
const Materials = lazy(() => import('@/pages/Materials'))
|
const Materials = lazy(() => import('@/pages/Materials'))
|
||||||
const Forbidden = lazy(() => import('@/pages/Forbidden'))
|
const Forbidden = lazy(() => import('@/pages/Forbidden'))
|
||||||
@@ -193,6 +194,16 @@ export const router = createBrowserRouter([
|
|||||||
</ModeratorGuard>
|
</ModeratorGuard>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'channels',
|
||||||
|
element: (
|
||||||
|
<AdminGuard>
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<Channels />
|
||||||
|
</Suspense>
|
||||||
|
</AdminGuard>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'materials/subjects',
|
path: 'materials/subjects',
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
Reference in New Issue
Block a user