feat(types): add image types and enhance Post/Comment interfaces
Some checks failed
Admin CI / build-and-push-web (push) Failing after 2m17s
Some checks failed
Admin CI / build-and-push-web (push) Failing after 2m17s
Add PostImage and CommentImage types for handling post/comment images. Update Post interface with likes_count, comments_count, views_count, and images. Update Comment interface with likes_count, replies_count, and images. Add display_name and priority to Role type. Include dynamic color CSS utilities and UI enhancements for Comments/Posts pages. Add defensive empty array fallback for subjects data.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client'
|
||||||
import type { Comment, ApiResponse, PaginatedResponse } from '@/types'
|
import type { Comment, ApiResponse, PaginatedResponse, CommentImage } from '@/types'
|
||||||
|
|
||||||
// 评论查询参数
|
// 评论查询参数
|
||||||
export interface CommentQueryParams {
|
export interface CommentQueryParams {
|
||||||
@@ -24,6 +24,8 @@ export interface CommentDetail extends Comment {
|
|||||||
post?: {
|
post?: {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
content?: string
|
||||||
|
images?: CommentImage[]
|
||||||
}
|
}
|
||||||
ip_address?: string
|
ip_address?: string
|
||||||
device_info?: string
|
device_info?: string
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ const menuItems: MenuItem[] = [
|
|||||||
|
|
||||||
export default function Sidebar({ collapsed, onCollapse }: SidebarProps) {
|
export default function Sidebar({ collapsed, onCollapse }: SidebarProps) {
|
||||||
const { hasAnyRole } = useAuthStore()
|
const { hasAnyRole } = useAuthStore()
|
||||||
const [expandedItems, setExpandedItems] = useState<string[]>(['学习资料'])
|
const [expandedItems, setExpandedItems] = useState<string[]>([])
|
||||||
|
|
||||||
// 切换展开状态
|
// 切换展开状态
|
||||||
const toggleExpand = (title: string) => {
|
const toggleExpand = (title: string) => {
|
||||||
@@ -178,6 +178,7 @@ export default function Sidebar({ collapsed, onCollapse }: SidebarProps) {
|
|||||||
<NavLink
|
<NavLink
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path!}
|
to={item.path!}
|
||||||
|
end
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
|||||||
@@ -31,3 +31,12 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.text-dynamic-color {
|
||||||
|
color: var(--dynamic-color);
|
||||||
|
}
|
||||||
|
.bg-dynamic-color {
|
||||||
|
background-color: var(--dynamic-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
ThumbsUp,
|
||||||
|
Check,
|
||||||
|
Reply,
|
||||||
|
ImageIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
@@ -242,23 +246,30 @@ export default function Comments() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 筛选栏 */}
|
{/* 筛选栏 */}
|
||||||
<Card>
|
<Card className="border-slate-200 shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader className="pb-3">
|
||||||
<CardTitle>筛选条件</CardTitle>
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Search className="h-5 w-5 text-primary" />
|
||||||
|
筛选条件
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>根据条件筛选评论</CardDescription>
|
<CardDescription>根据条件筛选评论</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex-1 min-w-[200px] max-w-sm">
|
<div className="flex-1 min-w-[240px] max-w-md">
|
||||||
<Input
|
<div className="relative">
|
||||||
placeholder="搜索评论内容..."
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||||
value={keyword}
|
<Input
|
||||||
onChange={(e) => setKeyword(e.target.value)}
|
placeholder="搜索评论内容..."
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
value={keyword}
|
||||||
/>
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
className="pl-10 bg-slate-50 border-slate-200 focus:bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-[160px] bg-slate-50 border-slate-200">
|
||||||
<SelectValue placeholder="状态筛选" />
|
<SelectValue placeholder="状态筛选" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -269,7 +280,7 @@ export default function Comments() {
|
|||||||
<SelectItem value="deleted">已删除</SelectItem>
|
<SelectItem value="deleted">已删除</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button onClick={handleSearch}>
|
<Button onClick={handleSearch} className="bg-primary hover:bg-primary/90">
|
||||||
<Search className="mr-2 h-4 w-4" />
|
<Search className="mr-2 h-4 w-4" />
|
||||||
搜索
|
搜索
|
||||||
</Button>
|
</Button>
|
||||||
@@ -279,12 +290,17 @@ export default function Comments() {
|
|||||||
|
|
||||||
{/* 批量操作栏 */}
|
{/* 批量操作栏 */}
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<Card className="border-orange-200 bg-orange-50">
|
<Card className="border-amber-200 bg-gradient-to-r from-amber-50 to-orange-50 shadow-sm">
|
||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-orange-800">
|
<div className="flex items-center gap-3">
|
||||||
已选择 {selectedIds.length} 项
|
<div className="p-2 bg-amber-100 rounded-lg">
|
||||||
</span>
|
<Check className="h-4 w-4 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-amber-900">
|
||||||
|
已选择 <span className="font-bold">{selectedIds.length}</span> 项
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -302,107 +318,158 @@ export default function Comments() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 评论表格 */}
|
{/* 评论表格 */}
|
||||||
<Card>
|
<Card className="border-slate-200 shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader className="pb-4 border-b border-slate-100">
|
||||||
<CardTitle>评论列表</CardTitle>
|
<div className="flex items-center justify-between">
|
||||||
<CardDescription>
|
<div>
|
||||||
共 {total} 条记录
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
</CardDescription>
|
<MessageSquare className="h-5 w-5 text-primary" />
|
||||||
|
评论列表
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
共 <span className="font-medium text-slate-900">{total}</span> 条记录
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
|
||||||
<TableHead className="w-12">
|
<TableHead className="w-12 rounded-tl-lg">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isAllSelected}
|
checked={isAllSelected}
|
||||||
onCheckedChange={handleSelectAll}
|
onCheckedChange={handleSelectAll}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-16">ID</TableHead>
|
<TableHead className="w-16">ID</TableHead>
|
||||||
|
<TableHead className="w-20">预览</TableHead>
|
||||||
<TableHead>内容</TableHead>
|
<TableHead>内容</TableHead>
|
||||||
<TableHead>评论者</TableHead>
|
<TableHead>评论者</TableHead>
|
||||||
<TableHead>所属帖子</TableHead>
|
<TableHead>所属帖子</TableHead>
|
||||||
<TableHead className="w-24">状态</TableHead>
|
<TableHead className="w-24">状态</TableHead>
|
||||||
<TableHead className="w-16">点赞</TableHead>
|
<TableHead className="w-16">点赞</TableHead>
|
||||||
<TableHead className="w-36">评论时间</TableHead>
|
<TableHead className="w-36">评论时间</TableHead>
|
||||||
<TableHead className="w-20">操作</TableHead>
|
<TableHead className="w-20 rounded-tr-lg">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={9} className="text-center py-8">
|
<TableCell colSpan={10} className="text-center py-12">
|
||||||
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<span className="text-sm text-muted-foreground">加载中...</span>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : comments.length === 0 ? (
|
) : comments.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
<TableCell colSpan={10} className="text-center py-12 text-muted-foreground">
|
||||||
暂无数据
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="p-4 bg-slate-100 rounded-full">
|
||||||
|
<MessageSquare className="h-8 w-8 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<span>暂无数据</span>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
comments.map((comment) => (
|
comments.map((comment) => (
|
||||||
<TableRow key={comment.id}>
|
<TableRow
|
||||||
|
key={comment.id}
|
||||||
|
className="group hover:bg-slate-50/80 transition-colors"
|
||||||
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedIds.includes(comment.id)}
|
checked={selectedIds.includes(comment.id)}
|
||||||
onCheckedChange={(checked) => handleSelect(comment.id, !!checked)}
|
onCheckedChange={(checked) => handleSelect(comment.id, !!checked)}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-sm">{comment.id}</TableCell>
|
<TableCell className="font-mono text-xs text-slate-500">#{comment.id.slice(-6)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="relative w-14 h-14 rounded-lg overflow-hidden bg-slate-100 ring-1 ring-slate-200 group-hover:ring-primary/30 transition-all">
|
||||||
|
{comment.images && comment.images.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={comment.images[0].url}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{comment.images.length > 1 && (
|
||||||
|
<div className="absolute bottom-0 right-0 bg-black/60 text-white text-[10px] px-1.5 py-0.5 rounded-tl-md">
|
||||||
|
+{comment.images.length - 1}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<MessageSquare className="h-5 w-5 text-slate-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="max-w-[250px]">
|
<div className="max-w-[250px]">
|
||||||
<p className="text-sm truncate" title={comment.content}>
|
<p className="text-sm text-slate-700 truncate group-hover:text-primary transition-colors" title={comment.content}>
|
||||||
{truncateContent(comment.content)}
|
{truncateContent(comment.content, 80)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2.5">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-9 w-9 ring-2 ring-white shadow-sm">
|
||||||
<AvatarImage src={comment.author.avatar} />
|
<AvatarImage src={comment.author.avatar} />
|
||||||
<AvatarFallback>{getAvatarFallback(comment.author)}</AvatarFallback>
|
<AvatarFallback className="bg-primary/10 text-primary text-xs font-medium">
|
||||||
|
{getAvatarFallback(comment.author)}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="font-medium">{comment.author.nickname || comment.author.username}</div>
|
<div className="font-medium text-sm text-slate-900 truncate">{comment.author.nickname || comment.author.username}</div>
|
||||||
<div className="text-xs text-muted-foreground">@{comment.author.username}</div>
|
<div className="text-xs text-slate-500 truncate">@{comment.author.username}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-1 text-sm">
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
<ExternalLink className="h-3.5 w-3.5 text-slate-400" />
|
||||||
<span className="truncate max-w-[150px]" title={comment.post_title}>
|
<span className="truncate max-w-[140px] text-slate-600" title={comment.post_title}>
|
||||||
{comment.post_title || `帖子 #${comment.post_id}`}
|
{comment.post_title || `帖子 #${comment.post_id}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge className={`${statusColors[comment.status]} text-white`}>
|
<Badge className={`${statusColors[comment.status]} text-white font-medium text-xs px-2.5 py-1`}>
|
||||||
{statusText[comment.status]}
|
{statusText[comment.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{comment.like_count}</TableCell>
|
<TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-1 text-slate-600">
|
||||||
|
<ThumbsUp className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-sm">{comment.likes_count}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-slate-500">
|
||||||
{formatDate(comment.created_at)}
|
{formatDate(comment.created_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-slate-100">
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
<DropdownMenuItem onClick={() => handleViewDetail(comment)}>
|
<DropdownMenuItem onClick={() => handleViewDetail(comment)} className="cursor-pointer">
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
<Eye className="mr-2 h-4 w-4 text-blue-500" />
|
||||||
查看详情
|
查看详情
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => openDeleteDialog(comment)}
|
onClick={() => openDeleteDialog(comment)}
|
||||||
className="text-red-600"
|
className="text-red-600 cursor-pointer focus:text-red-600"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
删除
|
删除
|
||||||
@@ -417,7 +484,7 @@ export default function Comments() {
|
|||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{/* 分页 */}
|
{/* 分页 */}
|
||||||
<div className="mt-4">
|
<div className="p-4 border-t border-slate-100 bg-slate-50/50">
|
||||||
<PaginationNavigator
|
<PaginationNavigator
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
totalPages={Math.ceil(total / pageSize)}
|
totalPages={Math.ceil(total / pageSize)}
|
||||||
@@ -429,59 +496,151 @@ export default function Comments() {
|
|||||||
|
|
||||||
{/* 详情对话框 */}
|
{/* 详情对话框 */}
|
||||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader className="border-b pb-4">
|
||||||
<DialogTitle>评论详情</DialogTitle>
|
<DialogTitle className="text-xl flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-5 w-5 text-primary" />
|
||||||
|
评论详情
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{detailLoading ? (
|
{detailLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
</div>
|
</div>
|
||||||
) : selectedComment ? (
|
) : selectedComment ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6 py-2">
|
||||||
<div className="flex items-center gap-4">
|
{/* 评论者信息卡片 */}
|
||||||
<Avatar className="h-12 w-12">
|
<div className="flex items-center gap-4 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl">
|
||||||
|
<Avatar className="h-14 w-14 ring-2 ring-white shadow-sm">
|
||||||
<AvatarImage src={selectedComment.author?.avatar} />
|
<AvatarImage src={selectedComment.author?.avatar} />
|
||||||
<AvatarFallback>{getAvatarFallback(selectedComment.author)}</AvatarFallback>
|
<AvatarFallback className="bg-primary/10 text-primary font-semibold text-lg">
|
||||||
|
{getAvatarFallback(selectedComment.author)}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<div className="font-medium">{selectedComment.author?.nickname || selectedComment.author?.username}</div>
|
<div className="font-semibold text-lg">{selectedComment.author?.nickname || selectedComment.author?.username}</div>
|
||||||
<div className="text-sm text-muted-foreground">@{selectedComment.author?.username}</div>
|
<div className="text-sm text-slate-500">@{selectedComment.author?.username}</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={`${statusColors[selectedComment.status]} text-white ml-auto`}>
|
<Badge className={`${statusColors[selectedComment.status]} text-white font-medium px-3 py-1`}>
|
||||||
{statusText[selectedComment.status]}
|
{statusText[selectedComment.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 所属帖子 */}
|
{/* 所属帖子 */}
|
||||||
{selectedComment.post && (
|
{selectedComment.post && (
|
||||||
<Card className="bg-muted/50">
|
<Card className="bg-slate-50 border-slate-200">
|
||||||
<CardContent className="py-3">
|
<CardContent className="py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-start gap-3">
|
||||||
<div>
|
<div className="p-2 bg-blue-100 rounded-lg">
|
||||||
<div className="text-xs text-muted-foreground mb-1">所属帖子</div>
|
<ExternalLink className="h-4 w-4 text-blue-600" />
|
||||||
<div className="font-medium">{selectedComment.post.title}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm">
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs text-slate-500 mb-1">所属帖子</div>
|
||||||
|
<div className="font-medium text-slate-900">{selectedComment.post.title}</div>
|
||||||
|
{selectedComment.post.content && (
|
||||||
|
<div className="text-sm text-slate-500 mt-1 truncate">
|
||||||
|
{selectedComment.post.content.substring(0, 100)}...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="shrink-0">
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 帖子图片预览 */}
|
||||||
|
{selectedComment.post.images && selectedComment.post.images.length > 0 && (
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
{selectedComment.post.images.slice(0, 3).map((img, index) => (
|
||||||
|
<img
|
||||||
|
key={index}
|
||||||
|
src={img.url}
|
||||||
|
alt=""
|
||||||
|
className="w-16 h-16 object-cover rounded-lg ring-1 ring-slate-200"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{selectedComment.post.images.length > 3 && (
|
||||||
|
<div className="w-16 h-16 bg-slate-200 rounded-lg flex items-center justify-center text-xs text-slate-500">
|
||||||
|
+{selectedComment.post.images.length - 3}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 回复信息 */}
|
||||||
|
{selectedComment.reply_to_user && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-amber-50 rounded-lg border border-amber-100">
|
||||||
|
<Reply className="h-4 w-4 text-amber-600" />
|
||||||
|
<span className="text-sm text-amber-800">
|
||||||
|
回复给 <span className="font-medium">@{selectedComment.reply_to_user.nickname || selectedComment.reply_to_user.username}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 评论内容 */}
|
{/* 评论内容 */}
|
||||||
<div>
|
<div className="space-y-3">
|
||||||
<div className="text-xs text-muted-foreground mb-1">评论内容</div>
|
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||||
<p className="bg-muted/50 p-3 rounded-lg whitespace-pre-wrap">{selectedComment.content}</p>
|
<MessageSquare className="h-4 w-4 text-primary" />
|
||||||
|
<span>评论内容</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 p-4 rounded-xl border border-slate-100">
|
||||||
|
<p className="text-slate-700 whitespace-pre-wrap leading-relaxed">{selectedComment.content}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-6 text-sm text-muted-foreground">
|
{/* 评论图片 */}
|
||||||
<span>👍 {selectedComment.like_count} 点赞</span>
|
{selectedComment.images && selectedComment.images.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||||
|
<ImageIcon className="h-4 w-4 text-primary" />
|
||||||
|
<span>评论图片 ({selectedComment.images.length}张)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
|
||||||
|
{selectedComment.images.map((img, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="group relative aspect-square rounded-xl overflow-hidden bg-slate-100 ring-1 ring-slate-200 hover:ring-primary/50 transition-all"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={img.url}
|
||||||
|
alt={`图片 ${index + 1}`}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 24 24" fill="none" stroke="%239ca3af" stroke-width="1"%3E%3Crect x="3" y="3" width="18" height="18" rx="2" ry="2"/%3E%3Ccircle cx="8.5" cy="8.5" r="1.5"/%3E%3Cpolyline points="21 15 16 10 5 21"/%3E%3C/svg%3E';
|
||||||
|
(e.target as HTMLImageElement).classList.add('p-4');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 统计数据 */}
|
||||||
|
<div className="flex items-center gap-6 p-4 bg-slate-50 rounded-xl">
|
||||||
|
<div className="flex items-center gap-2 text-slate-600">
|
||||||
|
<div className="p-2 bg-rose-100 rounded-lg">
|
||||||
|
<ThumbsUp className="h-4 w-4 text-rose-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold text-slate-900">{selectedComment.likes_count}</div>
|
||||||
|
<div className="text-xs text-slate-500">点赞</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground">
|
{/* 时间信息 */}
|
||||||
评论时间:{formatDate(selectedComment.created_at)}
|
<div className="flex items-center justify-between text-sm text-slate-500 pt-4 border-t border-slate-100">
|
||||||
|
<span>评论时间:{formatDate(selectedComment.created_at)}</span>
|
||||||
|
{selectedComment.updated_at !== selectedComment.created_at && (
|
||||||
|
<span>更新于:{formatDate(selectedComment.updated_at)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export default function MaterialSubjects() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const response = await subjectsApi.getSubjects()
|
const response = await subjectsApi.getSubjects()
|
||||||
const list = response.data.data
|
const list = response.data.data || []
|
||||||
setSubjects(list)
|
setSubjects(list)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load subjects:', error)
|
console.error('Failed to load subjects:', error)
|
||||||
@@ -273,13 +273,13 @@ export default function MaterialSubjects() {
|
|||||||
<TableRow key={subject.id}>
|
<TableRow key={subject.id}>
|
||||||
<TableCell>{subject.sort_order}</TableCell>
|
<TableCell>{subject.sort_order}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<IconComponent className="h-6 w-6" style={{ color: subject.color }} />
|
<IconComponent className="h-6 w-6 text-dynamic-color" style={{ '--dynamic-color': subject.color } as React.CSSProperties} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">{subject.name}</TableCell>
|
<TableCell className="font-medium">{subject.name}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div
|
<div
|
||||||
className="w-6 h-6 rounded"
|
className="w-6 h-6 rounded bg-dynamic-color"
|
||||||
style={{ backgroundColor: subject.color }}
|
style={{ '--dynamic-color': subject.color } as React.CSSProperties}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[200px] truncate">
|
<TableCell className="max-w-[200px] truncate">
|
||||||
@@ -302,6 +302,7 @@ export default function MaterialSubjects() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleOpenEdit(subject)}
|
onClick={() => handleOpenEdit(subject)}
|
||||||
|
title="编辑"
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -309,6 +310,7 @@ export default function MaterialSubjects() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleOpenDelete(subject)}
|
onClick={() => handleOpenDelete(subject)}
|
||||||
|
title="删除"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -370,6 +372,7 @@ export default function MaterialSubjects() {
|
|||||||
? 'border-primary bg-primary/10'
|
? 'border-primary bg-primary/10'
|
||||||
: 'border-muted hover:border-muted-foreground'
|
: 'border-muted hover:border-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
|
title={iconName}
|
||||||
>
|
>
|
||||||
<IconComponent className="h-5 w-5" />
|
<IconComponent className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -386,12 +389,12 @@ export default function MaterialSubjects() {
|
|||||||
key={color.value}
|
key={color.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFormData({ ...formData, color: color.value })}
|
onClick={() => setFormData({ ...formData, color: color.value })}
|
||||||
className={`w-8 h-8 rounded-full border-2 transition-colors ${
|
className={`w-8 h-8 rounded-full border-2 transition-colors bg-dynamic-color ${
|
||||||
formData.color === color.value
|
formData.color === color.value
|
||||||
? 'border-primary scale-110'
|
? 'border-primary scale-110'
|
||||||
: 'border-transparent hover:scale-105'
|
: 'border-transparent hover:scale-105'
|
||||||
}`}
|
}`}
|
||||||
style={{ backgroundColor: color.value }}
|
style={{ '--dynamic-color': color.value } as React.CSSProperties}
|
||||||
title={color.name}
|
title={color.name}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -427,6 +430,7 @@ export default function MaterialSubjects() {
|
|||||||
checked={formData.is_active ?? true}
|
checked={formData.is_active ?? true}
|
||||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
|
aria-label="启用此学科"
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="is_active">启用此学科</Label>
|
<Label htmlFor="is_active">启用此学科</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ import {
|
|||||||
X,
|
X,
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
ImageIcon,
|
||||||
|
ThumbsUp,
|
||||||
|
MessageCircle,
|
||||||
|
Eye as EyeIcon,
|
||||||
|
Pin,
|
||||||
|
Star,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
@@ -298,23 +304,30 @@ export default function Posts() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 筛选栏 */}
|
{/* 筛选栏 */}
|
||||||
<Card>
|
<Card className="border-slate-200 shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader className="pb-3">
|
||||||
<CardTitle>筛选条件</CardTitle>
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Search className="h-5 w-5 text-primary" />
|
||||||
|
筛选条件
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>根据条件筛选帖子</CardDescription>
|
<CardDescription>根据条件筛选帖子</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex-1 min-w-[200px] max-w-sm">
|
<div className="flex-1 min-w-[240px] max-w-md">
|
||||||
<Input
|
<div className="relative">
|
||||||
placeholder="搜索帖子标题或内容..."
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||||
value={keyword}
|
<Input
|
||||||
onChange={(e) => setKeyword(e.target.value)}
|
placeholder="搜索帖子标题或内容..."
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
value={keyword}
|
||||||
/>
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
className="pl-10 bg-slate-50 border-slate-200 focus:bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-[160px] bg-slate-50 border-slate-200">
|
||||||
<SelectValue placeholder="状态筛选" />
|
<SelectValue placeholder="状态筛选" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -325,7 +338,7 @@ export default function Posts() {
|
|||||||
<SelectItem value="deleted">已删除</SelectItem>
|
<SelectItem value="deleted">已删除</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button onClick={handleSearch}>
|
<Button onClick={handleSearch} className="bg-primary hover:bg-primary/90">
|
||||||
<Search className="mr-2 h-4 w-4" />
|
<Search className="mr-2 h-4 w-4" />
|
||||||
搜索
|
搜索
|
||||||
</Button>
|
</Button>
|
||||||
@@ -335,18 +348,24 @@ export default function Posts() {
|
|||||||
|
|
||||||
{/* 批量操作栏 */}
|
{/* 批量操作栏 */}
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<Card className="border-orange-200 bg-orange-50">
|
<Card className="border-amber-200 bg-gradient-to-r from-amber-50 to-orange-50 shadow-sm">
|
||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-orange-800">
|
<div className="flex items-center gap-3">
|
||||||
已选择 {selectedIds.length} 项
|
<div className="p-2 bg-amber-100 rounded-lg">
|
||||||
</span>
|
<Check className="h-4 w-4 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-amber-900">
|
||||||
|
已选择 <span className="font-bold">{selectedIds.length}</span> 项
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleBatchModerate('published')}
|
onClick={() => handleBatchModerate('published')}
|
||||||
disabled={batchLoading}
|
disabled={batchLoading}
|
||||||
|
className="bg-white border-green-200 text-green-700 hover:bg-green-50 hover:text-green-800"
|
||||||
>
|
>
|
||||||
<Check className="mr-2 h-4 w-4" />
|
<Check className="mr-2 h-4 w-4" />
|
||||||
批量通过
|
批量通过
|
||||||
@@ -356,6 +375,7 @@ export default function Posts() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleBatchModerate('rejected')}
|
onClick={() => handleBatchModerate('rejected')}
|
||||||
disabled={batchLoading}
|
disabled={batchLoading}
|
||||||
|
className="bg-white border-amber-200 text-amber-700 hover:bg-amber-50 hover:text-amber-800"
|
||||||
>
|
>
|
||||||
<X className="mr-2 h-4 w-4" />
|
<X className="mr-2 h-4 w-4" />
|
||||||
批量拒绝
|
批量拒绝
|
||||||
@@ -376,107 +396,162 @@ export default function Posts() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 帖子表格 */}
|
{/* 帖子表格 */}
|
||||||
<Card>
|
<Card className="border-slate-200 shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader className="pb-4 border-b border-slate-100">
|
||||||
<CardTitle>帖子列表</CardTitle>
|
<div className="flex items-center justify-between">
|
||||||
<CardDescription>
|
<div>
|
||||||
共 {total} 条记录
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
</CardDescription>
|
<Eye className="h-5 w-5 text-primary" />
|
||||||
|
帖子列表
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
共 <span className="font-medium text-slate-900">{total}</span> 条记录
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
|
||||||
<TableHead className="w-12">
|
<TableHead className="w-12 rounded-tl-lg">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isAllSelected}
|
checked={isAllSelected}
|
||||||
onCheckedChange={handleSelectAll}
|
onCheckedChange={handleSelectAll}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-16">ID</TableHead>
|
<TableHead className="w-16">ID</TableHead>
|
||||||
<TableHead>标题</TableHead>
|
<TableHead className="w-20">预览</TableHead>
|
||||||
|
<TableHead>标题/内容</TableHead>
|
||||||
<TableHead>作者</TableHead>
|
<TableHead>作者</TableHead>
|
||||||
<TableHead className="w-24">状态</TableHead>
|
<TableHead className="w-24">状态</TableHead>
|
||||||
<TableHead className="w-20">点赞</TableHead>
|
<TableHead className="w-20">互动</TableHead>
|
||||||
<TableHead className="w-20">评论</TableHead>
|
|
||||||
<TableHead className="w-36">发布时间</TableHead>
|
<TableHead className="w-36">发布时间</TableHead>
|
||||||
<TableHead className="w-28">操作</TableHead>
|
<TableHead className="w-28 rounded-tr-lg">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={9} className="text-center py-8">
|
<TableCell colSpan={9} className="text-center py-12">
|
||||||
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<span className="text-sm text-muted-foreground">加载中...</span>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : posts.length === 0 ? (
|
) : posts.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
<TableCell colSpan={9} className="text-center py-12 text-muted-foreground">
|
||||||
暂无数据
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="p-4 bg-slate-100 rounded-full">
|
||||||
|
<Eye className="h-8 w-8 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<span>暂无数据</span>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
posts.map((post) => (
|
posts.map((post) => (
|
||||||
<TableRow key={post.id}>
|
<TableRow
|
||||||
|
key={post.id}
|
||||||
|
className="group hover:bg-slate-50/80 transition-colors"
|
||||||
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedIds.includes(post.id)}
|
checked={selectedIds.includes(post.id)}
|
||||||
onCheckedChange={(checked) => handleSelect(post.id, !!checked)}
|
onCheckedChange={(checked) => handleSelect(post.id, !!checked)}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-sm">{post.id}</TableCell>
|
<TableCell className="font-mono text-xs text-slate-500">#{post.id.slice(-6)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="max-w-[300px]">
|
<div className="relative w-14 h-14 rounded-lg overflow-hidden bg-slate-100 ring-1 ring-slate-200 group-hover:ring-primary/30 transition-all">
|
||||||
<div className="font-medium truncate">{post.title}</div>
|
{post.images && post.images.length > 0 ? (
|
||||||
<div className="text-sm text-muted-foreground truncate">
|
<img
|
||||||
{post.content.substring(0, 50)}...
|
src={post.images[0].url}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<ImageIcon className="h-5 w-5 text-slate-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{post.images && post.images.length > 1 && (
|
||||||
|
<div className="absolute bottom-0 right-0 bg-black/60 text-white text-[10px] px-1.5 py-0.5 rounded-tl-md">
|
||||||
|
+{post.images.length - 1}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="max-w-[280px] space-y-1">
|
||||||
|
<div className="font-medium text-slate-900 truncate group-hover:text-primary transition-colors">
|
||||||
|
{post.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-500 truncate">
|
||||||
|
{post.content.substring(0, 60)}{post.content.length > 60 ? '...' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2.5">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-9 w-9 ring-2 ring-white shadow-sm">
|
||||||
<AvatarImage src={post.author.avatar} />
|
<AvatarImage src={post.author.avatar} />
|
||||||
<AvatarFallback>{getAvatarFallback(post.author)}</AvatarFallback>
|
<AvatarFallback className="bg-primary/10 text-primary text-xs font-medium">
|
||||||
|
{getAvatarFallback(post.author)}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="font-medium">{post.author.nickname || post.author.username}</div>
|
<div className="font-medium text-sm text-slate-900 truncate">{post.author.nickname || post.author.username}</div>
|
||||||
<div className="text-xs text-muted-foreground">@{post.author.username}</div>
|
<div className="text-xs text-slate-500 truncate">@{post.author.username}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge className={`${statusColors[post.status]} text-white`}>
|
<Badge className={`${statusColors[post.status]} text-white font-medium text-xs px-2.5 py-1`}>
|
||||||
{statusText[post.status]}
|
{statusText[post.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{post.like_count}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{post.comment_count}</TableCell>
|
<div className="flex flex-col gap-1 text-xs">
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-1 text-slate-600">
|
||||||
|
<ThumbsUp className="h-3 w-3" />
|
||||||
|
<span>{post.likes_count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-slate-600">
|
||||||
|
<MessageCircle className="h-3 w-3" />
|
||||||
|
<span>{post.comments_count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-slate-500">
|
||||||
{formatDate(post.created_at)}
|
{formatDate(post.created_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-slate-100">
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
<DropdownMenuItem onClick={() => handleViewDetail(post)}>
|
<DropdownMenuItem onClick={() => handleViewDetail(post)} className="cursor-pointer">
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
<Eye className="mr-2 h-4 w-4 text-blue-500" />
|
||||||
查看详情
|
查看详情
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{post.status === 'pending' && (
|
{post.status === 'pending' && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => openModerateDialog(post, 'published')}>
|
<DropdownMenuItem onClick={() => openModerateDialog(post, 'published')} className="cursor-pointer">
|
||||||
<Check className="mr-2 h-4 w-4" />
|
<Check className="mr-2 h-4 w-4 text-green-500" />
|
||||||
通过审核
|
通过审核
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => openModerateDialog(post, 'rejected')}>
|
<DropdownMenuItem onClick={() => openModerateDialog(post, 'rejected')} className="cursor-pointer">
|
||||||
<X className="mr-2 h-4 w-4" />
|
<X className="mr-2 h-4 w-4 text-amber-500" />
|
||||||
拒绝
|
拒绝
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
@@ -484,7 +559,7 @@ export default function Posts() {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => openDeleteDialog(post)}
|
onClick={() => openDeleteDialog(post)}
|
||||||
className="text-red-600"
|
className="text-red-600 cursor-pointer focus:text-red-600"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
删除
|
删除
|
||||||
@@ -499,7 +574,7 @@ export default function Posts() {
|
|||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{/* 分页 */}
|
{/* 分页 */}
|
||||||
<div className="mt-4">
|
<div className="p-4 border-t border-slate-100 bg-slate-50/50">
|
||||||
<PaginationNavigator
|
<PaginationNavigator
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
totalPages={Math.ceil(total / pageSize)}
|
totalPages={Math.ceil(total / pageSize)}
|
||||||
@@ -511,43 +586,144 @@ export default function Posts() {
|
|||||||
|
|
||||||
{/* 详情对话框 */}
|
{/* 详情对话框 */}
|
||||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader className="border-b pb-4">
|
||||||
<DialogTitle>帖子详情</DialogTitle>
|
<DialogTitle className="text-xl flex items-center gap-2">
|
||||||
|
<Eye className="h-5 w-5 text-primary" />
|
||||||
|
帖子详情
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{detailLoading ? (
|
{detailLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
</div>
|
</div>
|
||||||
) : selectedPost ? (
|
) : selectedPost ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6 py-2">
|
||||||
<div className="flex items-center gap-4">
|
{/* 作者信息卡片 */}
|
||||||
<Avatar className="h-12 w-12">
|
<div className="flex items-center gap-4 p-4 bg-gradient-to-r from-slate-50 to-slate-100 rounded-xl">
|
||||||
|
<Avatar className="h-14 w-14 ring-2 ring-white shadow-sm">
|
||||||
<AvatarImage src={selectedPost.author?.avatar} />
|
<AvatarImage src={selectedPost.author?.avatar} />
|
||||||
<AvatarFallback>{getAvatarFallback(selectedPost.author)}</AvatarFallback>
|
<AvatarFallback className="bg-primary/10 text-primary font-semibold text-lg">
|
||||||
|
{getAvatarFallback(selectedPost.author)}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<div className="font-medium">{selectedPost.author?.nickname || selectedPost.author?.username}</div>
|
<div className="font-semibold text-lg">{selectedPost.author?.nickname || selectedPost.author?.username}</div>
|
||||||
<div className="text-sm text-muted-foreground">@{selectedPost.author?.username}</div>
|
<div className="text-sm text-muted-foreground">@{selectedPost.author?.username}</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={`${statusColors[selectedPost.status]} text-white ml-auto`}>
|
<div className="flex flex-col items-end gap-2">
|
||||||
{statusText[selectedPost.status]}
|
<Badge className={`${statusColors[selectedPost.status]} text-white font-medium px-3 py-1`}>
|
||||||
</Badge>
|
{statusText[selectedPost.status]}
|
||||||
</div>
|
</Badge>
|
||||||
|
<div className="flex gap-1">
|
||||||
<div>
|
{selectedPost.is_pinned && (
|
||||||
<h3 className="text-xl font-bold mb-2">{selectedPost.title}</h3>
|
<Badge variant="outline" className="text-amber-600 border-amber-200 bg-amber-50">
|
||||||
<p className="text-muted-foreground whitespace-pre-wrap">{selectedPost.content}</p>
|
<Pin className="h-3 w-3 mr-1" />
|
||||||
|
置顶
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{selectedPost.is_featured && (
|
||||||
|
<Badge variant="outline" className="text-purple-600 border-purple-200 bg-purple-50">
|
||||||
|
<Star className="h-3 w-3 mr-1" />
|
||||||
|
精华
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-6 text-sm text-muted-foreground">
|
{/* 帖子内容 */}
|
||||||
<span>👍 {selectedPost.like_count} 点赞</span>
|
<div className="space-y-4">
|
||||||
<span>💬 {selectedPost.comment_count} 评论</span>
|
<h3 className="text-2xl font-bold text-slate-900 leading-tight">{selectedPost.title}</h3>
|
||||||
<span>👁 {selectedPost.view_count} 浏览</span>
|
<div className="prose prose-slate max-w-none">
|
||||||
|
<p className="text-slate-700 whitespace-pre-wrap leading-relaxed text-base">{selectedPost.content}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground">
|
{/* 图片展示 */}
|
||||||
发布时间:{formatDate(selectedPost.created_at)}
|
{selectedPost.images && selectedPost.images.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||||
|
<ImageIcon className="h-4 w-4 text-primary" />
|
||||||
|
<span>图片附件 ({selectedPost.images.length}张)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||||
|
{selectedPost.images.map((img, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="group relative aspect-square rounded-xl overflow-hidden bg-slate-100 ring-1 ring-slate-200 hover:ring-primary/50 transition-all"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={img.url}
|
||||||
|
alt={`图片 ${index + 1}`}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 24 24" fill="none" stroke="%239ca3af" stroke-width="1"%3E%3Crect x="3" y="3" width="18" height="18" rx="2" ry="2"/%3E%3Ccircle cx="8.5" cy="8.5" r="1.5"/%3E%3Cpolyline points="21 15 16 10 5 21"/%3E%3C/svg%3E';
|
||||||
|
(e.target as HTMLImageElement).classList.add('p-4');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 标签展示 */}
|
||||||
|
{selectedPost.tags && selectedPost.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedPost.tags.map((tag, index) => (
|
||||||
|
<Badge key={index} variant="secondary" className="bg-slate-100 text-slate-700 hover:bg-slate-200">
|
||||||
|
#{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 统计数据 */}
|
||||||
|
<div className="flex items-center gap-6 p-4 bg-slate-50 rounded-xl">
|
||||||
|
<div className="flex items-center gap-2 text-slate-600">
|
||||||
|
<div className="p-2 bg-rose-100 rounded-lg">
|
||||||
|
<ThumbsUp className="h-4 w-4 text-rose-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold text-slate-900">{selectedPost.likes_count}</div>
|
||||||
|
<div className="text-xs text-slate-500">点赞</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-slate-600">
|
||||||
|
<div className="p-2 bg-blue-100 rounded-lg">
|
||||||
|
<MessageCircle className="h-4 w-4 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold text-slate-900">{selectedPost.comments_count}</div>
|
||||||
|
<div className="text-xs text-slate-500">评论</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-slate-600">
|
||||||
|
<div className="p-2 bg-emerald-100 rounded-lg">
|
||||||
|
<EyeIcon className="h-4 w-4 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold text-slate-900">{selectedPost.views_count}</div>
|
||||||
|
<div className="text-xs text-slate-500">浏览</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 时间信息 */}
|
||||||
|
<div className="flex items-center justify-between text-sm text-slate-500 pt-4 border-t border-slate-100">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>发布时间:{formatDate(selectedPost.created_at)}</span>
|
||||||
|
{selectedPost.group_name && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
圈子:{selectedPost.group_name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedPost.updated_at !== selectedPost.created_at && (
|
||||||
|
<span>更新于:{formatDate(selectedPost.updated_at)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -105,10 +105,12 @@ export default function Users() {
|
|||||||
const response = await rolesApi.getRoles()
|
const response = await rolesApi.getRoles()
|
||||||
const roles = response.data.data || []
|
const roles = response.data.data || []
|
||||||
setAllRoles(roles.map(r => ({
|
setAllRoles(roles.map(r => ({
|
||||||
id: r.name, // 使用 name 作为 id
|
id: r.name,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
|
display_name: r.display_name,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
created_at: '', // 后端列表接口不返回这些字段
|
priority: r.priority,
|
||||||
|
created_at: '',
|
||||||
updated_at: '',
|
updated_at: '',
|
||||||
})))
|
})))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ export type CommentStatus = 'pending' | 'published' | 'rejected' | 'deleted'
|
|||||||
export interface Role {
|
export interface Role {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
display_name: string
|
||||||
description?: string
|
description?: string
|
||||||
|
priority: number
|
||||||
permissions?: string[]
|
permissions?: string[]
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
@@ -45,6 +47,17 @@ export interface User {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 帖子图片
|
||||||
|
export interface PostImage {
|
||||||
|
id?: string
|
||||||
|
url: string
|
||||||
|
thumbnail_url?: string
|
||||||
|
preview_url?: string
|
||||||
|
preview_url_large?: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
// 帖子
|
// 帖子
|
||||||
export interface Post {
|
export interface Post {
|
||||||
id: string
|
id: string
|
||||||
@@ -52,13 +65,19 @@ export interface Post {
|
|||||||
content: string
|
content: string
|
||||||
author: User
|
author: User
|
||||||
status: PostStatus
|
status: PostStatus
|
||||||
like_count: number
|
likes_count: number
|
||||||
comment_count: number
|
comments_count: number
|
||||||
view_count: number
|
views_count: number
|
||||||
|
images?: PostImage[]
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 评论图片
|
||||||
|
export interface CommentImage {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
// 评论
|
// 评论
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
id: string
|
id: string
|
||||||
@@ -67,7 +86,9 @@ export interface Comment {
|
|||||||
post_id: string
|
post_id: string
|
||||||
post_title?: string
|
post_title?: string
|
||||||
status: CommentStatus
|
status: CommentStatus
|
||||||
like_count: number
|
likes_count: number
|
||||||
|
replies_count?: number
|
||||||
|
images?: CommentImage[]
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user