后台看板增加时段显示和新增用户数量
后台 Dashboard 日期控件改为起始日期和终止日期 新增本时段筛选并扩展前后端 Dashboard 查询契约 新增新增用户数指标,按 profile_dashboard_state.created_at 统计当前时间窗注册账号 补充后台看板测试和运营看板文档口径
This commit is contained in:
@@ -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}` : '';
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user