diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..e5c45f1 --- /dev/null +++ b/.env.development @@ -0,0 +1,2 @@ +# 开发环境 API 地址 +VITE_API_BASE_URL=https://bbs.littlelan.cn/api/v1 \ No newline at end of file diff --git a/src/api/channels.ts b/src/api/channels.ts new file mode 100644 index 0000000..773b98f --- /dev/null +++ b/src/api/channels.ts @@ -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>('/admin/channels'), + + // 创建频道 + createChannel: (data: ChannelUpsertRequest) => + apiClient.post>('/admin/channels', data), + + // 更新频道 + updateChannel: (id: string, data: ChannelUpsertRequest) => + apiClient.put>(`/admin/channels/${id}`, data), + + // 删除频道 + deleteChannel: (id: string) => + apiClient.delete>(`/admin/channels/${id}`), +} + +export default channelsApi diff --git a/src/api/index.ts b/src/api/index.ts index 8598609..e0c9eea 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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' diff --git a/src/components/Layout/Sidebar.tsx b/src/components/Layout/Sidebar.tsx index 7ad97a0..7675f31 100644 --- a/src/components/Layout/Sidebar.tsx +++ b/src/components/Layout/Sidebar.tsx @@ -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, diff --git a/src/pages/Channels.tsx b/src/pages/Channels.tsx new file mode 100644 index 0000000..da33a8b --- /dev/null +++ b/src/pages/Channels.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const pageSize = 10 + + // 创建/编辑对话框 + const [dialogOpen, setDialogOpen] = useState(false) + const [editingChannel, setEditingChannel] = useState(null) + const [formData, setFormData] = useState({ + name: '', + slug: '', + description: '', + sort_order: 0, + is_active: true, + }) + const [dialogLoading, setDialogLoading] = useState(false) + + // 删除确认对话框 + const [deleteOpen, setDeleteOpen] = useState(false) + const [deletingChannel, setDeletingChannel] = useState(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 ( +
+
+
+

频道管理

+

管理帖子的分类频道

+
+
+ + +
+
+ + + + + + 频道列表 + + 共 {channels.length} 个频道 + + + + + + 排序 + 频道名称 + Slug + 描述 + 状态 + 创建时间 + 操作 + + + + {loading ? ( + + + +

加载中...

+
+
+ ) : paginatedChannels.length === 0 ? ( + + + 暂无数据,点击「添加频道」创建第一个频道 + + + ) : ( + paginatedChannels.map((channel) => ( + + {channel.sort_order} + {channel.name} + + + {channel.slug} + + + + {channel.description || '-'} + + + + {channel.is_active ? '启用' : '禁用'} + + + + {formatDate(channel.created_at)} + + +
+ + +
+
+
+ )) + )} +
+
+ + {totalPages > 1 && ( +
+ +
+ )} +
+
+ + {/* 创建/编辑对话框 */} + + + + + {editingChannel ? '编辑频道' : '添加频道'} + + + {editingChannel ? '修改频道信息' : '创建新的帖子分类频道'} + + +
+
+ + handleNameChange(e.target.value)} + placeholder="如:校园生活" + /> +
+ +
+ + setFormData({ ...formData, slug: e.target.value })} + placeholder="如:campus-life" + /> +

+ 用于 URL 和 API 调用,建议使用英文小写、数字和连字符 +

+
+ +
+ +