Files
jiang13-forum/frontend/src/pages/admin/AdminDashboardPage.tsx
freefire ba60a9b1c4 feat: 调整 Feed 排序为新评论/新帖子/推荐帖
默认按新评论;推荐排序优先 featured,并将用户可见「精华」文案改为「推荐」。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 01:59:01 +08:00

263 lines
9.1 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 { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { FileText, Flag, MessageSquare, Link2, Activity } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Badge } from '@/components/ui/badge';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { AdminDashboard, AdminDashboardTraffic } from '../../api/types';
import { cn } from '@/lib/utils';
import CommunitySupportStrip from '../../components/admin/CommunitySupportStrip';
function formatDashNum(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 10_000) return `${(n / 1000).toFixed(1)}k`;
return String(n ?? 0);
}
function pvTrendLabel(today: number, yesterday: number) {
if (yesterday <= 0) {
return today > 0 ? '昨日无数据' : '较昨日 —';
}
const delta = ((today - yesterday) / yesterday) * 100;
if (Math.abs(delta) < 0.5) return '与昨日持平';
const abs = Math.abs(delta).toFixed(0);
return delta > 0 ? `较昨日 ↑ ${abs}%` : `较昨日 ↓ ${abs}%`;
}
function trendTone(today: number, yesterday: number): 'up' | 'down' | 'flat' {
if (yesterday <= 0) return 'flat';
const delta = today - yesterday;
if (delta > 0) return 'up';
if (delta < 0) return 'down';
return 'flat';
}
export default function AdminDashboardPage() {
const nav = useNavigate();
const { ready } = useAdminGuard();
const [data, setData] = useState<AdminDashboard | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!ready) return;
api.adminDashboard()
.then(setData)
.finally(() => setLoading(false));
}, [ready]);
if (!ready || loading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!data) return null;
const pendingPosts = data.pending_posts ?? 0;
const pendingComments = data.pending_comments ?? 0;
const pendingReports = data.pending_reports ?? 0;
const pendingFriendLinks = data.pending_friend_links ?? 0;
const pendingTotal = pendingPosts + pendingComments + pendingReports + pendingFriendLinks;
const traffic: AdminDashboardTraffic = data.traffic ?? {
enabled: false, today_pv: 0, today_uv: 0, yesterday_pv: 0, total_pv: 0,
};
const queues = [
{
key: 'posts',
label: '待审帖子',
count: pendingPosts,
hint: '新帖与修改',
to: '/admin/posts',
icon: FileText,
},
{
key: 'comments',
label: '待审评论',
count: pendingComments,
hint: '评论与回复',
to: '/admin/comments',
icon: MessageSquare,
},
{
key: 'reports',
label: '待处理举报',
count: pendingReports,
hint: '用户举报',
to: '/admin/reports',
icon: Flag,
},
{
key: 'links',
label: '待审友链',
count: pendingFriendLinks,
hint: '友链申请',
to: '/admin/links',
icon: Link2,
},
];
const scale = [
{ label: '注册用户', value: data.users },
{ label: '帖子总数', value: data.posts },
{ label: '板块数量', value: data.boards },
{ label: '评论总数', value: data.comments },
];
const tone = trendTone(traffic.today_pv, traffic.yesterday_pv);
return (
<div className="admin-page admin-dash-page">
<div className="admin-page-head admin-dash-head">
<div className="admin-dash-head-title">
<h1></h1>
{pendingTotal > 0 && (
<Badge variant="orange">{pendingTotal} </Badge>
)}
</div>
<p>访</p>
</div>
<div className="admin-dash-bento">
<section className="admin-dash-traffic" aria-label="今日访问">
<div className="admin-dash-traffic-top">
<div className="admin-dash-section-label">
<span className="admin-monitor-section-bar" aria-hidden />
<h2>访</h2>
</div>
<button
type="button"
className="admin-text-link admin-dash-link"
onClick={() => nav('/admin/monitor')}
>
<Activity size={14} aria-hidden />
</button>
</div>
{!traffic.enabled ? (
<div className="admin-dash-traffic-empty">
<p>访</p>
<button
type="button"
className="admin-text-link"
onClick={() => nav('/admin/monitor?tab=settings')}
>
</button>
</div>
) : (
<>
<div className="admin-dash-traffic-pv">{formatDashNum(traffic.today_pv)}</div>
<div className="admin-dash-traffic-meta">
<span>访 (PV)</span>
<span className="admin-dash-traffic-uv">访 (UV) {formatDashNum(traffic.today_uv)}</span>
</div>
<div className={cn('admin-dash-traffic-trend', `is-${tone}`)}>
{pvTrendLabel(traffic.today_pv, traffic.yesterday_pv)}
</div>
<p className="admin-dash-traffic-total">
访 {formatDashNum(traffic.total_pv)}
<span className="admin-dash-traffic-hint"></span>
</p>
</>
)}
</section>
<section className="admin-dash-queue" aria-label="待处理事项">
<div className="admin-dash-section-label admin-dash-queue-head">
<span className="admin-monitor-section-bar" aria-hidden />
<h2></h2>
{pendingTotal === 0 && (
<span className="admin-section-muted"></span>
)}
</div>
<div className="admin-dash-queue-grid">
{queues.map((q) => {
const Icon = q.icon;
const hasWork = q.count > 0;
return (
<button
key={q.key}
type="button"
className={cn('admin-dash-queue-card', hasWork && 'has-work')}
onClick={() => nav(q.to)}
>
<div className="admin-dash-queue-card-top">
<Icon size={16} aria-hidden />
<span className="admin-dash-queue-count">{q.count}</span>
</div>
<div className="admin-dash-queue-label">{q.label}</div>
<div className="admin-dash-queue-hint">{q.hint}</div>
</button>
);
})}
</div>
</section>
</div>
<section className="admin-dash-scale" aria-label="内容规模">
<div className="admin-dash-section-label">
<span className="admin-monitor-section-bar" aria-hidden />
<h2></h2>
</div>
<div className="admin-dash-metric-grid">
{scale.map((s) => (
<div key={s.label} className="admin-dash-metric-card">
<div className="admin-dash-metric-value">{formatDashNum(s.value)}</div>
<div className="admin-dash-metric-label">{s.label}</div>
</div>
))}
</div>
</section>
<CommunitySupportStrip />
<div className="admin-card">
<div className="admin-card-head">
<span></span>
<button type="button" className="admin-text-link" onClick={() => nav('/admin/posts')}>
</button>
</div>
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data.recent_posts.map((p) => (
<tr key={p.id}>
<td>{p.id}</td>
<td>
<button type="button" className="admin-text-link" onClick={() => nav(`/post/${p.id}`)}>
{p.title}
</button>
</td>
<td>
{p.user?.id ? (
<button type="button" className="admin-text-link" onClick={() => nav(`/user/${p.user!.id}`)}>
{p.user.nickname}
</button>
) : '—'}
</td>
<td className="space-x-1">
{p.featured ? <Badge variant="orange"></Badge> : null}
{p.pinned ? <Badge variant="green"></Badge> : null}
{p.board_pinned ? <Badge variant="green"></Badge> : null}
{!p.featured && !p.pinned && !p.board_pinned ? '—' : null}
</td>
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
</tr>
))}
</tbody>
</table>
{data.recent_posts.length === 0 && <div className="admin-empty"></div>}
</div>
</div>
);
}