新增后台Dashboard并修正画板规范图选择
新增后台 Dashboard 默认入口、日周月筛选和运营汇总页签 补充后台 Dashboard 数据契约、接口统计和文档 修正画板角色与图标规范图选择、缓存和提示状态 修复创作页桌面导航类型收窄错误 更新 Codex 环境初始化脚本和相关回归测试记录
This commit is contained in:
@@ -2,6 +2,8 @@ import type {
|
||||
AdminUpsertCreationEntryEventBannersRequest,
|
||||
AdminUpsertCreationEntryTypeConfigRequest,
|
||||
AdminCreationEntryConfigResponse,
|
||||
AdminDashboardQuery,
|
||||
AdminDashboardResponse,
|
||||
AdminDebugHttpRequest,
|
||||
AdminDebugHttpResponse,
|
||||
AdminDisableProfileRedeemCodeRequest,
|
||||
@@ -143,6 +145,16 @@ export function getAdminOverview(token: string) {
|
||||
return request<AdminOverviewResponse>('/admin/api/overview', { token });
|
||||
}
|
||||
|
||||
export function getAdminDashboard(
|
||||
token: string,
|
||||
query: AdminDashboardQuery = {},
|
||||
) {
|
||||
return request<AdminDashboardResponse>(
|
||||
`/admin/api/dashboard${buildDashboardQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminDatabaseTables(token: string) {
|
||||
return request<AdminDatabaseTableListResponse>('/admin/api/database/tables', {
|
||||
token,
|
||||
@@ -402,6 +414,14 @@ function buildQueryString(query: AdminTrackingEventListQuery) {
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildDashboardQuery(query: AdminDashboardQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'granularity', query.granularity);
|
||||
appendQueryParam(params, 'anchor', query.anchor);
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'search', query.search);
|
||||
|
||||
@@ -54,6 +54,74 @@ export interface AdminOverviewResponse {
|
||||
database: AdminDatabaseOverviewPayload;
|
||||
}
|
||||
|
||||
export type AdminDashboardGranularity = 'day' | 'week' | 'month';
|
||||
|
||||
export interface AdminDashboardQuery {
|
||||
granularity?: AdminDashboardGranularity;
|
||||
anchor?: string;
|
||||
}
|
||||
|
||||
export interface AdminDashboardResponse {
|
||||
range: AdminDashboardRangePayload;
|
||||
metrics: AdminDashboardMetricsPayload;
|
||||
charts: AdminDashboardChartPayload[];
|
||||
operations: AdminDashboardOperationsPayload;
|
||||
warnings: string[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminDashboardRangePayload {
|
||||
granularity: AdminDashboardGranularity;
|
||||
anchorDate: string;
|
||||
periodStartDate: string;
|
||||
periodEndDate: string;
|
||||
periodLabel: string;
|
||||
}
|
||||
|
||||
export interface AdminDashboardMetricsPayload {
|
||||
generatedAssets: number;
|
||||
consumedMudPoints: number;
|
||||
totalRegisteredUsers: number;
|
||||
visitUsers: number;
|
||||
totalVisitUsers: number;
|
||||
visitCount: number;
|
||||
totalVisitCount: number;
|
||||
currentUsers: number;
|
||||
}
|
||||
|
||||
export interface AdminDashboardChartPayload {
|
||||
id: string;
|
||||
title: string;
|
||||
unit: string;
|
||||
total: number;
|
||||
buckets: AdminDashboardChartBucketPayload[];
|
||||
}
|
||||
|
||||
export interface AdminDashboardChartBucketPayload {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AdminDashboardOperationsPayload {
|
||||
cards: AdminDashboardOperationMetricPayload[];
|
||||
assetKindBreakdown: AdminDashboardBreakdownRowPayload[];
|
||||
moduleVisitBreakdown: AdminDashboardBreakdownRowPayload[];
|
||||
}
|
||||
|
||||
export interface AdminDashboardOperationMetricPayload {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface AdminDashboardBreakdownRowPayload {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AdminServiceOverviewPayload {
|
||||
bindHost: string;
|
||||
bindPort: number;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
setStoredAdminToken,
|
||||
} from '../auth/adminAuthStore';
|
||||
import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage';
|
||||
import {AdminDashboardPage} from '../pages/AdminDashboardPage';
|
||||
import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
|
||||
import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
|
||||
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
|
||||
@@ -167,6 +168,9 @@ export function AdminApp() {
|
||||
onLogout={handleLogout}
|
||||
onRouteChange={handleRouteChange}
|
||||
>
|
||||
{routeId === 'dashboard' ? (
|
||||
<AdminDashboardPage token={token} onUnauthorized={handleUnauthorized} />
|
||||
) : null}
|
||||
{routeId === 'overview' ? (
|
||||
<AdminOverviewPage token={token} onUnauthorized={handleUnauthorized} />
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Activity,
|
||||
Bug,
|
||||
BadgeDollarSign,
|
||||
Coins,
|
||||
@@ -29,7 +30,8 @@ interface AdminShellProps {
|
||||
}
|
||||
|
||||
const routeIcons = {
|
||||
overview: LayoutDashboard,
|
||||
dashboard: LayoutDashboard,
|
||||
overview: Activity,
|
||||
tables: Database,
|
||||
debug: Bug,
|
||||
tracking: Table2,
|
||||
|
||||
@@ -2,6 +2,17 @@ import {expect, test} from 'vitest';
|
||||
|
||||
import {adminRoutes, resolveAdminRoute, routeHash} from './adminRoutes';
|
||||
|
||||
test('后台默认进入 Dashboard', () => {
|
||||
expect(adminRoutes[0]).toEqual({
|
||||
id: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
hash: '#dashboard',
|
||||
});
|
||||
expect(resolveAdminRoute('')).toBe('dashboard');
|
||||
expect(resolveAdminRoute('#unknown')).toBe('dashboard');
|
||||
expect(routeHash('dashboard')).toBe('#dashboard');
|
||||
});
|
||||
|
||||
// 中文注释:后台入口公告必须作为独立导航存在,避免公告表单被误藏在入口开关页。
|
||||
test('后台入口公告路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** 后台单页应用可导航的路由标识,入口公告独立于入口开关维护。 */
|
||||
export type AdminRouteId =
|
||||
| 'dashboard'
|
||||
| 'overview'
|
||||
| 'tables'
|
||||
| 'debug'
|
||||
@@ -21,7 +22,8 @@ export interface AdminRouteDefinition {
|
||||
}
|
||||
|
||||
export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{id: 'overview', label: '总览', hash: '#overview'},
|
||||
{id: 'dashboard', label: 'Dashboard', hash: '#dashboard'},
|
||||
{id: 'overview', label: '服务总览', hash: '#overview'},
|
||||
{id: 'tables', label: '表查询', hash: '#tables'},
|
||||
{id: 'debug', label: 'API 调试', hash: '#debug'},
|
||||
{id: 'tracking', label: '埋点数据', hash: '#tracking'},
|
||||
@@ -35,12 +37,12 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
|
||||
];
|
||||
|
||||
/** 根据地址栏 hash 解析后台路由,未知 hash 回落到总览页。 */
|
||||
/** 根据地址栏 hash 解析后台路由,未知 hash 回落到 Dashboard。 */
|
||||
export function resolveAdminRoute(hash: string): AdminRouteId {
|
||||
const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? '';
|
||||
return (
|
||||
adminRoutes.find((route) => route.hash === normalizedHash)?.id ??
|
||||
'overview'
|
||||
'dashboard'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +51,6 @@ export function routeHash(routeId: AdminRouteId) {
|
||||
return (
|
||||
adminRoutes.find((route) => route.id === routeId)?.hash ??
|
||||
adminRoutes[0]?.hash ??
|
||||
'#overview'
|
||||
'#dashboard'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {beforeEach, expect, test, vi} from 'vitest';
|
||||
|
||||
import {getAdminDashboard} from '../api/adminApiClient';
|
||||
import type {AdminDashboardResponse} from '../api/adminApiTypes';
|
||||
import {AdminDashboardPage} from './AdminDashboardPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
getAdminDashboard: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const dashboardResponse: AdminDashboardResponse = {
|
||||
range: {
|
||||
granularity: 'day',
|
||||
anchorDate: '2026-06-23',
|
||||
periodStartDate: '2026-06-23',
|
||||
periodEndDate: '2026-06-23',
|
||||
periodLabel: '2026-06-23',
|
||||
},
|
||||
metrics: {
|
||||
generatedAssets: 12,
|
||||
consumedMudPoints: 88,
|
||||
totalRegisteredUsers: 1200,
|
||||
visitUsers: 34,
|
||||
totalVisitUsers: 456,
|
||||
visitCount: 98,
|
||||
totalVisitCount: 9876,
|
||||
currentUsers: 7,
|
||||
},
|
||||
charts: [
|
||||
{
|
||||
id: 'generated-assets',
|
||||
title: '生产素材',
|
||||
unit: '个',
|
||||
total: 12,
|
||||
buckets: [{key: '2026-06-23', label: '2026-06-23', value: 12}],
|
||||
},
|
||||
],
|
||||
operations: {
|
||||
cards: [
|
||||
{
|
||||
id: 'period-generated-assets',
|
||||
label: '生产素材',
|
||||
value: 12,
|
||||
unit: '个',
|
||||
},
|
||||
],
|
||||
assetKindBreakdown: [
|
||||
{key: 'editor_generated_image', label: '画板图片', value: 9},
|
||||
],
|
||||
moduleVisitBreakdown: [{key: 'auth', label: '认证', value: 30}],
|
||||
},
|
||||
warnings: [],
|
||||
generatedAt: '2026-06-23T12:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAdminDashboard).mockResolvedValue(dashboardResponse);
|
||||
});
|
||||
|
||||
test('Dashboard 默认加载今日指标并支持运营汇总页签', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText('今日总生产素材数')).toBeTruthy();
|
||||
expect(screen.getByText('总注册用户')).toBeTruthy();
|
||||
expect(screen.getByText('当前使用人数(五分钟统计一次)')).toBeTruthy();
|
||||
expect(screen.getByText('生产素材')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '运营汇总' }));
|
||||
|
||||
expect(screen.getByText('素材类型分布')).toBeTruthy();
|
||||
expect(screen.getByText('画板图片')).toBeTruthy();
|
||||
expect(screen.getByText('访问模块分布')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Dashboard 选择周时带上 week granularity 和周一 anchor', 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 waitFor(() => {
|
||||
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
|
||||
granularity: 'week',
|
||||
anchor: '2026-06-29',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('Dashboard 选择月份时带上 month granularity 和月初 anchor', 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 waitFor(() => {
|
||||
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
|
||||
granularity: 'month',
|
||||
anchor: '2026-07-01',
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -271,6 +271,216 @@ button:disabled {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-dashboard-heading {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.admin-dashboard-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-dashboard-granularity {
|
||||
width: 188px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.admin-dashboard-date-field {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.admin-dashboard-tabs {
|
||||
display: inline-grid;
|
||||
width: fit-content;
|
||||
grid-template-columns: repeat(2, minmax(96px, 1fr));
|
||||
gap: 6px;
|
||||
border: 1px solid #e1ccbb;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.admin-dashboard-tabs button {
|
||||
min-height: 38px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: #755a49;
|
||||
background: transparent;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.admin-dashboard-tabs button[data-active="true"] {
|
||||
color: #8f3f27;
|
||||
background: #f4e5d7;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-grid,
|
||||
.admin-dashboard-chart-grid,
|
||||
.admin-dashboard-operation-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-dashboard-operation-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-dashboard-chart-grid,
|
||||
.admin-dashboard-operations {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-dashboard-operations {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-dashboard-operations > .admin-panel:first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-card {
|
||||
gap: 8px;
|
||||
min-height: 132px;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-card[data-compact="true"] {
|
||||
min-height: 112px;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-card span {
|
||||
color: #8f7868;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-card strong {
|
||||
color: #3d1f10;
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-card small {
|
||||
color: #a38f80;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-dashboard-chart-card {
|
||||
min-height: 286px;
|
||||
}
|
||||
|
||||
.admin-dashboard-bars {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(18px, 1fr);
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
min-height: 196px;
|
||||
overflow-x: auto;
|
||||
padding: 4px 2px 0;
|
||||
}
|
||||
|
||||
.admin-dashboard-bar-item {
|
||||
display: grid;
|
||||
min-width: 18px;
|
||||
gap: 7px;
|
||||
align-items: end;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.admin-dashboard-bar-track {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 18px;
|
||||
height: 160px;
|
||||
align-items: flex-end;
|
||||
overflow: hidden;
|
||||
border-radius: 7px;
|
||||
background: #f4e5d7;
|
||||
}
|
||||
|
||||
.admin-dashboard-bar-track span {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 7px 7px 0 0;
|
||||
background: linear-gradient(180deg, #c87955, #8f3f27);
|
||||
}
|
||||
|
||||
.admin-dashboard-bar-item small {
|
||||
max-width: 48px;
|
||||
overflow: hidden;
|
||||
color: #8f7868;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-panel {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-row {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-row > div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-row strong,
|
||||
.admin-dashboard-breakdown-row span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-row strong {
|
||||
color: #3d1f10;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-row span {
|
||||
color: #8f7868;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-track {
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #f4e5d7;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #8f3f27;
|
||||
}
|
||||
|
||||
.admin-two-column {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(320px, 1.1fr);
|
||||
@@ -1048,6 +1258,8 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-overview-grid,
|
||||
.admin-dashboard-chart-grid,
|
||||
.admin-dashboard-operations,
|
||||
.admin-two-column,
|
||||
.admin-two-column-wide,
|
||||
.admin-pricing-grid,
|
||||
@@ -1058,6 +1270,25 @@ button:disabled {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-dashboard-heading {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.admin-dashboard-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.admin-dashboard-granularity,
|
||||
.admin-dashboard-date-field,
|
||||
.admin-dashboard-actions .admin-secondary-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-grid,
|
||||
.admin-dashboard-operation-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-field-compact {
|
||||
max-width: none;
|
||||
}
|
||||
@@ -1116,6 +1347,23 @@ button:disabled {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-dashboard-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-grid,
|
||||
.admin-dashboard-operation-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-card {
|
||||
min-height: 112px;
|
||||
}
|
||||
|
||||
.admin-dashboard-chart-card {
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.admin-header-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user