后台看板增加时段显示和新增用户数量
后台 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;
|
||||
|
||||
Reference in New Issue
Block a user