Files
admin/src/pages/Channels.tsx

394 lines
13 KiB
TypeScript
Raw Normal View History

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 data = response.data.data
const list = Array.isArray(data) ? data : (data?.list || [])
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>
)
}