新增后台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
+1
View File
@@ -5,6 +5,7 @@ name = "Genarrative"
[setup]
script = '''
cp "$CODEX_SOURCE_TREE_PATH/.env.secrets.local" "$CODEX_WORKTREE_PATH/.env.secrets.local"
git reset --hard
npm install
npm run codegraph:init
npm run codegraph:index
+2 -1
View File
@@ -1,6 +1,6 @@
# 图片画布编辑器 Lovart 化执行跟踪
更新时间:`2026-06-17`
更新时间:`2026-06-23`
## 目标
@@ -73,6 +73,7 @@
- `npm run check:encoding`
- `git diff --check`
- Headless Playwright smoke`http://127.0.0.1:10000/editor` 可展示画布、缩放菜单、底部工具栏和图片工具栏;只启动 `dev:web``/api/*` 代理 500 属于未启动后端的预期现象。
- 2026-06-23 规范图选择回归修正:角色规范槽只接受画布中的规范图,图标素材与 UI 设计规范槽只接受图标规范图;选择普通角色图、图标图或其它不合格图片时不写回引用,并在画板顶部显示 warning toast。已验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
- 2026-06-12 Lovart 布局修正 smoke`http://127.0.0.1:10000/editor` 已移除左侧竖向工具栏和右侧独立图层栏;素材、已生成文件、图层统一收在左侧可折叠面板,中央画布和周边面板保持浅色一体布局;截图留存于 `output/playwright/editor-left-integrated-light.png`
- 2026-06-12 外圈背景修正 smoke`http://127.0.0.1:10000/editor` 的编辑器宿主和画布根容器已铺成同一块白色工作台,不再通过圆角边框露出底部平台背景;截图留存于 `output/playwright/editor-no-outer-background.png`
- 2026-06-12 画布背景与原生菜单修正 smoke:`http://127.0.0.1:10000/editor` 已拦截编辑器区域右键菜单,禁用长按文本选择 / iOS callout,并移除画布网格线与棋盘格底纹;截图留存于 `output/playwright/editor-plain-background.png`
+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;
}
+1 -1
View File
@@ -40,7 +40,7 @@ Expo React Native 移动壳和 Tauri 桌面壳的工程结构、同源 WebView
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。Pingora 只作为独立二进制影子网关试点时,边界、路由口径与替换前验收见 [【开发运维】Pingora独立网关试点-2026-06-11.md](./technical/【开发运维】Pingora独立网关试点-2026-06-11.md)。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。Pingora 只作为独立二进制影子网关试点时,边界、路由口径与替换前验收见 [【开发运维】Pingora独立网关试点-2026-06-11.md](./technical/【开发运维】Pingora独立网关试点-2026-06-11.md)。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md),Dashboard 默认入口、运营指标和统计口径见 [【后台管理】Dashboard运营看板方案-2026-06-23.md](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
@@ -3689,3 +3689,11 @@
- 2026-06-22 调整:release 打包资源在 Windows WebView 内可能以 `http://tauri.localhost/index.html` 出现,这仍是 Tauri 内部资源,不允许被导航拦截交给系统浏览器;`shell/navigation.rs` 必须允许 `http` / `https``*.localhost` 留在 WebView。Windows release 二进制必须使用 GUI subsystem,避免正式包启动时额外弹出控制台窗口。
- 影响范围:`apps/desktop-shell/src-tauri/tauri.conf.json``apps/desktop-shell/scripts/check-config.mjs``scripts/dev.test.ts`、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run test -- scripts/dev.test.ts -t "Linux 桌面壳显式指定 web-port"``cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml desktop_main_window_config_uses_dev_url_in_dev_builds desktop_webview_navigation_stays_on_packaged_or_same_origin_pages``npm run desktop-shell:typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-23 后台默认入口切到 Dashboard 运营看板
- 背景:后台需要默认进入运营数据面板,而不是服务 / 数据库状态页;看板要同时支持日 / 周 / 月筛选,并展示生产素材、泥点消耗、注册、访问和当前使用人数。
- 决策:`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 后才能统计。
- 影响范围:`/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`
@@ -0,0 +1,40 @@
# 后台 Dashboard 运营看板方案
## 范围
- 后台管理默认入口为 `#dashboard`,原服务 / 数据库状态页保留为 `#overview`,导航展示名为“服务总览”。
- Dashboard 由 `GET /admin/api/dashboard` 提供统一 BFF 投影,前端只展示后端返回的 `range``metrics``charts``operations``warnings`
- 本次不修改 SpacetimeDB schema,不新增统计表;读取现有 private 表后在 api-server 聚合。
## 查询参数
`GET /admin/api/dashboard?granularity=day|week|month&anchor=YYYY-MM-DD`
- `granularity` 默认为 `day`
- `anchor` 使用北京时间日历日期。`week``month` 选择包含该日期的自然周 / 自然月。
- 前端在日 / 周 / 月模式分别展示日期、周、月份选择器;周选择器会把选中的周转换为该周周一作为 `anchor`,月份选择器会转换为当月 1 日作为 `anchor`
- 前端每 5 分钟自动刷新一次,同时保留手动刷新按钮。
## 指标口径
- 生产素材数:`editor_project_resource``source_type = 'generated'` 的资源,按 `created_at` 映射到北京时间业务日。
- 消耗泥点数:`profile_wallet_ledger``source_type = asset_operation_consume``amount_delta < 0` 的流水绝对值,按 `created_at` 映射到北京时间业务日。
- 总注册用户:`profile_dashboard_state` 行数。
- 访问次数:`tracking_daily_stat``scope_kind = site` 的日聚合次数。它表示站点级成功路由 / 站点级事件,不把用户级钱包、任务、生成等业务操作混入访问次数。
- 访问人数:当前数据只具备登录用户维度,按 `tracking_daily_stat``scope_kind = user``scope_id` 去重;匿名访问人数需要未来补充稳定 visitor id 后才能统计。
- 当前使用人数:最近 5 分钟内 `tracking_event` 中有 `user_id` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。
- 运营汇总页签:复用同一时间窗,展示运营指标卡、素材类型分布和访问模块分布。
## 前后端文件
- 后端路由:`server-rs/crates/api-server/src/modules/admin.rs`
- 后端聚合:`server-rs/crates/api-server/src/admin.rs`
- 契约:`server-rs/crates/shared-contracts/src/admin.rs``apps/admin-web/src/api/adminApiTypes.ts`
- 前端页面:`apps/admin-web/src/pages/AdminDashboardPage.tsx`
- 后台路由:`apps/admin-web/src/app/adminRoutes.ts`
## 验证
- `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`
@@ -54,7 +54,7 @@ npm run check:server-rs-ddd
路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组:
- 健康检查:`GET /healthz`
- 后台管理:`/admin/api/*`,包括登录、概览、HTTP debug、埋点、表查询、创作入口开关、作品互动配置、作品可见性、兑换码、邀请码、任务配置和充值商品配置。
- 后台管理:`/admin/api/*`,包括登录、Dashboard 运营看板、概览、HTTP debug、埋点、表查询、创作入口开关、作品互动配置、作品可见性、兑换码、邀请码、任务配置和充值商品配置。Dashboard 指标口径见 [`docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md`](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)。
- 认证与账号:`/api/auth/*``/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
- 平台基础能力:`/api/llm/*``/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
File diff suppressed because it is too large Load Diff
@@ -5,12 +5,13 @@ use axum::{
use crate::{
admin::{
admin_debug_http, admin_get_creation_entry_config, admin_get_editor_generation_pricing,
admin_list_database_table_rows, admin_list_database_tables, admin_list_tracking_events,
admin_list_work_visibility, admin_login, admin_me, admin_overview,
admin_update_work_visibility, admin_upsert_creation_entry_config,
admin_upsert_creation_entry_event_banners_config, admin_upsert_editor_generation_pricing,
admin_upsert_public_work_interaction_config, require_admin_auth,
admin_dashboard, admin_debug_http, admin_get_creation_entry_config,
admin_get_editor_generation_pricing, admin_list_database_table_rows,
admin_list_database_tables, admin_list_tracking_events, admin_list_work_visibility,
admin_login, admin_me, admin_overview, admin_update_work_visibility,
admin_upsert_creation_entry_config, admin_upsert_creation_entry_event_banners_config,
admin_upsert_editor_generation_pricing, admin_upsert_public_work_interaction_config,
require_admin_auth,
},
runtime_profile::{
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
@@ -39,6 +40,13 @@ pub fn router(state: AppState) -> Router<AppState> {
require_admin_auth,
)),
)
.route(
"/admin/api/dashboard",
get(admin_dashboard).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/debug/http",
axum::routing::post(admin_debug_http).route_layer(middleware::from_fn_with_state(
@@ -157,6 +157,99 @@ pub struct AdminOverviewResponse {
pub database: AdminDatabaseOverviewPayload,
}
/// 后台 dashboard 查询参数;anchor 使用北京时间日历日期 YYYY-MM-DD。
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDashboardQuery {
pub granularity: Option<String>,
pub anchor: Option<String>,
}
/// 后台 dashboard 返回运营指标、趋势图和读取告警。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDashboardResponse {
pub range: AdminDashboardRangePayload,
pub metrics: AdminDashboardMetricsPayload,
pub charts: Vec<AdminDashboardChartPayload>,
pub operations: AdminDashboardOperationsPayload,
pub warnings: Vec<String>,
pub generated_at: String,
}
/// dashboard 当前筛选范围,所有日/周/月图表共用同一时间窗。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDashboardRangePayload {
pub granularity: String,
pub anchor_date: String,
pub period_start_date: String,
pub period_end_date: String,
pub period_label: String,
}
/// dashboard 首屏核心指标。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDashboardMetricsPayload {
pub generated_assets: u64,
pub consumed_mud_points: u64,
pub total_registered_users: u64,
pub visit_users: u64,
pub total_visit_users: u64,
pub visit_count: u64,
pub total_visit_count: u64,
pub current_users: u64,
}
/// dashboard 图表定义,前端只渲染后端给出的 bucket。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDashboardChartPayload {
pub id: String,
pub title: String,
pub unit: String,
pub total: u64,
pub buckets: Vec<AdminDashboardChartBucketPayload>,
}
/// dashboard 单个趋势桶。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDashboardChartBucketPayload {
pub key: String,
pub label: String,
pub value: u64,
}
/// dashboard 运营页签的常用运营看板。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDashboardOperationsPayload {
pub cards: Vec<AdminDashboardOperationMetricPayload>,
pub asset_kind_breakdown: Vec<AdminDashboardBreakdownRowPayload>,
pub module_visit_breakdown: Vec<AdminDashboardBreakdownRowPayload>,
}
/// dashboard 运营指标卡。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDashboardOperationMetricPayload {
pub id: String,
pub label: String,
pub value: u64,
pub unit: String,
}
/// dashboard 常用排行 / 分布行。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDashboardBreakdownRowPayload {
pub key: String,
pub label: String,
pub value: u64,
}
// 服务概览描述当前 api-server 与 SpacetimeDB 连接配置。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
@@ -540,20 +540,35 @@ describe('ImageCanvasGenerationDialogModel', () => {
characterReferences: [],
};
const sourceLayer = createLayer({ title: '参考图' });
const characterSpecLayer = createLayer({
title: '角色规范',
assetKind: 'spec',
});
expect(assignCharacterSpecReference(characterDialog, sourceLayer)).toEqual(
expect(
assignCharacterSpecReference(characterDialog, characterSpecLayer),
).toEqual(
expect.objectContaining({
status: 'idle',
errorMessage: undefined,
composerOpen: true,
characterSpecReference: expect.objectContaining({
id: 'canvas-layer-source',
label: '参考图',
label: '角色规范',
src: 'data:image/png;base64,source',
resourceId: 'resource-source',
}),
}),
);
expect(assignCharacterSpecReference(characterDialog, sourceLayer)).toBe(
characterDialog,
);
expect(
assignCharacterSpecReference(
characterDialog,
createLayer({ title: '角色形象', assetKind: 'character' }),
),
).toBe(characterDialog);
expect(
appendCharacterReference(characterDialog, sourceLayer),
).toMatchObject({
@@ -108,7 +108,7 @@ function getReferenceMediaType(layer: CanvasLayer) {
: 'image';
}
function isIconSpecReferenceLayer(layer: CanvasLayer) {
export function isIconSpecReferenceLayer(layer: CanvasLayer) {
if (layer.assetKind === 'icon-spec') {
return true;
}
@@ -126,6 +126,13 @@ function isIconSpecReferenceLayer(layer: CanvasLayer) {
);
}
export function isCharacterSpecReferenceLayer(layer: CanvasLayer) {
if (getReferenceMediaType(layer) !== 'image' || layer.assetKind !== 'spec') {
return false;
}
return !isIconSpecReferenceLayer(layer);
}
function appendLimitedSeedanceCanvasReference(
references: NonNullable<GenerateDialogState['generationReferences']>,
layer: CanvasLayer,
@@ -731,7 +738,7 @@ export function assignCharacterSpecReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
return dialog?.mode === 'character' && getReferenceMediaType(layer) === 'image'
return dialog?.mode === 'character' && isCharacterSpecReferenceLayer(layer)
? {
...resetFailedGenerationDialog(dialog),
characterSpecReference: createCanvasLayerReference(layer),
@@ -373,6 +373,36 @@ describe('ImageCanvasGenerationLayerModel', () => {
});
});
it('keeps the spritesheet layer when icon results are missing', () => {
const layers = createIconSpritesheetResultLayers({
generated: {
spritesheetImageSrc: 'data:image/png;base64,sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
iconImageSrcs: [],
prompt: '图标 prompt',
actualPrompt: '图标 actual prompt',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-icons',
priceMudPoints: 20,
},
iconResults: undefined,
startIndex: 11,
canvasSize: { width: 900, height: 640 },
viewport: { x: 0, y: 0, scale: 1 },
generationInputs: createGenerationInputs(),
});
expect(layers).toHaveLength(1);
expect(layers[0]).toMatchObject({
id: 'layer-icon-spritesheet-11',
title: '图标素材图集 11',
assetKind: 'icon-spritesheet',
src: 'data:image/png;base64,sheet',
});
});
it('creates audio result layers centered on the active placeholder', () => {
const layer = createAudioResultLayer({
generated: {
@@ -43,7 +43,7 @@ type QuickEditResultLayerOptions = {
type IconSpritesheetResultLayerOptions = {
generated: EditorIconSpritesheetGenerationResult;
iconResults: EditorIconSpritesheetIconResult[];
iconResults?: EditorIconSpritesheetIconResult[] | null;
startIndex: number;
canvasSize: CanvasSize;
viewport: CanvasViewport;
@@ -299,7 +299,8 @@ export function createIconSpritesheetResultLayers({
let cursorY = startY;
let rowHeight = 0;
const iconLayers = iconResults.map((icon, index) => {
const normalizedIconResults = Array.isArray(iconResults) ? iconResults : [];
const iconLayers = normalizedIconResults.map((icon, index) => {
const resource = icon.resource;
const asset = icon.asset;
const originalWidth = icon.width || 128;
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type {
@@ -147,6 +147,57 @@ describe('ImageCanvasWorldView', () => {
expect(within(layerButton).queryByAltText('画布图片:角色主图')).toBeNull();
});
it('marks an already decoded cached image as loaded', async () => {
const completeDescriptor = Object.getOwnPropertyDescriptor(
HTMLImageElement.prototype,
'complete',
);
const naturalWidthDescriptor = Object.getOwnPropertyDescriptor(
HTMLImageElement.prototype,
'naturalWidth',
);
Object.defineProperty(HTMLImageElement.prototype, 'complete', {
configurable: true,
get: () => true,
});
Object.defineProperty(HTMLImageElement.prototype, 'naturalWidth', {
configurable: true,
get: () => 128,
});
try {
renderWorldView();
const image = screen.getByAltText('画布图片:角色主图');
await waitFor(() => {
expect(image.className).toContain(
'image-canvas-editor__layer-image--loaded',
);
});
} finally {
if (completeDescriptor) {
Object.defineProperty(
HTMLImageElement.prototype,
'complete',
completeDescriptor,
);
} else {
Reflect.deleteProperty(HTMLImageElement.prototype, 'complete');
}
if (naturalWidthDescriptor) {
Object.defineProperty(
HTMLImageElement.prototype,
'naturalWidth',
naturalWidthDescriptor,
);
} else {
Reflect.deleteProperty(HTMLImageElement.prototype, 'naturalWidth');
}
}
});
it('resolves image layers from objectKey before falling back to src', () => {
useResolvedAssetReadUrlMock.mockImplementation(
(_source: string, options?: { objectKey?: string | null }) => ({
@@ -339,6 +339,10 @@ function stopMediaControlKeyPropagation(
event.stopPropagation();
}
function isImageAlreadyDecoded(image: HTMLImageElement | null) {
return Boolean(image?.complete && image.naturalWidth > 0);
}
function getLayerMetricLabel(layer: CanvasLayer) {
return `${Math.round(layer.originalWidth)} x ${Math.round(
layer.originalHeight,
@@ -636,10 +640,11 @@ function ImageCanvasImageLayer({
refreshKey: layer.taskId ?? layer.resourceId,
},
);
const imageRef = useRef<HTMLImageElement | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
setIsLoaded(false);
setIsLoaded(isImageAlreadyDecoded(imageRef.current));
}, [resolvedUrl]);
const isLoading =
@@ -657,6 +662,7 @@ function ImageCanvasImageLayer({
) : null}
{resolvedUrl ? (
<img
ref={imageRef}
src={resolvedUrl}
alt={`画布图片:${layer.title}`}
decoding="async"
@@ -708,6 +714,7 @@ function ImageCanvasImageSequenceLayer({
currentFrame?.frameIndex ?? frameIndex + 1
}`,
});
const frameImageRef = useRef<HTMLImageElement | null>(null);
const [isFrameLoaded, setIsFrameLoaded] = useState(false);
useEffect(() => {
@@ -716,7 +723,7 @@ function ImageCanvasImageSequenceLayer({
}, [layer.taskId, layer.resourceId, frames.length]);
useEffect(() => {
setIsFrameLoaded(false);
setIsFrameLoaded(isImageAlreadyDecoded(frameImageRef.current));
}, [resolvedUrl]);
useEffect(() => {
@@ -747,6 +754,7 @@ function ImageCanvasImageSequenceLayer({
) : null}
{resolvedUrl ? (
<img
ref={frameImageRef}
src={resolvedUrl}
alt={`画布序列帧:${layer.title}`}
decoding="async"
@@ -1583,6 +1583,44 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(screen.getByTestId('fit-count').textContent).toBe('1');
});
it('keeps the extracted spritesheet when extracted icon results are missing', async () => {
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt: '仅提取被红色框框选的素材并整理成spritesheet',
actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
});
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
assetKind: 'ui-design',
objectKey: 'generated/ui-design.png',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提取UI素材' }));
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-spritesheet-1:源图 素材图集:-:icon-spritesheet',
);
});
expect(screen.getByTestId('layers').textContent).not.toContain(
'layer-icon-2:',
);
expect(screen.getByTestId('selected').textContent).toBe(
'layer-icon-spritesheet-1',
);
expect(screen.getByTestId('fit-count').textContent).toBe('1');
});
it('renders selected UI marks into the extraction reference image and sends derived extraction pricing', async () => {
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',

Some files were not shown because too many files have changed in this diff Show More