Add channels management to Sidebar and API index; update MaterialSubjects for icon handling
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user