diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index c12cc7b1e..e5da99613 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -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 diff --git a/TRACKING.md b/TRACKING.md index 9d577ebd0..40ab2e64d 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -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`。 diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index dcd5340d4..c0d2329f4 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -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('/admin/api/overview', { token }); } +export function getAdminDashboard( + token: string, + query: AdminDashboardQuery = {}, +) { + return request( + `/admin/api/dashboard${buildDashboardQuery(query)}`, + { token }, + ); +} + export function getAdminDatabaseTables(token: string) { return request('/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); diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index c876dcc40..3dc767592 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -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; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 8a30a7afd..78e45b7fb 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -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' ? ( + + ) : null} {routeId === 'overview' ? ( ) : null} diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx index cd975a644..2c9770439 100644 --- a/apps/admin-web/src/app/AdminShell.tsx +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -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, diff --git a/apps/admin-web/src/app/adminRoutes.test.ts b/apps/admin-web/src/app/adminRoutes.test.ts index 6428d7892..cd26f5405 100644 --- a/apps/admin-web/src/app/adminRoutes.test.ts +++ b/apps/admin-web/src/app/adminRoutes.test.ts @@ -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({ diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index d6a0769af..f5eb1849f 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -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' ); } diff --git a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx new file mode 100644 index 000000000..64f5412ac --- /dev/null +++ b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx @@ -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(); + + 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(); + + 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(); + + 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', + }); + }); +}); diff --git a/apps/admin-web/src/pages/AdminDashboardPage.tsx b/apps/admin-web/src/pages/AdminDashboardPage.tsx new file mode 100644 index 000000000..ce76add07 --- /dev/null +++ b/apps/admin-web/src/pages/AdminDashboardPage.tsx @@ -0,0 +1,502 @@ +import {RefreshCw} from 'lucide-react'; +import {useCallback, useEffect, useMemo, useState} from 'react'; + +import {getAdminDashboard} from '../api/adminApiClient'; +import type { + AdminDashboardBreakdownRowPayload, + AdminDashboardChartPayload, + AdminDashboardGranularity, + AdminDashboardResponse, +} from '../api/adminApiTypes'; +import {handlePageError} from './pageUtils'; + +interface AdminDashboardPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +type AdminDashboardTab = 'metrics' | 'operations'; + +const granularityOptions: Array<{ + id: AdminDashboardGranularity; + label: string; +}> = [ + {id: 'day', label: '日'}, + {id: 'week', label: '周'}, + {id: 'month', label: '月'}, +]; + +export function AdminDashboardPage({ + token, + onUnauthorized, +}: AdminDashboardPageProps) { + const [dashboard, setDashboard] = useState( + null, + ); + const [granularity, setGranularity] = + useState('day'); + const [anchor, setAnchor] = useState(() => formatDateInput(new Date())); + 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); + setErrorMessage(''); + try { + const response = await getAdminDashboard(token, { + granularity, + anchor, + }); + setDashboard(response); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsLoading(false); + } + }, [anchor, granularity, onUnauthorized, token]); + + useEffect(() => { + void loadDashboard(); + }, [loadDashboard]); + + useEffect(() => { + const timer = window.setInterval( + () => { + void loadDashboard(); + }, + 5 * 60 * 1000, + ); + return () => window.clearInterval(timer); + }, [loadDashboard]); + + const metrics = dashboard?.metrics; + const metricCards = useMemo( + () => [ + { + id: 'generated-assets', + label: `${rangePrefix(granularity)}总生产素材数`, + value: metrics?.generatedAssets ?? 0, + unit: '个', + }, + { + id: 'consumed-mud-points', + label: `${rangePrefix(granularity)}总消耗泥点数`, + value: metrics?.consumedMudPoints ?? 0, + unit: '泥点', + }, + { + id: 'total-registered-users', + label: '总注册用户', + value: metrics?.totalRegisteredUsers ?? 0, + unit: '人', + }, + { + id: 'visit-users', + label: `${rangePrefix(granularity)}访问人数`, + value: metrics?.visitUsers ?? 0, + unit: '人', + }, + { + id: 'total-visit-users', + label: '总访问人数', + value: metrics?.totalVisitUsers ?? 0, + unit: '人', + }, + { + id: 'visit-count', + label: `${rangePrefix(granularity)}访问次数`, + value: metrics?.visitCount ?? 0, + unit: '次', + }, + { + id: 'total-visit-count', + label: '总访问次数', + value: metrics?.totalVisitCount ?? 0, + unit: '次', + }, + { + id: 'current-users', + label: '当前使用人数(五分钟统计一次)', + value: metrics?.currentUsers ?? 0, + unit: '人', + }, + ], + [granularity, metrics], + ); + + return ( +
+
+
+

Dashboard

+

{dashboard?.range.periodLabel ?? '运营指标'}

+
+
+
+ {granularityOptions.map((option) => ( + + ))} +
+ + +
+
+ +
+ + +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + {dashboard?.warnings.length ? ( +
+
+

读取告警

+ {dashboard.warnings.length} +
+
    + {dashboard.warnings.map((message) => ( +
  • {message}
  • + ))} +
+
+ ) : null} + + {activeTab === 'metrics' ? ( + <> +
+ {metricCards.map((metric) => ( + + ))} +
+ +
+ {(dashboard?.charts ?? []).map((chart) => ( + + ))} + {dashboard && dashboard.charts.length === 0 ? ( +
暂无图表
+ ) : null} +
+ + ) : ( +
+
+
+

运营指标

+ {dashboard?.range.periodLabel ?? '-'} +
+
+ {(dashboard?.operations.cards ?? []).map((card) => ( + + ))} +
+
+ + + +
+ )} +
+ ); +} + +function MetricCard({ + label, + value, + unit, + compact = false, +}: { + label: string; + value: number; + unit: string; + compact?: boolean; +}) { + return ( +
+ {label} + {formatNumber(value)} + {unit} +
+ ); +} + +function ChartPanel({chart}: {chart: AdminDashboardChartPayload}) { + const maxValue = Math.max(1, ...chart.buckets.map((bucket) => bucket.value)); + return ( +
+
+

{chart.title}

+ + {formatNumber(chart.total)} {chart.unit} + +
+
+ {chart.buckets.map((bucket) => ( +
+
+ +
+ {bucket.label} +
+ ))} +
+
+ ); +} + +function BreakdownPanel({ + title, + rows, + unit, +}: { + title: string; + rows: AdminDashboardBreakdownRowPayload[]; + unit: string; +}) { + const maxValue = Math.max(1, ...rows.map((row) => row.value)); + return ( +
+
+

{title}

+ {rows.length} +
+
+ {rows.map((row) => ( +
+
+ {row.label} + + {formatNumber(row.value)} {unit} + +
+
+ +
+
+ ))} + {rows.length === 0 ? ( +
暂无数据
+ ) : null} +
+
+ ); +} + +function formatNumber(value: number) { + return new Intl.NumberFormat('zh-CN').format(value); +} + +function formatDateInput(date: Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + 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 '本周'; + } + if (granularity === 'month') { + return '本月'; + } + return '今日'; +} + +function dateToWeekInputValue(dateValue: string) { + const date = parseDateValueAsUtc(dateValue); + if (!date) { + return ''; + } + + 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, + ); + return `${weekYear}-W${String(week).padStart(2, '0')}`; +} + +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 parseDateValueAsUtc(value: string) { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) { + return null; + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(Date.UTC(year, month - 1, day)); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() + 1 !== month || + date.getUTCDate() !== day + ) { + return null; + } + return date; +} + +function formatUtcDate(date: Date) { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 6aa13364f..5cc78df0d 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -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; } diff --git a/docs/README.md b/docs/README.md index e991bca52..9f4736622 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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)。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a1dc00759..82123a2f7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.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`。 diff --git a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md new file mode 100644 index 000000000..aa3de40e3 --- /dev/null +++ b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md @@ -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` diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index b89419150..41d4435f9 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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 和语音代理。 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index eae310620..200db9373 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -1,5 +1,5 @@ use std::{ - collections::BTreeSet, + collections::{BTreeMap, BTreeSet}, fs, net::{IpAddr, Ipv4Addr, Ipv6Addr}, }; @@ -19,15 +19,18 @@ use serde::Deserialize; use serde_json::{Map, Value}; use shared_contracts::admin::{ AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload, - AdminDatabaseOverviewPayload, AdminDatabaseTableListResponse, AdminDatabaseTableRowPayload, - AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse, AdminDatabaseTableStatPayload, - AdminDebugHeaderInput, AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest, - AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload, - AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery, - AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest, - AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryEventBannersRequest, - AdminUpsertCreationEntryTypeConfigRequest, AdminUpsertPublicWorkInteractionConfigRequest, - AdminWorkVisibilityListResponse, + AdminDashboardBreakdownRowPayload, AdminDashboardChartBucketPayload, + AdminDashboardChartPayload, AdminDashboardMetricsPayload, AdminDashboardOperationMetricPayload, + AdminDashboardOperationsPayload, AdminDashboardQuery, AdminDashboardRangePayload, + AdminDashboardResponse, AdminDatabaseOverviewPayload, AdminDatabaseTableListResponse, + AdminDatabaseTableRowPayload, AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse, + AdminDatabaseTableStatPayload, AdminDebugHeaderInput, AdminDebugHttpRequest, + AdminDebugHttpResponse, AdminLoginRequest, AdminLoginResponse, AdminMeResponse, + AdminOverviewResponse, AdminServiceOverviewPayload, AdminSessionPayload, + AdminTrackingEventEntryPayload, AdminTrackingEventListQuery, AdminTrackingEventListResponse, + AdminUpdateWorkVisibilityRequest, AdminUpdateWorkVisibilityResponse, + AdminUpsertCreationEntryEventBannersRequest, AdminUpsertCreationEntryTypeConfigRequest, + AdminUpsertPublicWorkInteractionConfigRequest, AdminWorkVisibilityListResponse, }; use shared_contracts::creation_entry_config::{ encode_unified_creation_spec_response, validate_unified_creation_spec_for_play, @@ -58,6 +61,8 @@ const ADMIN_TRACKING_EVENT_DEFAULT_LIMIT: u32 = 200; 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_ACTIVE_WINDOW_MICROS: i64 = 5 * 60 * 1_000_000; #[derive(Clone, Debug)] pub struct AuthenticatedAdmin { @@ -159,6 +164,16 @@ pub async fn admin_overview( Ok(json_success_body(Some(&request_context), overview)) } +pub async fn admin_dashboard( + State(state): State, + Extension(request_context): Extension, + Extension(_admin): Extension, + Query(query): Query, +) -> Result, AppError> { + let dashboard = build_admin_dashboard(&state, query).await?; + Ok(json_success_body(Some(&request_context), dashboard)) +} + pub async fn admin_debug_http( State(state): State, Extension(request_context): Extension, @@ -613,6 +628,627 @@ async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPaylo } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AdminDashboardGranularity { + Day, + Week, + Month, +} + +struct AdminDashboardRangeResolved { + granularity: AdminDashboardGranularity, + anchor_date_key: i64, + start_date_key: i64, + end_date_key: i64, +} + +#[derive(Default)] +struct AdminDashboardSeries { + buckets: BTreeMap, +} + +#[derive(Default)] +struct AdminDashboardAssetStats { + generated_assets: AdminDashboardSeries, + asset_kind_breakdown: BTreeMap, + warnings: Vec, +} + +#[derive(Default)] +struct AdminDashboardWalletStats { + consumed_mud_points: AdminDashboardSeries, + recharged_mud_points: u64, + warnings: Vec, +} + +#[derive(Default)] +struct AdminDashboardTrackingStats { + visit_count: AdminDashboardSeries, + visit_users: u64, + total_visit_count: u64, + total_visit_users: u64, + current_users: u64, + module_visit_breakdown: BTreeMap, + warnings: Vec, +} + +async fn build_admin_dashboard( + state: &AppState, + query: AdminDashboardQuery, +) -> Result { + let range = resolve_admin_dashboard_range(query)?; + let mut warnings = Vec::new(); + + let total_registered_users = match fetch_admin_dashboard_count( + state, + "SELECT COUNT(*) AS row_count FROM profile_dashboard_state", + ) + .await + { + Ok(value) => value, + Err(message) => { + warnings.push(format!("注册用户总数读取失败:{message}")); + 0 + } + }; + + let asset_stats = fetch_admin_dashboard_asset_stats(state, &range).await; + warnings.extend(asset_stats.warnings.iter().cloned()); + + let wallet_stats = fetch_admin_dashboard_wallet_stats(state, &range).await; + warnings.extend(wallet_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 metrics = AdminDashboardMetricsPayload { + generated_assets, + consumed_mud_points, + total_registered_users, + visit_users: tracking_stats.visit_users, + total_visit_users: tracking_stats.total_visit_users, + visit_count: tracking_stats.visit_count.total(), + total_visit_count: tracking_stats.total_visit_count, + current_users: tracking_stats.current_users, + }; + + let charts = vec![ + build_admin_dashboard_chart( + "generated-assets", + "生产素材", + "个", + &asset_stats.generated_assets, + &range, + ), + build_admin_dashboard_chart( + "consumed-mud-points", + "消耗泥点", + "泥点", + &wallet_stats.consumed_mud_points, + &range, + ), + build_admin_dashboard_chart( + "visit-users", + "访问人数", + "人", + &AdminDashboardSeries::single_total(&range, tracking_stats.visit_users), + &range, + ), + build_admin_dashboard_chart( + "visit-count", + "访问次数", + "次", + &tracking_stats.visit_count, + &range, + ), + ]; + + Ok(AdminDashboardResponse { + range: build_admin_dashboard_range_payload(&range), + metrics: metrics.clone(), + charts, + operations: build_admin_dashboard_operations( + &metrics, + wallet_stats.recharged_mud_points, + asset_stats.asset_kind_breakdown, + tracking_stats.module_visit_breakdown, + ), + warnings, + generated_at: module_runtime::format_utc_micros(current_admin_dashboard_utc_micros()), + }) +} + +fn resolve_admin_dashboard_range( + query: AdminDashboardQuery, +) -> Result { + let granularity = match query + .granularity + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("day") + .to_ascii_lowercase() + .as_str() + { + "day" => AdminDashboardGranularity::Day, + "week" => AdminDashboardGranularity::Week, + "month" => AdminDashboardGranularity::Month, + _ => { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("dashboard granularity 只支持 day/week/month")); + } + }; + let anchor_date_key = match query + .anchor + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(anchor) => { + module_runtime::parse_analytics_calendar_date_key(anchor).map_err(|_| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("dashboard anchor 必须是合法 YYYY-MM-DD 日期") + })? + } + None => { + module_runtime::runtime_profile_beijing_day_key(current_admin_dashboard_utc_micros()) + } + }; + let dimension = module_runtime::build_analytics_date_dimension_from_date_key(anchor_date_key); + let (start_date_key, end_date_key) = match granularity { + AdminDashboardGranularity::Day => (anchor_date_key, anchor_date_key), + AdminDashboardGranularity::Week => { + (dimension.week_start_date_key, dimension.week_end_date_key) + } + AdminDashboardGranularity::Month => { + (dimension.month_start_date_key, dimension.month_end_date_key) + } + }; + Ok(AdminDashboardRangeResolved { + granularity, + anchor_date_key, + start_date_key, + end_date_key, + }) +} + +fn build_admin_dashboard_range_payload( + range: &AdminDashboardRangeResolved, +) -> AdminDashboardRangePayload { + let anchor = admin_dashboard_date_label(range.anchor_date_key); + let start = admin_dashboard_date_label(range.start_date_key); + let end = admin_dashboard_date_label(range.end_date_key); + let period_label = match range.granularity { + AdminDashboardGranularity::Day => anchor.clone(), + AdminDashboardGranularity::Week => format!("{start} 至 {end}"), + AdminDashboardGranularity::Month => { + let dimension = + module_runtime::build_analytics_date_dimension_from_date_key(range.anchor_date_key); + let month_key = dimension.month_key; + format!("{:04}-{:02}", month_key / 100, month_key % 100) + } + }; + AdminDashboardRangePayload { + granularity: range.granularity.as_str().to_string(), + anchor_date: anchor, + period_start_date: start, + period_end_date: end, + period_label, + } +} + +async fn fetch_admin_dashboard_asset_stats( + state: &AppState, + range: &AdminDashboardRangeResolved, +) -> AdminDashboardAssetStats { + let mut stats = AdminDashboardAssetStats::default(); + let sql = format!( + "SELECT asset_kind, created_at FROM editor_project_resource WHERE source_type = 'generated' 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()); + for row in rows { + let Some(columns) = row.as_array() else { + continue; + }; + let asset_kind = columns + .first() + .and_then(value_to_string) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "未分类".to_string()); + let Some(created_at_micros) = columns.get(1).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.generated_assets.add(day_key, 1); + *stats.asset_kind_breakdown.entry(asset_kind).or_insert(0) += 1; + } + } + stats +} + +async fn fetch_admin_dashboard_wallet_stats( + state: &AppState, + range: &AdminDashboardRangeResolved, +) -> AdminDashboardWalletStats { + let mut stats = AdminDashboardWalletStats::default(); + let sql = format!( + "SELECT amount_delta, source_type, created_at FROM profile_wallet_ledger 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()); + for row in rows { + let Some(columns) = row.as_array() else { + continue; + }; + let amount_delta = columns.get(0).and_then(value_to_i64).unwrap_or(0); + let source_type = columns + .get(1) + .and_then(wallet_ledger_source_type_to_string) + .unwrap_or_default(); + let Some(created_at_micros) = columns.get(2).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) { + continue; + } + match source_type.as_str() { + "asset_operation_consume" if amount_delta < 0 => { + stats + .consumed_mud_points + .add(day_key, amount_delta.unsigned_abs()); + } + "points_recharge" if amount_delta > 0 => { + stats.recharged_mud_points = stats + .recharged_mud_points + .saturating_add(amount_delta as u64); + } + _ => {} + } + } + stats +} + +async fn fetch_admin_dashboard_tracking_stats( + state: &AppState, + range: &AdminDashboardRangeResolved, +) -> AdminDashboardTrackingStats { + let mut stats = AdminDashboardTrackingStats::default(); + let daily_sql = format!( + "SELECT event_key, scope_kind, scope_id, day_key, count FROM tracking_daily_stat LIMIT {}", + ADMIN_DASHBOARD_ROW_LIMIT + ); + let daily_rows = match fetch_admin_dashboard_rows(state, &daily_sql).await { + Ok(rows) => rows, + Err(message) => { + stats + .warnings + .push(format!("访问聚合统计读取失败:{message}")); + Vec::new() + } + }; + warn_if_dashboard_rows_may_be_truncated(&mut stats.warnings, "访问聚合", daily_rows.len()); + let mut period_users = BTreeSet::new(); + let mut total_users = BTreeSet::new(); + for row in daily_rows { + let Some(columns) = row.as_array() else { + continue; + }; + let scope_kind = columns + .get(1) + .and_then(tracking_scope_kind_to_string) + .unwrap_or_default(); + let scope_id = columns.get(2).and_then(value_to_string).unwrap_or_default(); + let day_key = columns.get(3).and_then(value_to_i64).unwrap_or_default(); + let count = columns.get(4).and_then(value_to_u64).unwrap_or_default(); + if scope_kind == "site" { + stats.total_visit_count = stats.total_visit_count.saturating_add(count); + } + if scope_kind == "user" && !scope_id.trim().is_empty() && scope_id != "anonymous" { + total_users.insert(scope_id.clone()); + } + if range.contains_day_key(day_key) { + if scope_kind == "site" { + stats.visit_count.add(day_key, count); + } + if scope_kind == "user" && !scope_id.trim().is_empty() && scope_id != "anonymous" { + period_users.insert(scope_id); + } + } + } + stats.visit_users = period_users.len() as u64; + stats.total_visit_users = total_users.len() as u64; + + let event_sql = format!( + "SELECT user_id, module_key, day_key, occurred_at FROM tracking_event WHERE day_key >= {} AND day_key <= {} LIMIT {}", + range.start_date_key, range.end_date_key, ADMIN_DASHBOARD_ROW_LIMIT + ); + let event_rows = match fetch_admin_dashboard_rows(state, &event_sql).await { + Ok(rows) => rows, + Err(message) => { + stats + .warnings + .push(format!("访问明细统计读取失败:{message}")); + Vec::new() + } + }; + warn_if_dashboard_rows_may_be_truncated(&mut stats.warnings, "访问明细", event_rows.len()); + for row in event_rows { + let Some(columns) = row.as_array() else { + continue; + }; + let module_key = columns + .get(1) + .and_then(value_to_string) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + *stats.module_visit_breakdown.entry(module_key).or_insert(0) += 1; + } + + let active_since_micros = + current_admin_dashboard_utc_micros().saturating_sub(ADMIN_DASHBOARD_ACTIVE_WINDOW_MICROS); + let active_start_day_key = module_runtime::runtime_profile_beijing_day_key(active_since_micros); + let active_end_day_key = + module_runtime::runtime_profile_beijing_day_key(current_admin_dashboard_utc_micros()); + let current_sql = format!( + "SELECT user_id, occurred_at FROM tracking_event WHERE day_key >= {} AND day_key <= {} LIMIT {}", + active_start_day_key, active_end_day_key, ADMIN_DASHBOARD_ROW_LIMIT + ); + let current_rows = match fetch_admin_dashboard_rows(state, ¤t_sql).await { + Ok(rows) => rows, + Err(message) => { + stats + .warnings + .push(format!("当前使用人数读取失败:{message}")); + Vec::new() + } + }; + warn_if_dashboard_rows_may_be_truncated( + &mut stats.warnings, + "当前使用人数", + current_rows.len(), + ); + let mut current_users = BTreeSet::new(); + for row in current_rows { + let Some(columns) = row.as_array() else { + continue; + }; + let Some(occurred_at_micros) = columns.get(1).and_then(timestamp_value_to_micros) else { + continue; + }; + if occurred_at_micros >= active_since_micros { + if let Some(user_id) = columns + .first() + .and_then(value_to_string) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty() && value != "anonymous") + { + current_users.insert(user_id); + } + } + } + stats.current_users = current_users.len() as u64; + stats +} + +async fn fetch_admin_dashboard_count(state: &AppState, sql: &str) -> Result { + let client = Client::new(); + let server_root = state.config.spacetime_server_url.trim_end_matches('/'); + let database = state.config.spacetime_database.trim(); + let token = resolve_admin_spacetime_sql_token(state); + fetch_spacetime_sql_count(&client, server_root, database, token.as_deref(), sql).await +} + +async fn fetch_admin_dashboard_rows(state: &AppState, sql: &str) -> Result, String> { + let client = Client::new(); + let server_root = state.config.spacetime_server_url.trim_end_matches('/'); + let database = state.config.spacetime_database.trim(); + let token = resolve_admin_spacetime_sql_token(state); + let payload = + fetch_spacetime_sql_json(&client, server_root, database, token.as_deref(), sql).await?; + extract_first_sql_rows(payload) +} + +fn build_admin_dashboard_chart( + id: &str, + title: &str, + unit: &str, + series: &AdminDashboardSeries, + range: &AdminDashboardRangeResolved, +) -> AdminDashboardChartPayload { + AdminDashboardChartPayload { + id: id.to_string(), + title: title.to_string(), + unit: unit.to_string(), + total: series.total(), + buckets: range + .day_keys() + .into_iter() + .map(|day_key| AdminDashboardChartBucketPayload { + key: admin_dashboard_date_label(day_key), + label: admin_dashboard_bucket_label(day_key, range.granularity), + value: series.value(day_key), + }) + .collect(), + } +} + +fn build_admin_dashboard_operations( + metrics: &AdminDashboardMetricsPayload, + recharged_mud_points: u64, + asset_kind_breakdown: BTreeMap, + module_visit_breakdown: BTreeMap, +) -> AdminDashboardOperationsPayload { + AdminDashboardOperationsPayload { + cards: vec![ + AdminDashboardOperationMetricPayload { + id: "total-registered-users".to_string(), + label: "注册用户".to_string(), + value: metrics.total_registered_users, + unit: "人".to_string(), + }, + AdminDashboardOperationMetricPayload { + id: "period-visit-users".to_string(), + label: "访问人数".to_string(), + value: metrics.visit_users, + unit: "人".to_string(), + }, + AdminDashboardOperationMetricPayload { + id: "period-generated-assets".to_string(), + label: "生产素材".to_string(), + value: metrics.generated_assets, + unit: "个".to_string(), + }, + AdminDashboardOperationMetricPayload { + id: "period-recharged-mud-points".to_string(), + label: "充值入账泥点".to_string(), + value: recharged_mud_points, + unit: "泥点".to_string(), + }, + ], + asset_kind_breakdown: top_dashboard_breakdown_rows(asset_kind_breakdown, 8), + module_visit_breakdown: top_dashboard_breakdown_rows(module_visit_breakdown, 8), + } +} + +fn top_dashboard_breakdown_rows( + values: BTreeMap, + limit: usize, +) -> Vec { + let mut rows = values.into_iter().collect::>(); + rows.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0))); + rows.into_iter() + .take(limit) + .map(|(key, value)| AdminDashboardBreakdownRowPayload { + label: dashboard_breakdown_label(&key), + key, + value, + }) + .collect() +} + +fn dashboard_breakdown_label(key: &str) -> String { + match key { + "editor_generated_image" => "画板图片".to_string(), + "editor_character_image" => "角色形象".to_string(), + "editor_spec_image" => "生成规范".to_string(), + "editor_quick_edit_image" => "快速编辑".to_string(), + "editor_ui_design_image" => "UI 设计图".to_string(), + "publication-material" => "宣发素材".to_string(), + "icon-spritesheet" => "图标图集".to_string(), + "icon" => "图标".to_string(), + "profile" => "个人中心".to_string(), + "asset" => "素材".to_string(), + "auth" => "认证".to_string(), + "puzzle" => "拼图".to_string(), + "match3d" => "抓大鹅".to_string(), + "square-hole" => "方洞挑战".to_string(), + "creative-agent" => "创作 Agent".to_string(), + "external-generation" => "外部生成".to_string(), + "unknown" => "未分类".to_string(), + value => value.to_string(), + } +} + +fn warn_if_dashboard_rows_may_be_truncated(warnings: &mut Vec, label: &str, len: usize) { + if len >= ADMIN_DASHBOARD_ROW_LIMIT as usize { + warnings.push(format!( + "{label}统计已达到单次读取上限 {} 行,展示结果可能低于真实值", + ADMIN_DASHBOARD_ROW_LIMIT + )); + } +} + +fn admin_dashboard_date_label(day_key: i64) -> String { + module_runtime::build_analytics_date_dimension_from_date_key(day_key).calendar_date +} + +fn admin_dashboard_bucket_label(day_key: i64, granularity: AdminDashboardGranularity) -> String { + let date = admin_dashboard_date_label(day_key); + match granularity { + AdminDashboardGranularity::Day => date, + AdminDashboardGranularity::Week => date.split('-').skip(1).collect::>().join("/"), + AdminDashboardGranularity::Month => date + .rsplit('-') + .next() + .map(ToOwned::to_owned) + .unwrap_or(date), + } +} + +fn current_admin_dashboard_utc_micros() -> i64 { + (OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64 +} + +impl AdminDashboardGranularity { + fn as_str(self) -> &'static str { + match self { + Self::Day => "day", + Self::Week => "week", + Self::Month => "month", + } + } +} + +impl AdminDashboardRangeResolved { + fn contains_day_key(&self, day_key: i64) -> bool { + day_key >= self.start_date_key && day_key <= self.end_date_key + } + + fn day_keys(&self) -> Vec { + (self.start_date_key..=self.end_date_key).collect() + } +} + +impl AdminDashboardSeries { + fn add(&mut self, day_key: i64, value: u64) { + *self.buckets.entry(day_key).or_insert(0) = self + .buckets + .get(&day_key) + .copied() + .unwrap_or(0) + .saturating_add(value); + } + + fn value(&self, day_key: i64) -> u64 { + self.buckets.get(&day_key).copied().unwrap_or(0) + } + + fn total(&self) -> u64 { + self.buckets.values().copied().sum() + } + + fn single_total(range: &AdminDashboardRangeResolved, value: u64) -> Self { + let mut series = Self::default(); + series.add(range.anchor_date_key, value); + series + } +} + fn build_spacetime_schema_url(server_root: &str, database: &str) -> String { format!("{server_root}/v1/database/{database}/schema?{SPACETIME_SCHEMA_VERSION_QUERY}") } @@ -1366,16 +2002,7 @@ fn required_i64_column(columns: &[Value], index: usize, field_name: &str) -> Res let value = columns .get(index) .ok_or_else(|| format!("埋点行缺少 {field_name}"))?; - match value { - Value::Number(number) => number - .as_i64() - .ok_or_else(|| format!("埋点行 {field_name} 不是整数")), - Value::String(text) => text - .trim() - .parse::() - .map_err(|error| format!("埋点行 {field_name} 解析失败:{error}")), - _ => Err(format!("埋点行 {field_name} 类型非法")), - } + value_to_i64(value).ok_or_else(|| format!("埋点行 {field_name} 类型非法")) } fn value_to_string(value: &Value) -> Option { @@ -1403,6 +2030,66 @@ fn value_array_to_string(items: &[Value]) -> Option { Some(Value::Array(items.to_vec()).to_string()) } +fn value_to_i64(value: &Value) -> Option { + match value { + Value::Number(number) => number + .as_i64() + .or_else(|| number.as_u64().and_then(|value| i64::try_from(value).ok())), + Value::String(text) => text.trim().parse::().ok(), + Value::Object(object) => object.get("some").and_then(value_to_i64), + Value::Array(items) if items.len() == 1 => items.first().and_then(value_to_i64), + Value::Array(items) if items.len() == 2 => { + let tag = items.first().and_then(Value::as_u64)?; + if tag == 0 { + items.get(1).and_then(value_to_i64) + } else { + None + } + } + _ => None, + } +} + +fn value_to_u64(value: &Value) -> Option { + match value { + Value::Number(number) => number + .as_u64() + .or_else(|| number.as_i64().and_then(|value| u64::try_from(value).ok())), + Value::String(text) => text.trim().parse::().ok(), + Value::Object(object) => object.get("some").and_then(value_to_u64), + Value::Array(items) if items.len() == 1 => items.first().and_then(value_to_u64), + Value::Array(items) if items.len() == 2 => { + let tag = items.first().and_then(Value::as_u64)?; + if tag == 0 { + items.get(1).and_then(value_to_u64) + } else { + None + } + } + _ => None, + } +} + +fn timestamp_value_to_micros(value: &Value) -> Option { + value_to_i64(value).or_else(|| { + let text = value_to_string(value)?; + parse_timestamp_text_to_micros(&text) + }) +} + +fn parse_timestamp_text_to_micros(text: &str) -> Option { + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + if let Ok(value) = trimmed.parse::() { + return Some(value); + } + OffsetDateTime::parse(trimmed, &Rfc3339) + .ok() + .and_then(|timestamp| i64::try_from(timestamp.unix_timestamp_nanos() / 1_000).ok()) +} + fn tracking_scope_kind_to_string(value: &Value) -> Option { match value { Value::String(text) => Some(text.clone()), @@ -1428,6 +2115,56 @@ fn tracking_scope_kind_to_string(value: &Value) -> Option { } } +fn wallet_ledger_source_type_to_string(value: &Value) -> Option { + match value { + Value::String(text) => Some(normalize_wallet_ledger_source_type_text(text)), + Value::Object(object) => object + .get("tag") + .or_else(|| object.get("variant")) + .or_else(|| object.get("name")) + .and_then(value_to_string) + .map(|value| normalize_wallet_ledger_source_type_text(&value)), + Value::Array(items) => { + let index = items.first().and_then(Value::as_u64)?; + Some( + match index { + 0 => "snapshot_sync", + 1 => "new_user_registration_reward", + 2 => "invite_inviter_reward", + 3 => "invite_invitee_reward", + 4 => "points_recharge", + 5 => "asset_operation_consume", + 6 => "asset_operation_refund", + 7 => "redeem_code_reward", + 8 => "puzzle_author_incentive_claim", + 9 => "daily_task_reward", + _ => return Some(Value::Array(items.to_vec()).to_string()), + } + .to_string(), + ) + } + _ => value_to_string(value).map(|value| normalize_wallet_ledger_source_type_text(&value)), + } +} + +fn normalize_wallet_ledger_source_type_text(value: &str) -> String { + let trimmed = value.trim(); + let mut normalized = String::new(); + for (index, character) in trimmed.chars().enumerate() { + if character.is_ascii_uppercase() { + if index > 0 { + normalized.push('_'); + } + normalized.push(character.to_ascii_lowercase()); + } else if character == '-' || character == ' ' { + normalized.push('_'); + } else { + normalized.push(character); + } + } + normalized +} + fn timestamp_to_display_string(value: &Value) -> Option { match value { Value::Array(items) if items.len() == 1 => items.first().and_then(value_to_string), @@ -1602,16 +2339,20 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess #[cfg(test)] mod tests { use super::{ - 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, + AdminDashboardGranularity, 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, normalize_table_count_error, parse_admin_database_table_rows_sql_response, - parse_admin_tracking_events_sql_response, parse_spacetime_sql_count_response, trim_preview, + parse_admin_tracking_events_sql_response, parse_spacetime_sql_count_response, + resolve_admin_dashboard_range, timestamp_value_to_micros, trim_preview, + wallet_ledger_source_type_to_string, }; use axum::{http::StatusCode, response::IntoResponse}; use serde_json::json; - use shared_contracts::admin::{AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery}; + use shared_contracts::admin::{ + AdminDashboardQuery, AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery, + }; #[test] fn normalize_debug_path_rejects_absolute_url() { @@ -1774,6 +2515,77 @@ mod tests { assert_eq!(count, 3); } + #[test] + fn resolve_admin_dashboard_range_supports_day_week_month() { + let day = resolve_admin_dashboard_range(AdminDashboardQuery { + granularity: Some("day".to_string()), + anchor: Some("2026-06-23".to_string()), + }) + .expect("day range should resolve"); + assert_eq!(day.granularity, AdminDashboardGranularity::Day); + assert_eq!( + day.start_date_key, + module_runtime::parse_analytics_calendar_date_key("2026-06-23").unwrap() + ); + assert_eq!(day.end_date_key, day.start_date_key); + + let week = resolve_admin_dashboard_range(AdminDashboardQuery { + granularity: Some("week".to_string()), + anchor: Some("2026-06-23".to_string()), + }) + .expect("week range should resolve"); + assert_eq!( + week.start_date_key, + module_runtime::parse_analytics_calendar_date_key("2026-06-22").unwrap() + ); + assert_eq!( + week.end_date_key, + module_runtime::parse_analytics_calendar_date_key("2026-06-28").unwrap() + ); + + let month = resolve_admin_dashboard_range(AdminDashboardQuery { + granularity: Some("month".to_string()), + anchor: Some("2026-06-23".to_string()), + }) + .expect("month range should resolve"); + assert_eq!( + month.start_date_key, + module_runtime::parse_analytics_calendar_date_key("2026-06-01").unwrap() + ); + assert_eq!( + month.end_date_key, + module_runtime::parse_analytics_calendar_date_key("2026-06-30").unwrap() + ); + } + + #[test] + fn timestamp_value_to_micros_accepts_sql_shapes() { + assert_eq!( + timestamp_value_to_micros(&json!(1778207451731746_i64)), + Some(1778207451731746) + ); + assert_eq!( + timestamp_value_to_micros(&json!([1778207451731746_i64])), + Some(1778207451731746) + ); + assert_eq!( + timestamp_value_to_micros(&json!("2026-05-07T08:30:51.731746Z")), + Some(1778142651731746) + ); + } + + #[test] + fn wallet_ledger_source_type_to_string_maps_sats_enum() { + assert_eq!( + wallet_ledger_source_type_to_string(&json!([5, []])).as_deref(), + Some("asset_operation_consume") + ); + assert_eq!( + wallet_ledger_source_type_to_string(&json!("AssetOperationConsume")).as_deref(), + Some("asset_operation_consume") + ); + } + #[test] fn clamp_admin_database_table_limit_uses_default_and_bounds() { assert_eq!(clamp_admin_database_table_limit(None), 100); diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index 58f5629c4..62f272cf7 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -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 { 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( diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 4820d0038..4b62fc137 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -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, + pub anchor: Option, +} + +/// 后台 dashboard 返回运营指标、趋势图和读取告警。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminDashboardResponse { + pub range: AdminDashboardRangePayload, + pub metrics: AdminDashboardMetricsPayload, + pub charts: Vec, + pub operations: AdminDashboardOperationsPayload, + pub warnings: Vec, + 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, +} + +/// 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, + pub asset_kind_breakdown: Vec, + pub module_visit_breakdown: Vec, +} + +/// 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")] diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index 2fe74381b..e40487f25 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -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({ diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 989e45a10..c54fc5dd5 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -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, 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), diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts index 5dabd3961..b931ddc58 100644 --- a/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts @@ -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: { diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts index 5ec21fac3..65fa1aaef 100644 --- a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts @@ -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; diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 7f7c2b6da..7c8301309 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -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 }) => ({ diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 473a82eea..0226bccda 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -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(null); const [isLoaded, setIsLoaded] = useState(false); useEffect(() => { - setIsLoaded(false); + setIsLoaded(isImageAlreadyDecoded(imageRef.current)); }, [resolvedUrl]); const isLoading = @@ -657,6 +662,7 @@ function ImageCanvasImageLayer({ ) : null} {resolvedUrl ? ( {`画布图片:${layer.title}`}(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 ? ( {`画布序列帧:${layer.title}`} { 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( + , + ); + + 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', diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index d52a45c71..2b6c3da20 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -759,7 +759,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ } const nextLayers = addIconSpritesheetResultLayers( generated, - generated.iconImageSrcs, + generated.iconImageSrcs ?? [], generationInputs, extractionFrame, undefined, @@ -868,7 +868,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ } addIconSpritesheetResultLayers( generated, - generated.iconImageSrcs, + generated.iconImageSrcs ?? [], submissionPlan.generationInputs, canvasCompletionPlaceholder, canvasDialog.id, diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx index e1b70d30e..be112d68c 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx @@ -184,6 +184,20 @@ function GenerationSurfaceHarness() { > 切换宣发素材 + + +