Files
admin/src/pages/Channels.tsx
lafay bfb018b766
Some checks failed
Admin CI / build-and-push-web (push) Failing after 2m29s
fix(api): handle varying API response formats in channels data retrieval
Add defensive check to handle both direct array responses and nested list format from API, ensuring consistent channel data display regardless of response structure.
2026-04-04 00:38:52 +08:00

394 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}