|
@@ -0,0 +1,170 @@
|
|
|
|
|
+import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
|
|
|
|
|
+import { useMutation } from '@tanstack/react-query';
|
|
|
|
|
+import { AlertCircle, Inbox } from 'lucide-react';
|
|
|
|
|
+import type { DateRange } from 'react-day-picker';
|
|
|
|
|
+import { queryFunnelTrend } from '../../api/funnel';
|
|
|
|
|
+import type { FunnelTrendRequest, FunnelTrendResponse } from '../../api/types';
|
|
|
|
|
+import { eachDay, FIXED_FUNNEL_STEPS, formatSnapshotDt, toSnapshotParam } from './period';
|
|
|
|
|
+import { formatRate, formatUv } from './format';
|
|
|
|
|
+import {
|
|
|
|
|
+ TrendRangeSelect,
|
|
|
|
|
+ type TrendRangeMode,
|
|
|
|
|
+} from './components/TrendRangeSelect';
|
|
|
|
|
+import type { TrendSeries } from './components/TrendChart';
|
|
|
|
|
+import { Card, CardContent } from '@/components/ui/card';
|
|
|
|
|
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
|
|
|
+import { Skeleton } from '@/components/ui/skeleton';
|
|
|
|
|
+
|
|
|
|
|
+// ECharts 较重 → 懒加载,与漏斗图共用同一 chunk。
|
|
|
|
|
+const TrendChart = lazy(() =>
|
|
|
|
|
+ import('./components/TrendChart').then((m) => ({ default: m.TrendChart })),
|
|
|
|
|
+);
|
|
|
|
|
+
|
|
|
|
|
+// 折线配色:UV 5 步 / 转换率 4 段(mint 主色 + 可区分色)。
|
|
|
|
|
+const UV_COLORS = ['#04CB94', '#0EA5E9', '#6366F1', '#F59E0B', '#EF4444'];
|
|
|
|
|
+const CONV_COLORS = ['#0EA5E9', '#6366F1', '#F59E0B', '#EF4444'];
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 趋势视图(漏斗页「趋势」Tab)。事件 UV 按日折线 + 相邻转换率按日折线,共用
|
|
|
|
|
+ * 一次 trend 查询。范围:近30天(默认)/ 自定义。缺口断线、不补零。
|
|
|
|
|
+ */
|
|
|
|
|
+export function TrendView() {
|
|
|
|
|
+ const [mode, setMode] = useState<TrendRangeMode>('recent30');
|
|
|
|
|
+ const [customRange, setCustomRange] = useState<DateRange | undefined>();
|
|
|
|
|
+
|
|
|
|
|
+ const mutation = useMutation<FunnelTrendResponse, Error, FunnelTrendRequest>({
|
|
|
|
|
+ mutationFn: queryFunnelTrend,
|
|
|
|
|
+ });
|
|
|
|
|
+ const { mutate } = mutation;
|
|
|
|
|
+
|
|
|
|
|
+ // mode==='custom' 时 customRange 必为完整区间(onPickCustomRange 同时设两者)。
|
|
|
|
|
+ const customFrom =
|
|
|
|
|
+ mode === 'custom' && customRange?.from ? toSnapshotParam(customRange.from) : undefined;
|
|
|
|
|
+ const customTo =
|
|
|
|
|
+ mode === 'custom' && customRange?.to ? toSnapshotParam(customRange.to) : undefined;
|
|
|
|
|
+
|
|
|
|
|
+ // 首次挂载(近30天)与范围变化时查询。近30天省略 bounds → 后端取最新 30 天。
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (mode === 'recent30') {
|
|
|
|
|
+ mutate({});
|
|
|
|
|
+ } else if (customFrom && customTo) {
|
|
|
|
|
+ mutate({ start_dt: customFrom, end_dt: customTo });
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [mode, customFrom, customTo, mutate]);
|
|
|
|
|
+
|
|
|
|
|
+ const chart = useMemo(() => {
|
|
|
|
|
+ const data = mutation.data;
|
|
|
|
|
+ if (!data || data.data_status !== 'ready' || !data.start_dt || !data.end_dt) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 连续日期轴:API 省略的缺口日填 null → 断线(不补零)。
|
|
|
|
|
+ const days = eachDay(data.start_dt, data.end_dt);
|
|
|
|
|
+ const byDt = new Map(data.points.map((p) => [p.dt, p]));
|
|
|
|
|
+ const categories = days.map(formatSnapshotDt);
|
|
|
|
|
+
|
|
|
|
|
+ const uvSeries: TrendSeries[] = FIXED_FUNNEL_STEPS.map((step, i) => ({
|
|
|
|
|
+ name: step.name,
|
|
|
|
|
+ color: UV_COLORS[i],
|
|
|
|
|
+ data: days.map((dt) => byDt.get(dt)?.results[i]?.uv ?? null),
|
|
|
|
|
+ }));
|
|
|
|
|
+ // 相邻转换率 = 进入该步的转化(results[i+1].conversion_rate),取步骤 2..5。
|
|
|
|
|
+ const convSeries: TrendSeries[] = FIXED_FUNNEL_STEPS.slice(1).map((step, i) => ({
|
|
|
|
|
+ name: `${step.name}率`,
|
|
|
|
|
+ color: CONV_COLORS[i],
|
|
|
|
|
+ data: days.map((dt) => byDt.get(dt)?.results[i + 1]?.conversion_rate ?? null),
|
|
|
|
|
+ }));
|
|
|
|
|
+ return { categories, uvSeries, convSeries };
|
|
|
|
|
+ }, [mutation.data]);
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex flex-col gap-5 w-full">
|
|
|
|
|
+ <div className="flex items-start gap-3 flex-wrap">
|
|
|
|
|
+ <div className="flex flex-col gap-1">
|
|
|
|
|
+ <h3 className="text-base font-semibold tracking-tight m-0">按日趋势</h3>
|
|
|
|
|
+ <p className="text-sm text-muted-foreground m-0">
|
|
|
|
|
+ 事件 UV 与相邻转换率的每日变化
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="ml-auto flex flex-col items-end gap-1">
|
|
|
|
|
+ <TrendRangeSelect
|
|
|
|
|
+ mode={mode}
|
|
|
|
|
+ customRange={customRange}
|
|
|
|
|
+ onPickRecent30={() => setMode('recent30')}
|
|
|
|
|
+ onPickCustomRange={(from, to) => {
|
|
|
|
|
+ setCustomRange({ from, to });
|
|
|
|
|
+ setMode('custom');
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ {/* 范围文案:始终占位固定行高,避免显示/隐藏挤动下方内容。 */}
|
|
|
|
|
+ <span className="h-4 text-xs leading-4 text-muted-foreground">
|
|
|
|
|
+ {!mutation.isPending && mutation.data?.start_dt && mutation.data?.end_dt
|
|
|
|
|
+ ? `${formatSnapshotDt(mutation.data.start_dt)} ~ ${formatSnapshotDt(mutation.data.end_dt)}`
|
|
|
|
|
+ : ''}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {mutation.isError ? (
|
|
|
|
|
+ <Alert variant="destructive">
|
|
|
|
|
+ <AlertCircle className="size-4" />
|
|
|
|
|
+ <AlertTitle>查询失败</AlertTitle>
|
|
|
|
|
+ <AlertDescription>
|
|
|
|
|
+ {mutation.error?.message ?? '请求趋势接口时发生错误,请稍后重试。'}
|
|
|
|
|
+ </AlertDescription>
|
|
|
|
|
+ </Alert>
|
|
|
|
|
+ ) : mutation.isPending || !mutation.data ? (
|
|
|
|
|
+ <div className="flex flex-col gap-5" data-testid="trend-loading">
|
|
|
|
|
+ <Skeleton className="h-[300px] w-full" />
|
|
|
|
|
+ <Skeleton className="h-[300px] w-full" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : !chart ? (
|
|
|
|
|
+ <Card>
|
|
|
|
|
+ <CardContent className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
|
|
|
|
+ <Inbox className="size-10 text-muted-foreground/60" />
|
|
|
|
|
+ <p className="text-sm text-muted-foreground m-0">
|
|
|
|
|
+ 数据缺失:所选范围暂无产出数据,未做补零处理。
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </CardContent>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <Suspense
|
|
|
|
|
+ fallback={
|
|
|
|
|
+ <div className="flex flex-col gap-5">
|
|
|
|
|
+ <Skeleton className="h-[300px] w-full" />
|
|
|
|
|
+ <Skeleton className="h-[300px] w-full" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ }
|
|
|
|
|
+ >
|
|
|
|
|
+ <Card>
|
|
|
|
|
+ <CardContent className="pt-5">
|
|
|
|
|
+ <h4 className="text-sm font-medium text-muted-foreground m-0 mb-1 px-1">
|
|
|
|
|
+ 事件 UV
|
|
|
|
|
+ </h4>
|
|
|
|
|
+ <TrendChart
|
|
|
|
|
+ categories={chart.categories}
|
|
|
|
|
+ series={chart.uvSeries}
|
|
|
|
|
+ valueFormatter={formatUv}
|
|
|
|
|
+ testId="trend-chart-uv"
|
|
|
|
|
+ />
|
|
|
|
|
+ </CardContent>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ <Card>
|
|
|
|
|
+ <CardContent className="pt-5">
|
|
|
|
|
+ <h4 className="text-sm font-medium text-muted-foreground m-0 mb-1 px-1">
|
|
|
|
|
+ 相邻转换率
|
|
|
|
|
+ </h4>
|
|
|
|
|
+ <TrendChart
|
|
|
|
|
+ categories={chart.categories}
|
|
|
|
|
+ series={chart.convSeries}
|
|
|
|
|
+ valueFormatter={(v) => formatRate(v)}
|
|
|
|
|
+ testId="trend-chart-conversion"
|
|
|
|
|
+ />
|
|
|
|
|
+ </CardContent>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Suspense>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export default TrendView;
|