后台看板增加时段显示和新增用户数量

后台 Dashboard 日期控件改为起始日期和终止日期

新增本时段筛选并扩展前后端 Dashboard 查询契约

新增新增用户数指标,按 profile_dashboard_state.created_at 统计当前时间窗注册账号

补充后台看板测试和运营看板文档口径
This commit is contained in:
2026-06-24 20:57:03 +08:00
parent 468bdd5cb5
commit 0c55d1ca87
9 changed files with 376 additions and 135 deletions
+2
View File
@@ -441,6 +441,8 @@ function buildDashboardQuery(query: AdminDashboardQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'granularity', query.granularity);
appendQueryParam(params, 'anchor', query.anchor);
appendQueryParam(params, 'startDate', query.startDate);
appendQueryParam(params, 'endDate', query.endDate);
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
+4 -1
View File
@@ -54,11 +54,13 @@ export interface AdminOverviewResponse {
database: AdminDatabaseOverviewPayload;
}
export type AdminDashboardGranularity = 'day' | 'week' | 'month';
export type AdminDashboardGranularity = 'day' | 'week' | 'month' | 'period';
export interface AdminDashboardQuery {
granularity?: AdminDashboardGranularity;
anchor?: string;
startDate?: string;
endDate?: string;
}
export interface AdminDashboardResponse {
@@ -82,6 +84,7 @@ export interface AdminDashboardMetricsPayload {
generatedAssets: number;
consumedMudPoints: number;
totalRegisteredUsers: number;
newRegisteredUsers: number;
visitUsers: number;
totalVisitUsers: number;
visitCount: number;
@@ -1,12 +1,12 @@
/* @vitest-environment jsdom */
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {beforeEach, expect, test, vi} from 'vitest';
import { beforeEach, expect, test, vi } from 'vitest';
import {getAdminDashboard} from '../api/adminApiClient';
import type {AdminDashboardResponse} from '../api/adminApiTypes';
import {AdminDashboardPage} from './AdminDashboardPage';
import { getAdminDashboard } from '../api/adminApiClient';
import type { AdminDashboardResponse } from '../api/adminApiTypes';
import { AdminDashboardPage } from './AdminDashboardPage';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
@@ -28,6 +28,7 @@ const dashboardResponse: AdminDashboardResponse = {
generatedAssets: 12,
consumedMudPoints: 88,
totalRegisteredUsers: 1200,
newRegisteredUsers: 16,
visitUsers: 34,
totalVisitUsers: 456,
visitCount: 98,
@@ -40,7 +41,7 @@ const dashboardResponse: AdminDashboardResponse = {
title: '生产素材',
unit: '个',
total: 12,
buckets: [{key: '2026-06-23', label: '2026-06-23', value: 12}],
buckets: [{ key: '2026-06-23', label: '2026-06-23', value: 12 }],
},
],
operations: {
@@ -53,9 +54,9 @@ const dashboardResponse: AdminDashboardResponse = {
},
],
assetKindBreakdown: [
{key: 'editor_generated_image', label: '画板图片', value: 9},
{ key: 'editor_generated_image', label: '画板图片', value: 9 },
],
moduleVisitBreakdown: [{key: 'auth', label: '认证', value: 30}],
moduleVisitBreakdown: [{ key: 'auth', label: '认证', value: 30 }],
},
warnings: [],
generatedAt: '2026-06-23T12:00:00Z',
@@ -70,8 +71,9 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () =
const user = userEvent.setup();
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
expect(await screen.findByText('日总生产素材数')).toBeTruthy();
expect(await screen.findByText('日总生产素材数')).toBeTruthy();
expect(screen.getByText('总注册用户')).toBeTruthy();
expect(screen.getByText('本日新增用户数')).toBeTruthy();
expect(screen.getByText('当前使用人数(五分钟统计一次)')).toBeTruthy();
expect(screen.getByText('生产素材')).toBeTruthy();
@@ -82,38 +84,64 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () =
expect(screen.getByText('访问模块分布')).toBeTruthy();
});
test('Dashboard 选择周时带上 week granularity 和周一 anchor', async () => {
test('Dashboard 选择周时按当前终止日期填充整周范围', async () => {
const user = userEvent.setup();
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByText('日总生产素材数');
await user.click(screen.getByRole('button', { name: '周' }));
fireEvent.change(screen.getByLabelText('周'), {
target: {value: '2026-W27'},
await screen.findByText('日总生产素材数');
fireEvent.change(screen.getByLabelText('终止日期'), {
target: { value: '2026-07-02' },
});
await user.click(screen.getByRole('button', { name: '本周' }));
await waitFor(() => {
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
granularity: 'week',
anchor: '2026-06-29',
anchor: '2026-07-05',
startDate: '2026-06-29',
endDate: '2026-07-05',
});
});
});
test('Dashboard 选择月份时带上 month granularity 和月初 anchor', async () => {
test('Dashboard 选择本月时按当前终止日期填充整月范围', async () => {
const user = userEvent.setup();
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByText('日总生产素材数');
await user.click(screen.getByRole('button', { name: '月' }));
fireEvent.change(screen.getByLabelText('月份'), {
target: {value: '2026-07'},
await screen.findByText('日总生产素材数');
fireEvent.change(screen.getByLabelText('终止日期'), {
target: { value: '2026-07-12' },
});
await user.click(screen.getByRole('button', { name: '本月' }));
await waitFor(() => {
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
granularity: 'month',
anchor: '2026-07-01',
anchor: '2026-07-31',
startDate: '2026-07-01',
endDate: '2026-07-31',
});
});
});
test('Dashboard 手动选择起止日期时使用本时段查询', async () => {
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByText('本日总生产素材数');
fireEvent.change(screen.getByLabelText('起始日期'), {
target: { value: '2026-06-01' },
});
fireEvent.change(screen.getByLabelText('终止日期'), {
target: { value: '2026-06-12' },
});
await waitFor(() => {
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
granularity: 'period',
anchor: undefined,
startDate: '2026-06-01',
endDate: '2026-06-12',
});
});
expect(screen.getByText('本时段新增用户数')).toBeTruthy();
});
+124 -105
View File
@@ -1,14 +1,14 @@
import {RefreshCw} from 'lucide-react';
import {useCallback, useEffect, useMemo, useState} from 'react';
import { RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {getAdminDashboard} from '../api/adminApiClient';
import { getAdminDashboard } from '../api/adminApiClient';
import type {
AdminDashboardBreakdownRowPayload,
AdminDashboardChartPayload,
AdminDashboardGranularity,
AdminDashboardResponse,
} from '../api/adminApiTypes';
import {handlePageError} from './pageUtils';
import { handlePageError } from './pageUtils';
interface AdminDashboardPageProps {
token: string;
@@ -21,9 +21,10 @@ const granularityOptions: Array<{
id: AdminDashboardGranularity;
label: string;
}> = [
{id: 'day', label: '日'},
{id: 'week', label: '周'},
{id: 'month', label: '月'},
{ id: 'day', label: '日' },
{ id: 'week', label: '周' },
{ id: 'month', label: '月' },
{ id: 'period', label: '本时段' },
];
export function AdminDashboardPage({
@@ -35,11 +36,13 @@ export function AdminDashboardPage({
);
const [granularity, setGranularity] =
useState<AdminDashboardGranularity>('day');
const [anchor, setAnchor] = useState(() => formatDateInput(new Date()));
const [dateRange, setDateRange] = useState(() => {
const today = formatDateInput(new Date());
return { startDate: today, endDate: today };
});
const [activeTab, setActiveTab] = useState<AdminDashboardTab>('metrics');
const [errorMessage, setErrorMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const anchorInput = buildAnchorInput(granularity, anchor);
const loadDashboard = useCallback(async () => {
setIsLoading(true);
@@ -47,7 +50,9 @@ export function AdminDashboardPage({
try {
const response = await getAdminDashboard(token, {
granularity,
anchor,
anchor: granularity === 'period' ? undefined : dateRange.endDate,
startDate: dateRange.startDate,
endDate: dateRange.endDate,
});
setDashboard(response);
} catch (error: unknown) {
@@ -55,7 +60,13 @@ export function AdminDashboardPage({
} finally {
setIsLoading(false);
}
}, [anchor, granularity, onUnauthorized, token]);
}, [
dateRange.endDate,
dateRange.startDate,
granularity,
onUnauthorized,
token,
]);
useEffect(() => {
void loadDashboard();
@@ -92,6 +103,12 @@ export function AdminDashboardPage({
value: metrics?.totalRegisteredUsers ?? 0,
unit: '人',
},
{
id: 'new-registered-users',
label: `${rangePrefix(granularity)}新增用户数`,
value: metrics?.newRegisteredUsers ?? 0,
unit: '人',
},
{
id: 'visit-users',
label: `${rangePrefix(granularity)}访问人数`,
@@ -134,6 +151,28 @@ export function AdminDashboardPage({
<p>{dashboard?.range.periodLabel ?? '运营指标'}</p>
</div>
<div className="admin-dashboard-actions">
<div className="admin-dashboard-period-fields">
<label className="admin-field admin-dashboard-date-field">
<span></span>
<input
type="date"
value={dateRange.startDate}
onChange={(event) =>
handleDateRangeChange('startDate', event.target.value)
}
/>
</label>
<label className="admin-field admin-dashboard-date-field">
<span></span>
<input
type="date"
value={dateRange.endDate}
onChange={(event) =>
handleDateRangeChange('endDate', event.target.value)
}
/>
</label>
</div>
<div
className="admin-segmented-control admin-dashboard-granularity"
role="tablist"
@@ -144,29 +183,12 @@ export function AdminDashboardPage({
key={option.id}
type="button"
data-active={option.id === granularity}
onClick={() => setGranularity(option.id)}
onClick={() => handleGranularityChange(option.id)}
>
{option.label}
</button>
))}
</div>
<label className="admin-field admin-dashboard-date-field">
<span>{anchorInput.label}</span>
<input
key={granularity}
type={anchorInput.type}
value={anchorInput.value}
onChange={(event) => {
const nextAnchor = normalizeAnchorInputValue(
granularity,
event.target.value,
);
if (nextAnchor) {
setAnchor(nextAnchor);
}
}}
/>
</label>
<button
className="admin-secondary-button"
disabled={isLoading}
@@ -276,6 +298,28 @@ export function AdminDashboardPage({
)}
</section>
);
function handleGranularityChange(nextGranularity: AdminDashboardGranularity) {
setGranularity(nextGranularity);
setDateRange((current) =>
nextGranularity === 'period'
? normalizeDateRange(current)
: buildPresetDateRange(nextGranularity, current.endDate),
);
}
function handleDateRangeChange(
field: 'startDate' | 'endDate',
value: string,
) {
if (!parseDateValueAsUtc(value)) {
return;
}
setGranularity('period');
setDateRange((current) =>
normalizeDateRange({ ...current, [field]: value }),
);
}
}
function MetricCard({
@@ -301,7 +345,7 @@ function MetricCard({
);
}
function ChartPanel({chart}: {chart: AdminDashboardChartPayload}) {
function ChartPanel({ chart }: { chart: AdminDashboardChartPayload }) {
const maxValue = Math.max(1, ...chart.buckets.map((bucket) => bucket.value));
return (
<section className="admin-panel admin-dashboard-chart-card">
@@ -381,41 +425,6 @@ function formatDateInput(date: Date) {
return `${year}-${month}-${day}`;
}
function buildAnchorInput(granularity: AdminDashboardGranularity, anchor: string) {
if (granularity === 'week') {
return {
label: '周',
type: 'week',
value: dateToWeekInputValue(anchor),
};
}
if (granularity === 'month') {
return {
label: '月份',
type: 'month',
value: anchor.slice(0, 7),
};
}
return {
label: '日期',
type: 'date',
value: anchor,
};
}
function normalizeAnchorInputValue(
granularity: AdminDashboardGranularity,
value: string,
) {
if (granularity === 'week') {
return weekInputValueToMondayDate(value);
}
if (granularity === 'month') {
return monthInputValueToFirstDate(value);
}
return parseDateValueAsUtc(value) ? value : '';
}
function rangePrefix(granularity: AdminDashboardGranularity) {
if (granularity === 'week') {
return '本周';
@@ -423,55 +432,65 @@ function rangePrefix(granularity: AdminDashboardGranularity) {
if (granularity === 'month') {
return '本月';
}
return '今日';
if (granularity === 'period') {
return '本时段';
}
return '本日';
}
function dateToWeekInputValue(dateValue: string) {
function buildPresetDateRange(
granularity: AdminDashboardGranularity,
selectedDate: string,
) {
const fallbackDate = formatDateInput(new Date());
const anchor = parseDateValueAsUtc(selectedDate)
? selectedDate
: fallbackDate;
if (granularity === 'week') {
return buildWeekDateRange(anchor);
}
if (granularity === 'month') {
return buildMonthDateRange(anchor);
}
return { startDate: anchor, endDate: anchor };
}
function buildWeekDateRange(dateValue: string) {
const date = parseDateValueAsUtc(dateValue);
if (!date) {
return '';
return { startDate: dateValue, endDate: dateValue };
}
const day = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - day);
const weekYear = date.getUTCFullYear();
const yearStart = new Date(Date.UTC(weekYear, 0, 1));
const week = Math.ceil(
((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7,
const weekday = date.getUTCDay() || 7;
const monday = new Date(date);
monday.setUTCDate(date.getUTCDate() - weekday + 1);
const sunday = new Date(monday);
sunday.setUTCDate(monday.getUTCDate() + 6);
return { startDate: formatUtcDate(monday), endDate: formatUtcDate(sunday) };
}
function buildMonthDateRange(dateValue: string) {
const date = parseDateValueAsUtc(dateValue);
if (!date) {
return { startDate: dateValue, endDate: dateValue };
}
const firstDate = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1),
);
return `${weekYear}-W${String(week).padStart(2, '0')}`;
const lastDate = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0),
);
return {
startDate: formatUtcDate(firstDate),
endDate: formatUtcDate(lastDate),
};
}
function weekInputValueToMondayDate(value: string) {
const match = /^(\d{4})-W(\d{2})$/.exec(value);
if (!match) {
return '';
}
const year = Number(match[1]);
const week = Number(match[2]);
if (!Number.isInteger(year) || week < 1 || week > 53) {
return '';
}
const jan4 = new Date(Date.UTC(year, 0, 4));
const jan4Day = jan4.getUTCDay() || 7;
const monday = new Date(jan4);
monday.setUTCDate(jan4.getUTCDate() - jan4Day + 1 + (week - 1) * 7);
const mondayDate = formatUtcDate(monday);
return dateToWeekInputValue(mondayDate) === value ? mondayDate : '';
}
function monthInputValueToFirstDate(value: string) {
const match = /^(\d{4})-(\d{2})$/.exec(value);
if (!match) {
return '';
}
const year = Number(match[1]);
const month = Number(match[2]);
const firstDate = `${year}-${String(month).padStart(2, '0')}-01`;
return parseDateValueAsUtc(firstDate) ? firstDate : '';
function normalizeDateRange(range: { startDate: string; endDate: string }) {
return range.startDate > range.endDate
? { startDate: range.endDate, endDate: range.startDate }
: range;
}
function parseDateValueAsUtc(value: string) {
+17 -1
View File
@@ -284,10 +284,17 @@ button:disabled {
}
.admin-dashboard-granularity {
width: 188px;
width: 250px;
flex: 0 0 auto;
}
.admin-dashboard-period-fields {
display: grid;
width: 352px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.admin-dashboard-date-field {
width: 170px;
}
@@ -1279,11 +1286,16 @@ button:disabled {
}
.admin-dashboard-granularity,
.admin-dashboard-period-fields,
.admin-dashboard-date-field,
.admin-dashboard-actions .admin-secondary-button {
width: 100%;
}
.admin-dashboard-period-fields {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-dashboard-metric-grid,
.admin-dashboard-operation-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1351,6 +1363,10 @@ button:disabled {
width: 100%;
}
.admin-dashboard-period-fields {
grid-template-columns: 1fr;
}
.admin-dashboard-metric-grid,
.admin-dashboard-operation-grid {
grid-template-columns: 1fr;
@@ -3710,6 +3710,7 @@
- 背景:后台需要默认进入运营数据面板,而不是服务 / 数据库状态页;看板要同时支持日 / 周 / 月筛选,并展示生产素材、泥点消耗、注册、访问和当前使用人数。
- 决策:`apps/admin-web` 默认路由改为 `#dashboard`,原 `#overview` 保留为“服务总览”。Dashboard 统一通过 `GET /admin/api/dashboard` 读取 api-server 后端投影,不让前端绕过 BFF 直接访问 SpacetimeDB。后端不新增 SpacetimeDB schema,聚合现有 `editor_project_resource``profile_wallet_ledger``profile_dashboard_state``tracking_daily_stat``tracking_event`
- 指标口径:生产素材数统计 `editor_project_resource.source_type = generated`;消耗泥点数统计 `profile_wallet_ledger.source_type = asset_operation_consume` 的负向流水绝对值;访问次数只统计 `tracking_daily_stat.scope_kind = site`;访问人数和当前使用人数按登录用户去重,匿名访问人数需要未来补 visitor id 后才能统计
- 2026-06-24 补充:Dashboard 日期控件改为起始日期 / 终止日期;`granularity=period` 使用 `startDate` / `endDate` 自定义闭区间,`day` / `week` / `month` 保留 `anchor` 兼容并由前端自动填充对应范围,页面周期切换展示为“本日 / 本周 / 本月 / 本时段”
- 指标口径:生产素材数统计 `editor_project_resource.source_type = generated`;消耗泥点数统计 `profile_wallet_ledger.source_type = asset_operation_consume` 的负向流水绝对值;总注册用户和新增用户数均来自 `profile_dashboard_state`,其中新增用户数按 `created_at` 落入当前时间窗统计;访问次数只统计 `tracking_daily_stat.scope_kind = site`;访问人数和当前使用人数按登录用户去重,匿名访问人数需要未来补 visitor id 后才能统计。
- 影响范围:`/admin/api/dashboard``shared-contracts` admin DTO、`apps/admin-web` 默认路由和 Dashboard 页面、后台运营文档。
- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml admin``npm run admin-web:typecheck``npx vitest run apps/admin-web/src/pages/AdminDashboardPage.test.tsx apps/admin-web/src/app/adminRoutes.test.ts --reporter verbose``npm run check:encoding``git diff --check`
@@ -8,11 +8,12 @@
## 查询参数
`GET /admin/api/dashboard?granularity=day|week|month&anchor=YYYY-MM-DD`
`GET /admin/api/dashboard?granularity=day|week|month|period&anchor=YYYY-MM-DD&startDate=YYYY-MM-DD&endDate=YYYY-MM-DD`
- `granularity` 默认为 `day`
- `anchor` 使用北京时间日历日期。`week``month` 选择包含该日期的自然周 / 自然月。
- 前端在日 / 周 / 月模式分别展示日期、周、月份选择器;周选择器会把选中的周转换为该周周一作为 `anchor`,月份选择器会转换为当月 1 日作为 `anchor`
- `anchor` 使用北京时间日历日期,保留给 `day` / `week` / `month` 兼容旧查询口径`week``month` 选择包含该日期的自然周 / 自然月。
- `period` 使用 `startDate` / `endDate` 作为闭区间自定义时段,最多 366 天
- 前端统一展示起始日期和终止日期两个日期选择器;点击“本日 / 本周 / 本月”会按当前终止日期自动填充对应自然日 / 自然周 / 自然月范围,手动修改起止日期或点击“本时段”后按当前日期范围查询。
- 前端每 5 分钟自动刷新一次,同时保留手动刷新按钮。
## 指标口径
@@ -20,6 +21,7 @@
- 生产素材数:`editor_project_resource``source_type = 'generated'` 的资源,按 `created_at` 映射到北京时间业务日。
- 消耗泥点数:`profile_wallet_ledger``source_type = asset_operation_consume``amount_delta < 0` 的流水绝对值,按 `created_at` 映射到北京时间业务日。
- 总注册用户:`profile_dashboard_state` 行数。
- 新增用户数:`profile_dashboard_state``created_at` 落在当前筛选时间窗内的账号数,按北京时间业务日归属,支持本日 / 本周 / 本月 / 本时段切换。
- 访问次数:`tracking_daily_stat``scope_kind = site` 的日聚合次数。它表示站点级成功路由 / 站点级事件,不把用户级钱包、任务、生成等业务操作混入访问次数。
- 访问人数:当前数据只具备登录用户维度,按 `tracking_daily_stat``scope_kind = user``scope_id` 去重;匿名访问人数需要未来补充稳定 visitor id 后才能统计。
- 当前使用人数:最近 5 分钟内 `tracking_event` 中有 `user_id` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。
+166 -2
View File
@@ -62,6 +62,7 @@ const ADMIN_TRACKING_EVENT_MAX_LIMIT: u32 = 1000;
const ADMIN_DATABASE_TABLE_DEFAULT_LIMIT: u32 = 100;
const ADMIN_DATABASE_TABLE_MAX_LIMIT: u32 = 500;
const ADMIN_DASHBOARD_ROW_LIMIT: u32 = 50_000;
const ADMIN_DASHBOARD_MAX_PERIOD_DAYS: i64 = 366;
const ADMIN_DASHBOARD_ACTIVE_WINDOW_MICROS: i64 = 5 * 60 * 1_000_000;
#[derive(Clone, Debug)]
@@ -633,6 +634,7 @@ enum AdminDashboardGranularity {
Day,
Week,
Month,
Period,
}
struct AdminDashboardRangeResolved {
@@ -661,6 +663,12 @@ struct AdminDashboardWalletStats {
warnings: Vec<String>,
}
#[derive(Default)]
struct AdminDashboardRegistrationStats {
new_registered_users: AdminDashboardSeries,
warnings: Vec<String>,
}
#[derive(Default)]
struct AdminDashboardTrackingStats {
visit_count: AdminDashboardSeries,
@@ -698,15 +706,20 @@ async fn build_admin_dashboard(
let wallet_stats = fetch_admin_dashboard_wallet_stats(state, &range).await;
warnings.extend(wallet_stats.warnings.iter().cloned());
let registration_stats = fetch_admin_dashboard_registration_stats(state, &range).await;
warnings.extend(registration_stats.warnings.iter().cloned());
let tracking_stats = fetch_admin_dashboard_tracking_stats(state, &range).await;
warnings.extend(tracking_stats.warnings.iter().cloned());
let generated_assets = asset_stats.generated_assets.total();
let consumed_mud_points = wallet_stats.consumed_mud_points.total();
let new_registered_users = registration_stats.new_registered_users.total();
let metrics = AdminDashboardMetricsPayload {
generated_assets,
consumed_mud_points,
total_registered_users,
new_registered_users,
visit_users: tracking_stats.visit_users,
total_visit_users: tracking_stats.total_visit_users,
visit_count: tracking_stats.visit_count.total(),
@@ -775,11 +788,16 @@ fn resolve_admin_dashboard_range(
"day" => AdminDashboardGranularity::Day,
"week" => AdminDashboardGranularity::Week,
"month" => AdminDashboardGranularity::Month,
"period" => AdminDashboardGranularity::Period,
_ => {
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("dashboard granularity 只支持 day/week/month"));
.with_message("dashboard granularity 只支持 day/week/month/period"));
}
};
if matches!(granularity, AdminDashboardGranularity::Period) {
return resolve_admin_dashboard_period_range(query);
}
let anchor_date_key = match query
.anchor
.as_deref()
@@ -805,6 +823,7 @@ fn resolve_admin_dashboard_range(
AdminDashboardGranularity::Month => {
(dimension.month_start_date_key, dimension.month_end_date_key)
}
AdminDashboardGranularity::Period => unreachable!("period range is resolved above"),
};
Ok(AdminDashboardRangeResolved {
granularity,
@@ -814,6 +833,52 @@ fn resolve_admin_dashboard_range(
})
}
fn resolve_admin_dashboard_period_range(
query: AdminDashboardQuery,
) -> Result<AdminDashboardRangeResolved, AppError> {
let start_date_key = parse_admin_dashboard_date_param(
query.start_date.as_deref(),
"dashboard startDate 必须是合法 YYYY-MM-DD 日期",
)?;
let end_date_key = parse_admin_dashboard_date_param(
query.end_date.as_deref(),
"dashboard endDate 必须是合法 YYYY-MM-DD 日期",
)?;
if start_date_key > end_date_key {
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("dashboard startDate 不能晚于 endDate"));
}
if end_date_key - start_date_key + 1 > ADMIN_DASHBOARD_MAX_PERIOD_DAYS {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message(format!(
"dashboard 自定义时段最多支持 {}",
ADMIN_DASHBOARD_MAX_PERIOD_DAYS
)),
);
}
Ok(AdminDashboardRangeResolved {
granularity: AdminDashboardGranularity::Period,
anchor_date_key: end_date_key,
start_date_key,
end_date_key,
})
}
fn parse_admin_dashboard_date_param(
value: Option<&str>,
error_message: &str,
) -> Result<i64, AppError> {
let date = value
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error_message.to_string())
})?;
module_runtime::parse_analytics_calendar_date_key(date).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error_message.to_string())
})
}
fn build_admin_dashboard_range_payload(
range: &AdminDashboardRangeResolved,
) -> AdminDashboardRangePayload {
@@ -829,6 +894,7 @@ fn build_admin_dashboard_range_payload(
let month_key = dimension.month_key;
format!("{:04}-{:02}", month_key / 100, month_key % 100)
}
AdminDashboardGranularity::Period => format!("{start}{end}"),
};
AdminDashboardRangePayload {
granularity: range.granularity.as_str().to_string(),
@@ -932,6 +998,50 @@ async fn fetch_admin_dashboard_wallet_stats(
stats
}
async fn fetch_admin_dashboard_registration_stats(
state: &AppState,
range: &AdminDashboardRangeResolved,
) -> AdminDashboardRegistrationStats {
let mut stats = AdminDashboardRegistrationStats::default();
let sql = format!(
"SELECT created_at FROM profile_dashboard_state LIMIT {}",
ADMIN_DASHBOARD_ROW_LIMIT
);
let rows = match fetch_admin_dashboard_rows(state, &sql).await {
Ok(rows) => rows,
Err(message) => {
stats
.warnings
.push(format!("新增用户统计读取失败:{message}"));
return stats;
}
};
warn_if_dashboard_rows_may_be_truncated(&mut stats.warnings, "新增用户", rows.len());
apply_admin_dashboard_registration_rows(&mut stats, range, rows.iter());
stats
}
fn apply_admin_dashboard_registration_rows<'a, I>(
stats: &mut AdminDashboardRegistrationStats,
range: &AdminDashboardRangeResolved,
rows: I,
) where
I: IntoIterator<Item = &'a Value>,
{
for row in rows {
let Some(columns) = row.as_array() else {
continue;
};
let Some(created_at_micros) = columns.first().and_then(timestamp_value_to_micros) else {
continue;
};
let day_key = module_runtime::runtime_profile_beijing_day_key(created_at_micros);
if range.contains_day_key(day_key) {
stats.new_registered_users.add(day_key, 1);
}
}
}
async fn fetch_admin_dashboard_tracking_stats(
state: &AppState,
range: &AdminDashboardRangeResolved,
@@ -1197,6 +1307,7 @@ fn admin_dashboard_bucket_label(day_key: i64, granularity: AdminDashboardGranula
.next()
.map(ToOwned::to_owned)
.unwrap_or(date),
AdminDashboardGranularity::Period => date.split('-').skip(1).collect::<Vec<_>>().join("/"),
}
}
@@ -1210,6 +1321,7 @@ impl AdminDashboardGranularity {
Self::Day => "day",
Self::Week => "week",
Self::Month => "month",
Self::Period => "period",
}
}
}
@@ -2339,7 +2451,8 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess
#[cfg(test)]
mod tests {
use super::{
AdminDashboardGranularity, apply_admin_database_table_filters,
AdminDashboardGranularity, AdminDashboardRegistrationStats, AdminDashboardSeries,
apply_admin_dashboard_registration_rows, apply_admin_database_table_filters,
build_admin_database_table_row, build_admin_tracking_events_sql, build_body_preview,
build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit,
clamp_admin_tracking_event_limit, is_safe_spacetime_table_name, normalize_debug_path,
@@ -2520,6 +2633,7 @@ mod tests {
let day = resolve_admin_dashboard_range(AdminDashboardQuery {
granularity: Some("day".to_string()),
anchor: Some("2026-06-23".to_string()),
..Default::default()
})
.expect("day range should resolve");
assert_eq!(day.granularity, AdminDashboardGranularity::Day);
@@ -2532,6 +2646,7 @@ mod tests {
let week = resolve_admin_dashboard_range(AdminDashboardQuery {
granularity: Some("week".to_string()),
anchor: Some("2026-06-23".to_string()),
..Default::default()
})
.expect("week range should resolve");
assert_eq!(
@@ -2546,6 +2661,7 @@ mod tests {
let month = resolve_admin_dashboard_range(AdminDashboardQuery {
granularity: Some("month".to_string()),
anchor: Some("2026-06-23".to_string()),
..Default::default()
})
.expect("month range should resolve");
assert_eq!(
@@ -2558,6 +2674,54 @@ mod tests {
);
}
#[test]
fn resolve_admin_dashboard_range_supports_custom_period() {
let period = resolve_admin_dashboard_range(AdminDashboardQuery {
granularity: Some("period".to_string()),
start_date: Some("2026-06-10".to_string()),
end_date: Some("2026-06-18".to_string()),
..Default::default()
})
.expect("period range should resolve");
assert_eq!(period.granularity, AdminDashboardGranularity::Period);
assert_eq!(
period.start_date_key,
module_runtime::parse_analytics_calendar_date_key("2026-06-10").unwrap()
);
assert_eq!(
period.end_date_key,
module_runtime::parse_analytics_calendar_date_key("2026-06-18").unwrap()
);
assert_eq!(period.anchor_date_key, period.end_date_key);
}
#[test]
fn apply_admin_dashboard_registration_rows_counts_created_accounts_in_range() {
let range = resolve_admin_dashboard_range(AdminDashboardQuery {
granularity: Some("period".to_string()),
start_date: Some("2026-06-10".to_string()),
end_date: Some("2026-06-18".to_string()),
..Default::default()
})
.expect("period range should resolve");
let rows = vec![
json!(["2026-06-09T15:59:59Z"]),
json!(["2026-06-10T00:30:00Z"]),
json!(["2026-06-18T15:59:59Z"]),
json!(["2026-06-18T16:00:00Z"]),
json!(["bad-timestamp"]),
];
let mut stats = AdminDashboardRegistrationStats {
new_registered_users: AdminDashboardSeries::default(),
warnings: Vec::new(),
};
apply_admin_dashboard_registration_rows(&mut stats, &range, rows.iter());
assert_eq!(stats.new_registered_users.total(), 2);
}
#[test]
fn timestamp_value_to_micros_accepts_sql_shapes() {
assert_eq!(
@@ -157,12 +157,17 @@ pub struct AdminOverviewResponse {
pub database: AdminDatabaseOverviewPayload,
}
/// 后台 dashboard 查询参数;anchor 使用北京时间日历日期 YYYY-MM-DD。
/// 后台 dashboard 查询参数;日期均使用北京时间日历日期 YYYY-MM-DD。
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDashboardQuery {
pub granularity: Option<String>,
/// day / week / month 兼容旧查询口径,表示被选中的日期。
pub anchor: Option<String>,
/// period 自定义时段起始日期,闭区间。
pub start_date: Option<String>,
/// period 自定义时段终止日期,闭区间。
pub end_date: Option<String>,
}
/// 后台 dashboard 返回运营指标、趋势图和读取告警。
@@ -195,6 +200,7 @@ pub struct AdminDashboardMetricsPayload {
pub generated_assets: u64,
pub consumed_mud_points: u64,
pub total_registered_users: u64,
pub new_registered_users: u64,
pub visit_users: u64,
pub total_visit_users: u64,
pub visit_count: u64,