'use client' /** * 趋势折线图(自绘 SVG,无第三方图表库依赖) * * 设计目标: * - 把后端返回的稀疏时序数据补零/降采样后画成面积折线图 * - 提供均值参考线、峰值标注、统计摘要、hover tooltip * - 视觉对齐首页 Apple 基线(rounded-apple / sky-teal 配色 / tabular-nums) * * 数据契约:与 lib/admin-material-stats.ts 返回的 dailyUploads/monthlyUploads 一致 * { bucket: '2026-06-01' | '2026-06', count: number }[] */ import { useMemo, useState } from 'react' export interface TrendPoint { bucket: string count: number } type Granularity = 'day' | 'month' type Accent = 'sky' | 'teal' interface TrendLineChartProps { data: TrendPoint[] granularity: Granularity accent?: Accent /** 筛选范围起始(YYYY-MM-DD),用于补零和降采样判断;可选 */ startDate?: string /** 筛选范围结束(YYYY-MM-DD);可选 */ endDate?: string } // 降采样阈值:超过这个点数就按周聚合,保证折线可读 const MAX_POINTS_BEFORE_DOWNSAMPLE = 90 // SVG 画布参数 const VIEW_W = 760 const VIEW_H = 240 const PAD_L = 8 const PAD_R = 8 const PAD_T = 28 // 顶部留白给峰值标签 const PAD_B = 28 // 底部留白给 x 轴标签 const ACCENT_TOKENS: Record = { sky: { stroke: '#0ea5e9', // sky-500 fillStart: '#0ea5e9', dot: '#0284c7', // sky-600 soft: 'bg-sky-50 text-sky-700 dark:bg-sky-950/40 dark:text-sky-300', }, teal: { stroke: '#14b8a6', // teal-500 fillStart: '#14b8a6', dot: '#0d9488', // teal-600 soft: 'bg-teal-50 text-teal-700 dark:bg-teal-950/40 dark:text-teal-300', }, } // ---------- 日期工具 ---------- function parseDay(value: string): Date | null { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) if (!m) return null const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) return Number.isNaN(d.getTime()) ? null : d } function parseMonth(value: string): Date | null { const m = /^(\d{4})-(\d{2})$/.exec(value) if (!m) return null const d = new Date(Number(m[1]), Number(m[2]) - 1, 1) return Number.isNaN(d.getTime()) ? null : d } function formatDayShort(d: Date): string { return `${d.getMonth() + 1}/${d.getDate()}` } function formatMonthShort(d: Date): string { return `${d.getMonth() + 1}月` } function addDay(d: Date, n: number): Date { const r = new Date(d) r.setDate(r.getDate() + n) return r } function addMonth(d: Date, n: number): Date { const r = new Date(d) r.setMonth(r.getMonth() + n) return r } function diffDays(a: Date, b: Date): number { return Math.round((b.getTime() - a.getTime()) / 86400000) } function sameDay(a: Date, b: Date): boolean { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() } function sameMonth(a: Date, b: Date): boolean { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() } // ---------- 数据预处理:补零 + 降采样 ---------- interface PreparedPoint { key: string label: string count: number /** 是否原始数据里真实存在的点(补零点为 false,用于 tooltip 区分) */ real: boolean } /** * 把后端稀疏数据补齐成连续序列。 * - day: 按 [start, end] 逐天补零;跨度超阈值时按周降采样(每周取最大值代表) * - month: 按 [start, end] 逐月补零 * * 没有显式 start/end 时,用数据自身的首尾作为范围(退化模式,至少保证画得出来)。 */ function prepareData(data: TrendPoint[], granularity: Granularity, startDate?: string, endDate?: string): PreparedPoint[] { if (!data.length) return [] if (granularity === 'month') { const all = data.map((d) => ({ d: parseMonth(d.bucket)!, count: d.count })).filter((x) => x.d) if (!all.length) return [] all.sort((a, b) => a.d.getTime() - b.d.getTime()) let startD = startDate ? parseDay(startDate) : null let endD = endDate ? parseDay(endDate) : null // month 范围按首尾月兜底 if (!startD) startD = new Date(all[0].d) if (!endD) endD = new Date(all[all.length - 1].d) const byKey = new Map() all.forEach((x) => byKey.set(`${x.d.getFullYear()}-${String(x.d.getMonth() + 1).padStart(2, '0')}`, x.count)) const out: PreparedPoint[] = [] const cursor = new Date(startD.getFullYear(), startD.getMonth(), 1) const stop = new Date(endD.getFullYear(), endD.getMonth(), 1) while (cursor.getTime() <= stop.getTime()) { const key = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}` const count = byKey.has(key) ? byKey.get(key)! : 0 out.push({ key, label: formatMonthShort(cursor), count, real: byKey.has(key) }) cursor.setMonth(cursor.getMonth() + 1) } return out } // day const all = data.map((d) => ({ d: parseDay(d.bucket)!, count: d.count })).filter((x) => x.d) if (!all.length) return [] all.sort((a, b) => a.d.getTime() - b.d.getTime()) let startD = startDate ? parseDay(startDate) : null let endD = endDate ? parseDay(endDate) : null if (!startD) startD = new Date(all[0].d) if (!endD) endD = new Date(all[all.length - 1].d) const byKey = new Map() all.forEach((x) => byKey.set(formatKeyDay(x.d), x.count)) const raw: PreparedPoint[] = [] let cursor = new Date(startD) while (cursor.getTime() <= endD.getTime()) { const key = formatKeyDay(cursor) const has = byKey.has(key) raw.push({ key, label: formatDayShort(cursor), count: has ? byKey.get(key)! : 0, real: has }) cursor = addDay(cursor, 1) } // 降采样:跨度太大(>90 天)按周聚合,每周取该周内最大值,标签用该周首日 if (raw.length > MAX_POINTS_BEFORE_DOWNSAMPLE) { const buckets: PreparedPoint[][] = [] raw.forEach((p, i) => { const weekIdx = Math.floor(i / 7) if (!buckets[weekIdx]) buckets[weekIdx] = [] buckets[weekIdx].push(p) }) return buckets.map((group) => { const max = group.reduce((m, x) => (x.count > m.count ? x : m), group[0]) return { ...max, label: group[0].label } }) } return raw } function formatKeyDay(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } // ---------- 统计摘要 ---------- interface Summary { peak: { label: string; key: string; count: number } | null avg: number total: number /** 环比:后半段均值 vs 前半段均值 */ qoq: number | null } function calcSummary(points: PreparedPoint[]): Summary { if (!points.length) return { peak: null, avg: 0, total: 0, qoq: null } let peak: Summary['peak'] = null let total = 0 points.forEach((p) => { total += p.count if (!peak || p.count > peak.count) peak = { label: p.label, key: p.key, count: p.count } }) const avg = total / points.length // 环比:前一半 vs 后一半的均值变化 const mid = Math.floor(points.length / 2) let qoq: number | null = null if (mid > 0 && points.length - mid > 0) { const firstAvg = points.slice(0, mid).reduce((s, p) => s + p.count, 0) / mid const secondAvg = points.slice(mid).reduce((s, p) => s + p.count, 0) / (points.length - mid) if (firstAvg > 0) qoq = (secondAvg - firstAvg) / firstAvg } return { peak, avg, total, qoq } } // ---------- 组件 ---------- export default function TrendLineChart({ data, granularity, accent = 'sky', startDate, endDate, }: TrendLineChartProps) { const [hoverIdx, setHoverIdx] = useState(null) const tokens = ACCENT_TOKENS[accent] const points = useMemo( () => prepareData(data, granularity, startDate, endDate), [data, granularity, startDate, endDate] ) const summary = useMemo(() => calcSummary(points), [points]) if (!data.length || !points.length) { return (
当前条件下暂无趋势数据
) } // 坐标映射 const innerW = VIEW_W - PAD_L - PAD_R const innerH = VIEW_H - PAD_T - PAD_B const maxCount = Math.max(...points.map((p) => p.count), 1) // y 轴上限留 10% 余量,避免峰值顶到天花板 const yMax = maxCount * 1.1 const xAt = (i: number) => PAD_L + (points.length === 1 ? innerW / 2 : (i / (points.length - 1)) * innerW) const yAt = (count: number) => PAD_T + innerH - (count / yMax) * innerH // 折线路径 const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xAt(i).toFixed(1)} ${yAt(p.count).toFixed(1)}`).join(' ') // 面积路径(折线 + 回到底边闭合) const areaPath = `${linePath} L ${xAt(points.length - 1).toFixed(1)} ${(PAD_T + innerH).toFixed(1)} ` + `L ${xAt(0).toFixed(1)} ${(PAD_T + innerH).toFixed(1)} Z` const avgY = yAt(summary.avg) const peakIdx = points.findIndex((p) => summary.peak && p.key === summary.peak.key && p.count === summary.peak.count) // x 轴标签:稀疏显示,最多 ~7 个 const labelStep = Math.max(1, Math.ceil(points.length / 7)) return (
{/* 统计摘要条 */}
{summary.peak && summary.peak.count > 0 && ( 峰值 {summary.peak.label} ({summary.peak.count} 条) )} 均值 {summary.avg.toFixed(1)} 条/{granularity === 'day' ? '天' : '月'} 合计 {summary.total} 条 {summary.qoq !== null && ( = 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'}> 环比 {summary.qoq >= 0 ? '▲' : '▼'} {(Math.abs(summary.qoq) * 100).toFixed(1)}% )}
{/* SVG 图表 */}
setHoverIdx(null)} > {/* 面积填充 */} {/* 均值参考线(虚线) */} {summary.avg > 0 && ( 均值 {summary.avg.toFixed(1)} )} {/* 折线 */} {/* x 轴标签 */} {points.map((p, i) => i % labelStep === 0 || i === points.length - 1 ? ( {p.label} ) : null )} {/* 峰值标注点 */} {peakIdx >= 0 && summary.peak && summary.peak.count > 0 && ( {summary.peak.count} )} {/* hover 命中点 + 透明捕获矩形 */} {points.map((p, i) => { const segW = innerW / points.length const cx = PAD_L + segW * i + segW / 2 return ( setHoverIdx(i)} /> ) })} {/* hover 高亮点 */} {hoverIdx !== null && ( )} {/* tooltip(HTML 浮层,定位到 hover 点) */} {hoverIdx !== null && (
{points[hoverIdx].key}
{points[hoverIdx].count} 条
)}
) }