538de50bb1
- Prisma User 模型新增 username 字段(唯一索引) - 注册先查重复再创建,返回友好中文提示(非 500) - 登录支持用户名/手机号/邮箱三种方式 - 前端注册表单增加用户名输入框,预校验 2-20 位格式 - 新增 scripts/deploy.sh:一键构建并部署主站+镜像站+重启后端+重载 Nginx - 镜像站 www.yuzhiran.com.cn Nginx 配置与主站同步 - AI 助手架构升级:用户端/管理后台均采用完整 Tool Calling 架构 - 新增 UserAiAssistantService(18 工具)+ AiAssistantController - admin 助手新增 search + mark-all-notifications-read 工具 - 修复注册 500 错误:catch Prisma P2002 → BadRequestException - Baidu Analytics Script 注入 root layout
136 lines
5.2 KiB
TypeScript
Executable File
136 lines
5.2 KiB
TypeScript
Executable File
'use client';
|
|
|
|
import { useEffect, useState, useCallback } from 'react';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { API_BASE } from '@/lib/config';
|
|
|
|
interface PendingComment {
|
|
id: number;
|
|
content: string;
|
|
status: string;
|
|
createdAt: string;
|
|
user: { id: number; nickname: string; avatar: string | null };
|
|
post: { id: number; title: string };
|
|
}
|
|
|
|
export default function AdminCommentsPage() {
|
|
const [comments, setComments] = useState<PendingComment[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [tab, setTab] = useState<'pending' | 'approved' | 'rejected'>('pending');
|
|
|
|
const base = API_BASE;
|
|
function headers() {
|
|
const t = localStorage.getItem('adminToken');
|
|
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
|
|
}
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const url = tab === 'pending'
|
|
? '/admin/comments/pending'
|
|
: tab === 'approved'
|
|
? '/admin/comments?status=PUBLISHED'
|
|
: '/admin/comments?status=REJECTED';
|
|
const res = await fetch(`${base}${url}`, { headers: headers() });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setComments(data.items || []);
|
|
}
|
|
} catch (e) { console.error(e) }
|
|
setLoading(false);
|
|
}, [tab, base]);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
async function approve(id: number) {
|
|
try {
|
|
const res = await fetch(`${base}/admin/comments/${id}/approve`, {
|
|
method: 'PUT', headers: headers(),
|
|
});
|
|
if (res.ok) setComments(prev => prev.filter(c => c.id !== id));
|
|
} catch (e) { console.error(e) }
|
|
}
|
|
|
|
async function reject(id: number) {
|
|
const reason = prompt('请输入拒绝原因:');
|
|
if (!reason) return;
|
|
try {
|
|
const res = await fetch(`${base}/admin/comments/${id}/reject`, {
|
|
method: 'PUT', headers: headers(), body: JSON.stringify({ reason }),
|
|
});
|
|
if (res.ok) setComments(prev => prev.filter(c => c.id !== id));
|
|
} catch (e) { console.error(e) }
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="border-b border-border bg-card px-4 py-4">
|
|
<h1 className="text-2xl font-bold text-foreground">评论审核</h1>
|
|
</div>
|
|
<div className="max-w-4xl mx-auto p-6">
|
|
<div className="flex gap-1 mb-6 bg-muted rounded-lg p-1">
|
|
{(['pending', 'approved', 'rejected'] as const).map(t => (
|
|
<button key={t} onClick={() => setTab(t)}
|
|
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
|
|
tab === t ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
|
}`}>
|
|
{t === 'pending' ? '待审核' : t === 'approved' ? '已通过' : '已拒绝'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="space-y-3">
|
|
{[1,2,3].map(i => (
|
|
<div key={i} className="bg-card rounded-xl border border-border p-4">
|
|
<Skeleton className="h-4 w-32 mb-2" />
|
|
<Skeleton className="h-4 w-full mb-2" />
|
|
<Skeleton className="h-4 w-3/4" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : comments.length === 0 ? (
|
|
<div className="text-center py-20 text-muted-foreground">
|
|
<p className="text-4xl mb-4">{tab === 'pending' ? '✅' : '📝'}</p>
|
|
<p>{tab === 'pending' ? '暂无待审核评论' : tab === 'approved' ? '暂无已通过评论' : '暂无已拒绝评论'}</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{comments.map(c => (
|
|
<div key={c.id} className="bg-card rounded-xl border border-border p-4">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<div className="w-7 h-7 rounded-full bg-brand-100 flex items-center justify-center text-brand-600 text-xs font-bold">
|
|
{c.user.nickname?.[0] || 'U'}
|
|
</div>
|
|
<span className="text-sm font-medium text-foreground">{c.user.nickname || '用户'}</span>
|
|
<span className="text-xs text-muted-foreground">· {new Date(c.createdAt).toLocaleString('zh-CN')}</span>
|
|
</div>
|
|
<p className="text-sm text-foreground mb-2">{c.content}</p>
|
|
<div className="flex items-center justify-between">
|
|
<a href={`/community/${c.post.id}`} target="_blank"
|
|
className="text-xs text-brand-600 hover:underline">
|
|
来源: {c.post.title}
|
|
</a>
|
|
{tab === 'pending' && (
|
|
<div className="flex gap-2">
|
|
<button onClick={() => approve(c.id)}
|
|
className="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700">
|
|
通过
|
|
</button>
|
|
<button onClick={() => reject(c.id)}
|
|
className="px-3 py-1 text-xs bg-red-500 text-white rounded-lg hover:bg-red-600">
|
|
拒绝
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|