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 { groupsApi } from './groups'
|
||||
export type { GroupQueryParams, GroupDetail, GroupMember } from './groups'
|
||||
export { channelsApi } from './channels'
|
||||
export type { Channel, ChannelUpsertRequest } from './channels'
|
||||
export { dashboardApi } from './dashboard'
|
||||
export type { DashboardStatsData, ActivityTrendData, ContentStatsData, PendingContent } from './dashboard'
|
||||
export { subjectsApi, materialsApi } from './materials'
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
FolderOpen,
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
Layers,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useState } from 'react'
|
||||
@@ -67,6 +68,12 @@ const menuItems: MenuItem[] = [
|
||||
icon: UsersIcon,
|
||||
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MODERATOR],
|
||||
},
|
||||
{
|
||||
title: '频道管理',
|
||||
path: '/channels',
|
||||
icon: Layers,
|
||||
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN],
|
||||
},
|
||||
{
|
||||
title: '学习资料',
|
||||
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 { Label } from '@/components/ui/label'
|
||||
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'
|
||||
|
||||
// 颜色预设选项
|
||||
@@ -38,12 +39,61 @@ const COLOR_PRESETS = [
|
||||
{ name: '青色', value: '#06b6d4' },
|
||||
]
|
||||
|
||||
// 图标预设选项
|
||||
// 图标预设选项(使用 lucide 图标名称)
|
||||
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() {
|
||||
const [subjects, setSubjects] = useState<MaterialSubject[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -55,7 +105,7 @@ export default function MaterialSubjects() {
|
||||
const [editingSubject, setEditingSubject] = useState<MaterialSubject | null>(null)
|
||||
const [formData, setFormData] = useState<SubjectUpsertRequest>({
|
||||
name: '',
|
||||
icon: '📚',
|
||||
icon: 'BookOpen',
|
||||
color: '#3b82f6',
|
||||
description: '',
|
||||
sort_order: 0,
|
||||
@@ -73,7 +123,8 @@ export default function MaterialSubjects() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await subjectsApi.getSubjects()
|
||||
setSubjects(response.data.data || [])
|
||||
const list = response.data.data
|
||||
setSubjects(list)
|
||||
} catch (error) {
|
||||
console.error('Failed to load subjects:', error)
|
||||
setSubjects([])
|
||||
@@ -91,7 +142,7 @@ export default function MaterialSubjects() {
|
||||
setEditingSubject(null)
|
||||
setFormData({
|
||||
name: '',
|
||||
icon: '📚',
|
||||
icon: 'BookOpen',
|
||||
color: '#3b82f6',
|
||||
description: '',
|
||||
sort_order: subjects.length,
|
||||
@@ -216,13 +267,15 @@ export default function MaterialSubjects() {
|
||||
</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>
|
||||
paginatedSubjects.map((subject) => {
|
||||
const IconComponent = getIconComponent(subject.icon)
|
||||
return (
|
||||
<TableRow key={subject.id}>
|
||||
<TableCell>{subject.sort_order}</TableCell>
|
||||
<TableCell>
|
||||
<IconComponent className="h-6 w-6" style={{ color: subject.color }} />
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{subject.name}</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
className="w-6 h-6 rounded"
|
||||
@@ -261,8 +314,9 @@ export default function MaterialSubjects() {
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -270,7 +324,7 @@ export default function MaterialSubjects() {
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4">
|
||||
<PaginationNavigator
|
||||
page={page}
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
@@ -304,20 +358,23 @@ export default function MaterialSubjects() {
|
||||
<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>
|
||||
))}
|
||||
{ICON_PRESETS.map((iconName) => {
|
||||
const IconComponent = getIconComponent(iconName)
|
||||
return (
|
||||
<button
|
||||
key={iconName}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, icon: iconName })}
|
||||
className={`w-10 h-10 rounded border-2 transition-colors flex items-center justify-center ${
|
||||
formData.icon === iconName
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-muted hover:border-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<IconComponent className="h-5 w-5" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -407,7 +464,7 @@ export default function MaterialSubjects() {
|
||||
<Button
|
||||
variant="destructive"
|
||||
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" />}
|
||||
删除
|
||||
|
||||
@@ -117,7 +117,10 @@ export default function Materials() {
|
||||
const loadSubjects = useCallback(async () => {
|
||||
try {
|
||||
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) {
|
||||
console.error('Failed to load subjects:', error)
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ 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 Channels = lazy(() => import('@/pages/Channels'))
|
||||
const MaterialSubjects = lazy(() => import('@/pages/MaterialSubjects'))
|
||||
const Materials = lazy(() => import('@/pages/Materials'))
|
||||
const Forbidden = lazy(() => import('@/pages/Forbidden'))
|
||||
@@ -193,6 +194,16 @@ export const router = createBrowserRouter([
|
||||
</ModeratorGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'channels',
|
||||
element: (
|
||||
<AdminGuard>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Channels />
|
||||
</Suspense>
|
||||
</AdminGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'materials/subjects',
|
||||
element: (
|
||||
|
||||
Reference in New Issue
Block a user