feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { Search, X } from "lucide-react";
|
||||
import type { FormEvent } from "react";
|
||||
|
||||
/** 左图标、可清除、Enter 提交;壳层聚焦与聊天搜索一致 */
|
||||
/** 左图标、可清除、Enter 提交;加高壳层便于后台工具条点按 */
|
||||
export default function AdminSearchField({
|
||||
value,
|
||||
onChange,
|
||||
@@ -30,9 +30,13 @@ export default function AdminSearchField({
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="admin-field-shell admin-search-shell relative flex-1 min-w-0" role="search">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="admin-field-shell admin-search-shell relative flex-1 min-w-0"
|
||||
role="search"
|
||||
>
|
||||
<Search
|
||||
size={15}
|
||||
size={17}
|
||||
className="shrink-0 pointer-events-none"
|
||||
style={{ color: value ? "var(--accent)" : "var(--ink-3)" }}
|
||||
aria-hidden
|
||||
@@ -50,10 +54,10 @@ export default function AdminSearchField({
|
||||
type="button"
|
||||
onClick={clear}
|
||||
aria-label="清除搜索"
|
||||
className="w-6 h-6 inline-flex items-center justify-center rounded-full shrink-0 transition-colors hover:bg-[var(--accent-soft)]"
|
||||
className="w-7 h-7 inline-flex items-center justify-center rounded-full shrink-0 transition-colors hover:bg-[var(--accent-soft)]"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
<X size={14} />
|
||||
<X size={15} />
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
493
frontend/components/admin/AdminShell.tsx
Normal file
493
frontend/components/admin/AdminShell.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Megaphone,
|
||||
Palette,
|
||||
ShieldCheck,
|
||||
Coins,
|
||||
Globe,
|
||||
FolderOpen,
|
||||
Menu,
|
||||
MessageCircle,
|
||||
X,
|
||||
ArrowLeft,
|
||||
LogOut,
|
||||
User as UserIcon,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import type { User } from "@/lib/api";
|
||||
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
||||
import {
|
||||
canModerateAny,
|
||||
isAdminOrAbove,
|
||||
isStaff,
|
||||
isSuperOrOwner,
|
||||
type RoleUserLike,
|
||||
} from "@/lib/roles";
|
||||
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
|
||||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||
import { onForceLogout, onUserUpdate } from "@/lib/userEvents";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import RoleBadge from "@/components/RoleBadge";
|
||||
import NotificationBell from "@/components/NotificationBell";
|
||||
import ThemeToggle from "@/components/ThemeToggle";
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
exact?: boolean;
|
||||
visible: (u: RoleUserLike) => boolean;
|
||||
pendingBadge?: boolean;
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
// 分组导航:概览 → 内容运营 → 成员经济 → 站点配置
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
title: "概览",
|
||||
items: [
|
||||
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "内容",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/content",
|
||||
label: "内容管理",
|
||||
icon: FolderOpen,
|
||||
visible: (u) => canModerateAny(u),
|
||||
pendingBadge: true,
|
||||
},
|
||||
{
|
||||
href: "/admin/messages",
|
||||
label: "消息管理",
|
||||
icon: MessageCircle,
|
||||
visible: (u) => isStaff(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/announcements",
|
||||
label: "公告管理",
|
||||
icon: Megaphone,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "成员与经济",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/users",
|
||||
label: "用户管理",
|
||||
icon: Users,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/economy",
|
||||
label: "积分看板",
|
||||
icon: Coins,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "站点",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/settings",
|
||||
label: "站点设置",
|
||||
icon: Globe,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/appearance",
|
||||
label: "外观",
|
||||
icon: Palette,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function isActive(pathname: string, href: string, exact?: boolean) {
|
||||
return exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
function usePendingCounts(enabled: boolean) {
|
||||
const [counts, setCounts] = useState({ posts: 0, comments: 0 });
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let alive = true;
|
||||
const load = () => {
|
||||
apiAdminPendingCounts()
|
||||
.then((c) => {
|
||||
if (alive) setCounts(c);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
load();
|
||||
const off = realtime.on(RT_MODERATION_CHANGED, () => load());
|
||||
const offVisible = onDocumentVisible(load);
|
||||
return () => {
|
||||
alive = false;
|
||||
off();
|
||||
offVisible();
|
||||
};
|
||||
}, [enabled]);
|
||||
return counts.posts + counts.comments;
|
||||
}
|
||||
|
||||
function PendingBadge({ count }: { count: number }) {
|
||||
if (count <= 0) return null;
|
||||
return (
|
||||
<span
|
||||
className="ml-auto shrink-0 min-w-[20px] h-5 px-1.5 rounded-full inline-flex items-center justify-center text-[10.5px] font-medium tabular-nums"
|
||||
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
|
||||
aria-label={`${count} 条待审核`}
|
||||
>
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function visibleGroups(user: RoleUserLike) {
|
||||
return NAV_GROUPS.map((g) => ({
|
||||
...g,
|
||||
items: g.items.filter((i) => i.visible(user)),
|
||||
})).filter((g) => g.items.length > 0);
|
||||
}
|
||||
|
||||
function flatItems(user: RoleUserLike) {
|
||||
return visibleGroups(user).flatMap((g) => g.items);
|
||||
}
|
||||
|
||||
function AdminBrand({ siteName }: { siteName: string }) {
|
||||
return (
|
||||
<div className="admin-rail-brand">
|
||||
<span className="admin-rail-brand-mark" aria-hidden>
|
||||
<ShieldCheck size={18} />
|
||||
</span>
|
||||
<span className="leading-tight min-w-0">
|
||||
<span className="block text-[14.5px] font-extrabold tracking-tight truncate" style={{ color: "var(--ink)" }}>
|
||||
{siteName}
|
||||
</span>
|
||||
<span
|
||||
className="block text-[10.5px] mt-0.5 tracking-[0.12em] uppercase"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
J13 ADMIN
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminRailNav({
|
||||
user,
|
||||
onNavigate,
|
||||
}: {
|
||||
user: User;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const groups = visibleGroups(user);
|
||||
const pendingTotal = usePendingCounts(flatItems(user).some((i) => i.pendingBadge));
|
||||
|
||||
return (
|
||||
<nav className="admin-rail-nav" aria-label="后台导航">
|
||||
{groups.map((group) => (
|
||||
<div key={group.title} className="admin-rail-group">
|
||||
<p className="admin-rail-group-title">{group.title}</p>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{group.items.map((item) => {
|
||||
const active = isActive(pathname, item.href, item.exact);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={onNavigate}
|
||||
className={`admin-rail-link${active ? " is-active" : ""}`}
|
||||
>
|
||||
<Icon size={16} className="shrink-0" aria-hidden />
|
||||
{item.label}
|
||||
{item.pendingBadge && <PendingBadge count={pendingTotal} />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminUserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const itemCls =
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="admin-topbar-user"
|
||||
style={{
|
||||
borderColor: open ? "var(--accent)" : "var(--line)",
|
||||
background: open ? "var(--accent-soft)" : "var(--panel)",
|
||||
}}
|
||||
>
|
||||
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={28} />
|
||||
<span className="hidden sm:flex flex-col items-start min-w-0 leading-tight">
|
||||
<span
|
||||
className="text-[13px] font-medium max-w-[100px] truncate"
|
||||
style={{ color: open ? "var(--accent)" : "var(--ink)" }}
|
||||
>
|
||||
{user.nickname}
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden md:inline-flex shrink-0">
|
||||
<RoleBadge role={user.role} boardIds={user.board_ids} size={10} />
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="shrink-0 transition-transform duration-200"
|
||||
style={{
|
||||
color: "var(--ink-3)",
|
||||
transform: open ? "rotate(180deg)" : "none",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 mt-2 w-60 overflow-hidden z-50 j13-toast-in"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3.5"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={38} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[14px] truncate" style={{ color: "var(--ink)" }}>
|
||||
{user.nickname}
|
||||
</p>
|
||||
<p className="meta text-xs truncate">@{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-1.5">
|
||||
<Link href={`/u/${user.id}`} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||
<UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页
|
||||
</Link>
|
||||
<Link href="/settings" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||
<Settings size={15} style={{ color: "var(--ink-3)" }} /> 账号设置
|
||||
</Link>
|
||||
</div>
|
||||
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onLogout();
|
||||
}}
|
||||
className={itemCls}
|
||||
role="menuitem"
|
||||
style={{ color: "var(--danger)" }}
|
||||
>
|
||||
<LogOut size={15} /> 退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台独立壳:全高品牌侧栏 + 细顶栏 + 可滚动主区。
|
||||
* 不依赖公开站 Header/Footer;移动端用抽屉打开同源侧栏。
|
||||
*/
|
||||
export default function AdminShell({
|
||||
user: initialUser,
|
||||
siteName,
|
||||
initialTheme = "light",
|
||||
initialUnread = 0,
|
||||
children,
|
||||
}: {
|
||||
user: User;
|
||||
siteName: string;
|
||||
initialTheme?: "light" | "dark";
|
||||
initialUnread?: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [user, setUser] = useState(initialUser);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const brand = siteName.trim() || "姜十三论坛";
|
||||
|
||||
useEffect(() => {
|
||||
setUser(initialUser);
|
||||
}, [initialUser]);
|
||||
|
||||
useEffect(() => onUserUpdate((patch) => {
|
||||
setUser((prev) => ({ ...prev, ...patch }));
|
||||
}), []);
|
||||
|
||||
useEffect(() => onForceLogout(() => {
|
||||
setDrawerOpen(false);
|
||||
apiLogout()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
});
|
||||
}), [router]);
|
||||
|
||||
// 路由切换时关闭移动抽屉
|
||||
useEffect(() => {
|
||||
setDrawerOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// 抽屉打开时锁 body 滚(壳层本身已 overflow:hidden,这里防焦点穿透)
|
||||
useEffect(() => {
|
||||
if (!drawerOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setDrawerOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [drawerOpen]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await apiLogout();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const iconBtn =
|
||||
"w-10 h-10 flex items-center justify-center rounded-full transition-colors";
|
||||
const iconBtnStyle = {
|
||||
border: "1px solid var(--line)",
|
||||
background: "var(--panel)",
|
||||
color: "var(--ink-2)",
|
||||
} as const;
|
||||
|
||||
const railInner = (
|
||||
<>
|
||||
<AdminBrand siteName={brand} />
|
||||
<AdminRailNav user={user} onNavigate={() => setDrawerOpen(false)} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-admin-viewport className="admin-shell">
|
||||
{/* 桌面侧栏:宽度断点由 CSS 控制,避免自定义 display 盖掉 Tailwind hidden */}
|
||||
<aside className="admin-rail admin-rail-desktop" aria-label="管理侧栏">
|
||||
{railInner}
|
||||
</aside>
|
||||
|
||||
{/* 移动抽屉 */}
|
||||
{drawerOpen && (
|
||||
<div className="admin-drawer lg:hidden" role="dialog" aria-modal="true" aria-label="后台导航">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-drawer-backdrop"
|
||||
aria-label="关闭导航"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
/>
|
||||
<aside className="admin-rail admin-drawer-panel">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<AdminBrand siteName={brand} />
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
style={iconBtnStyle}
|
||||
aria-label="关闭导航"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<AdminRailNav user={user} onNavigate={() => setDrawerOpen(false)} />
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-main-col">
|
||||
<header className="admin-topbar">
|
||||
<div className="admin-topbar-left">
|
||||
<button
|
||||
type="button"
|
||||
className={`${iconBtn} lg:hidden`}
|
||||
style={iconBtnStyle}
|
||||
aria-label="打开导航"
|
||||
aria-expanded={drawerOpen}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
<Link href="/" className="admin-topbar-back">
|
||||
<ArrowLeft size={15} aria-hidden />
|
||||
返回站点
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-topbar-right">
|
||||
<ThemeToggle initialTheme={initialTheme} className={iconBtn} style={iconBtnStyle} />
|
||||
<NotificationBell initialUnread={initialUnread} />
|
||||
<AdminUserMenu user={user} onLogout={handleLogout} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" tabIndex={-1} className="admin-canvas outline-none">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
238
frontend/components/admin/charts.tsx
Normal file
238
frontend/components/admin/charts.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
const TOOLTIP_STYLE = {
|
||||
background: "var(--panel)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: 12,
|
||||
fontSize: 12,
|
||||
color: "var(--ink)",
|
||||
};
|
||||
|
||||
function shortDate(d: string) {
|
||||
if (!d || d.length < 10) return d;
|
||||
return d.slice(5);
|
||||
}
|
||||
|
||||
export function AdminChartCard({
|
||||
title,
|
||||
hint,
|
||||
children,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="panel p-4 sm:p-5 flex flex-col min-h-[260px]">
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
{title}
|
||||
</h3>
|
||||
{hint ? (
|
||||
<p className="meta text-[11.5px] mt-0.5 leading-relaxed">{hint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
<div className="flex-1 min-h-[180px] w-full overflow-x-auto">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VisitTrendChart({
|
||||
data,
|
||||
}: {
|
||||
data: { date: string; pv: number; uv: number }[];
|
||||
}) {
|
||||
const rows = data.map((d) => ({ ...d, label: shortDate(d.date) }));
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<AreaChart data={rows} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="j13Pv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--accent)" stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor="var(--accent)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="j13Uv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--ok)" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="var(--ok)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} width={36} allowDecimals={false} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Area type="monotone" dataKey="pv" name="PV" stroke="var(--accent)" fill="url(#j13Pv)" strokeWidth={2} />
|
||||
<Area type="monotone" dataKey="uv" name="UV" stroke="var(--ok)" fill="url(#j13Uv)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function DualLineChart({
|
||||
data,
|
||||
aKey,
|
||||
bKey,
|
||||
aName,
|
||||
bName,
|
||||
aColor = "var(--accent)",
|
||||
bColor = "var(--gold)",
|
||||
}: {
|
||||
data: { date: string; [k: string]: string | number }[];
|
||||
aKey: string;
|
||||
bKey: string;
|
||||
aName: string;
|
||||
bName: string;
|
||||
aColor?: string;
|
||||
bColor?: string;
|
||||
}) {
|
||||
const rows = data.map((d) => ({ ...d, label: shortDate(String(d.date)) }));
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={rows} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} width={36} allowDecimals={false} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey={aKey} name={aName} stroke={aColor} strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey={bKey} name={bName} stroke={bColor} strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function SingleAreaChart({
|
||||
data,
|
||||
dataKey,
|
||||
name,
|
||||
color = "var(--accent)",
|
||||
}: {
|
||||
data: { date: string; [k: string]: string | number }[];
|
||||
dataKey: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
}) {
|
||||
const rows = data.map((d) => ({ ...d, label: shortDate(String(d.date)) }));
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={rows} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} width={36} allowDecimals={false} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||
<Area type="monotone" dataKey={dataKey} name={name} stroke={color} fill={color} fillOpacity={0.15} strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const PIE_COLORS = [
|
||||
"var(--accent)",
|
||||
"var(--ok)",
|
||||
"var(--gold)",
|
||||
"var(--clay)",
|
||||
"var(--danger)",
|
||||
"#8b5cf6",
|
||||
"#06b6d4",
|
||||
"#84cc16",
|
||||
];
|
||||
|
||||
export function NamedBarChart({
|
||||
data,
|
||||
layout = "vertical",
|
||||
}: {
|
||||
data: { label: string; count: number }[];
|
||||
layout?: "vertical" | "horizontal";
|
||||
}) {
|
||||
if (layout === "horizontal") {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={Math.max(180, data.length * 28)}>
|
||||
<BarChart data={data} layout="vertical" margin={{ top: 4, right: 12, left: 8, bottom: 4 }}>
|
||||
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} allowDecimals={false} />
|
||||
<YAxis type="category" dataKey="label" width={72} tick={{ fill: "var(--ink-2)", fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||
<Bar dataKey="count" name="数量" fill="var(--accent)" radius={[0, 6, 6, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} width={36} allowDecimals={false} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||
<Bar dataKey="count" name="数量" fill="var(--accent)" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function NamedPieChart({ data }: { data: { label: string; count: number }[] }) {
|
||||
const total = data.reduce((s, d) => s + d.count, 0) || 1;
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie data={data} dataKey="count" nameKey="label" innerRadius={48} outerRadius={72} paddingAngle={2}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
formatter={(value, name) => {
|
||||
const n = typeof value === "number" ? value : Number(value) || 0;
|
||||
return [`${n}(${Math.round((n / total) * 100)}%)`, String(name ?? "")];
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeltaBarChart({
|
||||
data,
|
||||
}: {
|
||||
data: { date: string; granted: number; spent: number }[];
|
||||
}) {
|
||||
const rows = data.map((d) => ({ ...d, label: shortDate(d.date) }));
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={rows} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} width={40} allowDecimals={false} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Bar dataKey="granted" name="发放" fill="var(--ok)" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="spent" name="消耗" fill="var(--clay)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -13,3 +13,4 @@ export { default as AdminStatCard } from "./AdminStatCard";
|
||||
export { default as AdminIconButton } from "./AdminIconButton";
|
||||
export { default as AdminStatusChip, contentStatusChip } from "./AdminStatusChip";
|
||||
export type { AdminStatusTone } from "./AdminStatusChip";
|
||||
export { default as AdminShell } from "./AdminShell";
|
||||
|
||||
Reference in New Issue
Block a user