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

后台 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;