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