import React from 'react' import { isDark } from '../../../../utils/theme' import { DefaultLayout } from '../../../components/DefaultLayout' import type { PosterProps } from '../../../types/ctx' import { BoxplotGuide } from '../../components/BoxplotGuide' import { ChartCard } from '../../components/ChartCard' import { PlatformTable } from '../../components/PlatformTable' import { collectRankPlatforms, RankLegend, RankList } from '../../components/RankList' import { KpiCard } from '../../components/KpiCard' import { SSRChart } from '../../components/SSRChart' import { axisLabelStyle, axisLineStyle, CHART_FONT_FAMILY, getChartPalette, METRIC_META, PLATFORM_META, platformColor, PLATFORM_ORDER, splitLineStyle, withAlpha, WORK_TYPE_META } from '../../components/chartTheme' import { buildBucketOption, buildPlatformDonutOption, buildPlatformLegend, buildBoxplotOption, buildDurationCurveOption, buildHeatmapOption, buildSilenceBucketsOption, buildTrendLineOption, buildWeekdayOption, buildWorkTypeLegend } from '../../components/chartPresets' import { formatCompact, formatDateShort, formatPercent, formatWithCommas } from '../../components/format' import type { StatisticsPlatform } from '../../components/types' import type { GlobalStatisticsData, GroupRankItem, PlatformTrend, PlatformWorkTypeCount, SizeBucket, TrendPoint } from './types' /** * 全局解析统计海报。 * * 与群海报同一套密排卡片版式:每张图独占一张卡、卡内自带紧凑标题行, * 两列网格铺满 1440 宽的画面,尽量压低总高度以适配手机竖屏浏览。 * 图表由 ECharts 在 render 期同步出 SVG 字符串再注入,不用 hooks、不碰 DOM。 */ /** 海报内容区宽度:1440 - 左右各 72(p-18) */ const CONTENT_WIDTH = 1296 /** 两列网格:gap-14(56px) 下每张卡 620 */ const GRID_GAP = 56 const HALF_CARD_WIDTH = (CONTENT_WIDTH - GRID_GAP) / 2 /** 卡片内边距 p-6 = 24px,算画布宽度时必须减掉,否则 SVG 会溢出卡片边框 */ const CARD_PADDING = 24 const FULL_CHART_WIDTH = CONTENT_WIDTH - CARD_PADDING * 2 const HALF_CHART_WIDTH = HALF_CARD_WIDTH - CARD_PADDING * 2 const HALF_CHART_HEIGHT = 340 /** * 热力图的画布高度。 * 比同排的箱线图卡片高一截是刻意为之:箱线图那边多了个 `BoxplotGuide`(约 190px), * 两列网格会把同排卡片拉成等高,左卡不跟着长高的话底部就会空出一大块。 * 加上说明文字补到三行之后,两边内容高度基本对齐。 */ const HEATMAP_CHART_HEIGHT = 520 /** 平台分布里环形图的画布尺寸,剩下宽度留给右侧明细列表 */ const DONUT_SIZE = { width: 420, height: 300 } /** 平台趋势:分平台堆叠面积图 */ const buildPlatformTrendOption = (trend: PlatformTrend, dark: boolean) => { const palette = getChartPalette(dark) return { grid: { top: 44, right: 24, bottom: 56, left: 76 }, xAxis: { type: 'category', boundaryGap: false, data: trend.dates.map((date) => formatDateShort(date)), axisLine: axisLineStyle(palette), axisTick: { show: false }, axisLabel: axisLabelStyle(palette, 20, 45), splitLine: { show: false } }, yAxis: { type: 'value', name: '解析次数', nameTextStyle: { fontSize: 20, color: palette.textMuted, fontFamily: CHART_FONT_FAMILY, align: 'right' }, axisLine: { show: false }, axisTick: { show: false }, axisLabel: axisLabelStyle(palette, 20), splitLine: splitLineStyle(palette) }, series: trend.series.map((item) => { const color = platformColor(item.platform, dark) return { type: 'line', name: PLATFORM_META[item.platform].name, stack: 'total', smooth: 0.3, symbol: 'none', lineStyle: { width: 2, color }, itemStyle: { color }, areaStyle: { color: withAlpha(color, 0.5) }, data: item.values } }) } } /** 平台 × 内容形态:按平台归一的百分比堆叠条,比绝对堆叠更能看出各平台的口味差异 */ const buildPlatformWorkTypeOption = (rows: PlatformWorkTypeCount[], dark: boolean) => { const palette = getChartPalette(dark) // 平台顺序走全局常量,不跟着数据库返回顺序走,否则两次渲染的堆叠顺序可能不一样 const platforms = PLATFORM_ORDER.filter((platform) => rows.some((row) => row.platform === platform)) const workTypes = [...new Set(rows.map((row) => row.workType))] const countOf = (platform: StatisticsPlatform, workType: PlatformWorkTypeCount['workType']) => rows.find((row) => row.platform === platform && row.workType === workType)?.count ?? 0 const totalOf = (platform: StatisticsPlatform) => rows.filter((row) => row.platform === platform).reduce((sum, row) => sum + row.count, 0) return { grid: { top: 16, right: 24, bottom: 44, left: 150 }, xAxis: { type: 'value', max: 100, axisLine: { show: false }, axisTick: { show: false }, axisLabel: { ...axisLabelStyle(palette, 20), formatter: '{value}%' }, splitLine: splitLineStyle(palette) }, yAxis: { type: 'category', inverse: true, data: platforms.map((platform) => `${PLATFORM_META[platform].name} ${formatCompact(totalOf(platform))}`), axisLine: { show: false }, axisTick: { show: false }, axisLabel: { fontSize: 22, color: palette.text, fontFamily: CHART_FONT_FAMILY } }, series: workTypes.map((workType) => ({ type: 'bar', name: WORK_TYPE_META[workType].label, stack: 'share', barWidth: 52, itemStyle: { color: WORK_TYPE_META[workType].color }, label: { show: true, position: 'inside', fontSize: 20, color: palette.textOnColor, fontFamily: CHART_FONT_FAMILY, // 占比太小的段落放不下字,硬塞会糊成一团,交给下方图例认色 formatter: (params: { value: number }) => (params.value >= 14 ? `${Math.round(params.value)}%` : '') }, data: platforms.map((platform) => { const total = totalOf(platform) return total > 0 ? Number(((countOf(platform, workType) / total) * 100).toFixed(2)) : 0 }) })) } } /** 群组增长:累计群数的阶梯折线 */ const buildGroupGrowthOption = (growth: TrendPoint[], dark: boolean) => { const palette = getChartPalette(dark) return { grid: { top: 44, right: 24, bottom: 56, left: 76 }, xAxis: { type: 'category', boundaryGap: false, data: growth.map((point) => formatDateShort(point.date)), axisLine: axisLineStyle(palette), axisTick: { show: false }, axisLabel: axisLabelStyle(palette, 20, 45), splitLine: { show: false } }, yAxis: { type: 'value', name: '累计群数', nameTextStyle: { fontSize: 20, color: palette.textMuted, fontFamily: CHART_FONT_FAMILY, align: 'right' }, axisLine: { show: false }, axisTick: { show: false }, axisLabel: axisLabelStyle(palette, 20), minInterval: 1, splitLine: splitLineStyle(palette) }, series: [ { type: 'line', // 群数是跳变的,阶梯线比斜线诚实 step: 'end', symbol: 'none', lineStyle: { width: 4, color: palette.accent }, itemStyle: { color: palette.accent }, areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [ { offset: 0, color: withAlpha(palette.accent, 0.35) }, { offset: 1, color: withAlpha(palette.accent, 0.02) } ] } }, data: growth.map((point) => point.count) } ] } } /** * 全局解析统计组件 Global Statistics */ export const GlobalStatistics: React.FC> = (props) => { const dark = isDark(props.ctx) const data = props.data const palette = getChartPalette(dark) // 热力图的两个派生量:总人数(说明里报占比用)和最密集的那一格 const heatTotal = data.userActivity.cells.reduce((sum, [, , count]) => sum + count, 0) // 沉默分布里「最近一周还在用」的群占比,写进卡片说明 const silenceTotal = data.silenceBuckets.reduce((sum, bucket) => sum + bucket.count, 0) const silenceActive = data.silenceBuckets.slice(0, 2).reduce((sum, bucket) => sum + bucket.count, 0) const silenceActivePercent = silenceTotal > 0 ? Math.round((silenceActive / silenceTotal) * 100) : 0 const heatPeak = data.userActivity.cells.reduce( (peak, [x, y, count]) => (count > peak.count ? { x, y, count } : peak), { x: 0, y: 0, count: 0 } ) const platformTotal = data.platformData.reduce((sum, item) => sum + item.count, 0) const parsesPerUser = data.totalUsers > 0 ? data.totalParses / data.totalUsers : 0 const parsesPerGroup = data.totalGroups > 0 ? data.totalParses / data.totalGroups : 0 const hasTrend = data.trend.some((point) => point.count > 0) const hasPlatformTrend = data.platformTrend.dates.length > 0 const hasWorkTypes = data.workTypes.length > 0 const weekdayTotal = data.weekday.reduce((sum, count) => sum + count, 0) const trendIsSplit = Boolean(data.trendCompleteFrom) && data.trend.some((point) => point.date < data.trendCompleteFrom!) const platformLegend = buildPlatformLegend(data.platformData, dark) const maxPlatformCount = Math.max(...data.platformData.map((item) => item.count), 1) const groupRows = data.topGroups.map((group: GroupRankItem) => ({ name: group.name, avatar: group.avatar, segments: group.segments, label: `${formatWithCommas(group.totalParses)} 次 · ${formatWithCommas(group.uniqueUsers)} 人` })) return ( {/* 弥散光背景层 */}
{/* 杂色纹理层 */}
{/* 背景大字装饰 */}
GLOBAL
{/* 主要内容区域 */}
{/* 头部区域 */}
GLOBAL_ANALYTICS

全局解析统计

{data.statsSince ? `统计自 ${data.statsSince} · ` : ''}数据截止 {data.generatedAt}
{/* 指标条:6 张挤一行 */}
{/* 平台分布:环 + 明细条 */} {platformTotal > 0 && (
{ const detail = data.platformData.find((item) => item.platform === row.key) return { key: row.key, logo: row.logo, name: row.name, color: row.color, // 条长按「相对最大项」而不是相对总量:最大的那条顶满,量级差距一眼可见 barPercent: (row.count / maxPlatformCount) * 100, cells: [ formatWithCommas(row.count), `${row.percent}%`, detail ? formatWithCommas(detail.groups) : '—', detail ? formatWithCommas(detail.users) : '—' ] } })} />
)} {/* 全局趋势 */} {hasTrend ? ( <> {trendIsSplit ? (
近 30 天 · 虚线为全局日均 {data.dailyAverage.toFixed(1)} 次 ·{' '} {data.trendCompleteFrom} 之前的灰色虚线为旧版回填数据,口径失真仅作参考
) : (
近 30 天 · 虚线为全局日均 {data.dailyAverage.toFixed(1)} 次
)} ) : (
暂无趋势数据
该统计从本功能上线后开始按天记录,先积累几天再来看 ~
)}
{/* 平台趋势 / 平台 × 形态 */}
{hasPlatformTrend && (
{data.platformTrend.series.map((item) => (
{PLATFORM_META[item.platform].name}
))}
)} {hasWorkTypes && (
{buildWorkTypeLegend(data.workTypes).map((row) => (
{row.label}
))}
)}
{/* 解析耗时分布:单轴累计曲线 */} {data.metrics.map((item) => { // 拿「≤5s」那一档的累计占比当读法示例。档位会被裁剪,只能按标签找,不能按下标 const metricTotal = item.buckets.reduce((sum, bucket) => sum + bucket.count, 0) const fiveSecondIndex = item.buckets.findIndex((bucket) => bucket.upper === '≤5s') const fiveSecondShare = fiveSecondIndex >= 0 && metricTotal > 0 ? Math.round( (item.buckets.slice(0, fiveSecondIndex + 1).reduce((sum, bucket) => sum + bucket.count, 0) / metricTotal) * 100 ) : undefined return ( 横轴 = 耗时上限 · 纵轴 = 有多少比例的解析在这个时间内跑完 · 两条虚线是 50% / 90% 的位置
读法: {fiveSecondShare !== undefined ? ( <> 「≤5s」那个点是 {fiveSecondShare}%,意思就是 {fiveSecondShare}% 的解析 5 秒内跑完 ) : ( <>任取一点,横轴是耗时上限、纵轴就是跑完的比例 )} ;曲线越陡说明解析越集中在这一段 } >
) })} {/* 群组排行 */} {groupRows.length > 0 && ( )} {/* 群规模分布 / 群组增长 */}
{data.sizeBuckets.some((bucket: SizeBucket) => bucket.count > 0) && ( )} {data.groupGrowth.length > 0 && ( )}
{/* 用户活跃度矩阵 / 群组活跃度箱线 */}
{data.userActivity.cells.length > 0 && ( 横轴 → 用过几个群 · 纵轴 ↑ 总解析次数 · 格子数字 = 落在这档的用户数
共 {formatWithCommas(heatTotal)} 位用户 · 最密集的一格是「{data.userActivity.xLabels[heatPeak.x]} × {data.userActivity.yLabels[heatPeak.y]}」, 占 {formatPercent(heatPeak.count, heatTotal)}%
颜色越深人越多;左上角大片深色 = 大多数人只在少数群里轻度使用 } >
)} {data.groupActivity.length > 0 && ( 怎么算的:把每个群按群内使用人数分档, 档内所有群的总解析次数合起来画成一个箱体
横轴 = 使用人数档 · 纵轴 = 群总解析次数 · 箱体上方的数字是中位数 } >
)}
{/* 周内分布 / 群沉默分布 */}
{weekdayTotal > 0 && ( 横轴 = 周一到周日 · 纵轴 = 该星期几的日均解析次数 · 虚线 = 七日均值
柱子比虚线高 = 这天比平时更活跃;取日均而不是七天总和,是因为窗口不是整周 } >
)} {data.silenceBuckets.some((bucket) => bucket.count > 0) && ( 怎么算的:每个群按最后一次解析距今几天归到一档,柱子高度 = 落在这档的群数
绿 = 最近一周还在用 · 黄 = 一周到一月 · 灰 = 超过一月没动静 · 柱顶第二行是该档占全部群的比例
当前有 {silenceActivePercent}% 的群最近一周用过 } >
)}
{/* 底部信息 */}
TOTAL SERVICE
累计服务 {formatWithCommas(data.totalGroups)} 个群组 · 解析{' '} {formatWithCommas(data.totalParses)} 次
) } export default GlobalStatistics