394 lines
13 KiB
TypeScript
394 lines
13 KiB
TypeScript
|
|
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>
|
|||
|
|
)
|
|||
|
|
}
|