diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index abe743139..8841b9ae0 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -128,6 +128,8 @@ export interface AdminDashboardMetricsPayload { consumedMudPoints: number; totalRegisteredUsers: number; newRegisteredUsers: number; + day1Retention: AdminDashboardRetentionMetricPayload; + day7Retention: AdminDashboardRetentionMetricPayload; visitUsers: number; totalVisitUsers: number; visitCount: number; @@ -135,6 +137,12 @@ export interface AdminDashboardMetricsPayload { currentUsers: number; } +export interface AdminDashboardRetentionMetricPayload { + eligibleUsers: number; + retainedUsers: number; + rateBasisPoints: number; +} + export interface AdminDashboardChartPayload { id: string; title: string; @@ -459,6 +467,10 @@ export interface AdminEditorAssetPayload { generationCostMudPoints: number; createdAt: string; updatedAt: string; + generator: string; + taskGenerator: string; + taskCostMudPoints: number; + children: AdminEditorAssetPayload[]; } export interface AdminEditorAssetListResponse { diff --git a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx index 2c5164ecf..958dd723a 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx @@ -1,6 +1,12 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; @@ -29,6 +35,16 @@ const dashboardResponse: AdminDashboardResponse = { consumedMudPoints: 88, totalRegisteredUsers: 1200, newRegisteredUsers: 16, + day1Retention: { + eligibleUsers: 12, + retainedUsers: 3, + rateBasisPoints: 2500, + }, + day7Retention: { + eligibleUsers: 0, + retainedUsers: 0, + rateBasisPoints: 0, + }, visitUsers: 34, totalVisitUsers: 456, visitCount: 98, @@ -41,7 +57,10 @@ 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 }, + { key: '2026-06-24', label: '2026-06-24', value: 0 }, + ], }, ], operations: { @@ -82,8 +101,21 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () = expect(screen.getByText('本日生产素材数')).toBeTruthy(); expect(screen.getByText('总注册用户')).toBeTruthy(); expect(screen.getByText('本日新增用户数')).toBeTruthy(); - expect(screen.getByText('当前使用人数(五分钟统计一次)')).toBeTruthy(); + expect(screen.getByText('新增用户留存')).toBeTruthy(); + expect(screen.getByText('次日留存')).toBeTruthy(); + expect(screen.getByText('25%')).toBeTruthy(); + expect(screen.getByText('3 / 12 人')).toBeTruthy(); + const day7Card = screen.getByText('七日留存').closest('article'); + expect(day7Card).toBeTruthy(); + expect(within(day7Card as HTMLElement).getByText('-')).toBeTruthy(); + expect(within(day7Card as HTMLElement).getByText('0 / 0 人')).toBeTruthy(); + expect(screen.getByText('近 5 分钟活跃用户')).toBeTruthy(); expect(screen.getByText('生产素材')).toBeTruthy(); + expect( + screen + .getByTitle('2026-06-24: 0 个') + .firstElementChild?.getAttribute('style'), + ).toContain('height: 0%'); expect(screen.queryByRole('button', { name: '本时段' })).toBeNull(); await user.click(screen.getByRole('button', { name: '运营汇总' })); @@ -108,7 +140,7 @@ test('Dashboard 默认日期使用北京时间', async () => { }); }); -test('Dashboard 选择本周时按今天填充整周范围', async () => { +test('Dashboard 选择本周时按北京时间今天截断未来日期', async () => { const user = setupUser(); render(); @@ -123,12 +155,12 @@ test('Dashboard 选择本周时按今天填充整周范围', async () => { granularity: 'period', anchor: undefined, startDate: '2026-06-22', - endDate: '2026-06-28', + endDate: '2026-06-27', }); }); }); -test('Dashboard 选择本月时按今天填充整月范围', async () => { +test('Dashboard 选择本月时按北京时间今天截断未来日期', async () => { const user = setupUser(); render(); @@ -143,7 +175,7 @@ test('Dashboard 选择本月时按今天填充整月范围', async () => { granularity: 'period', anchor: undefined, startDate: '2026-06-01', - endDate: '2026-06-30', + endDate: '2026-06-27', }); }); }); @@ -188,6 +220,107 @@ test('Dashboard 手动选择起止日期时使用本时段查询', async () => { expect(screen.getByText('本时段新增用户数')).toBeTruthy(); }); +test('Dashboard 手动选择日期时不允许查询北京时间今天之后', async () => { + render(); + + await screen.findByText('本日生产素材数'); + const endDateInput = screen.getByLabelText('终止日期') as HTMLInputElement; + expect(endDateInput.max).toBe('2026-06-27'); + + fireEvent.change(endDateInput, { target: { value: '2026-07-12' } }); + + await waitFor(() => { + expect(endDateInput.value).toBe('2026-06-27'); + expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', { + granularity: 'period', + anchor: undefined, + startDate: '2026-06-27', + endDate: '2026-06-27', + }); + }); +}); + +test('Dashboard 四张趋势图共享横向日期窗口', async () => { + const baseChart = dashboardResponse.charts[0]!; + vi.mocked(getAdminDashboard).mockResolvedValue({ + ...dashboardResponse, + charts: [ + baseChart, + { + ...baseChart, + id: 'consumed-mud-points', + title: '消耗泥点', + unit: '泥点', + }, + ], + }); + render(); + + const chartRegions = await screen.findAllByRole('region', { + name: /趋势图$/, + }); + const [sourceChart, targetChart] = chartRegions; + if (!sourceChart || !targetChart) { + throw new Error('趋势图未完整渲染'); + } + setScrollableDimensions(sourceChart, { + clientWidth: 100, + scrollWidth: 500, + scrollLeft: 200, + }); + setScrollableDimensions(targetChart, { + clientWidth: 100, + scrollWidth: 500, + scrollLeft: 0, + }); + + fireEvent.scroll(sourceChart); + + await waitFor(() => { + expect(targetChart.scrollLeft).toBe(200); + }); +}); + +test('Dashboard 访问人数趋势明确区分每日桶与时段去重总数', async () => { + const baseChart = dashboardResponse.charts[0]!; + vi.mocked(getAdminDashboard).mockResolvedValue({ + ...dashboardResponse, + charts: [ + { + ...baseChart, + id: 'visit-users', + title: '每日访问人数', + unit: '人', + total: 34, + }, + ], + }); + + render(); + + expect(await screen.findByText('每日访问人数')).toBeTruthy(); + expect(screen.getByText('时段去重 34 人')).toBeTruthy(); +}); + function setupUser() { return userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); } + +function setScrollableDimensions( + element: HTMLElement, + dimensions: { + clientWidth: number; + scrollWidth: number; + scrollLeft: number; + }, +) { + Object.defineProperties(element, { + clientWidth: { configurable: true, value: dimensions.clientWidth }, + scrollWidth: { configurable: true, value: dimensions.scrollWidth }, + scrollLeft: { + configurable: true, + value: dimensions.scrollLeft, + writable: true, + }, + }); +} diff --git a/apps/admin-web/src/pages/AdminDashboardPage.tsx b/apps/admin-web/src/pages/AdminDashboardPage.tsx index d4f393504..d075e1ea4 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.tsx @@ -1,11 +1,12 @@ import { RefreshCw } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getAdminDashboard } from '../api/adminApiClient'; import type { AdminDashboardBreakdownRowPayload, AdminDashboardChartPayload, AdminDashboardGranularity, + AdminDashboardRetentionMetricPayload, AdminDashboardResponse, } from '../api/adminApiTypes'; import { handlePageError } from './pageUtils'; @@ -42,24 +43,29 @@ export function AdminDashboardPage({ const [activeTab, setActiveTab] = useState('metrics'); const [errorMessage, setErrorMessage] = useState(''); const [isLoading, setIsLoading] = useState(false); + const [chartScrollLeft, setChartScrollLeft] = useState(0); + const today = formatBeijingDateInput(new Date()); - const loadDashboard = useCallback(async (range = dateRange) => { - setIsLoading(true); - setErrorMessage(''); - try { - const response = await getAdminDashboard(token, { - granularity: 'period', - anchor: undefined, - startDate: range.startDate, - endDate: range.endDate, - }); - setDashboard(response); - } catch (error: unknown) { - handlePageError(error, onUnauthorized, setErrorMessage); - } finally { - setIsLoading(false); - } - }, [dateRange, onUnauthorized, token]); + const loadDashboard = useCallback( + async (range = dateRange) => { + setIsLoading(true); + setErrorMessage(''); + try { + const response = await getAdminDashboard(token, { + granularity: 'period', + anchor: undefined, + startDate: range.startDate, + endDate: range.endDate, + }); + setDashboard(response); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsLoading(false); + } + }, + [dateRange, onUnauthorized, token], + ); useEffect(() => { void loadDashboard(); @@ -75,6 +81,10 @@ export function AdminDashboardPage({ return () => window.clearInterval(timer); }, [loadDashboard]); + useEffect(() => { + setChartScrollLeft(0); + }, [dashboard?.range.periodEndDate, dashboard?.range.periodStartDate]); + const metrics = dashboard?.metrics; const totalMetricCards = useMemo( () => [ @@ -98,7 +108,7 @@ export function AdminDashboardPage({ }, { id: 'current-users', - label: '当前使用人数(五分钟统计一次)', + label: '近 5 分钟活跃用户', value: metrics?.currentUsers ?? 0, unit: '人', }, @@ -154,6 +164,7 @@ export function AdminDashboardPage({ 起始日期 handleDateRangeChange('startDate', event.target.value) @@ -164,6 +175,7 @@ export function AdminDashboardPage({ 终止日期 handleDateRangeChange('endDate', event.target.value) @@ -278,9 +290,25 @@ export function AdminDashboardPage({ +
+
+

新增用户留存

+ {dashboard?.range.periodLabel ?? '-'} +
+
+ + +
+
+
{(dashboard?.charts ?? []).map((chart) => ( - + ))} {dashboard && dashboard.charts.length === 0 ? (
暂无图表
@@ -347,9 +375,10 @@ export function AdminDashboardPage({ if (!parseDateValueAsUtc(value)) { return; } + const clampedValue = value > today ? today : value; setGranularity('period'); setDateRange((current) => - normalizeDateRange({ ...current, [field]: value }), + normalizeDateRange({ ...current, [field]: clampedValue }), ); } } @@ -377,17 +406,75 @@ function MetricCard({ ); } -function ChartPanel({ chart }: { chart: AdminDashboardChartPayload }) { +function RetentionCard({ + label, + metric, +}: { + label: string; + metric?: AdminDashboardRetentionMetricPayload; +}) { + const hasEligibleUsers = Boolean(metric?.eligibleUsers); + return ( +
+ {label} + + {hasEligibleUsers && metric + ? formatRateBasisPoints(metric.rateBasisPoints) + : '-'} + +
+ 留存人数 / 可观察新增人数 + + {metric + ? `${formatNumber(metric.retainedUsers)} / ${formatNumber(metric.eligibleUsers)} 人` + : '-'} + +
+
+ ); +} + +function ChartPanel({ + chart, + scrollLeft, + onScrollLeftChange, +}: { + chart: AdminDashboardChartPayload; + scrollLeft: number; + onScrollLeftChange: (scrollLeft: number) => void; +}) { + const barsRef = useRef(null); const maxValue = Math.max(1, ...chart.buckets.map((bucket) => bucket.value)); + + useEffect(() => { + const element = barsRef.current; + if (!element) { + return; + } + if (Math.abs(element.scrollLeft - scrollLeft) > 1) { + element.scrollLeft = scrollLeft; + } + }, [chart.buckets.length, scrollLeft]); + return (

{chart.title}

+ {chart.id === 'visit-users' ? '时段去重 ' : ''} {formatNumber(chart.total)} {chart.unit}
-
+
{ + onScrollLeftChange(event.currentTarget.scrollLeft); + }} + > {chart.buckets.map((bucket) => (
@@ -450,6 +540,13 @@ function formatNumber(value: number) { return new Intl.NumberFormat('zh-CN').format(value); } +function formatRateBasisPoints(value: number) { + return new Intl.NumberFormat('zh-CN', { + style: 'percent', + maximumFractionDigits: 2, + }).format(value / 10_000); +} + function formatBeijingDateInput(date: Date) { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', @@ -498,9 +595,7 @@ function buildWeekDateRange(dateValue: string) { 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) }; + return { startDate: formatUtcDate(monday), endDate: dateValue }; } function buildMonthDateRange(dateValue: string) { @@ -512,12 +607,9 @@ function buildMonthDateRange(dateValue: string) { const firstDate = new Date( Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1), ); - const lastDate = new Date( - Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0), - ); return { startDate: formatUtcDate(firstDate), - endDate: formatUtcDate(lastDate), + endDate: dateValue, }; } diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index c0cca000c..9d360b81c 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -148,6 +148,10 @@ const generatedAsset: AdminEditorAssetPayload = { generationCostMudPoints: 12, createdAt: '2026-07-04T10:00:00Z', updatedAt: '2026-07-04T10:00:00Z', + generator: '角色抠图', + taskGenerator: '角色生成', + taskCostMudPoints: 12, + children: [], }; beforeEach(() => { @@ -245,6 +249,80 @@ test('后台素材查询不展示分类筛选和分类列', async () => { expect(screen.queryByRole('columnheader', { name: '分类' })).toBeNull(); }); +test('后台素材查询将中间产物按真实类型逐行收纳在最终产物下', async () => { + const providerSource: AdminEditorAssetPayload = { + ...generatedAsset, + assetId: 'asset-source', + label: '角色形象生成原图', + objectKey: + 'generated-character-drafts/editor/character-images/provider-source-task-1.png', + imageSrc: + '/generated-character-drafts/editor/character-images/provider-source-task-1.png', + assetKind: 'character', + generator: '角色生图', + taskGenerator: '角色生成', + generationCostMudPoints: 12, + taskCostMudPoints: 12, + children: [], + }; + const postprocessOutput: AdminEditorAssetPayload = { + ...generatedAsset, + assetId: 'asset-postprocess', + label: '角色抠图阶段产物', + objectKey: + 'generated-character-drafts/editor/character-images/background-removal-task-1.png', + imageSrc: + '/generated-character-drafts/editor/character-images/background-removal-task-1.png', + assetKind: 'character', + generator: '角色抠图', + taskGenerator: '角色生成', + generationCostMudPoints: 0, + taskCostMudPoints: 0, + children: [], + }; + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries: [ + { + ...generatedAsset, + generationCostMudPoints: 0, + taskCostMudPoints: 12, + children: [providerSource, postprocessOutput], + }, + ], + nextCursor: null, + }); + + render( + , + ); + + const finalRow = (await screen.findByText('角色形象 1')).closest('tr'); + expect(finalRow).not.toBeNull(); + expect(within(finalRow!).getByText('12 泥点')).toBeTruthy(); + expect(screen.queryByText('角色形象生成原图')).toBeNull(); + expect(screen.queryByText('角色抠图阶段产物')).toBeNull(); + + fireEvent.click( + within(finalRow!).getByRole('button', { name: '展开中间产物' }), + ); + + const sourceRow = (await screen.findByText('角色形象生成原图')).closest('tr'); + const postprocessRow = screen.getByText('角色抠图阶段产物').closest('tr'); + expect(sourceRow).not.toBeNull(); + expect(postprocessRow).not.toBeNull(); + expect(within(sourceRow!).getByText('角色生图')).toBeTruthy(); + expect(within(sourceRow!).getByText('12 泥点')).toBeTruthy(); + expect(within(postprocessRow!).getByText('角色抠图')).toBeTruthy(); + expect( + within(postprocessRow!).getByText('本阶段不额外扣费'), + ).toBeTruthy(); + + fireEvent.click(within(sourceRow!).getByRole('button', { name: '详情' })); + const detail = await screen.findByRole('dialog', { name: '素材详情' }); + expect(within(detail).getByText('角色生图')).toBeTruthy(); + expect(within(detail).getByText('12 泥点')).toBeTruthy(); +}); + test('后台素材查询缩略图使用 objectKey 换签后展示', async () => { render( , diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx index 5bb5d25fa..e23a00aae 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx @@ -1,5 +1,5 @@ -import { Eye, FileText, RefreshCcw, X } from 'lucide-react'; -import type { ReactNode } from 'react'; +import { ChevronDown, ChevronRight, Eye, FileText, RefreshCcw, X } from 'lucide-react'; +import { Fragment, type ReactNode } from 'react'; import { useCallback, useEffect, useState } from 'react'; import { @@ -44,6 +44,9 @@ export function AdminEditorAssetQueryPage({ useState(null); const [previewEntry, setPreviewEntry] = useState(null); + const [expandedTaskIds, setExpandedTaskIds] = useState>( + () => new Set(), + ); const [promptPreview, setPromptPreview] = useState<{ title: string; prompt: string; @@ -149,7 +152,7 @@ export function AdminEditorAssetQueryPage({