Add channels management to Sidebar and API index; update MaterialSubjects for icon handling

This commit is contained in:
lafay
2026-03-26 01:27:59 +08:00
parent 2910dd6ccf
commit c71e6c6587
8 changed files with 553 additions and 33 deletions

2
.env.development Normal file
View File

@@ -0,0 +1,2 @@
# 开发环境 API 地址
VITE_API_BASE_URL=https://bbs.littlelan.cn/api/v1

44
src/api/channels.ts Normal file
View 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

View File

@@ -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'

View File

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

View File

@@ -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" />}

View File

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

View File

@@ -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: (