feat: 管理端网站监控,浏览量写入独立 monitor.db

请求日志按日 JSONL;page_views 不进主库,避免统计数据撑大 jiang13.db。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-31 07:55:45 +08:00
parent 16b6a18096
commit 429956c594
54 changed files with 5504 additions and 146 deletions

View File

@@ -1,14 +1,38 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { FileText, Flag, MessageSquare, Link2 } from 'lucide-react';
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 } from '../../api/types';
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();
@@ -32,20 +56,16 @@ export default function AdminDashboardPage() {
const pendingReports = data.pending_reports ?? 0;
const pendingFriendLinks = data.pending_friend_links ?? 0;
const pendingTotal = pendingPosts + pendingComments + pendingReports + pendingFriendLinks;
const stats = [
{ label: '注册用户', value: data.users },
{ label: '帖子总数', value: data.posts },
{ label: '板块数量', value: data.boards },
{ label: '评论总数', value: data.comments },
];
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: '新帖与修改待审核',
hint: '新帖与修改',
to: '/admin/posts',
icon: FileText,
},
@@ -53,7 +73,7 @@ export default function AdminDashboardPage() {
key: 'comments',
label: '待审评论',
count: pendingComments,
hint: '评论与回复待审核',
hint: '评论与回复',
to: '/admin/comments',
icon: MessageSquare,
},
@@ -61,7 +81,7 @@ export default function AdminDashboardPage() {
key: 'reports',
label: '待处理举报',
count: pendingReports,
hint: '用户举报需人工处理',
hint: '用户举报',
to: '/admin/reports',
icon: Flag,
},
@@ -69,60 +89,121 @@ export default function AdminDashboardPage() {
key: 'links',
label: '待审友链',
count: pendingFriendLinks,
hint: '用户提交的友情链接申请',
hint: '友链申请',
to: '/admin/links',
icon: Link2,
},
];
return (
<div className="admin-page">
<div className="admin-page-head">
<h1></h1>
<p></p>
</div>
const scale = [
{ label: '注册用户', value: data.users },
{ label: '帖子总数', value: data.posts },
{ label: '板块数量', value: data.boards },
{ label: '评论总数', value: data.comments },
];
<section className="admin-queue-section" aria-label="待处理事项">
<div className="admin-section-label">
<span></span>
{pendingTotal > 0 ? (
<Badge variant="orange">{pendingTotal} </Badge>
) : (
<span className="admin-section-muted"></span>
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>
<div className="admin-queue-grid">
{queues.map(q => {
const Icon = q.icon;
const hasWork = q.count > 0;
return (
<button
key={q.key}
type="button"
className={cn('admin-queue-card', hasWork && 'has-work')}
onClick={() => nav(q.to)}
>
<div className="admin-queue-card-top">
<Icon size={18} aria-hidden />
<span className="admin-queue-count">{q.count}</span>
</div>
<div className="admin-queue-label">{q.label}</div>
<div className="admin-queue-hint">{q.hint}</div>
</button>
);
})}
</div>
</section>
<p>访</p>
</div>
<section className="admin-stat-section" aria-label="运行概览">
<div className="admin-section-label">
<span></span>
<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-stat-grid">
{stats.map(s => (
<div key={s.label} className="admin-stat-card">
<div className="admin-stat-value">{s.value}</div>
<div className="admin-stat-label">{s.label}</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>
@@ -148,7 +229,7 @@ export default function AdminDashboardPage() {
</tr>
</thead>
<tbody>
{data.recent_posts.map(p => (
{data.recent_posts.map((p) => (
<tr key={p.id}>
<td>{p.id}</td>
<td>