Some checks failed
Admin CI / build-and-push-web (push) Failing after 2m29s
Add defensive check to handle both direct array responses and nested list format from API, ensuring consistent channel data display regardless of response structure.
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 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>
|
||
)
|
||
} |