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 捕获
412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
'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>
|
||
)
|
||
}
|