import { Activity, Share2 } from "lucide-react"
import { formatStat, toneColor } from "@/lib/data-display"
import {
StatBadge,
StatCard,
StatHeader,
StatIcon,
StatLabel,
StatMeta,
StatProgress,
StatSegments,
StatTrend,
StatValue,
} from "@/components/ui/stat-card/stat-card"
const SOURCES = [
{ label: "搜索", value: 148, tone: "blue" },
{ label: "直接", value: 93, tone: "violet" },
{ label: "社交", value: 64, tone: "amber" },
{ label: "外链", value: 37, tone: "cyan" },
] as const
const SOURCE_TOTAL = SOURCES.reduce((sum, item) => sum + item.value, 0)
// 三张独立的统计卡片:大数字与涨跌、配额进度、通栏的占比分布——都只用数字与条形,图表交给 mini-chart / chart-card
export default function StatCardDemo() {
return (
<div className="grid w-full max-w-xl gap-3 sm:grid-cols-2">
<StatCard>
<StatHeader>
<StatLabel>API 调用量</StatLabel>
<StatIcon tone="blue">
<Activity />
</StatIcon>
</StatHeader>
<StatValue value={12840000} unit="次" size="lg" />
<StatTrend value={12.4}>较上周</StatTrend>
</StatCard>
<StatCard>
<StatHeader className="items-center">
<StatLabel>本月构建配额</StatLabel>
<StatBadge status="watch">偏高</StatBadge>
</StatHeader>
<StatValue value="4320" unit="/ 6000 分钟" />
<StatProgress value={72} tone="usage" label="构建配额已用 72%" />
<StatMeta>
<span>已用 72%</span>
<span>剩余 12 天</span>
</StatMeta>
</StatCard>
<StatCard className="sm:col-span-2">
<StatHeader>
<StatLabel>流量来源</StatLabel>
<StatIcon tone="violet">
<Share2 />
</StatIcon>
</StatHeader>
<StatValue value={SOURCE_TOTAL} unit="次访问" />
<StatSegments segments={[...SOURCES]} className="h-2" />
<ul className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs sm:grid-cols-4">
{SOURCES.map((item) => (
<li key={item.label} className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: toneColor(item.tone) }}
/>
<span className="text-muted-foreground">{item.label}</span>
<span className="ml-auto font-medium tabular-nums">
{formatStat((item.value / SOURCE_TOTAL) * 100, {
style: "percent",
digits: 0,
})}
</span>
</li>
))}
</ul>
</StatCard>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/stat-card.json用法
统计卡片不是一个大组件,而是一组小原语:StatCard 提供外壳,其余按需要拼进去。同一份数据在不同卡片里长什么样,由你选哪几块决定。
import {
StatCard,
StatHeader,
StatLabel,
StatTrend,
StatValue,
} from "@/components/ui/stat-card/stat-card"
export function Stats() {
return (
<StatCard>
<StatHeader>
<StatLabel>API 调用量</StatLabel>
</StatHeader>
<StatValue value={12840000} unit="次" />
<StatTrend value={12.4}>较上周</StatTrend>
</StatCard>
)
}StatValue 收到数字时用 formatStat 格式化:默认按 zh-CN 压缩,12840000 显示为 1284万,单位 unit 跟在数字后面,字号更小、颜色更淡。数字一律 tabular-nums,涨跌时宽度不抖。
| 原语 | 用途 |
|---|---|
StatCard / StatGroup | 卡片外壳 / 只设置涨跌配色约定的容器 |
StatHeader / StatLabel / StatIcon | 标题行、指标名、图标方块 |
StatValue / StatTrend / StatMeta | 大号数字、涨跌幅、两端对齐的说明行 |
StatStatus / StatBadge | 状态行(图标 + 两行文字)、状态徽标 |
StatProgress / StatMeter | 进度条、分格计量条 |
StatSegments / StatDistribution | 横向分段条、分段条加图例格 |
StatZones | 分区表(快 / 正常 / 慢 / 严重) |
示例
概览面板
四块统计共用细线拼成一块面板:KPI 与迷你走势、配额进度、分格健康度、流量来源分布。
import { Activity, HeartPulse, Share2 } from "lucide-react"
import { formatStat, toneColor } from "@/lib/data-display"
import { MiniChart } from "@/components/ui/mini-chart/mini-chart"
import {
StatBadge,
StatCard,
StatHeader,
StatIcon,
StatLabel,
StatMeta,
StatMeter,
StatProgress,
StatSegments,
StatTrend,
StatValue,
} from "@/components/ui/stat-card/stat-card"
const CALLS = [38, 44, 41, 52, 49, 63, 58, 71, 66, 84, 79, 96].map(
(value, index) => ({ label: `${index + 1} 时`, value }),
)
const SOURCES = [
{ label: "搜索引擎", value: 148, tone: "blue" },
{ label: "直接访问", value: 93, tone: "violet" },
{ label: "社交媒体", value: 64, tone: "amber" },
{ label: "外链", value: 37, tone: "cyan" },
] as const
const SOURCE_TOTAL = SOURCES.reduce((sum, item) => sum + item.value, 0)
// 同一组原语拼成一块概览面板:卡片之间共用细线,不再是四张孤立的小卡;窄屏只留前两块
const CELL = "flex flex-col gap-3 rounded-none border-0 bg-transparent p-4"
export default function StatCardOverviewDemo() {
return (
<div className="w-full max-w-3xl overflow-hidden rounded-lg border border-edge bg-card">
<div className="flex items-center justify-between gap-3 border-b border-edge px-4 py-3">
<span className="text-sm font-medium">站点概览</span>
<span className="font-mono text-xs text-muted-foreground">
近 7 天 · 实时
</span>
</div>
<div className="grid sm:grid-cols-2">
<StatCard className={`${CELL} border-b border-edge sm:border-r`}>
<StatHeader>
<StatLabel>API 调用量</StatLabel>
<StatIcon tone="blue">
<Activity />
</StatIcon>
</StatHeader>
<div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-3">
<div className="flex flex-col gap-2">
<StatValue
value={12840000}
unit="次"
size="lg"
className="whitespace-nowrap"
/>
<StatTrend value={12.4}>较上周</StatTrend>
</div>
<MiniChart
type="area"
tone="blue"
data={CALLS}
seriesLabel="调用量"
className="h-12 w-28 shrink-0"
label="今日每小时 API 调用量"
/>
</div>
</StatCard>
<StatCard className={`${CELL} border-b border-edge max-sm:border-b-0`}>
<StatHeader className="items-center">
<StatLabel>本月构建配额</StatLabel>
<StatBadge status="watch">偏高</StatBadge>
</StatHeader>
<StatValue value="4320" unit="/ 6000 分钟" />
<div className="flex flex-col gap-2">
<StatProgress
value={72}
tone="usage"
size="lg"
label="构建配额已用 72%"
/>
<StatMeta>
<span>已用 72%</span>
<span>剩余 12 天</span>
</StatMeta>
</div>
</StatCard>
<StatCard
className={`${CELL} border-b border-edge max-sm:hidden sm:border-r sm:border-b-0`}
>
<StatHeader className="items-center">
<StatLabel>服务健康度</StatLabel>
<StatIcon tone="green">
<HeartPulse />
</StatIcon>
</StatHeader>
<StatValue value="99.98" unit="%" />
<StatMeter
value={92}
segments={16}
className="h-6 w-full"
label="服务健康度 92%"
/>
<StatMeta>
<span>可用 4/5 个区域</span>
<span>P95 128 ms</span>
</StatMeta>
</StatCard>
<StatCard className={`${CELL} max-sm:hidden`}>
<StatHeader>
<StatLabel>流量来源</StatLabel>
<StatIcon tone="violet">
<Share2 />
</StatIcon>
</StatHeader>
<StatValue value={SOURCE_TOTAL} unit="次访问" />
<StatSegments segments={[...SOURCES]} className="h-2.5" />
<ul className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
{SOURCES.map((item) => (
<li key={item.label} className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: toneColor(item.tone) }}
/>
<span className="truncate text-muted-foreground">
{item.label}
</span>
<span className="ml-auto font-medium tabular-nums">
{formatStat((item.value / SOURCE_TOTAL) * 100, {
style: "percent",
digits: 0,
})}
</span>
</li>
))}
</ul>
</StatCard>
</div>
</div>
)
}
目标与状态
StatStatus 是卡片底部的一行:状态图标方块、说明文字和状态名。状态有四种——ok 达标、watch 关注、risk 风险、review 审核中,各自带固定的图标和色调;加 render={<a href="…" />} 与 showChevron 就成了一个入口。
import {
StatCard,
StatHeader,
StatLabel,
StatStatus,
StatValue,
} from "@/components/ui/stat-card/stat-card"
const GOALS = [
{
label: "本月 API 调用量",
value: 12840000,
done: 5,
total: 5,
status: "ok",
},
{ label: "构建成功率", value: "98.6%", done: 3, total: 5, status: "watch" },
{
label: "首屏 LCP 达标率",
value: "71.2%",
done: 1,
total: 5,
status: "risk",
},
{ label: "新站点内容审核", value: 24, done: 2, total: 4, status: "review" },
] as const
export default function StatCardGoalDemo() {
return (
<div className="grid w-full max-w-3xl gap-3 sm:grid-cols-2">
{GOALS.map((goal) => (
<StatCard key={goal.label}>
<StatHeader>
<StatLabel>{goal.label}</StatLabel>
</StatHeader>
<StatValue value={goal.value} size="lg" />
<StatStatus status={goal.status} showChevron render={<a href="#" />}>
{goal.done}/{goal.total} 个目标
</StatStatus>
</StatCard>
))}
</div>
)
}
配额与进度
StatProgress 的 tone="usage" 按用量自动变色:65% 起关注、90% 起风险。usageStatus 和 healthStatus 把百分比换成状态名,可以拿去驱动 StatBadge。
import { Database, Hammer, Globe } from "lucide-react"
import {
StatBadge,
StatCard,
StatHeader,
StatIcon,
StatLabel,
StatMeta,
StatProgress,
StatValue,
usageStatus,
} from "@/components/ui/stat-card/stat-card"
const QUOTAS = [
{ icon: Globe, title: "CDN 流量", used: 3.8, max: 10, unit: "TB" },
{ icon: Hammer, title: "构建时长", used: 5120, max: 6000, unit: "分钟" },
{ icon: Database, title: "对象存储", used: 41, max: 100, unit: "GB" },
] as const
const BADGE = {
ok: "健康",
watch: "偏高",
risk: "紧张",
review: "审核中",
} as const
export default function StatCardQuotaDemo() {
return (
<div className="grid w-full max-w-3xl gap-3 sm:grid-cols-3">
{QUOTAS.map(({ icon: Icon, title, used, max, unit }) => {
const percent = (used / max) * 100
const status = usageStatus(percent)
return (
<StatCard key={title}>
<StatHeader className="items-center">
<span className="flex items-center gap-2">
<StatIcon>
<Icon />
</StatIcon>
<span className="text-sm font-medium">{title}</span>
</span>
<StatBadge status={status}>{BADGE[status]}</StatBadge>
</StatHeader>
<div className="flex flex-col gap-1">
<StatLabel>剩余</StatLabel>
<StatValue value={max - used} unit={unit} />
</div>
<div className="flex flex-col gap-1.5">
{/* 进度条表示已用,颜色按用量自动变:65% 起关注,90% 起风险 */}
<StatProgress
value={used}
max={max}
tone="usage"
label={`${title}已用`}
/>
<StatMeta>
<span>已用 {Math.round(percent)}%</span>
<span>
{used} / {max} {unit}
</span>
</StatMeta>
</div>
</StatCard>
)
})}
</div>
)
}
延迟分区
StatZones 展示「落在哪一档」的分布:每行一根彩色竖条、分区名、区间和占比。默认颜色按绿、蓝、琥珀、玫红排列,也可以逐行指定 tone。
import {
StatCard,
StatHeader,
StatLabel,
StatValue,
StatZones,
} from "@/components/ui/stat-card/stat-card"
const ZONES = [
{ label: "快", range: "< 50 ms", share: 62.4 },
{ label: "正常", range: "50–200 ms", share: 28.1 },
{ label: "慢", range: "200–500 ms", share: 7.6 },
{ label: "严重", range: "> 500 ms", share: 1.9 },
]
export default function StatCardLatencyDemo() {
return (
<StatCard className="w-full max-w-md">
<StatHeader>
<div className="flex flex-col gap-2">
<StatLabel>/api/posts 平均响应</StatLabel>
<StatValue value={86} unit="ms" size="lg" />
</div>
<div className="flex flex-col items-end gap-2">
<StatLabel>请求数</StatLabel>
<StatValue value={1284000} size="sm" />
</div>
</StatHeader>
<StatZones zones={ZONES} />
</StatCard>
)
}
占比分布
StatDistribution 把合计、分段条和图例格放在一起,图例同时写出占比与数值。只想要那条分段条时用 StatSegments,或设置 legend={false}。
import {
StatCard,
StatDistribution,
StatLabel,
StatValue,
} from "@/components/ui/stat-card/stat-card"
const SOURCES = [
{ label: "搜索引擎", value: 148200 },
{ label: "直接访问", value: 92600 },
{ label: "社交媒体", value: 64300 },
{ label: "RSS 与外链", value: 36900 },
]
const TOTAL = SOURCES.reduce((sum, item) => sum + item.value, 0)
export default function StatCardDistributionDemo() {
return (
<StatCard className="w-full max-w-3xl gap-4">
<div className="flex flex-col gap-2">
<StatLabel>博客阅读量 · 近 30 天</StatLabel>
<StatValue value={TOTAL} unit="次" size="lg" />
</div>
<StatDistribution segments={SOURCES} />
</StatCard>
)
}
分格计量
StatMeter 是 N 个小格,按值从左向右依次点亮;颜色默认按健康度变化,低于 40% 风险、低于 75% 关注。
import {
StatCard,
StatHeader,
StatLabel,
StatMeter,
} from "@/components/ui/stat-card/stat-card"
const SERVICES = [
{ label: "站点可用率", detail: "近 30 天", value: 99.9 },
{ label: "图片 CDN 命中率", detail: "近 7 天", value: 62 },
{ label: "搜索索引新鲜度", detail: "上次重建 3 天前", value: 28 },
]
export default function StatCardHealthDemo() {
return (
<StatCard className="w-full max-w-3xl gap-4">
<StatHeader>
<span className="text-sm font-semibold">服务健康</span>
</StatHeader>
<div className="grid gap-5 sm:grid-cols-3">
{SERVICES.map((service) => (
<div key={service.label} className="flex flex-col gap-2">
<StatLabel>{service.label}</StatLabel>
<div className="flex items-center gap-3">
<StatMeter value={service.value} label={service.label} />
<div className="flex flex-col">
<span className="text-base font-semibold tabular-nums">
{service.value}%
</span>
<span className="text-xs whitespace-nowrap text-muted-foreground">
{service.detail}
</span>
</div>
</div>
</div>
))}
</div>
</StatCard>
)
}
涨跌配色约定
涨跌色有两套习惯:国际通用绿涨红跌,国内行情软件红涨绿跌。组件不把颜色写死,只读 --stat-up / --stat-down 两个 CSS 变量,由 convention 改写:
intl(默认):绿涨红跌;cn:红涨绿跌。
变量沿 DOM 向下继承,所以在最外层的 StatGroup(或任意一张 StatCard)上设置一次,里面所有涨跌幅都会跟着变,包括 迷你图 与 图表卡片 里的涨跌色。涨跌之外的状态色(达标 / 风险)不受它影响,达标永远是绿、风险永远是红。
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
StatCard,
StatGroup,
StatLabel,
StatTrend,
StatValue,
type StatGroupProps,
} from "@/components/ui/stat-card/stat-card"
export default function StatCardConventionDemo() {
const [convention, setConvention] =
React.useState<NonNullable<StatGroupProps["convention"]>>("cn")
return (
<div className="flex w-full max-w-2xl flex-col gap-3">
<div
className="flex items-center gap-2"
role="group"
aria-label="涨跌配色约定"
>
<Button
size="sm"
variant={convention === "intl" ? "default" : "outline"}
aria-pressed={convention === "intl"}
onClick={() => setConvention("intl")}
>
绿涨红跌
</Button>
<Button
size="sm"
variant={convention === "cn" ? "default" : "outline"}
aria-pressed={convention === "cn"}
onClick={() => setConvention("cn")}
>
红涨绿跌
</Button>
</div>
{/* convention 设在外层的 StatGroup 上,里面的卡片都沿用它 */}
<StatGroup convention={convention} className="grid gap-3 sm:grid-cols-3">
<StatCard>
<StatLabel>CDN 流量</StatLabel>
<StatValue value={4.82} unit="TB" />
<StatTrend value={15.2}>较上月</StatTrend>
</StatCard>
<StatCard>
<StatLabel>回源请求</StatLabel>
<StatValue value={918000} unit="次" />
<StatTrend value={-6.7}>较上月</StatTrend>
</StatCard>
<StatCard>
<StatLabel>P95 延迟</StatLabel>
<StatValue value={128} unit="ms" />
{/* 延迟越低越好:下降用「好」的颜色,箭头方向不变 */}
<StatTrend value={-8.3} invert>
较上月
</StatTrend>
</StatCard>
</StatGroup>
</div>
)
}
延迟、错误率这类「越低越好」的指标,给 StatTrend 加 invert:下降用「好」的颜色,箭头方向不变。上面第三张卡片就是这样用的:切到红涨绿跌后,延迟下降显示为红色,因为红色在这一套约定里是「好」的颜色。
数字格式
formatStat 是独立的工具函数,卡片内部用的就是它:
import { formatStat } from "@/lib/data-display"
formatStat(12480) // "1.2万"
formatStat(345000000) // "3.5亿"
formatStat(12480, { locale: "en" }) // "12.5K"
formatStat(1250000, { style: "currency" }) // "¥125万"
formatStat(12.5, { style: "percent" }) // "12.5%",传入的是百分点
formatStat(6.2, { style: "percent", sign: true }) // "+6.2%"API
StatCard
涨跌配色约定:intl 绿涨红跌,cn 红涨绿跌。
变量沿 DOM 继承,给外层容器设置一次,里面所有卡片与图表都会跟着变;不设置时沿用外层,最外层默认 intl
DataConvention | undefined根元素 data-slot="stat-card",设置 convention 时带 data-convention。
StatGroup
这一组里所有卡片、涨跌幅与图表共用的涨跌配色约定
类型DataConvention | undefinedStatIcon
图标色调;不设置时为次要文字色
类型DataTone | undefinedStatValue
数值;数字会按 format 格式化,字符串原样显示
string | number | undefined数字格式,默认 zh-CN 压缩(1.2万、3.4亿)
StatFormatOptions | undefined单位,跟在数字后,字号更小、颜色更淡
类型React.ReactNode字号:sm 20px、default 24px、lg 30px
"default" | "lg" | "sm" | undefined"default"自定义内容,传入后替代 value
React.ReactNodeStatTrend
涨跌幅,单位是百分点:12.4 表示 +12.4%
number数字格式,默认带正负号的百分比
类型StatFormatOptions | undefined越低越好的指标(延迟、错误率):下降显示为「好」的颜色,箭头方向不变
类型boolean | undefinedfalse显示方向箭头
类型boolean | undefinedtrue单独指定涨跌配色约定,不设置时沿用外层
类型DataConvention | undefined覆盖读屏文字(上升 / 下降 / 持平)
类型Partial<Pick<StatLabels, "down" | "flat" | "up">> | undefined数字后的说明,如「较上周」
类型React.ReactNode根元素带 data-trend="up" | "down" | "flat"。
StatStatus
状态:达标 / 关注 / 风险 / 审核中,决定图标、色调和默认文字
类型StatStatusName覆盖状态文字
类型string | undefined覆盖四种状态的默认文字
类型Partial<StatLabels> | undefined右侧显示 > 箭头,配合 render 做成可点击的入口
boolean | undefinedfalse根元素带 data-status。
StatBadge
用状态决定色调与默认文字;与 tone 二选一
StatStatusName | undefined直接指定色调
类型DataTone | undefined覆盖四种状态的默认文字
类型Partial<StatLabels> | undefinedStatProgress
当前值
类型number满值
类型number | undefined100填充色。"usage":按用量自动变色(65% 起关注、90% 起风险),适合配额;
"health":按健康度变色(低于 40% 风险、低于 75% 关注)
"health" | "usage" | DataTone | undefined"primary"粗细:sm 4px、default 6px、lg 8px
"default" | "lg" | "sm" | undefined"default"读屏名称
类型string | undefined覆盖默认文案
类型Partial<StatLabels> | undefinedStatMeter
0–100 的百分比,按它点亮格子
类型number格数
类型number | undefined10点亮的颜色。默认按健康度自动变色:低于 40% 风险、低于 75% 关注,其余达标
类型"health" | "usage" | DataTone | undefined"health"读屏名称
类型string | undefined覆盖默认文案
类型Partial<StatLabels> | undefinedStatSegments
分段数据
类型StatSegment[]读屏名称;默认把每段的占比念出来
类型string | undefinedStatDistribution
分段数据
类型StatSegment[]图例里每项数值的格式,默认 zh-CN 压缩
StatFormatOptions | undefined显示图例格(色条 + 分类名 + 占比 + 数值)
类型boolean | undefinedtrue图例列数上限,窄屏自动减少
类型2 | 3 | 4 | undefined4StatZones
分区,按从好到差的顺序
类型StatZone[]覆盖表头文案
类型Partial<Pick<StatLabels, "rangeShare" | "zone">> | undefinedStatHeader、StatLabel、StatMeta 只是带样式的容器,属性同对应的原生元素。
工具函数
| 函数 | 说明 |
|---|---|
formatStat(value, options) | 数字格式化,options 为 style、locale、compact、currency、digits、sign |
formatDay(date, style, locale) | 日期轴文字:long → 2月14日,short → 02/14,weekday → 一 |
createAxisFormatter(max, options) | 按整条轴统一单位的刻度格式化:最大值 ≥ 1 万时所有刻度都用「万」(9000 显示为 0.9万),≥ 1 亿用「亿」;en 换成 K / M / B |
usageStatus(percent) / healthStatus(percent) | 百分比换成 ok / watch / risk,分别是「越高越危险」和「越高越好」两种口径 |
这些工具随条目一起安装到 lib/data-display.ts,迷你图 和 图表卡片 共用同一份。
可访问性
- 进度条是
role="progressbar",计量条与进度环是role="meter",都带aria-valuenow等取值,并用label提供读屏名称。 - 涨跌幅同时有箭头图标和
sr-only的「上升 / 下降 / 持平」;状态行、徽标永远带文字,颜色不是唯一信息。 - 分段条是
role="img",名称默认把每段的占比念出来;图例格写明占比与数值。 - 进度条、计量条、分段条的入场动画在系统开启「减少动态效果」时直接到位。
注意事项
- 小数位与压缩单位由
Intl.NumberFormat决定:zh-CN是「万 / 亿」,en是 K / M / B。Intl实例按参数缓存,反复格式化没有额外开销。 - 日期轴按 UTC 解析与输出,
"2026-02-14"这样的字符串在任何时区得到同样的文字,静态导出的 HTML 与客户端首屏一致。 - 进度条、计量条、分段条服务端直接输出最终宽度和点亮的格数,入场动画是纯 CSS(
@keyframes,样式由组件以<style href precedence>提供,React 19 会去重并提升到<head>),没有脚本也完整显示。
源码
import * as React from "react"
import { ArrowDownRight, ArrowUpRight, Minus } from "lucide-react"
import { cn } from "@/lib/utils"
import {
TREND_TEXT,
conventionClass,
formatStat,
toneColor,
trendOf,
type DataConvention,
type DataTone,
type StatFormatOptions,
type Trend,
} from "@/lib/data-display"
import {
DEFAULT_LABELS,
healthStatus,
usageStatus,
type StatLabels,
type StatStatusName,
} from "./config"
import {
StatBadge,
StatStatus,
type StatBadgeProps,
type StatStatusProps,
} from "./stat-status"
import {
StatDistribution,
StatMeter,
StatProgress,
StatSegments,
type StatDistributionProps,
type StatMeterProps,
type StatProgressProps,
type StatSegment,
type StatSegmentsProps,
} from "./stat-bars"
import { StatZones, type StatZone, type StatZonesProps } from "./stat-zones"
type StatCardProps = React.ComponentProps<"div"> & {
/**
* 涨跌配色约定:`intl` 绿涨红跌,`cn` 红涨绿跌。
* 变量沿 DOM 继承,给外层容器设置一次,里面所有卡片与图表都会跟着变;不设置时沿用外层,最外层默认 `intl`
*/
convention?: DataConvention
}
/** 统计卡片的外壳:细边框、无阴影,内容用下面的原语自由组合。 */
function StatCard({ className, convention, ...props }: StatCardProps) {
return (
<div
data-slot="stat-card"
data-convention={convention}
className={cn(
"flex min-w-0 flex-col gap-3 rounded-lg border border-edge bg-card p-4 text-sm text-card-foreground",
conventionClass(convention),
className,
)}
{...props}
/>
)
}
type StatGroupProps = React.ComponentProps<"div"> & {
/** 这一组里所有卡片、涨跌幅与图表共用的涨跌配色约定 */
convention?: DataConvention
}
/** 一组卡片的容器:只负责统一设置 `convention`,布局由 `className` 决定。 */
function StatGroup({ className, convention, ...props }: StatGroupProps) {
return (
<div
data-slot="stat-group"
data-convention={convention}
className={cn(conventionClass(convention), className)}
{...props}
/>
)
}
/** 标题行:左边放 `StatLabel`,右边放图标或状态徽标。 */
function StatHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="stat-header"
className={cn("flex items-start justify-between gap-3", className)}
{...props}
/>
)
}
/** 指标名称,12–13px 的次要文字。 */
function StatLabel({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="stat-label"
className={cn("min-w-0 truncate text-[13px] leading-5 text-muted-foreground", className)}
{...props}
/>
)
}
type StatIconProps = React.ComponentProps<"span"> & {
/** 图标色调;不设置时为次要文字色 */
tone?: DataTone
}
/** 图标方块:`size-8` 的细边框小方块,里面放一个 lucide 图标。 */
function StatIcon({ className, tone, style, ...props }: StatIconProps) {
return (
<span
data-slot="stat-icon"
aria-hidden="true"
data-tone={tone}
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-md border border-edge bg-muted/50 text-muted-foreground data-[tone]:text-[color:var(--stat-tone)] [&_svg:not([class*='size-'])]:size-4",
className,
)}
style={
tone
? ({
"--stat-tone": toneColor(tone),
...style,
} as React.CSSProperties)
: style
}
{...props}
/>
)
}
type StatValueProps = Omit<React.ComponentProps<"div">, "children"> & {
/** 数值;数字会按 `format` 格式化,字符串原样显示 */
value?: number | string
/** 数字格式,默认 `zh-CN` 压缩(1.2万、3.4亿) */
format?: StatFormatOptions
/** 单位,跟在数字后,字号更小、颜色更淡 */
unit?: React.ReactNode
/**
* 字号:`sm` 20px、`default` 24px、`lg` 30px
* @default "default"
*/
size?: "sm" | "default" | "lg"
/** 自定义内容,传入后替代 `value` */
children?: React.ReactNode
}
const VALUE_SIZES = {
sm: "text-xl",
default: "text-2xl",
lg: "text-3xl",
} as const
/** 大号数字加单位;数字一律等宽对齐(`tabular-nums`)。 */
function StatValue({
className,
value,
format,
unit,
size = "default",
children,
...props
}: StatValueProps) {
const text = typeof value === "number" ? formatStat(value, format) : value
return (
<div
data-slot="stat-value"
data-size={size}
className={cn(
"flex items-baseline gap-1.5 leading-none font-semibold tracking-tight tabular-nums",
VALUE_SIZES[size],
className,
)}
{...props}
>
<span data-slot="stat-value-number">{children ?? text}</span>
{unit ? (
<span
data-slot="stat-unit"
className="text-sm font-normal tracking-normal text-muted-foreground"
>
{unit}
</span>
) : null}
</div>
)
}
type StatTrendProps = Omit<React.ComponentProps<"span">, "children"> & {
/** 涨跌幅,单位是百分点:`12.4` 表示 +12.4% */
value: number
/** 数字格式,默认带正负号的百分比 */
format?: StatFormatOptions
/**
* 越低越好的指标(延迟、错误率):下降显示为「好」的颜色,箭头方向不变
* @default false
*/
invert?: boolean
/**
* 显示方向箭头
* @default true
*/
showIcon?: boolean
/** 单独指定涨跌配色约定,不设置时沿用外层 */
convention?: DataConvention
/** 覆盖读屏文字(上升 / 下降 / 持平) */
labels?: Partial<Pick<StatLabels, "up" | "down" | "flat">>
/** 数字后的说明,如「较上周」 */
children?: React.ReactNode
}
const TREND_ICONS = {
up: ArrowUpRight,
down: ArrowDownRight,
flat: Minus,
} as const
const OPPOSITE: Record<Trend, Trend> = { up: "down", down: "up", flat: "flat" }
/** 涨跌幅:箭头 + 带符号的百分比;颜色来自 `convention`,同时有箭头和读屏文字,不只靠颜色。 */
function StatTrend({
className,
value,
format,
invert = false,
showIcon = true,
convention,
labels,
children,
...props
}: StatTrendProps) {
const trend = trendOf(value)
const Icon = TREND_ICONS[trend]
const text = { ...DEFAULT_LABELS, ...labels }
return (
<span
data-slot="stat-trend"
data-trend={trend}
className={cn(
"inline-flex flex-wrap items-center gap-0.5 whitespace-nowrap text-xs font-medium tabular-nums",
TREND_TEXT[invert ? OPPOSITE[trend] : trend],
conventionClass(convention),
className,
)}
{...props}
>
{showIcon ? <Icon aria-hidden="true" className="size-3.5 shrink-0" /> : null}
<span className="sr-only">{text[trend]}</span>
{formatStat(value, { style: "percent", sign: true, ...format })}
{children ? (
<span className="ml-1 font-normal text-muted-foreground">
{children}
</span>
) : null}
</span>
)
}
/** 两端对齐的说明行,放在进度条 / 计量条下面:左边「剩余 62%」,右边「已用 3.8 / 10 GB」。 */
function StatMeta({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="stat-meta"
className={cn(
"flex items-center justify-between gap-3 text-xs text-muted-foreground tabular-nums",
className,
)}
{...props}
/>
)
}
export {
StatBadge,
StatCard,
StatDistribution,
StatGroup,
StatHeader,
StatIcon,
StatLabel,
StatMeta,
StatMeter,
StatProgress,
StatSegments,
StatStatus,
StatTrend,
StatValue,
StatZones,
healthStatus,
usageStatus,
}
export type {
StatBadgeProps,
StatCardProps,
StatDistributionProps,
StatGroupProps,
StatIconProps,
StatLabels,
StatMeterProps,
StatProgressProps,
StatSegment,
StatSegmentsProps,
StatStatusName,
StatStatusProps,
StatTrendProps,
StatValueProps,
StatZone,
StatZonesProps,
}
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { toneColor, type DataTone } from "@/lib/data-display"
import {
DEFAULT_LABELS,
STATUS_ICONS,
STATUS_TONES,
type StatLabels,
type StatStatusName,
} from "./config"
// 浅色下多彩色调直接当文字色对比度偏低,向前景色混入一部分压深;暗色下同样的混合会略微提亮,两边都清晰。
const TONE_TEXT =
"text-[color:color-mix(in_oklab,var(--stat-tone)_72%,var(--foreground))]"
function toneVars(tone: DataTone, style?: React.CSSProperties) {
return { "--stat-tone": toneColor(tone), ...style } as React.CSSProperties
}
type StatStatusProps = useRender.ComponentProps<"div"> & {
/** 状态:达标 / 关注 / 风险 / 审核中,决定图标、色调和默认文字 */
status: StatStatusName
/** 覆盖状态文字 */
label?: string
/** 覆盖四种状态的默认文字 */
labels?: Partial<StatLabels>
/**
* 右侧显示 `>` 箭头,配合 `render` 做成可点击的入口
* @default false
*/
showChevron?: boolean
}
/**
* 状态行:状态图标方块 + 两行文字(上:说明,如「3/5 个目标」;下:状态名)。
* 想让整行可点击,用 `render={<a href="…" />}` 换成链接。
*/
function StatStatus({
className,
status,
label,
labels,
showChevron = false,
render,
children,
style,
...props
}: StatStatusProps) {
const Icon = STATUS_ICONS[status]
const text = label ?? { ...DEFAULT_LABELS, ...labels }[status]
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(
{
className: cn(
"flex w-full items-center gap-3 rounded-md bg-muted/50 px-3 py-2 text-left transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
render && "hover:bg-muted",
className,
),
style: toneVars(STATUS_TONES[status], style),
children: (
<>
<span
aria-hidden="true"
className="flex size-8 shrink-0 items-center justify-center rounded-md bg-[color:var(--stat-tone)] text-background [&_svg]:size-4"
>
<Icon />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5 text-xs leading-4 font-medium">
<span className="truncate text-foreground">{children}</span>
<span className={TONE_TEXT}>{text}</span>
</span>
{showChevron ? (
<ChevronRight
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground"
/>
) : null}
</>
),
},
{
"data-slot": "stat-status",
"data-status": status,
...props,
} as React.ComponentProps<"div">,
),
})
}
type StatBadgeProps = React.ComponentProps<"span"> & {
/** 用状态决定色调与默认文字;与 `tone` 二选一 */
status?: StatStatusName
/** 直接指定色调 */
tone?: DataTone
/** 覆盖四种状态的默认文字 */
labels?: Partial<StatLabels>
}
/** 状态徽标:色点 + 文字的小药丸。颜色之外永远有文字,不依赖颜色传达状态。 */
function StatBadge({
className,
status,
tone,
labels,
style,
children,
...props
}: StatBadgeProps) {
const color = tone ?? (status ? STATUS_TONES[status] : "muted")
return (
<span
data-slot="stat-badge"
data-status={status}
className={cn(
"inline-flex h-5 w-fit shrink-0 items-center gap-1.5 rounded-full border border-edge px-2 text-xs font-medium whitespace-nowrap text-foreground",
className,
)}
style={toneVars(color, style)}
{...props}
>
<span
aria-hidden="true"
className="size-1.5 rounded-full bg-[color:var(--stat-tone)]"
/>
{children ?? (status ? { ...DEFAULT_LABELS, ...labels }[status] : null)}
</span>
)
}
export { StatBadge, StatStatus }
export type { StatBadgeProps, StatStatusProps }
import * as React from "react"
import { cn } from "@/lib/utils"
import {
SERIES_TONES,
formatStat,
motionStyles,
toneColor,
type DataTone,
type StatFormatOptions,
} from "@/lib/data-display"
import {
DEFAULT_LABELS,
STATUS_TONES,
healthStatus,
usageStatus,
type StatLabels,
} from "./config"
function toneVar(tone: DataTone, style?: React.CSSProperties) {
return { "--stat-tone": toneColor(tone), ...style } as React.CSSProperties
}
const clampPercent = (value: number, max: number) =>
max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0
type StatProgressProps = Omit<React.ComponentProps<"div">, "children"> & {
/** 当前值 */
value: number
/**
* 满值
* @default 100
*/
max?: number
/**
* 填充色。`"usage"`:按用量自动变色(65% 起关注、90% 起风险),适合配额;
* `"health"`:按健康度变色(低于 40% 风险、低于 75% 关注)
* @default "primary"
*/
tone?: DataTone | "usage" | "health"
/**
* 粗细:`sm` 4px、`default` 6px、`lg` 8px
* @default "default"
*/
size?: "sm" | "default" | "lg"
/** 读屏名称 */
label?: string
/** 覆盖默认文案 */
labels?: Partial<StatLabels>
}
const BAR_SIZES = { sm: "h-1", default: "h-1.5", lg: "h-2" } as const
function autoTone(tone: StatProgressProps["tone"], percent: number): DataTone {
if (tone === "usage") return STATUS_TONES[usageStatus(percent)]
if (tone === "health") return STATUS_TONES[healthStatus(percent)]
return tone ?? "primary"
}
/** 进度条:服务端直接输出最终宽度,填充用 CSS 从左向右展开;系统开启「减少动态效果」时不播放。 */
function StatProgress({
className,
value,
max = 100,
tone,
size = "default",
label,
labels,
style,
...props
}: StatProgressProps) {
const percent = clampPercent(value, max)
return (
<div
data-slot="stat-progress"
role="progressbar"
aria-label={label ?? { ...DEFAULT_LABELS, ...labels }.progress}
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={value}
className={cn(
"w-full overflow-hidden rounded-full bg-muted",
BAR_SIZES[size],
className,
)}
style={toneVar(autoTone(tone, percent), style)}
{...props}
>
{motionStyles()}
<div
data-slot="stat-progress-fill"
data-dd-anim="reveal"
className="h-full rounded-full bg-[color:var(--stat-tone)]"
style={{ width: `${percent}%` }}
/>
</div>
)
}
type StatMeterProps = Omit<React.ComponentProps<"div">, "children"> & {
/** 0–100 的百分比,按它点亮格子 */
value: number
/**
* 格数
* @default 10
*/
segments?: number
/**
* 点亮的颜色。默认按健康度自动变色:低于 40% 风险、低于 75% 关注,其余达标
* @default "health"
*/
tone?: DataTone | "health" | "usage"
/** 读屏名称 */
label?: string
/** 覆盖默认文案 */
labels?: Partial<StatLabels>
}
/** 分格计量条:N 个小格,按值从左到右依次点亮;常用来表示服务健康度。 */
function StatMeter({
className,
value,
segments = 10,
tone = "health",
label,
labels,
style,
...props
}: StatMeterProps) {
const percent = clampPercent(value, 100)
const lit = Math.round((percent * segments) / 100)
return (
<div
data-slot="stat-meter"
role="meter"
aria-label={label ?? { ...DEFAULT_LABELS, ...labels }.meter}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={value}
className={cn("flex h-6 w-28 items-stretch gap-0.5", className)}
style={toneVar(autoTone(tone, percent), style)}
{...props}
>
{motionStyles()}
{Array.from({ length: segments }, (_, index) =>
index < lit ? (
<span
key={index}
data-state="on"
data-dd-anim="rise"
className="flex-1 origin-bottom rounded-[3px] bg-[color:var(--stat-tone)]"
style={{ animationDelay: `${index * 40}ms` }}
/>
) : (
<span
key={index}
data-state="off"
className="flex-1 rounded-[3px] bg-foreground/8"
/>
),
)}
</div>
)
}
type StatSegment = {
/** 分类名 */
label: string
/** 数值,按占比决定宽度 */
value: number
/** 色调,不设置时按顺序取默认色板 */
tone?: DataTone
}
type StatSegmentsProps = Omit<React.ComponentProps<"div">, "children"> & {
/** 分段数据 */
segments: StatSegment[]
/** 读屏名称;默认把每段的占比念出来 */
label?: string
}
/** 横向分段条:每段宽度按占比分配,段间留 2px 缝;整条从左向右扫出。 */
function StatSegments({
className,
segments,
label,
...props
}: StatSegmentsProps) {
const total = segments.reduce((sum, item) => sum + item.value, 0)
const summary =
label ??
segments
.map(
(item) =>
`${item.label} ${formatStat(total ? (item.value / total) * 100 : 0, { style: "percent" })}`,
)
.join(",")
return (
<div
data-slot="stat-segments"
role="img"
aria-label={summary}
className={cn("h-2 w-full", className)}
{...props}
>
{motionStyles()}
<div
data-dd-anim="reveal"
className="flex size-full gap-0.5 overflow-hidden rounded-full"
>
{segments.map((item, index) =>
item.value > 0 ? (
<span
key={item.label}
className="h-full min-w-0 basis-0"
style={{
flexGrow: item.value,
backgroundColor: toneColor(
item.tone ?? SERIES_TONES[index % SERIES_TONES.length],
),
}}
/>
) : null,
)}
</div>
</div>
)
}
type StatDistributionProps = Omit<React.ComponentProps<"div">, "children"> & {
/** 分段数据 */
segments: StatSegment[]
/** 图例里每项数值的格式,默认 `zh-CN` 压缩 */
format?: StatFormatOptions
/**
* 显示图例格(色条 + 分类名 + 占比 + 数值)
* @default true
*/
legend?: boolean
/**
* 图例列数上限,窄屏自动减少
* @default 4
*/
columns?: 2 | 3 | 4
}
const LEGEND_COLUMNS = {
2: "grid-cols-1 sm:grid-cols-2",
3: "grid-cols-1 sm:grid-cols-3",
4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
} as const
/** 占比分布:分段条加图例格。图例同时写出占比和数值,不靠颜色区分分类。 */
function StatDistribution({
className,
segments,
format,
legend = true,
columns = 4,
...props
}: StatDistributionProps) {
const total = segments.reduce((sum, item) => sum + item.value, 0)
return (
<div
data-slot="stat-distribution"
className={cn("flex flex-col gap-3", className)}
{...props}
>
<StatSegments segments={segments} />
{legend ? (
<ul
data-slot="stat-legend"
className={cn("grid gap-2", LEGEND_COLUMNS[columns])}
>
{segments.map((item, index) => {
const percent = total ? (item.value / total) * 100 : 0
return (
<li
key={item.label}
data-slot="stat-legend-item"
className="flex items-center gap-2.5 rounded-md border border-edge px-3 py-2"
>
<span
aria-hidden="true"
className="h-8 w-1 shrink-0 rounded-full"
style={{
backgroundColor: toneColor(
item.tone ?? SERIES_TONES[index % SERIES_TONES.length],
),
}}
/>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-xs text-muted-foreground">
{item.label}
</span>
<span className="text-sm font-semibold tabular-nums">
{formatStat(percent, { style: "percent" })}
<span className="ml-1.5 font-normal text-muted-foreground">
{formatStat(item.value, format)}
</span>
</span>
</div>
</li>
)
})}
</ul>
) : null}
</div>
)
}
export { StatDistribution, StatMeter, StatProgress, StatSegments }
export type {
StatDistributionProps,
StatMeterProps,
StatProgressProps,
StatSegment,
StatSegmentsProps,
}
import * as React from "react"
import { cn } from "@/lib/utils"
import {
formatStat,
toneColor,
type DataTone,
} from "@/lib/data-display"
import { DEFAULT_LABELS, type StatLabels } from "./config"
type StatZone = {
/** 分区名,如「快」 */
label: string
/** 区间,如 `< 50 ms` */
range: string
/** 占比,单位是百分点:`62.4` 表示 62.4% */
share: number
/** 色条色调,不设置时按顺序取绿 / 蓝 / 琥珀 / 玫红 */
tone?: DataTone
}
type StatZonesProps = Omit<React.ComponentProps<"div">, "children"> & {
/** 分区,按从好到差的顺序 */
zones: StatZone[]
/** 覆盖表头文案 */
labels?: Partial<Pick<StatLabels, "zone" | "rangeShare">>
}
const ZONE_TONES: DataTone[] = ["green", "blue", "amber", "rose"]
/** 分区表:每行一根彩色竖条 + 分区名 + 区间 + 占比,适合延迟、响应码这类「落在哪一档」的分布。 */
function StatZones({ className, zones, labels, ...props }: StatZonesProps) {
const text = { ...DEFAULT_LABELS, ...labels }
return (
<div
data-slot="stat-zones"
className={cn("flex flex-col text-sm", className)}
{...props}
>
<div className="flex items-center justify-between border-b border-edge pb-2 text-xs text-muted-foreground">
<span>{text.zone}</span>
<span>{text.rangeShare}</span>
</div>
<ul className="divide-y divide-edge">
{zones.map((zone, index) => (
<li
key={zone.label}
data-slot="stat-zone"
className="flex items-center justify-between gap-3 py-2 last:pb-0"
>
<span className="flex min-w-0 items-center gap-2.5">
<span
aria-hidden="true"
className="h-5 w-1 shrink-0 rounded-full"
style={{
backgroundColor: toneColor(
zone.tone ?? ZONE_TONES[index % ZONE_TONES.length],
),
}}
/>
<span className="truncate">{zone.label}</span>
</span>
<span className="flex shrink-0 items-center gap-3 tabular-nums">
<span className="text-muted-foreground">{zone.range}</span>
<span className="w-14 text-right font-semibold">
{formatStat(zone.share, { style: "percent" })}
</span>
</span>
</li>
))}
</ul>
</div>
)
}
export { StatZones }
export type { StatZone, StatZonesProps }
import {
Minus,
ShieldAlert,
TrendingDown,
TrendingUp,
type LucideIcon,
} from "lucide-react"
import type { DataTone } from "@/lib/data-display"
/** 达标 / 关注 / 风险 / 审核中,四种状态与它们的色调、图标。 */
type StatStatusName = "ok" | "watch" | "risk" | "review"
const STATUS_TONES: Record<StatStatusName, DataTone> = {
ok: "green",
watch: "amber",
risk: "rose",
review: "blue",
}
const STATUS_ICONS: Record<StatStatusName, LucideIcon> = {
ok: TrendingUp,
watch: Minus,
risk: TrendingDown,
review: ShieldAlert,
}
type StatLabels = {
ok: string
watch: string
risk: string
review: string
/** 涨跌方向的读屏文字 */
up: string
down: string
flat: string
/** 进度条、计量条的读屏名称 */
progress: string
meter: string
/** 分布条的读屏名称 */
distribution: string
/** 延迟分区表的表头 */
zone: string
rangeShare: string
}
const DEFAULT_LABELS: StatLabels = {
ok: "达标",
watch: "关注",
risk: "风险",
review: "审核中",
up: "上升",
down: "下降",
flat: "持平",
progress: "进度",
meter: "计量",
distribution: "占比分布",
zone: "分区",
rangeShare: "范围 / 占比",
}
/** 「用量」口径:越高越危险。默认 65% 起关注,90% 起风险。 */
function usageStatus(
percent: number,
thresholds: { watch?: number; risk?: number } = {},
): StatStatusName {
const { watch = 65, risk = 90 } = thresholds
return percent >= risk ? "risk" : percent >= watch ? "watch" : "ok"
}
/** 「健康度」口径:越高越好。低于 40% 风险,低于 75% 关注。 */
function healthStatus(
percent: number,
thresholds: { risk?: number; watch?: number } = {},
): StatStatusName {
const { risk = 40, watch = 75 } = thresholds
return percent < risk ? "risk" : percent < watch ? "watch" : "ok"
}
export { DEFAULT_LABELS, STATUS_ICONS, STATUS_TONES, healthStatus, usageStatus }
export type { StatLabels, StatStatusName }
import * as React from "react"
/**
* 数据展示共用工具:涨跌配色约定、色调、数字与日期格式化。
* stat-card、mini-chart、chart-card 三个条目共用这一份,所以涨跌色、数字格式只在这里定义。
*/
/**
* 涨跌配色约定:
* - `intl`(默认):绿涨红跌;
* - `cn`:红涨绿跌,国内行情软件的习惯。
*/
type DataConvention = "intl" | "cn"
// 约定只改写 `--stat-up` / `--stat-down` 两个 CSS 变量。变量沿 DOM 向下继承,
// 所以给整块看板的外层设一次 `convention`,里面所有涨跌色(包括图表里的 SVG)都跟着变,
// 不需要 context,服务端组件也能用。没有设置时回落到默认的绿涨红跌。
const CONVENTION_CLASSES: Record<DataConvention, string> = {
intl: "[--stat-up:var(--flow-green,var(--primary))] [--stat-down:var(--flow-rose,var(--destructive))]",
cn: "[--stat-up:var(--flow-rose,var(--destructive))] [--stat-down:var(--flow-green,var(--primary))]",
}
/** 把 `convention` 换成要加到根元素上的类名;不传返回 `undefined`,沿用外层的约定。 */
function conventionClass(convention?: DataConvention) {
return convention ? CONVENTION_CLASSES[convention] : undefined
}
type Trend = "up" | "down" | "flat"
function trendOf(value: number): Trend {
return value > 0 ? "up" : value < 0 ? "down" : "flat"
}
/** 涨跌文字色(Tailwind 只认完整类名,所以三种写全)。 */
const TREND_TEXT: Record<Trend, string> = {
up: "text-[color:var(--stat-up,var(--flow-green,var(--primary)))]",
down: "text-[color:var(--stat-down,var(--flow-rose,var(--destructive)))]",
flat: "text-muted-foreground",
}
/**
* 内置色调:
* - `primary`、`foreground`、`muted`、`destructive` 直接引用主题变量,跟随颜色主题;
* - `blue` 等多彩色调引用 `--flow-*` 变量,没有安装时回落到主题强调色;
* - `chart-1` … `chart-5` 引用主题的图表色板;
* - `up` / `down` 是涨跌色,随 `convention` 变化。
*/
type DataToneName =
| "primary"
| "foreground"
| "muted"
| "destructive"
| "blue"
| "green"
| "amber"
| "rose"
| "violet"
| "cyan"
| "chart-1"
| "chart-2"
| "chart-3"
| "chart-4"
| "chart-5"
| "up"
| "down"
/** 色调:内置名,或任意 CSS 颜色(`"var(--brand)"`、`"oklch(0.7 0.2 150)"` 都可以)。 */
type DataTone = DataToneName | (string & {})
const TONES: Record<DataToneName, string> = {
primary: "var(--primary)",
foreground: "var(--foreground)",
muted: "var(--muted-foreground)",
destructive: "var(--destructive)",
blue: "var(--flow-blue, var(--primary))",
green: "var(--flow-green, var(--primary))",
amber: "var(--flow-amber, var(--primary))",
rose: "var(--flow-rose, var(--destructive))",
violet: "var(--flow-violet, var(--primary))",
cyan: "var(--flow-cyan, var(--primary))",
"chart-1": "var(--chart-1)",
"chart-2": "var(--chart-2)",
"chart-3": "var(--chart-3)",
"chart-4": "var(--chart-4)",
"chart-5": "var(--chart-5)",
up: "var(--stat-up, var(--flow-green, var(--primary)))",
down: "var(--stat-down, var(--flow-rose, var(--destructive)))",
}
/** 把色调换成可直接用于 `fill` / `stroke` / `color` 的 CSS 颜色。 */
function toneColor(tone: DataTone): string {
return Object.hasOwn(TONES, tone) ? TONES[tone as DataToneName] : tone
}
/**
* 多序列图表默认的色调轮换:六种互不相近的多彩色调。
* 不把主题强调色放进来:强调色会落在蓝、绿、紫、琥珀任意一种附近,
* 切到对应的颜色主题时会与其中一条序列撞色。单条序列想跟随主题,显式写 `tone="primary"`。
*/
const SERIES_TONES: DataTone[] = [
"blue",
"amber",
"green",
"violet",
"rose",
"cyan",
]
type StatFormatOptions = {
/**
* 数字样式:`number` 普通数字,`percent` 百分比(传入的 12.5 表示 12.5%),`currency` 货币
* @default "number"
*/
style?: "number" | "percent" | "currency"
/**
* BCP 47 区域标识;`zh-CN` 压缩成「万 / 亿」,`en` 压缩成 K / M / B
* @default "zh-CN"
*/
locale?: string
/**
* 压缩大数(1.2万、3.4亿);百分比不压缩
* @default true
*/
compact?: boolean
/**
* 货币代码,仅 `style="currency"` 生效;人民币显示 ¥
* @default "CNY"
*/
currency?: string
/**
* 最多保留的小数位
* @default 1(压缩与百分比),2(普通数字与货币)
*/
digits?: number
/**
* 正数前加 `+`(零不加),用于涨跌幅
* @default false
*/
sign?: boolean
}
const formatters = new Map<string, Intl.NumberFormat>()
/**
* 格式化统计数字,默认 `zh-CN` 压缩:`12480` → `1.2万`,`345000000` → `3.5亿`;
* `locale: "en"` 时为 `12K`、`345M`。同参数的 `Intl.NumberFormat` 会被缓存复用。
*/
function formatStat(value: number, options: StatFormatOptions = {}): string {
const {
style = "number",
locale = "zh-CN",
compact = style !== "percent",
currency = "CNY",
sign = false,
} = options
const digits = options.digits ?? (compact || style === "percent" ? 1 : 2)
const key = [style, locale, compact, currency, digits, sign].join("|")
let formatter = formatters.get(key)
if (!formatter) {
formatter = new Intl.NumberFormat(locale, {
style: style === "number" ? "decimal" : style,
currency: style === "currency" ? currency : undefined,
currencyDisplay: style === "currency" ? "narrowSymbol" : undefined,
notation: compact && style !== "percent" ? "compact" : "standard",
maximumFractionDigits: digits,
signDisplay: sign ? "exceptZero" : "auto",
})
formatters.set(key, formatter)
}
return formatter.format(style === "percent" ? value / 100 : value)
}
type DayStyle = "long" | "short" | "weekday"
const dayFormatters = new Map<string, Intl.DateTimeFormat>()
/**
* 格式化日期轴文字:`long` → `2月14日`,`short` → `02/14`,`weekday` → `一`(单字星期)。
* 日期按 UTC 解析与输出,`"2026-02-14"` 这类字符串在任何时区、任何环境下结果一致,不会引起水合差异。
*/
function formatDay(
input: string | number | Date,
style: DayStyle = "long",
locale = "zh-CN",
): string {
const key = `${style}|${locale}`
let formatter = dayFormatters.get(key)
if (!formatter) {
const options: Intl.DateTimeFormatOptions =
style === "weekday"
? { weekday: "narrow" }
: style === "short"
? { month: "2-digit", day: "2-digit" }
: { month: "long", day: "numeric" }
formatter = new Intl.DateTimeFormat(locale, { ...options, timeZone: "UTC" })
dayFormatters.set(key, formatter)
}
return formatter.format(new Date(input))
}
type AxisFormatOptions = Pick<
StatFormatOptions,
"style" | "locale" | "currency" | "digits"
>
// 压缩单位表:从大到小,命中第一个不超过轴最大值的单位
const AXIS_UNITS = {
zh: [
[1e8, "亿"],
[1e4, "万"],
],
"zh-trad": [
[1e8, "億"],
[1e4, "萬"],
],
en: [
[1e9, "B"],
[1e6, "M"],
[1e3, "K"],
],
} as const
/**
* 按「整条轴」统一单位的刻度格式化:先用轴的最大值选定一个单位,所有刻度都换算成它,
* 避免 `9000` 与 `1.2万` 混在同一根轴上。例如最大值 12000 时刻度是 `0`、`0.3万`、`0.6万`、`0.9万`、`1.2万`;
* 最大值 8000 时全部是整数(`2000`、`4000`…)。`en` 同理换成 K / M / B。
*
* @param max 轴上数据的最大绝对值;留 15% 余量,因为图表会把坐标轴上沿取整到比数据更大的刻度
*/
function createAxisFormatter(
max: number,
options: AxisFormatOptions = {},
): (value: number) => string {
const { style = "number", locale = "zh-CN", currency = "CNY" } = options
if (style === "percent")
return (value) =>
formatStat(value, { style: "percent", locale, digits: options.digits })
const family = /^zh-(TW|HK|MO|Hant)/i.test(locale)
? "zh-trad"
: /^zh/i.test(locale)
? "zh"
: "en"
const reach = Math.abs(max) * 1.15
const unit = AXIS_UNITS[family].find(([size]) => reach >= size)
const divisor = unit?.[0] ?? 1
const suffix = unit?.[1] ?? ""
const formatter = new Intl.NumberFormat(locale, {
style: style === "currency" ? "currency" : "decimal",
currency: style === "currency" ? currency : undefined,
currencyDisplay: style === "currency" ? "narrowSymbol" : undefined,
notation: "standard",
maximumFractionDigits: options.digits ?? 2,
})
return (value) =>
value === 0
? formatter.format(0)
: `${formatter.format(value / divisor)}${suffix}`
}
// 进度条、计量条、分段条、进度环、刻度仪表的入场动画:纯 CSS,服务端直接输出最终状态,
// 所以没有脚本时也完整显示;系统开启「减少动态效果」时不播放。
const MOTION_CSS = `@media (prefers-reduced-motion: no-preference) {
@keyframes dd-reveal { from { clip-path: inset(0 100% 0 0 round 9999px) } to { clip-path: inset(0 0 0 0 round 9999px) } }
@keyframes dd-rise { from { opacity: 0; transform: scaleY(0.35) } }
@keyframes dd-fade { from { opacity: 0 } }
@keyframes dd-arc { from { stroke-dasharray: 0 1 } }
[data-dd-anim="reveal"] { animation: dd-reveal 0.8s cubic-bezier(0.22, 1, 0.36, 1) both }
[data-dd-anim="rise"] { animation: dd-rise 0.3s cubic-bezier(0.22, 1, 0.36, 1) both }
[data-dd-anim="fade"] { animation: dd-fade 0.35s cubic-bezier(0.22, 1, 0.36, 1) both }
[data-dd-anim="arc"] { animation: dd-arc 0.9s cubic-bezier(0.22, 1, 0.36, 1) both }
}`
/**
* 渲染上面的动画样式。React 19 会按 `href` 去重并提升到 `<head>`,
* 同一页放多少个组件都只有一份;放在服务端组件里也能用。
*/
function motionStyles() {
return React.createElement(
"style",
{ href: "data-display-motion", precedence: "default" },
MOTION_CSS,
)
}
export {
TREND_TEXT,
SERIES_TONES,
conventionClass,
createAxisFormatter,
formatDay,
formatStat,
motionStyles,
toneColor,
trendOf,
}
export type {
AxisFormatOptions,
DataConvention,
DataTone,
DataToneName,
DayStyle,
StatFormatOptions,
Trend,
}