- Add libs/api/admin with admin API endpoints: sync models, workspace credit, billing alert check - Add workspace_alert_config model and alert service - Add Session::no_op() for background tasks without user context - Add admin/ Next.js admin panel (AI models, billing, workspaces, audit) - Start billing alert background task every 30 minutes
247 lines
8.8 KiB
TypeScript
247 lines
8.8 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { format } from "date-fns";
|
||
import LineChart from "@/components/admin/LineChart";
|
||
|
||
interface Stats {
|
||
userCount: number;
|
||
workspaceCount: number;
|
||
projectCount: number;
|
||
roomCount: number;
|
||
}
|
||
|
||
interface ActivityStats {
|
||
dau: Array<{ date: string; dau: number }>;
|
||
mau: number;
|
||
totalLogins: number;
|
||
last24h: number;
|
||
}
|
||
|
||
interface RecentUser {
|
||
uid: string;
|
||
username: string;
|
||
display_name: string | null;
|
||
created_at: string;
|
||
}
|
||
|
||
interface RecentWorkspace {
|
||
id: string;
|
||
slug: string;
|
||
name: string;
|
||
plan: string;
|
||
memberCount: number;
|
||
}
|
||
|
||
interface RecentProject {
|
||
id: string;
|
||
name: string;
|
||
workspaceName: string | null;
|
||
memberCount: number;
|
||
}
|
||
|
||
interface PlanDist {
|
||
plan: string;
|
||
count: number;
|
||
}
|
||
|
||
const PLAN_LABELS: Record<string, string> = {
|
||
free: "免费", starter: "入门", pro: "专业", enterprise: "企业", unknown: "未知",
|
||
};
|
||
|
||
export default function DashboardPage() {
|
||
const [stats, setStats] = useState<Stats | null>(null);
|
||
const [recentUsers, setRecentUsers] = useState<RecentUser[]>([]);
|
||
const [recentWorkspaces, setRecentWorkspaces] = useState<RecentWorkspace[]>([]);
|
||
const [recentProjects, setRecentProjects] = useState<RecentProject[]>([]);
|
||
const [planDistribution, setPlanDistribution] = useState<PlanDist[]>([]);
|
||
const [activity, setActivity] = useState<ActivityStats | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
Promise.all([
|
||
fetch("/api/platform/stats").then((r) => r.json()),
|
||
fetch("/api/platform/activity-stats").then((r) => r.json()),
|
||
])
|
||
.then(([statsData, activityData]) => {
|
||
if (statsData.stats) setStats(statsData.stats);
|
||
if (statsData.recentUsers) setRecentUsers(statsData.recentUsers);
|
||
if (statsData.recentWorkspaces) setRecentWorkspaces(statsData.recentWorkspaces);
|
||
if (statsData.recentProjects) setRecentProjects(statsData.recentProjects);
|
||
if (statsData.planDistribution) setPlanDistribution(statsData.planDistribution);
|
||
if (activityData.dau) setActivity(activityData);
|
||
})
|
||
.catch(console.error)
|
||
.finally(() => setLoading(false));
|
||
}, []);
|
||
|
||
const totalWorkspaces = planDistribution.reduce((sum, p) => sum + p.count, 0);
|
||
|
||
return (
|
||
<div className="admin-content">
|
||
<div className="page-header">
|
||
<h1 className="page-title">仪表盘</h1>
|
||
<p className="page-subtitle">平台概览</p>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="loading">加载中...</div>
|
||
) : stats ? (
|
||
<>
|
||
<div className="stats-grid">
|
||
<div className="stat-card">
|
||
<div className="stat-value">{stats.userCount.toLocaleString()}</div>
|
||
<div className="stat-label">用户总数</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-value">{stats.workspaceCount.toLocaleString()}</div>
|
||
<div className="stat-label">Workspace 总数</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-value">{stats.projectCount.toLocaleString()}</div>
|
||
<div className="stat-label">项目总数</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-value">{stats.roomCount.toLocaleString()}</div>
|
||
<div className="stat-label">聊天室总数</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* DAU Trend Chart */}
|
||
{activity && activity.dau.length > 0 && (
|
||
<div className="card" style={{ marginBottom: "16px" }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "16px" }}>
|
||
<h2 style={{ fontSize: "16px", fontWeight: 600 }}>DAU 趋势(近30天)</h2>
|
||
<div style={{ display: "flex", gap: "24px", fontSize: "13px" }}>
|
||
<span>MAU: <strong>{activity.mau}</strong></span>
|
||
<span>总登录: <strong>{activity.totalLogins.toLocaleString()}</strong></span>
|
||
<span>近24h: <strong>{activity.last24h}</strong></span>
|
||
</div>
|
||
</div>
|
||
<LineChart
|
||
data={activity.dau.map((d) => ({ date: d.date, value: d.dau }))}
|
||
label="DAU"
|
||
color="#6366f1"
|
||
width={700}
|
||
height={180}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Workspace Plan Distribution */}
|
||
{planDistribution.length > 0 && (
|
||
<div className="card" style={{ marginBottom: "16px" }}>
|
||
<h2 style={{ fontSize: "16px", fontWeight: 600, marginBottom: "16px" }}>Workspace 计划分布</h2>
|
||
<div style={{ display: "flex", gap: "16px", flexWrap: "wrap" }}>
|
||
{planDistribution.map((p) => (
|
||
<div key={p.plan} style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||
<span className="badge badge-neutral">{PLAN_LABELS[p.plan] || p.plan}</span>
|
||
<span style={{ fontSize: "14px", fontWeight: 600 }}>{p.count}</span>
|
||
<span style={{ fontSize: "12px", color: "#737373" }}>
|
||
({totalWorkspaces > 0 ? ((p.count / totalWorkspaces) * 100).toFixed(1) : 0}%)
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px", marginBottom: "16px" }}>
|
||
<div className="card">
|
||
<h2 style={{ fontSize: "16px", fontWeight: 600, marginBottom: "16px" }}>
|
||
近期注册用户
|
||
</h2>
|
||
{recentUsers.length === 0 ? (
|
||
<div className="empty-state">暂无数据</div>
|
||
) : (
|
||
<div className="table-container">
|
||
<table className="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>用户名</th>
|
||
<th>显示名</th>
|
||
<th>注册时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{recentUsers.map((u) => (
|
||
<tr key={u.uid}>
|
||
<td>{u.username}</td>
|
||
<td>{u.display_name || "-"}</td>
|
||
<td>{format(new Date(u.created_at), "yyyy-MM-dd")}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="card">
|
||
<h2 style={{ fontSize: "16px", fontWeight: 600, marginBottom: "16px" }}>
|
||
近期 Workspace
|
||
</h2>
|
||
{recentWorkspaces.length === 0 ? (
|
||
<div className="empty-state">暂无数据</div>
|
||
) : (
|
||
<div className="table-container">
|
||
<table className="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>名称</th>
|
||
<th>计划</th>
|
||
<th>成员</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{recentWorkspaces.map((w) => (
|
||
<tr key={w.id}>
|
||
<td>{w.name}</td>
|
||
<td>
|
||
<span className="badge badge-neutral">{PLAN_LABELS[w.plan] || w.plan}</span>
|
||
</td>
|
||
<td>{w.memberCount}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{recentProjects.length > 0 && (
|
||
<div className="card">
|
||
<h2 style={{ fontSize: "16px", fontWeight: 600, marginBottom: "16px" }}>
|
||
近期项目
|
||
</h2>
|
||
<div className="table-container">
|
||
<table className="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>名称</th>
|
||
<th>所属 Workspace</th>
|
||
<th>成员</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{recentProjects.map((p) => (
|
||
<tr key={p.id}>
|
||
<td>{p.name}</td>
|
||
<td>{p.workspaceName || <span style={{ color: "#a3a3a3" }}>无</span>}</td>
|
||
<td>{p.memberCount}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="alert alert-error">无法加载统计数据</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|