gitdataai/admin/src/app/dashboard/page.tsx
ZhenYi fb91f5a6c5 feat(admin): add admin panel with billing alerts and model sync
- 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
2026-04-19 20:48:59 +08:00

247 lines
8.8 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.

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