diff --git a/admin-web/src/components/charts/chart-legend.tsx b/admin-web/src/components/charts/chart-legend.tsx new file mode 100644 index 0000000..e8b42f6 --- /dev/null +++ b/admin-web/src/components/charts/chart-legend.tsx @@ -0,0 +1,16 @@ +export function ChartLegend({ + items, +}: { + items: Array<{ label: string; tone: "primary" | "violet" | "success" | "warning" }>; +}) { + return ( +
+ {items.map((item) => ( + + + {item.label} + + ))} +
+ ); +} diff --git a/admin-web/src/components/charts/cohort-heatmap.tsx b/admin-web/src/components/charts/cohort-heatmap.tsx new file mode 100644 index 0000000..ce3e1d2 --- /dev/null +++ b/admin-web/src/components/charts/cohort-heatmap.tsx @@ -0,0 +1,76 @@ +import type { AnalyticsCohort, AnalyticsRate } from "../../api/types"; +import { formatNumber } from "../../lib/format"; + +export function CohortHeatmap({ cohorts }: { cohorts: AnalyticsCohort[] }) { + return ( +
+ + + + + + + + + + + + + {cohorts.length === 0 ? ( + + + + ) : ( + cohorts.map((cohort) => ( + + + + + + + + )) + )} + +
D1、D7、D30 价值留存 cohort 热力图
激活日期用户D1D7D30
+ 当前周期暂无已激活 cohort +
+ {cohort.cohortDate} + {formatNumber(cohort.size)}
+
+ + + + + + + 窗口未成熟 +
+
+ ); +} + +function HeatmapCell({ rate }: { rate?: AnalyticsRate }) { + const label = rate?.percent == null ? "—" : `${rate.percent.toFixed(1)}%`; + return ( + + + {label} + + + ); +} + +function heatmapTone(percent?: number): string { + if (percent == null) return "heatmap-cell--unavailable"; + if (percent >= 40) return "heatmap-cell--high"; + if (percent >= 20) return "heatmap-cell--medium"; + return "heatmap-cell--low"; +} diff --git a/admin-web/src/components/charts/comparison-bar-chart.tsx b/admin-web/src/components/charts/comparison-bar-chart.tsx new file mode 100644 index 0000000..56b447c --- /dev/null +++ b/admin-web/src/components/charts/comparison-bar-chart.tsx @@ -0,0 +1,103 @@ +import { formatNumber } from "../../lib/format"; +import { ChartLegend } from "./chart-legend"; + +type ChartTone = "primary" | "violet" | "success" | "warning"; + +export interface ComparisonBarItem { + id?: string; + label: string; + value: number; + secondaryValue?: number; + hint?: string; +} + +export function ComparisonBarChart({ + items, + primaryLabel, + secondaryLabel, + primaryTone = "primary", + secondaryTone = "violet", + valueFormatter = formatNumber, + emptyText = "当前周期暂无可比较数据", +}: { + items: ComparisonBarItem[]; + primaryLabel: string; + secondaryLabel?: string; + primaryTone?: ChartTone; + secondaryTone?: ChartTone; + valueFormatter?: (value: number) => string; + emptyText?: string; +}) { + if (items.length === 0) { + return

{emptyText}

; + } + + const maximum = Math.max( + ...items.flatMap((item) => [item.value, item.secondaryValue ?? 0]), + 1, + ); + const legend = [ + { label: primaryLabel, tone: primaryTone }, + ...(secondaryLabel ? [{ label: secondaryLabel, tone: secondaryTone }] : []), + ]; + + return ( +
+ +
+ {items.map((item, index) => ( +
+
+ {item.label} + {item.hint ? {item.hint} : null} +
+ + {secondaryLabel != null && item.secondaryValue != null ? ( + + ) : null} +
+ ))} +
+
+ ); +} + +function Bar({ + label, + maximum, + tone, + value, + valueLabel, +}: { + label: string; + maximum: number; + tone: ChartTone; + value: number; + valueLabel: string; +}) { + return ( +
+ + + {valueLabel} + +
+ ); +} diff --git a/admin-web/src/components/charts/funnel-chart.tsx b/admin-web/src/components/charts/funnel-chart.tsx new file mode 100644 index 0000000..147a694 --- /dev/null +++ b/admin-web/src/components/charts/funnel-chart.tsx @@ -0,0 +1,54 @@ +import type { FunnelStep } from "../../api/types"; +import { formatNumber } from "../../lib/format"; + +export function FunnelChart({ + steps, + emptyText = "当前周期暂无漏斗数据", +}: { + steps: FunnelStep[]; + emptyText?: string; +}) { + if (steps.length === 0) { + return

{emptyText}

; + } + + const maximum = Math.max(steps[0]?.count ?? 0, ...steps.map((step) => step.count), 1); + + return ( +
+ {steps.map((step, index) => { + const previous = steps[index - 1]?.count; + const stepConversion = + previous == null || previous === 0 ? undefined : (step.count / previous) * 100; + const totalConversion = maximum === 0 ? 0 : (step.count / maximum) * 100; + + return ( +
+
+
+ {step.label} + + {index === 0 + ? "漏斗起点" + : `上一步转化 ${stepConversion == null ? "—" : `${stepConversion.toFixed(1)}%`}`} + +
+
+ {formatNumber(step.count)} + + 占起点 {totalConversion.toFixed(1)}% + +
+
+ +
+ ); + })} +
+ ); +} diff --git a/admin-web/src/components/charts/radial-metric.tsx b/admin-web/src/components/charts/radial-metric.tsx new file mode 100644 index 0000000..03352c4 --- /dev/null +++ b/admin-web/src/components/charts/radial-metric.tsx @@ -0,0 +1,44 @@ +type RadialTone = "primary" | "success" | "violet" | "warning"; + +export function RadialMetric({ + label, + percent, + detail, + tone = "primary", +}: { + label: string; + percent: number; + detail?: string; + tone?: RadialTone; +}) { + const value = Math.min(Math.max(percent, 0), 100); + + return ( +
+ + + + + {Math.round(value)}% + + +
+ {label} + {detail ?

{detail}

: null} +
+
+ ); +} diff --git a/admin-web/src/components/charts/trend-chart.tsx b/admin-web/src/components/charts/trend-chart.tsx new file mode 100644 index 0000000..5d7e658 --- /dev/null +++ b/admin-web/src/components/charts/trend-chart.tsx @@ -0,0 +1,111 @@ +import type { TrendPoint } from "../../api/types"; +import { formatNumber } from "../../lib/format"; + +export function TrendChart({ points }: { points: TrendPoint[] }) { + if (points.length === 0) { + return
暂无趋势数据
; + } + + const width = 760; + const height = 300; + const paddingX = 42; + const paddingY = 30; + const plotHeight = height - paddingY * 2; + const step = points.length > 1 ? (width - paddingX * 2) / (points.length - 1) : 0; + const registrationMax = Math.max(...points.map((point) => point.registrations), 1); + const creditMax = Math.max(...points.map((point) => point.creditsUsed), 1); + const coordinates = points.map((point, index) => { + const x = points.length === 1 ? width / 2 : paddingX + index * step; + return { + point, + x, + registrationY: + height - paddingY - (point.registrations / registrationMax) * plotHeight, + creditY: height - paddingY - (point.creditsUsed / creditMax) * plotHeight, + }; + }); + const registrationLine = coordinates + .map(({ x, registrationY }) => `${x},${registrationY}`) + .join(" "); + const creditLine = coordinates.map(({ x, creditY }) => `${x},${creditY}`).join(" "); + const labelEvery = Math.max(Math.ceil(points.length / 6), 1); + + return ( + <> +
+ + 新增用户与积分消耗趋势 + + 横轴为 UTC 日期,两条曲线分别表示新增用户数与积分消耗。 + + {[paddingY, height / 2, height - paddingY].map((y) => ( + + ))} + + {formatNumber(registrationMax)} 用户 + + + {formatNumber(creditMax)} 积分 + + + + {coordinates.map(({ point, x, registrationY, creditY }, index) => ( + + + + {point.date}:新增 {formatNumber(point.registrations)} 位用户 + + + + + {point.date}:消耗 {formatNumber(point.creditsUsed)} 积分 + + + {index % labelEvery === 0 || index === points.length - 1 ? ( + + {point.date.slice(5)} + + ) : null} + + ))} + +
+ + + + + + + + + + + {points.map((point) => ( + + + + + + ))} + +
新增用户与积分消耗趋势完整数据
UTC 日期新增用户消耗积分
{point.date}{formatNumber(point.registrations)}{formatNumber(point.creditsUsed)}
+ + ); +} diff --git a/admin-web/src/features/analytics/analytics-page.tsx b/admin-web/src/features/analytics/analytics-page.tsx index 91c60c5..e44995f 100644 --- a/admin-web/src/features/analytics/analytics-page.tsx +++ b/admin-web/src/features/analytics/analytics-page.tsx @@ -11,11 +11,10 @@ import { } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { adminApi } from "../../api/client"; -import type { - AnalyticsRate, - FunnelStep, - ProductAnalyticsOverview, -} from "../../api/types"; +import type { AnalyticsRate, ProductAnalyticsOverview } from "../../api/types"; +import { CohortHeatmap } from "../../components/charts/cohort-heatmap"; +import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart"; +import { FunnelChart } from "../../components/charts/funnel-chart"; import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives"; import { RangeControl } from "../../components/range-control"; import { formatNumber } from "../../lib/format"; @@ -87,52 +86,21 @@ export function AnalyticsPage() {
- + + + + -
- - - - - - - - - - - - - {data.retention.length === 0 ? ( - - - - ) : ( - data.retention.map((cohort) => ( - - - - - - - - )) - )} - -
D1、D7、D30 价值留存 cohort
激活日期用户D1D7D30
- 当前周期暂无已激活 cohort -
- {cohort.cohortDate} - {formatNumber(cohort.size)}
-
+
@@ -146,24 +114,21 @@ export function AnalyticsPage() { {data.aiFeatures.length === 0 ? (

等待客户端接入事件后显示

) : ( -
- {data.aiFeatures.map((item) => ( -
-
- {featureLabel(item.feature)} - - {executionModeLabel(item.executionMode)} - -
-

- {formatNumber(item.successes)} -

-

- {formatNumber(item.users)} 位成功用户 -

-
- ))} -
+ right.successes - left.successes) + .map((item) => ({ + id: `${item.feature}-${item.executionMode}`, + label: featureLabel(item.feature), + value: item.successes, + secondaryValue: item.users, + hint: executionModeLabel(item.executionMode), + }))} + primaryLabel="成功次数" + secondaryLabel="成功用户" + primaryTone="violet" + secondaryTone="success" + /> )} @@ -173,9 +138,17 @@ export function AnalyticsPage() { description="托管调用以服务端结算为准" icon={Activity} /> + + `${value.toFixed(1)}%`} + /> - + + + + + `${value.toFixed(1)}%`} + /> -
- {data.growth.channels.length === 0 ? ( -

暂无渠道归因数据

- ) : ( - data.growth.channels.map((channel) => ( -
- {channelLabel(channel.channel)} -

- {formatNumber(channel.installations)} -

-

- 激活 {rateLabel(channel.activationRate)} · {formatNumber(channel.activated)} 人 -

-
- )) - )} +
+ ({ + label: channelLabel(channel.channel), + value: channel.installations, + secondaryValue: channel.activated, + hint: `激活率 ${rateLabel(channel.activationRate)}`, + }))} + primaryLabel="新增安装" + secondaryLabel="24 小时激活" + emptyText="暂无渠道归因数据" + /> +
@@ -266,43 +274,6 @@ function SectionHeader({ ); } -function FunnelCard({ - title, - description, - steps, -}: { - title: string; - description: string; - steps: FunnelStep[]; -}) { - const maximum = Math.max(steps[0]?.count ?? 0, 1); - return ( - - -
- {steps.length === 0 ? ( -

当前周期暂无漏斗数据

- ) : ( - steps.map((step) => ( -
-
- {step.label} - {formatNumber(step.count)} -
- -
- )) - )} -
-
- ); -} - function MetricRows({ rows }: { rows: Array<[string, string]> }) { return (
@@ -316,14 +287,6 @@ function MetricRows({ rows }: { rows: Array<[string, string]> }) { ); } -function RetentionCell({ rate, last = false }: { rate?: AnalyticsRate; last?: boolean }) { - return ( - - {rateLabel(rate)} - - ); -} - function rateLabel(rate?: AnalyticsRate): string { return rate?.percent == null ? "—" : `${rate.percent.toFixed(1)}%`; } @@ -355,13 +318,6 @@ function latestMatureRetention( return rateLabel(data.retention.find((cohort) => cohort[key]?.percent != null)?.[key]); } -function retentionTone(percent?: number): string { - if (percent == null) return "text-muted"; - if (percent >= 40) return "rounded-md bg-success-soft px-2 py-1 text-success"; - if (percent >= 20) return "rounded-md bg-warning-soft px-2 py-1 text-warning"; - return "rounded-md bg-danger-soft px-2 py-1 text-danger"; -} - function channelLabel(channel: string): string { return { APP_STORE_ORGANIC: "App Store 自然量", diff --git a/admin-web/src/features/overview/overview-page.tsx b/admin-web/src/features/overview/overview-page.tsx index 63c9588..6a4e8ff 100644 --- a/admin-web/src/features/overview/overview-page.tsx +++ b/admin-web/src/features/overview/overview-page.tsx @@ -1,15 +1,16 @@ import { Activity, - ArrowUpRight, Coins, CreditCard, - Sparkles, - UserPlus, Users, } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { adminApi } from "../../api/client"; -import type { Overview, TrendPoint } from "../../api/types"; +import type { Overview } from "../../api/types"; +import { ChartLegend } from "../../components/charts/chart-legend"; +import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart"; +import { RadialMetric } from "../../components/charts/radial-metric"; +import { TrendChart } from "../../components/charts/trend-chart"; import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives"; import { RangeControl } from "../../components/range-control"; import { formatNumber, usageTypeLabel } from "../../lib/format"; @@ -85,10 +86,12 @@ export function OverviewPage() {

增长与消耗趋势

按 UTC 日期统计,双指标独立缩放

-
- - -
+
@@ -97,33 +100,25 @@ export function OverviewPage() {
-
-
-

周期摘要

-

当前统计窗口

-
- - - +

运营效率

+

活跃覆盖与积分流转对比

+
+
-
- - - -
+ @@ -137,161 +132,19 @@ export function OverviewPage() { {data.usage.length === 0 ? (
当前周期暂无使用记录
) : ( -
- {data.usage.map((item) => ( -
-
- - {usageTypeLabel(item.kind)} - - {formatNumber(item.requests)} 请求 -
- - {formatNumber(item.chargedCredits)} - - 已消耗积分 -
- ))} -
+ right.chargedCredits - left.chargedCredits) + .map((item) => ({ + label: usageTypeLabel(item.kind), + value: item.chargedCredits, + hint: `${formatNumber(item.requests)} 次请求`, + }))} + primaryLabel="已消耗积分" + primaryTone="violet" + /> )}
); } - -function Legend({ color, label }: { color: string; label: string }) { - return ( - - - {label} - - ); -} - -function SummaryRow({ - icon: Icon, - label, - value, -}: { - icon: typeof Users; - label: string; - value: string; -}) { - return ( -
- - - - {label} - {value} -
- ); -} - -export function TrendChart({ points }: { points: TrendPoint[] }) { - if (points.length === 0) { - return
暂无趋势数据
; - } - const width = 760; - const height = 300; - const paddingX = 42; - const paddingY = 30; - const plotHeight = height - paddingY * 2; - const step = points.length > 1 ? (width - paddingX * 2) / (points.length - 1) : 0; - const registrationMax = Math.max(...points.map((point) => point.registrations), 1); - const creditMax = Math.max(...points.map((point) => point.creditsUsed), 1); - const coordinates = points.map((point, index) => { - const x = points.length === 1 ? width / 2 : paddingX + index * step; - return { - point, - x, - registrationY: - height - paddingY - (point.registrations / registrationMax) * plotHeight, - creditY: height - paddingY - (point.creditsUsed / creditMax) * plotHeight, - }; - }); - const registrationLine = coordinates - .map(({ x, registrationY }) => `${x},${registrationY}`) - .join(" "); - const creditLine = coordinates.map(({ x, creditY }) => `${x},${creditY}`).join(" "); - const labelEvery = Math.max(Math.ceil(points.length / 6), 1); - - return ( - <> -
- - 新增用户与积分消耗趋势 - - 横轴为 UTC 日期,两条曲线分别表示新增用户数与积分消耗。 - - {[paddingY, height / 2, height - paddingY].map((y) => ( - - ))} - - {formatNumber(registrationMax)} 用户 - - - {formatNumber(creditMax)} 积分 - - - - {coordinates.map(({ point, x, registrationY, creditY }, index) => ( - - - - {point.date}:新增 {formatNumber(point.registrations)} 位用户 - - - - - {point.date}:消耗 {formatNumber(point.creditsUsed)} 积分 - - - {index % labelEvery === 0 || index === points.length - 1 ? ( - - {point.date.slice(5)} - - ) : null} - - ))} - -
- - - - - - - - - - - {points.map((point) => ( - - - - - - ))} - -
新增用户与积分消耗趋势完整数据
UTC 日期新增用户消耗积分
{point.date}{formatNumber(point.registrations)}{formatNumber(point.creditsUsed)}
- - ); -} diff --git a/admin-web/src/features/referrals/referrals-page.tsx b/admin-web/src/features/referrals/referrals-page.tsx index fd2d6bf..86dae2f 100644 --- a/admin-web/src/features/referrals/referrals-page.tsx +++ b/admin-web/src/features/referrals/referrals-page.tsx @@ -2,6 +2,9 @@ import { Award, CircleOff, Clock3, GitBranch, TrendingUp, Users } from "lucide-r import { useCallback, useEffect, useMemo, useState } from "react"; import { adminApi } from "../../api/client"; import type { ReferralOverview, ReferralRankingItem } from "../../api/types"; +import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart"; +import { FunnelChart } from "../../components/charts/funnel-chart"; +import { RadialMetric } from "../../components/charts/radial-metric"; import { DataTable, type DataColumn } from "../../components/data-table"; import { Card, @@ -115,60 +118,71 @@ export function ReferralsPage() {
- -
+ +

裂变漏斗

-

从分享触达到有效使用

+

从分享触达到有效使用,逐层观察流失

-
- {data.funnel.length === 0 ? ( -

当前周期暂无漏斗数据

- ) : ( - data.funnel.map((step, index) => { - const max = Math.max(data.funnel[0]?.count ?? 1, 1); - return ( -
-
- {step.label} - {formatNumber(step.count)} -
- -
- ); - }) - )} +
+
+
-

邀请排行

-

按有效邀请数与奖励积分排序

+

头部邀请贡献

+

比较邀请总数与达到奖励条件的有效邀请

- ({ + label: `第 ${index + 1} 名 · ${shortUserId(item.userId)}`, + value: item.invited, + secondaryValue: item.qualified, + hint: `${formatNumber(item.creditsEarned)} 奖励积分`, + }))} + primaryLabel="邀请" + secondaryLabel="有效" + primaryTone="warning" + secondaryTone="success" + emptyText="当前周期暂无排行数据" />
+ +
+
+

邀请排行明细

+

保留完整用户维度,便于运营核对

+
+ + + +
+ +
+ @@ -199,3 +213,7 @@ function Rank({ value }: { value: number }) { ); } + +function shortUserId(userId: string): string { + return userId.length <= 12 ? userId : `${userId.slice(0, 6)}…${userId.slice(-4)}`; +} diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css index b28b3e1..1513db4 100644 --- a/admin-web/src/styles.css +++ b/admin-web/src/styles.css @@ -304,26 +304,179 @@ button:disabled { stroke-width: 2.5; } +.chart-legend-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; +} + +.chart-legend-dot--primary { + background: var(--primary); +} + +.chart-legend-dot--violet { + background: var(--violet); +} + +.chart-legend-dot--success { + background: var(--success); +} + +.chart-legend-dot--warning { + background: var(--warning); +} + +.bar-progress, .funnel-progress { border: 0; appearance: none; } +.bar-progress::-webkit-progress-bar, .funnel-progress::-webkit-progress-bar { border-radius: 999px; background: var(--surface-muted); } +.bar-progress::-webkit-progress-value, .funnel-progress::-webkit-progress-value { border-radius: 999px; +} + +.bar-progress--primary::-webkit-progress-value { + background: var(--primary); +} + +.bar-progress--violet::-webkit-progress-value { + background: var(--violet); +} + +.bar-progress--success::-webkit-progress-value { + background: var(--success); +} + +.bar-progress--warning::-webkit-progress-value { + background: var(--warning); +} + +.funnel-progress::-webkit-progress-value { background: linear-gradient(90deg, var(--primary), var(--violet)); } +.bar-progress::-moz-progress-bar, .funnel-progress::-moz-progress-bar { border-radius: 999px; +} + +.bar-progress--primary::-moz-progress-bar { + background: var(--primary); +} + +.bar-progress--violet::-moz-progress-bar { + background: var(--violet); +} + +.bar-progress--success::-moz-progress-bar { + background: var(--success); +} + +.bar-progress--warning::-moz-progress-bar { + background: var(--warning); +} + +.funnel-progress::-moz-progress-bar { background: linear-gradient(90deg, var(--primary), var(--violet)); } +.radial-track, +.radial-value { + fill: none; + stroke-width: 11; +} + +.radial-track { + stroke: var(--surface-muted); +} + +.radial-value { + stroke-linecap: round; +} + +.radial-value--primary { + stroke: var(--primary); +} + +.radial-value--success { + stroke: var(--success); +} + +.radial-value--violet { + stroke: var(--violet); +} + +.radial-value--warning { + stroke: var(--warning); +} + +.radial-label { + fill: var(--foreground); + font-size: 20px; + font-weight: 750; + letter-spacing: -0.04em; +} + +.heatmap-cell { + display: block; + min-width: 4.25rem; + border-radius: 0.65rem; + padding: 0.65rem 0.5rem; + font-size: 0.75rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.heatmap-cell--high { + color: var(--success); + background: var(--success-soft); +} + +.heatmap-cell--medium { + color: var(--warning); + background: var(--warning-soft); +} + +.heatmap-cell--low { + color: var(--danger); + background: var(--danger-soft); +} + +.heatmap-cell--unavailable { + color: var(--muted); + background: var(--surface-muted); +} + +.heatmap-key { + width: 1rem; + height: 1rem; + border-radius: 0.3rem; +} + +.heatmap-key--high { + background: var(--success-soft); +} + +.heatmap-key--medium { + background: var(--warning-soft); +} + +.heatmap-key--low { + background: var(--danger-soft); +} + +.heatmap-key--unavailable { + background: var(--surface-muted); +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/admin-web/src/test/risk-flows.test.tsx b/admin-web/src/test/risk-flows.test.tsx index a1c8d4e..39d362a 100644 --- a/admin-web/src/test/risk-flows.test.tsx +++ b/admin-web/src/test/risk-flows.test.tsx @@ -3,7 +3,11 @@ import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { adminApi, ApiError } from "../api/client"; import type { UserDetail } from "../api/types"; -import { TrendChart } from "../features/overview/overview-page"; +import { CohortHeatmap } from "../components/charts/cohort-heatmap"; +import { ComparisonBarChart } from "../components/charts/comparison-bar-chart"; +import { FunnelChart } from "../components/charts/funnel-chart"; +import { RadialMetric } from "../components/charts/radial-metric"; +import { TrendChart } from "../components/charts/trend-chart"; import { GrantDialog } from "../features/users/users-page"; afterEach(() => { @@ -28,6 +32,35 @@ describe("高风险交互与 CSP", () => { expect(screen.getByText("35")).toBeTruthy(); }); + it("比较、漏斗、留存与环形图保持可访问且不生成内联样式", () => { + const { container } = render( + <> + + + + + , + ); + + expect(screen.getByRole("progressbar", { name: "自然量 新增安装:100" })).toBeTruthy(); + expect(screen.getByRole("progressbar", { name: /首次启动:100/ })).toBeTruthy(); + expect(screen.getByText("D1、D7、D30 价值留存 cohort 热力图")).toBeTruthy(); + expect(screen.getByRole("img", { name: "用户活跃率:50.0%" })).toBeTruthy(); + expect(container.querySelector("[style]")).toBeNull(); + }); + it("赠送结果未知时保持弹窗并复用同一幂等键重试", async () => { const grant = vi.spyOn(adminApi, "grantCredits").mockRejectedValue( new ApiError("NETWORK_ERROR", "网络连接失败,请检查网络", 0),