Files
ai-learning-platform/frontend/src/app/admin/comments/page.tsx
T
yuzhiran-dev 538de50bb1 注册支持用户名/手机号/邮箱 + 镜像站部署脚本 + AI 助手 Tool Calling 重构
- 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
2026-06-01 23:14:42 +08:00

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>
</>
);
}