新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
263 lines
9.7 KiB
TypeScript
263 lines
9.7 KiB
TypeScript
"use client";
|
||
|
||
import type { ComponentProps } from "react";
|
||
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)",
|
||
};
|
||
|
||
const LEGEND_STYLE = { fontSize: 12, overflow: "hidden" as const };
|
||
const LEGEND_STYLE_SM = { fontSize: 11, overflow: "hidden" as const };
|
||
|
||
/** 统一 Tooltip:关闭默认 cursor 灰框,避免选中/悬停黑边 */
|
||
function ChartTooltip(props: Omit<ComponentProps<typeof Tooltip>, "contentStyle" | "cursor">) {
|
||
return <Tooltip {...props} cursor={false} contentStyle={TOOLTIP_STYLE} />;
|
||
}
|
||
|
||
function shortDate(d: string) {
|
||
if (!d || d.length < 10) return d;
|
||
return d.slice(5);
|
||
}
|
||
|
||
export function AdminChartCard({
|
||
title,
|
||
hint,
|
||
children,
|
||
actions,
|
||
className = "",
|
||
}: {
|
||
title: string;
|
||
hint?: string;
|
||
children: React.ReactNode;
|
||
actions?: React.ReactNode;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<div className={`panel p-4 sm:p-5 flex flex-col ${className}`.trim()}>
|
||
<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>
|
||
{/* 捕获阶段拦截 SVG 内 mousedown:冒泡阶段太晚,浏览器已开始选区黑框;非 SVG(输入框/表格)不拦截,否则无法聚焦输入 */}
|
||
{/* overflow-hidden 会沿自身 padding box 裁剪:px/p- 相同值负 margin 外扩裁剪边界 3px(内容宽度不变),
|
||
避免贴边子元素(如明细工具条首个搜索框)的 2px 聚焦光环被裁掉 */}
|
||
<div
|
||
className="overflow-hidden px-[3px] -mx-[3px] [&_svg]:select-none [&_svg]:outline-none [&_.recharts-surface]:outline-none [&_.recharts-tooltip-cursor]:hidden"
|
||
onMouseDownCapture={(e) => {
|
||
if ((e.target as Element).closest("svg")) e.preventDefault();
|
||
}}
|
||
>
|
||
{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: 12, right: 12, left: 0, bottom: 0 }} accessibilityLayer={false}>
|
||
<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} />
|
||
<ChartTooltip />
|
||
<Legend wrapperStyle={LEGEND_STYLE} />
|
||
<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: 12, right: 12, left: 0, bottom: 0 }} accessibilityLayer={false}>
|
||
<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} />
|
||
<ChartTooltip />
|
||
<Legend wrapperStyle={LEGEND_STYLE} />
|
||
<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)",
|
||
height = 200,
|
||
}: {
|
||
data: { date: string; [k: string]: string | number }[];
|
||
dataKey: string;
|
||
name: string;
|
||
color?: string;
|
||
height?: number;
|
||
}) {
|
||
const rows = data.map((d) => ({ ...d, label: shortDate(String(d.date)) }));
|
||
return (
|
||
<ResponsiveContainer width="100%" height={height}>
|
||
<AreaChart data={rows} margin={{ top: 12, right: 12, left: 0, bottom: 0 }} accessibilityLayer={false}>
|
||
<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} />
|
||
<ChartTooltip />
|
||
<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") {
|
||
// 固定像素高度,避免 overflow 滚动条 ↔ ResponsiveContainer 互相触发闪烁
|
||
const chartH = Math.max(180, Math.min(320, data.length * 28));
|
||
return (
|
||
<ResponsiveContainer width="100%" height={chartH}>
|
||
<BarChart data={data} layout="vertical" margin={{ top: 8, right: 12, left: 8, bottom: 4 }} accessibilityLayer={false}>
|
||
<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} />
|
||
<ChartTooltip />
|
||
<Bar dataKey="count" name="数量" fill="var(--accent)" radius={[0, 6, 6, 0]} activeBar={false} />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
);
|
||
}
|
||
return (
|
||
<ResponsiveContainer width="100%" height={200}>
|
||
<BarChart data={data} margin={{ top: 12, right: 12, left: 0, bottom: 0 }} accessibilityLayer={false}>
|
||
<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} />
|
||
<ChartTooltip />
|
||
<Bar dataKey="count" name="数量" fill="var(--accent)" radius={[6, 6, 0, 0]} activeBar={false} />
|
||
</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 accessibilityLayer={false}>
|
||
<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>
|
||
<ChartTooltip
|
||
formatter={(value, name) => {
|
||
const n = typeof value === "number" ? value : Number(value) || 0;
|
||
return [`${n}(${Math.round((n / total) * 100)}%)`, String(name ?? "")];
|
||
}}
|
||
/>
|
||
<Legend wrapperStyle={LEGEND_STYLE_SM} />
|
||
</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: 12, right: 12, left: 0, bottom: 0 }} accessibilityLayer={false}>
|
||
<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} />
|
||
<ChartTooltip />
|
||
<Legend wrapperStyle={LEGEND_STYLE} />
|
||
<Bar dataKey="granted" name="发放" fill="var(--ok)" radius={[4, 4, 0, 0]} activeBar={false} />
|
||
<Bar dataKey="spent" name="消耗" fill="var(--clay)" radius={[4, 4, 0, 0]} activeBar={false} />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
);
|
||
}
|