Add admin dashboard filtering and sorting
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

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:
Rocky
2026-08-20 18:05:49 +08:00
parent 034a3e8745
commit 74c3fcd45f
39 changed files with 2990 additions and 431 deletions
@@ -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={[