Add admin dashboard filtering and sorting
Provide stable server-side list queries and focused chart controls so operators can inspect large datasets without misleading partial-page ordering.
This commit is contained in:
@@ -9,27 +9,47 @@ import {
|
||||
Target,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { adminApi } from "../../api/client";
|
||||
import type { AnalyticsRate, ProductAnalyticsOverview } from "../../api/types";
|
||||
import type {
|
||||
AnalyticsRate,
|
||||
ProductAnalyticsOverview,
|
||||
SortOrder,
|
||||
} from "../../api/types";
|
||||
import { ChartToolbar } from "../../components/chart-toolbar";
|
||||
import { CohortHeatmap } from "../../components/charts/cohort-heatmap";
|
||||
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||||
import { FunnelChart } from "../../components/charts/funnel-chart";
|
||||
import { FilterControl, ToggleFilter } from "../../components/filter-control";
|
||||
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
||||
import { RangeControl } from "../../components/range-control";
|
||||
import { SortControl } from "../../components/sort-control";
|
||||
import { formatNumber } from "../../lib/format";
|
||||
import { stableSort } from "../../lib/sort";
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const [range, setRange] = useState("30d");
|
||||
const [data, setData] = useState<ProductAnalyticsOverview>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [executionMode, setExecutionMode] = useState("");
|
||||
const [aiSort, setAiSort] = useState<"successes" | "users">("successes");
|
||||
const [aiOrder, setAiOrder] = useState<SortOrder>("desc");
|
||||
const [channelSort, setChannelSort] = useState<"installs" | "activated" | "rate">("installs");
|
||||
const [channelOrder, setChannelOrder] = useState<SortOrder>("desc");
|
||||
const [hideUnknown, setHideUnknown] = useState(false);
|
||||
const [cohortOrder, setCohortOrder] = useState<SortOrder>("desc");
|
||||
const [minimumCohortSize, setMinimumCohortSize] = useState(0);
|
||||
const [matureOnly, setMatureOnly] = useState(false);
|
||||
const requestVersion = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const version = ++requestVersion.current;
|
||||
setError(undefined);
|
||||
try {
|
||||
setData(await adminApi.productAnalytics(range));
|
||||
const response = await adminApi.productAnalytics(range);
|
||||
if (requestVersion.current === version) setData(response);
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
}
|
||||
}, [range]);
|
||||
|
||||
@@ -37,6 +57,47 @@ export function AnalyticsPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const visibleAiFeatures = useMemo(
|
||||
() =>
|
||||
stableSort(
|
||||
(data?.aiFeatures ?? []).filter(
|
||||
(item) => !executionMode || item.executionMode === executionMode,
|
||||
),
|
||||
(item) => (aiSort === "successes" ? item.successes : item.users),
|
||||
aiOrder,
|
||||
),
|
||||
[aiOrder, aiSort, data?.aiFeatures, executionMode],
|
||||
);
|
||||
const visibleChannels = useMemo(
|
||||
() =>
|
||||
stableSort(
|
||||
(data?.growth.channels ?? []).filter(
|
||||
(channel) => !hideUnknown || channel.channel !== "UNKNOWN",
|
||||
),
|
||||
(channel) =>
|
||||
channelSort === "installs"
|
||||
? channel.installations
|
||||
: channelSort === "activated"
|
||||
? channel.activated
|
||||
: channel.activationRate.percent ?? -1,
|
||||
channelOrder,
|
||||
),
|
||||
[channelOrder, channelSort, data?.growth.channels, hideUnknown],
|
||||
);
|
||||
const visibleCohorts = useMemo(
|
||||
() =>
|
||||
stableSort(
|
||||
(data?.retention ?? []).filter(
|
||||
(cohort) =>
|
||||
cohort.size >= minimumCohortSize &&
|
||||
(!matureOnly || cohort.d30?.percent != null),
|
||||
),
|
||||
(cohort) => cohort.cohortDate,
|
||||
cohortOrder,
|
||||
),
|
||||
[cohortOrder, data?.retention, matureOnly, minimumCohortSize],
|
||||
);
|
||||
|
||||
if (error) return <ErrorState error={error} retry={() => void load()} />;
|
||||
if (!data) return <LoadingState label="加载产品数据" />;
|
||||
|
||||
@@ -100,7 +161,28 @@ export function AnalyticsPage() {
|
||||
description="按首次 AI 成功日分组,未成熟窗口显示为 —"
|
||||
icon={ChartNoAxesCombined}
|
||||
/>
|
||||
<CohortHeatmap cohorts={data.retention} />
|
||||
<ChartToolbar label="留存 cohort 筛选与排序">
|
||||
<SortControl
|
||||
label="激活日期"
|
||||
value="date"
|
||||
order={cohortOrder}
|
||||
options={[{ value: "date", label: "激活日期" }]}
|
||||
onChange={(_, order) => setCohortOrder(order)}
|
||||
/>
|
||||
<FilterControl
|
||||
label="最小样本量"
|
||||
value={String(minimumCohortSize)}
|
||||
options={[
|
||||
{ value: "0", label: "不限" },
|
||||
{ value: "10", label: "至少 10 人" },
|
||||
{ value: "50", label: "至少 50 人" },
|
||||
{ value: "100", label: "至少 100 人" },
|
||||
]}
|
||||
onChange={(value) => setMinimumCohortSize(Number(value))}
|
||||
/>
|
||||
<ToggleFilter label="仅显示 D30 已成熟" checked={matureOnly} onChange={setMatureOnly} />
|
||||
</ChartToolbar>
|
||||
<CohortHeatmap cohorts={visibleCohorts} />
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
@@ -111,13 +193,36 @@ export function AnalyticsPage() {
|
||||
description="客户端功能与执行模式,仅包含白名单元数据"
|
||||
icon={BrainCircuit}
|
||||
/>
|
||||
{data.aiFeatures.length === 0 ? (
|
||||
<p className="p-8 text-center text-sm text-muted">等待客户端接入事件后显示</p>
|
||||
<ChartToolbar label="AI 使用结构筛选与排序">
|
||||
<FilterControl
|
||||
label="执行模式"
|
||||
value={executionMode}
|
||||
options={[
|
||||
{ value: "", label: "全部模式" },
|
||||
{ value: "MANAGED", label: "托管" },
|
||||
{ value: "LOCAL", label: "本地" },
|
||||
{ value: "BYOK", label: "BYOK" },
|
||||
]}
|
||||
onChange={setExecutionMode}
|
||||
/>
|
||||
<SortControl
|
||||
value={aiSort}
|
||||
order={aiOrder}
|
||||
options={[
|
||||
{ value: "successes", label: "成功次数" },
|
||||
{ value: "users", label: "成功用户" },
|
||||
]}
|
||||
onChange={(value, order) => {
|
||||
setAiSort(value);
|
||||
setAiOrder(order);
|
||||
}}
|
||||
/>
|
||||
</ChartToolbar>
|
||||
{visibleAiFeatures.length === 0 ? (
|
||||
<p className="p-8 text-center text-sm text-muted">当前筛选条件下暂无 AI 使用数据</p>
|
||||
) : (
|
||||
<ComparisonBarChart
|
||||
items={[...data.aiFeatures]
|
||||
.sort((left, right) => right.successes - left.successes)
|
||||
.map((item) => ({
|
||||
items={visibleAiFeatures.map((item) => ({
|
||||
id: `${item.feature}-${item.executionMode}`,
|
||||
label: featureLabel(item.feature),
|
||||
value: item.successes,
|
||||
@@ -227,9 +332,25 @@ export function AnalyticsPage() {
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader title="渠道质量" description="新增不是终点,重点比较 24 小时激活" icon={Users} />
|
||||
<ChartToolbar label="渠道质量筛选与排序">
|
||||
<SortControl
|
||||
value={channelSort}
|
||||
order={channelOrder}
|
||||
options={[
|
||||
{ value: "installs", label: "新增安装" },
|
||||
{ value: "activated", label: "激活人数" },
|
||||
{ value: "rate", label: "激活率" },
|
||||
]}
|
||||
onChange={(value, order) => {
|
||||
setChannelSort(value);
|
||||
setChannelOrder(order);
|
||||
}}
|
||||
/>
|
||||
<ToggleFilter label="隐藏未知来源" checked={hideUnknown} onChange={setHideUnknown} />
|
||||
</ChartToolbar>
|
||||
<div className="grid gap-0 xl:grid-cols-[1fr_260px]">
|
||||
<ComparisonBarChart
|
||||
items={data.growth.channels.map((channel) => ({
|
||||
items={visibleChannels.map((channel) => ({
|
||||
label: channelLabel(channel.channel),
|
||||
value: channel.installations,
|
||||
secondaryValue: channel.activated,
|
||||
@@ -237,7 +358,7 @@ export function AnalyticsPage() {
|
||||
}))}
|
||||
primaryLabel="新增安装"
|
||||
secondaryLabel="24 小时激活"
|
||||
emptyText="暂无渠道归因数据"
|
||||
emptyText="当前筛选条件下暂无渠道归因数据"
|
||||
/>
|
||||
<MetricRows
|
||||
rows={[
|
||||
|
||||
Reference in New Issue
Block a user