新增后台Dashboard并修正画板规范图选择

新增后台 Dashboard 默认入口、日周月筛选和运营汇总页签

补充后台 Dashboard 数据契约、接口统计和文档

修正画板角色与图标规范图选择、缓存和提示状态

修复创作页桌面导航类型收窄错误

更新 Codex 环境初始化脚本和相关回归测试记录
This commit is contained in:
2026-06-23 22:41:33 +08:00
parent d693279d8a
commit fbecb3c7cb
33 changed files with 2400 additions and 89 deletions
+20
View File
@@ -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);
+68
View File
@@ -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;
+4
View File
@@ -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}
+3 -1
View File
@@ -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({
+6 -4
View File
@@ -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
+248
View File
@@ -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;
}