From 0c55d1ca879b5639f569277be043196bb74b20bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Wed, 24 Jun 2026 20:57:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E7=9C=8B=E6=9D=BF=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=97=B6=E6=AE=B5=E6=98=BE=E7=A4=BA=E5=92=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=94=A8=E6=88=B7=E6=95=B0=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台 Dashboard 日期控件改为起始日期和终止日期 新增本时段筛选并扩展前后端 Dashboard 查询契约 新增新增用户数指标,按 profile_dashboard_state.created_at 统计当前时间窗注册账号 补充后台看板测试和运营看板文档口径 --- apps/admin-web/src/api/adminApiClient.ts | 2 + apps/admin-web/src/api/adminApiTypes.ts | 5 +- .../src/pages/AdminDashboardPage.test.tsx | 70 ++++-- .../src/pages/AdminDashboardPage.tsx | 229 ++++++++++-------- apps/admin-web/src/styles/admin.css | 18 +- .../shared-memory/decision-log.md | 3 +- ...台管理】Dashboard运营看板方案-2026-06-23.md | 8 +- server-rs/crates/api-server/src/admin.rs | 168 ++++++++++++- .../crates/shared-contracts/src/admin.rs | 8 +- 9 files changed, 376 insertions(+), 135 deletions(-) diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 81893939c..5df358f76 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -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}` : ''; } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 7f550de16..7e4617954 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -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; diff --git a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx index 64f5412ac..46c47f20e 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx @@ -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(); - 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(); - 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(); - 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(); + + 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(); +}); diff --git a/apps/admin-web/src/pages/AdminDashboardPage.tsx b/apps/admin-web/src/pages/AdminDashboardPage.tsx index ce76add07..e3277a236 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.tsx @@ -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('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('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({ {dashboard?.range.periodLabel ?? '运营指标'} + + + 起始日期 + + handleDateRangeChange('startDate', event.target.value) + } + /> + + + 终止日期 + + handleDateRangeChange('endDate', event.target.value) + } + /> + + setGranularity(option.id)} + onClick={() => handleGranularityChange(option.id)} > {option.label} ))} - - {anchorInput.label} - { - const nextAnchor = normalizeAnchorInputValue( - granularity, - event.target.value, - ); - if (nextAnchor) { - setAnchor(nextAnchor); - } - }} - /> - ); + + 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 ( @@ -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) { diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 5cc78df0d..d826c2eff 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -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; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 725ea49da..8fbf4c8ce 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md index aa3de40e3..d9a618bc5 100644 --- a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md +++ b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md @@ -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` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 200db9373..bc0f39f7c 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -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, } +#[derive(Default)] +struct AdminDashboardRegistrationStats { + new_registered_users: AdminDashboardSeries, + warnings: Vec, +} + #[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 { + 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 { + 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, +{ + 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::>().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!( diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 4b62fc137..346ee1607 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -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, + /// day / week / month 兼容旧查询口径,表示被选中的日期。 pub anchor: Option, + /// period 自定义时段起始日期,闭区间。 + pub start_date: Option, + /// period 自定义时段终止日期,闭区间。 + pub end_date: Option, } /// 后台 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,
{dashboard?.range.periodLabel ?? '运营指标'}