跳至正文
组件

Stat Card统计卡片

一组可组合的统计卡片原语:KPI 数字与涨跌幅(绿涨红跌 / 红涨绿跌可切换)、目标状态行、配额进度条、分格计量条、延迟分区表、占比分布条与图例,数字默认按万 / 亿压缩。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/stat-card.json

用法

统计卡片不是一个大组件,而是一组小原语:StatCard 提供外壳,其余按需要拼进去。同一份数据在不同卡片里长什么样,由你选哪几块决定。

app/stats.tsx
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 与迷你走势、配额进度、分格健康度、流量来源分布。

正在加载…

目标与状态

StatStatus 是卡片底部的一行:状态图标方块、说明文字和状态名。状态有四种——ok 达标、watch 关注、risk 风险、review 审核中,各自带固定的图标和色调;加 render={<a href="…" />} 与 showChevron 就成了一个入口。

正在加载…

配额与进度

StatProgress 的 tone="usage" 按用量自动变色:65% 起关注、90% 起风险。usageStatus 和 healthStatus 把百分比换成状态名,可以拿去驱动 StatBadge。

正在加载…

延迟分区

StatZones 展示「落在哪一档」的分布:每行一根彩色竖条、分区名、区间和占比。默认颜色按绿、蓝、琥珀、玫红排列,也可以逐行指定 tone。

正在加载…

占比分布

StatDistribution 把合计、分段条和图例格放在一起,图例同时写出占比与数值。只想要那条分段条时用 StatSegments,或设置 legend={false}。

正在加载…

分格计量

StatMeter 是 N 个小格,按值从左向右依次点亮;颜色默认按健康度变化,低于 40% 风险、低于 75% 关注。

正在加载…

涨跌配色约定

涨跌色有两套习惯:国际通用绿涨红跌,国内行情软件红涨绿跌。组件不把颜色写死,只读 --stat-up / --stat-down 两个 CSS 变量,由 convention 改写:

  • intl(默认):绿涨红跌;
  • cn:红涨绿跌。

变量沿 DOM 向下继承,所以在最外层的 StatGroup(或任意一张 StatCard)上设置一次,里面所有涨跌幅都会跟着变,包括 迷你图 与 图表卡片 里的涨跌色。涨跌之外的状态色(达标 / 风险)不受它影响,达标永远是绿、风险永远是红。

正在加载…

延迟、错误率这类「越低越好」的指标,给 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

根元素 data-slot="stat-card",设置 convention 时带 data-convention。

StatGroup

StatIcon

StatValue

StatTrend

根元素带 data-trend="up" | "down" | "flat"。

StatStatus

根元素带 data-status。

StatBadge

StatProgress

StatMeter

StatSegments

StatDistribution

StatZones

StatHeader、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>),没有脚本也完整显示。

源码

components/ui/stat-card/stat-card.tsx
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,
}
components/ui/stat-card/stat-status.tsx
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 }
components/ui/stat-card/stat-bars.tsx
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,
}
components/ui/stat-card/stat-zones.tsx
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 }
components/ui/stat-card/config.ts
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 }
lib/data-display.ts
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,
}