gongxiangp2/components/admin/TrendLineChart.tsx
chenpei c8556a281b feat: 数据统计趋势可视化升级 + 修复部署脚本 stderr bug
1. 趋势可视化升级(方案 A):
   - 新增 TrendLineChart 组件(自绘 SVG 折线图,无第三方依赖)
   - 每日/月度趋势从纯表格升级为面积折线图,默认图表视图
   - 数据补零 + 超过 90 天自动按周降采样
   - 统计摘要:峰值/均值/合计/环比,均值参考线,峰值标注
   - hover tooltip + 视图切换(图表/表格),表格视图完整保留
   - 修复 P1-2:每日趋势标题写死'近31天'改为动态显示真实天数
   - 表格视图去掉 .slice(-31) 截断,显示完整范围

2. 部署脚本 git-incremental-deploy.ps1 stderr bug 修复:
   - PowerShell 在 ErrorActionPreference=Stop 下把 git/plink 的 stderr
     提示当致命错误,导致部署在 Git Sync 阶段误中断
   - 在原生程序调用局部作用域改为 Continue,真实错误仍由 LASTEXITCODE 捕获
2026-06-27 14:21:45 +08:00

412 lines
14 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'
/**
* 趋势折线图(自绘 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<Accent, { stroke: string; fillStart: string; dot: string; soft: string }> = {
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<string, number>()
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<string, number>()
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<number | null>(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 (
<div className="py-16 text-center text-sm text-gray-400 dark:text-gray-500">
当前条件下暂无趋势数据
</div>
)
}
// 坐标映射
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 (
<div className="w-full">
{/* 统计摘要条 */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 px-1 pb-3 text-xs text-gray-500 dark:text-gray-400">
{summary.peak && summary.peak.count > 0 && (
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: tokens.dot }} />
峰值 <span className="font-semibold text-gray-900 dark:text-white tabular-nums">{summary.peak.label}</span>
<span className="tabular-nums">({summary.peak.count} 条)</span>
</span>
)}
<span>
均值 <span className="font-semibold text-gray-900 dark:text-white tabular-nums">{summary.avg.toFixed(1)}</span> 条/{granularity === 'day' ? '天' : '月'}
</span>
<span>
合计 <span className="font-semibold text-gray-900 dark:text-white tabular-nums">{summary.total}</span> 条
</span>
{summary.qoq !== null && (
<span className={summary.qoq >= 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)}%
</span>
)}
</div>
{/* SVG 图表 */}
<div className="relative">
<svg
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className="w-full h-auto"
preserveAspectRatio="none"
onMouseLeave={() => setHoverIdx(null)}
>
<defs>
<linearGradient id={`trend-fill-${accent}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={tokens.fillStart} stopOpacity="0.22" />
<stop offset="100%" stopColor={tokens.fillStart} stopOpacity="0" />
</linearGradient>
</defs>
{/* 面积填充 */}
<path d={areaPath} fill={`url(#trend-fill-${accent})`} />
{/* 均值参考线(虚线) */}
{summary.avg > 0 && (
<g>
<line
x1={PAD_L}
y1={avgY}
x2={VIEW_W - PAD_R}
y2={avgY}
stroke="#9ca3af"
strokeWidth="1"
strokeDasharray="4 4"
opacity="0.6"
/>
<text x={VIEW_W - PAD_R} y={avgY - 4} textAnchor="end" className="fill-gray-400" style={{ fontSize: 10 }}>
均值 {summary.avg.toFixed(1)}
</text>
</g>
)}
{/* 折线 */}
<path d={linePath} fill="none" stroke={tokens.stroke} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
{/* x 轴标签 */}
{points.map((p, i) =>
i % labelStep === 0 || i === points.length - 1 ? (
<text key={`lbl-${p.key}-${i}`} x={xAt(i)} y={VIEW_H - 8} textAnchor="middle" className="fill-gray-400" style={{ fontSize: 10 }}>
{p.label}
</text>
) : null
)}
{/* 峰值标注点 */}
{peakIdx >= 0 && summary.peak && summary.peak.count > 0 && (
<g>
<circle cx={xAt(peakIdx)} cy={yAt(summary.peak.count)} r="4" fill={tokens.dot} stroke="#fff" strokeWidth="1.5" />
<text x={xAt(peakIdx)} y={yAt(summary.peak.count) - 8} textAnchor="middle" className="fill-gray-700 dark:fill-gray-200" style={{ fontSize: 11, fontWeight: 600 }}>
{summary.peak.count}
</text>
</g>
)}
{/* hover 命中点 + 透明捕获矩形 */}
{points.map((p, i) => {
const segW = innerW / points.length
const cx = PAD_L + segW * i + segW / 2
return (
<rect
key={`hit-${p.key}-${i}`}
x={cx - segW / 2}
y={PAD_T}
width={segW}
height={innerH}
fill="transparent"
style={{ cursor: 'pointer' }}
onMouseEnter={() => setHoverIdx(i)}
/>
)
})}
{/* hover 高亮点 */}
{hoverIdx !== null && (
<g>
<line
x1={xAt(hoverIdx)}
y1={PAD_T}
x2={xAt(hoverIdx)}
y2={PAD_T + innerH}
stroke={tokens.stroke}
strokeWidth="1"
opacity="0.4"
/>
<circle cx={xAt(hoverIdx)} cy={yAt(points[hoverIdx].count)} r="4" fill={tokens.dot} stroke="#fff" strokeWidth="1.5" />
</g>
)}
</svg>
{/* tooltip(HTML 浮层,定位到 hover 点) */}
{hoverIdx !== null && (
<div
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full rounded-apple bg-white px-2.5 py-1.5 shadow-apple-lg border border-gray-100 dark:bg-gray-800 dark:border-gray-700"
style={{
left: `${(xAt(hoverIdx) / VIEW_W) * 100}%`,
top: `${(yAt(points[hoverIdx].count) / VIEW_H) * 100}%`,
marginTop: -8,
}}
>
<div className="text-[11px] text-gray-500 dark:text-gray-400 whitespace-nowrap">{points[hoverIdx].key}</div>
<div className="text-sm font-semibold text-gray-900 dark:text-white tabular-nums whitespace-nowrap">
{points[hoverIdx].count} 条
</div>
</div>
)}
</div>
</div>
)
}