diff --git a/.codex/hooks/pre-submit-compile-check.mjs b/.codex/hooks/pre-submit-compile-check.mjs index 97a5b305a..13f62b64b 100644 --- a/.codex/hooks/pre-submit-compile-check.mjs +++ b/.codex/hooks/pre-submit-compile-check.mjs @@ -14,20 +14,40 @@ if (hookInput && !isGitCommitCommand(extractShellCommand(hookInput))) { } const validationSteps = [ + { + label: 'Rust format check', + command: npmCommand, + args: + process.platform === 'win32' + ? ['/d', '/s', '/c', 'npm run check:rustfmt'] + : ['run', 'check:rustfmt'], + }, { label: 'TypeScript typecheck', command: npmCommand, - args: process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run typecheck'] : ['run', 'typecheck'], + args: + process.platform === 'win32' + ? ['/d', '/s', '/c', 'npm run typecheck'] + : ['run', 'typecheck'], }, { label: 'Admin web typecheck', command: npmCommand, - args: process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run admin-web:typecheck'] : ['run', 'admin-web:typecheck'], + args: + process.platform === 'win32' + ? ['/d', '/s', '/c', 'npm run admin-web:typecheck'] + : ['run', 'admin-web:typecheck'], }, { label: 'Rust api-server compile check', command: 'cargo', - args: ['check', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'], + args: [ + 'check', + '-p', + 'api-server', + '--manifest-path', + 'server-rs/Cargo.toml', + ], }, ]; @@ -66,7 +86,9 @@ function runStep(step) { } if (result.error) { - console.error(`[codex-hook] ${step.label} 启动失败:${result.error.message}`); + console.error( + `[codex-hook] ${step.label} 启动失败:${result.error.message}`, + ); return { ok: false, status: 1 }; } @@ -104,12 +126,15 @@ function extractShellCommand(input) { input?.command, ]; - const command = candidates.find(value => typeof value === 'string' && value.trim().length > 0); + const command = candidates.find( + (value) => typeof value === 'string' && value.trim().length > 0, + ); if (command) { return command; } - const shellCommand = input?.tool_input?.cmd ?? input?.toolInput?.cmd ?? input?.arguments?.cmd; + const shellCommand = + input?.tool_input?.cmd ?? input?.toolInput?.cmd ?? input?.arguments?.cmd; if (Array.isArray(shellCommand)) { return shellCommand.join(' '); } diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index abf8f6f3a..ac684ddf7 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -318,6 +318,14 @@ For image edit/redraw that should replace an existing canvas layer, pass `projec For sound effects and BGM, `assetFolderId` and `assetLabel` can write the generated audio to the account asset library, same as image/video generation. +## Successful Responses with Warnings + +Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction can return HTTP 2xx with an optional structured `warning`. A 2xx response means the task completed, but it does not guarantee that every requested post-processed derivative exists. + +- Apply the returned `project` and media snapshots before interpreting optional derivatives: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. When `warning.code` is `postprocess-failed-source-preserved`, the saved provider source image is the authoritative main result. Character output has no transparent derivative; icon spritesheet and UI extraction output have neither a transparent spritesheet nor slices. Display `warning.reason` directly, and do not synthesize missing derivatives or restart generation. +- `sliceWarning` is a separate condition used only when transparent spritesheet post-processing succeeded but automatic slicing failed. Keep `sliceWarning.reason` as the original diagnostic and continue using the complete transparent spritesheet; a UI may add context when displaying it, but must not rewrite the stored reason. +- The service contract keeps `warning` and `sliceWarning` mutually exclusive. As defensive handling for a malformed response containing both, treat the general `warning` as authoritative and do not misclassify the source-preserved result as a slicing-only warning. + ## Guardrails - Do not invent endpoints outside the OpenAPI, especially internal worker or runtime task-list routes. diff --git a/.codex/skills/genarrative-external-editor-api/references/api-selection.md b/.codex/skills/genarrative-external-editor-api/references/api-selection.md index 747d7ea28..77f7269e9 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-selection.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-selection.md @@ -78,6 +78,14 @@ Ask a follow-up only when two routes could both be correct and produce different All generation requests should be placed into both the current canvas and its same-name asset-library folder. For endpoints that support `assetLabel`, pass it. For UI extraction, use `spritesheetLabel`. For icon spritesheet, the folder is enough. For character animation, the endpoint does not return `asset`; after success call `POST /api/external/v1/editor/assets` using the first returned frame as `imageSrc`, the session `assetFolderId`, and `assetKind: "character-animation"`. +## HTTP 2xx Warning Handling + +Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction may return HTTP 2xx while carrying a structured `warning`; completion does not imply that all post-processed derivatives exist. + +- Consume the returned `project` and media snapshots as authoritative: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. `warning.code: "postprocess-failed-source-preserved"` means the saved provider source is the main result. Character output has no transparent derivative, while icon spritesheet and UI extraction have no transparent spritesheet and no slices. Display `warning.reason` directly; do not construct missing assets or retry the provider generation from scratch. +- `sliceWarning` is only for a transparent spritesheet that was created successfully but could not be split automatically. Use the complete transparent spritesheet and preserve `sliceWarning.reason` as the original diagnostic; it is not a post-processing/source-preserved warning. +- The service contract keeps `warning` and `sliceWarning` mutually exclusive. If a malformed response contains both, prioritize the general `warning` over `sliceWarning` defensively. + ## Reference Image Upload If the user provides a local file as a reference image, run upload before the generation request: diff --git a/.env.local b/.env.local index 66c7cb8e0..49f8cc1e8 100644 --- a/.env.local +++ b/.env.local @@ -29,6 +29,7 @@ GENARRATIVE_LLM_PROVIDER="ark" GENARRATIVE_LLM_BASE_URL="https://ark.cn-beijing.volces.com/api/v3" GENARRATIVE_LLM_API_KEY="eb750614-e0b5-402a-bfea-4224862d251e" GENARRATIVE_LLM_MODEL="doubao-1-5-pro-32k-character-250715" +GENARRATIVE_EDITOR_BGFILTER_BASE_URL="https://u1082648-b442-cd409e05.westx.seetacloud.com:8443" APIMART_BASE_URL="https://api.apimart.ai/v1" APIMART_API_KEY="" APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000 @@ -36,6 +37,7 @@ DASHSCOPE_SCENE_IMAGE_MODEL="wan2.2-t2i-flash" DASHSCOPE_REFERENCE_IMAGE_MODEL="qwen-image-2.0" DASHSCOPE_COVER_IMAGE_MODEL="wan2.2-t2i-flash" ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS=420000 + # 启用服务端大模型调试日志(记录所有输入输出) LLM_DEBUG_LOG="true" diff --git a/.gitignore b/.gitignore index ee516d36a..b9ff3d97f 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ temp*build*/ .worktrees/ .rag/ .env.secrets.local +nohup.out spacetime.local.json deploy/container/api-server.env deploy/container/worker-smoke/ diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 73540117a..8b1f4eb25 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -398,10 +398,11 @@ export function getAdminAssetReadUrl( export function listAdminEditorAssets( token: string, query: AdminEditorAssetListQuery = {}, + signal?: AbortSignal, ) { return request( `/admin/api/editor-assets${buildEditorAssetListQuery(query)}`, - { token }, + { token, signal }, ); } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 8841b9ae0..2aff05de0 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -128,6 +128,7 @@ export interface AdminDashboardMetricsPayload { consumedMudPoints: number; totalRegisteredUsers: number; newRegisteredUsers: number; + newUserPaymentConversion: AdminDashboardPaymentConversionPayload; day1Retention: AdminDashboardRetentionMetricPayload; day7Retention: AdminDashboardRetentionMetricPayload; visitUsers: number; @@ -137,6 +138,12 @@ export interface AdminDashboardMetricsPayload { currentUsers: number; } +export interface AdminDashboardPaymentConversionPayload { + paidUsers: number; + newRegisteredUsers: number; + rateBasisPoints: number; +} + export interface AdminDashboardRetentionMetricPayload { eligibleUsers: number; retainedUsers: number; @@ -460,6 +467,7 @@ export interface AdminEditorAssetPayload { model?: string | null; provider?: string | null; taskId?: string | null; + groupTaskId?: string | null; assetKind?: string | null; generationInputs?: Record | null; sourceResourceId?: string | null; diff --git a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx index 958dd723a..e8aa77b61 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx @@ -35,6 +35,11 @@ const dashboardResponse: AdminDashboardResponse = { consumedMudPoints: 88, totalRegisteredUsers: 1200, newRegisteredUsers: 16, + newUserPaymentConversion: { + paidUsers: 5, + newRegisteredUsers: 16, + rateBasisPoints: 3125, + }, day1Retention: { eligibleUsers: 12, retainedUsers: 3, @@ -101,7 +106,20 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () = expect(screen.getByText('本日生产素材数')).toBeTruthy(); expect(screen.getByText('总注册用户')).toBeTruthy(); expect(screen.getByText('本日新增用户数')).toBeTruthy(); - expect(screen.getByText('新增用户留存')).toBeTruthy(); + expect(screen.getByText('新增用户转化与留存')).toBeTruthy(); + const paymentRateCard = screen + .getByText('本日新增用户付费率') + .closest('article'); + expect(paymentRateCard).toBeTruthy(); + expect( + within(paymentRateCard as HTMLElement).getByText('31.25%'), + ).toBeTruthy(); + expect( + within(paymentRateCard as HTMLElement).getByText('付费人数 / 新增人数'), + ).toBeTruthy(); + expect( + within(paymentRateCard as HTMLElement).getByText('5 / 16 人'), + ).toBeTruthy(); expect(screen.getByText('次日留存')).toBeTruthy(); expect(screen.getByText('25%')).toBeTruthy(); expect(screen.getByText('3 / 12 人')).toBeTruthy(); @@ -218,6 +236,33 @@ test('Dashboard 手动选择起止日期时使用本时段查询', async () => { }); }); expect(screen.getByText('本时段新增用户数')).toBeTruthy(); + expect(screen.getByText('本时段新增用户付费率')).toBeTruthy(); +}); + +test('Dashboard 新增用户付费率分母为零时显示横线', async () => { + vi.mocked(getAdminDashboard).mockResolvedValue({ + ...dashboardResponse, + metrics: { + ...dashboardResponse.metrics, + newRegisteredUsers: 0, + newUserPaymentConversion: { + paidUsers: 0, + newRegisteredUsers: 0, + rateBasisPoints: 0, + }, + }, + }); + + render(); + + const paymentRateCard = ( + await screen.findByText('本日新增用户付费率') + ).closest('article'); + expect(paymentRateCard).toBeTruthy(); + expect(within(paymentRateCard as HTMLElement).getByText('-')).toBeTruthy(); + expect( + within(paymentRateCard as HTMLElement).getByText('0 / 0 人'), + ).toBeTruthy(); }); test('Dashboard 手动选择日期时不允许查询北京时间今天之后', async () => { diff --git a/apps/admin-web/src/pages/AdminDashboardPage.tsx b/apps/admin-web/src/pages/AdminDashboardPage.tsx index d075e1ea4..933abf45a 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.tsx @@ -6,7 +6,6 @@ import type { AdminDashboardBreakdownRowPayload, AdminDashboardChartPayload, AdminDashboardGranularity, - AdminDashboardRetentionMetricPayload, AdminDashboardResponse, } from '../api/adminApiTypes'; import { handlePageError } from './pageUtils'; @@ -292,12 +291,38 @@ export function AdminDashboardPage({
-

新增用户留存

+

新增用户转化与留存

{dashboard?.range.periodLabel ?? '-'}
- - + + +
@@ -406,27 +431,37 @@ function MetricCard({ ); } -function RetentionCard({ +function RateCard({ label, - metric, + numeratorLabel, + denominatorLabel, + numerator, + denominator, + rateBasisPoints, }: { label: string; - metric?: AdminDashboardRetentionMetricPayload; + numeratorLabel: string; + denominatorLabel: string; + numerator?: number; + denominator?: number; + rateBasisPoints?: number; }) { - const hasEligibleUsers = Boolean(metric?.eligibleUsers); + const hasDenominator = Boolean(denominator); return (
{label} - {hasEligibleUsers && metric - ? formatRateBasisPoints(metric.rateBasisPoints) + {hasDenominator && rateBasisPoints !== undefined + ? formatRateBasisPoints(rateBasisPoints) : '-'}
- 留存人数 / 可观察新增人数 + + {numeratorLabel} / {denominatorLabel} + - {metric - ? `${formatNumber(metric.retainedUsers)} / ${formatNumber(metric.eligibleUsers)} 人` + {numerator !== undefined && denominator !== undefined + ? `${formatNumber(numerator)} / ${formatNumber(denominator)} 人` : '-'}
diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index 9d360b81c..5bd550cd3 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -15,10 +15,16 @@ import { getAdminAssetReadUrl, listAdminEditorAssets, } from '../api/adminApiClient'; -import type { AdminEditorAssetPayload } from '../api/adminApiTypes'; +import type { + AdminEditorAssetListResponse, + AdminEditorAssetPayload, +} from '../api/adminApiTypes'; import { AdminEditorAssetQueryPage } from './AdminEditorAssetQueryPage'; vi.mock('../api/adminApiClient', () => ({ + formatAdminApiError: vi.fn((error: unknown) => + error instanceof Error ? error.message : '请求失败', + ), getAdminAssetReadUrl: vi.fn(), isAdminApiError: vi.fn( (error: unknown) => @@ -186,6 +192,16 @@ function generatedAssetAt(index: number): AdminEditorAssetPayload { }; } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + function thumbnailElementForLabel(label: string) { const row = screen.getByText(label).closest('tr'); const thumbnail = row?.querySelector('.admin-asset-query-thumb'); @@ -215,8 +231,8 @@ test('后台素材查询按用户、搜索和时间调用查询接口', async () await screen.findByRole('img', { name: '素材:角色形象 1' }); - fireEvent.change(screen.getByLabelText('用户 ID'), { - target: { value: 'user-1' }, + fireEvent.change(screen.getByLabelText('用户 ID / 陶泥号'), { + target: { value: 'SY-00000055' }, }); fireEvent.change(screen.getByLabelText('搜索'), { target: { value: '陶泥角色' }, @@ -229,16 +245,329 @@ test('后台素材查询按用户、搜索和时间调用查询接口', async () }); await waitFor(() => { - expect(listAdminEditorAssets).toHaveBeenLastCalledWith('admin-token', { - ownerUserId: 'user-1', - keyword: '陶泥角色', - createdAfter: '2026-07-01T00:00:00+08:00', - createdBefore: '2026-07-04T23:59:59.999+08:00', - limit: 80, - }); + expect(listAdminEditorAssets).toHaveBeenLastCalledWith( + 'admin-token', + { + ownerUserId: 'SY-00000055', + keyword: '陶泥角色', + createdAfter: '2026-07-01T00:00:00+08:00', + createdBefore: '2026-07-04T23:59:59.999+08:00', + limit: 80, + }, + expect.anything(), + ); }); }); +test('后台素材查询逐字输入时只请求最终筛选值', async () => { + installIntersectionObserverMock(); + render( + , + ); + + await screen.findByText('角色形象 1'); + vi.mocked(listAdminEditorAssets).mockClear(); + vi.useFakeTimers(); + const ownerInput = screen.getByLabelText('用户 ID / 陶泥号'); + const values = [ + 'S', + 'SY', + 'SY-', + 'SY-0', + 'SY-00', + 'SY-000', + 'SY-0000', + 'SY-00000', + 'SY-000000', + 'SY-0000005', + 'SY-00000055', + ]; + for (const [index, value] of values.entries()) { + fireEvent.change(ownerInput, { target: { value } }); + if (index < values.length - 1) { + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + expect(listAdminEditorAssets).not.toHaveBeenCalled(); + } + } + + expect(listAdminEditorAssets).not.toHaveBeenCalled(); + await act(async () => { + await vi.advanceTimersByTimeAsync(299); + }); + expect(listAdminEditorAssets).not.toHaveBeenCalled(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(listAdminEditorAssets).toHaveBeenCalledTimes(1); + expect(listAdminEditorAssets).toHaveBeenLastCalledWith( + 'admin-token', + { + ownerUserId: 'SY-00000055', + keyword: null, + createdAfter: null, + createdBefore: null, + limit: 80, + }, + expect.anything(), + ); +}); + +test('后台素材查询只应用最新筛选请求的结果和加载状态', async () => { + const staleResponse = createDeferred(); + const currentResponse = createDeferred(); + vi.mocked(listAdminEditorAssets) + .mockResolvedValueOnce({ + entries: [generatedAsset], + nextCursor: null, + }) + .mockImplementationOnce(() => staleResponse.promise) + .mockImplementationOnce(() => currentResponse.promise); + + render( + , + ); + + await screen.findByText('角色形象 1'); + fireEvent.change(screen.getByLabelText('用户 ID / 陶泥号'), { + target: { value: 'user-stale' }, + }); + await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(2)); + fireEvent.change(screen.getByLabelText('用户 ID / 陶泥号'), { + target: { value: 'SY-00000055' }, + }); + await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(3)); + + await act(async () => { + staleResponse.resolve({ + entries: [ + { ...generatedAsset, assetId: 'asset-stale', label: '旧筛选素材' }, + ], + nextCursor: 'cursor-stale', + }); + await staleResponse.promise; + }); + + expect(screen.queryByText('旧筛选素材')).toBeNull(); + expect(screen.getByRole('button', { name: '刷新中' })).toBeTruthy(); + + await act(async () => { + currentResponse.resolve({ + entries: [ + { ...generatedAsset, assetId: 'asset-current', label: '当前筛选素材' }, + ], + nextCursor: null, + }); + await currentResponse.promise; + }); + + expect(await screen.findByText('当前筛选素材')).toBeTruthy(); + expect(screen.queryByText('旧筛选素材')).toBeNull(); + expect(screen.getByRole('button', { name: '刷新' })).toBeTruthy(); +}); + +test('后台素材查询筛选失败时隐藏旧结果且不展示空态', async () => { + vi.mocked(listAdminEditorAssets) + .mockResolvedValueOnce({ + entries: [generatedAssetAt(1)], + nextCursor: 'cursor-old', + }) + .mockRejectedValueOnce(new Error('筛选失败')); + + render( + , + ); + + expect(await screen.findByText('角色形象 1')).toBeTruthy(); + expect(screen.getByRole('button', { name: '读取更多' })).toBeTruthy(); + + fireEvent.change(screen.getByLabelText('用户 ID / 陶泥号'), { + target: { value: 'SY-00000055' }, + }); + + expect(screen.queryByText('角色形象 1')).toBeNull(); + expect(screen.queryByRole('button', { name: '读取更多' })).toBeNull(); + expect(await screen.findByText('筛选失败')).toBeTruthy(); + expect(screen.queryByText('角色形象 1')).toBeNull(); + expect(screen.queryByRole('button', { name: '读取更多' })).toBeNull(); + expect(screen.queryByText('暂无生成素材')).toBeNull(); +}); + +test('后台素材查询取消旧筛选请求时不展示错误', async () => { + installIntersectionObserverMock(); + let staleSignal: AbortSignal | undefined; + vi.mocked(listAdminEditorAssets) + .mockResolvedValueOnce({ + entries: [generatedAsset], + nextCursor: null, + }) + .mockImplementationOnce((_token, _query, signal) => { + staleSignal = signal; + return new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => reject(new DOMException('请求已取消', 'AbortError')), + { once: true }, + ); + }); + }) + .mockResolvedValueOnce({ + entries: [ + { ...generatedAsset, assetId: 'asset-current', label: '最新筛选素材' }, + ], + nextCursor: null, + }); + + render( + , + ); + + await screen.findByText('角色形象 1'); + fireEvent.change(screen.getByLabelText('搜索'), { + target: { value: '旧筛选' }, + }); + await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(2)); + expect(staleSignal?.aborted).toBe(false); + vi.useFakeTimers(); + + fireEvent.change(screen.getByLabelText('搜索'), { + target: { value: '最新筛选' }, + }); + expect(staleSignal?.aborted).toBe(true); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.queryByRole('status')).toBeNull(); + expect(listAdminEditorAssets).toHaveBeenCalledTimes(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(299); + }); + expect(listAdminEditorAssets).toHaveBeenCalledTimes(2); + expect(screen.queryByRole('status')).toBeNull(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(listAdminEditorAssets).toHaveBeenCalledTimes(3); + expect(screen.getByText('最新筛选素材')).toBeTruthy(); + expect(screen.queryByRole('status')).toBeNull(); +}); + +test('后台素材查询手动刷新失败时保留当前结果和游标', async () => { + const refreshResponse = createDeferred(); + vi.mocked(listAdminEditorAssets) + .mockResolvedValueOnce({ + entries: [generatedAssetAt(1)], + nextCursor: 'cursor-1', + }) + .mockImplementationOnce(() => refreshResponse.promise); + + render( + , + ); + + expect(await screen.findByText('角色形象 1')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + expect(screen.getByText('角色形象 1')).toBeTruthy(); + expect( + screen.getByRole('button', { name: '读取更多' }).hasAttribute('disabled'), + ).toBe(true); + + await act(async () => { + refreshResponse.reject(new Error('刷新失败')); + try { + await refreshResponse.promise; + } catch { + // The page owns the rejected request. + } + }); + + expect(await screen.findByText('刷新失败')).toBeTruthy(); + expect(screen.getByText('角色形象 1')).toBeTruthy(); + expect( + screen.getByRole('button', { name: '读取更多' }).hasAttribute('disabled'), + ).toBe(false); + expect(screen.queryByText('暂无生成素材')).toBeNull(); +}); + +test('后台素材查询读取更多进行中手动刷新失败时保留原页', async () => { + const staleLoadMoreResponse = createDeferred(); + const refreshResponse = createDeferred(); + let staleLoadMoreSignal: AbortSignal | undefined; + vi.mocked(listAdminEditorAssets) + .mockResolvedValueOnce({ + entries: [generatedAssetAt(1)], + nextCursor: 'cursor-1', + }) + .mockImplementationOnce((_token, _query, signal) => { + staleLoadMoreSignal = signal; + return staleLoadMoreResponse.promise; + }) + .mockImplementationOnce(() => refreshResponse.promise) + .mockResolvedValueOnce({ + entries: [generatedAssetAt(81)], + nextCursor: null, + }); + + render( + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '读取更多' })); + await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(2)); + expect(staleLoadMoreSignal?.aborted).toBe(false); + + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + expect(staleLoadMoreSignal?.aborted).toBe(true); + expect(listAdminEditorAssets).toHaveBeenCalledTimes(3); + expect(screen.getByText('角色形象 1')).toBeTruthy(); + expect( + screen.getByRole('button', { name: '读取更多' }).hasAttribute('disabled'), + ).toBe(true); + + await act(async () => { + refreshResponse.reject(new Error('刷新失败')); + try { + await refreshResponse.promise; + } catch { + // The page owns the rejected request. + } + }); + + expect(await screen.findByText('刷新失败')).toBeTruthy(); + expect(screen.getByText('角色形象 1')).toBeTruthy(); + expect( + screen.getByRole('button', { name: '读取更多' }).hasAttribute('disabled'), + ).toBe(false); + + await act(async () => { + staleLoadMoreResponse.resolve({ + entries: [ + { ...generatedAsset, assetId: 'asset-stale', label: '旧分页素材' }, + ], + nextCursor: 'cursor-stale', + }); + await staleLoadMoreResponse.promise; + }); + expect(screen.queryByText('旧分页素材')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '读取更多' })); + expect(await screen.findByText('角色形象 81')).toBeTruthy(); + expect(listAdminEditorAssets).toHaveBeenLastCalledWith( + 'admin-token', + { + ownerUserId: null, + keyword: null, + createdAfter: null, + createdBefore: null, + limit: 80, + cursor: 'cursor-1', + }, + expect.anything(), + ); +}); + test('后台素材查询不展示分类筛选和分类列', async () => { render( , @@ -493,13 +822,87 @@ test('后台素材查询读取更多后为新进入可视区域的素材换签', objectKey: 'generated-character-drafts/editor/spec-81.png', expireSeconds: 300, }); - expect(listAdminEditorAssets).toHaveBeenLastCalledWith('admin-token', { - ownerUserId: null, - keyword: null, - createdAfter: null, - createdBefore: null, - limit: 80, - cursor: 'cursor-1', + expect(listAdminEditorAssets).toHaveBeenLastCalledWith( + 'admin-token', + { + ownerUserId: null, + keyword: null, + createdAfter: null, + createdBefore: null, + limit: 80, + cursor: 'cursor-1', + }, + expect.anything(), + ); +}); + +test('后台素材查询筛选刷新后忽略旧读取更多响应和游标', async () => { + const staleLoadMoreResponse = createDeferred(); + const refreshedResponse = createDeferred(); + let staleLoadMoreSignal: AbortSignal | undefined; + vi.mocked(listAdminEditorAssets) + .mockResolvedValueOnce({ + entries: [generatedAssetAt(1)], + nextCursor: 'cursor-old', + }) + .mockImplementationOnce((_token, _query, signal) => { + staleLoadMoreSignal = signal; + return staleLoadMoreResponse.promise; + }) + .mockImplementationOnce(() => refreshedResponse.promise) + .mockResolvedValueOnce({ entries: [], nextCursor: null }); + + render( + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '读取更多' })); + await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(2)); + fireEvent.change(screen.getByLabelText('用户 ID / 陶泥号'), { + target: { value: 'SY-00000055' }, + }); + expect(staleLoadMoreSignal?.aborted).toBe(true); + await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(3)); + await waitFor(() => + expect(screen.queryByRole('button', { name: '读取更多' })).toBeNull(), + ); + + await act(async () => { + refreshedResponse.resolve({ + entries: [ + { ...generatedAsset, assetId: 'asset-current', label: '当前用户素材' }, + ], + nextCursor: 'cursor-current', + }); + await refreshedResponse.promise; + }); + expect(await screen.findByText('当前用户素材')).toBeTruthy(); + + await act(async () => { + staleLoadMoreResponse.resolve({ + entries: [ + { ...generatedAsset, assetId: 'asset-stale', label: '旧分页素材' }, + ], + nextCursor: 'cursor-stale', + }); + await staleLoadMoreResponse.promise; + }); + + expect(screen.queryByText('旧分页素材')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '读取更多' })); + await waitFor(() => { + expect(listAdminEditorAssets).toHaveBeenLastCalledWith( + 'admin-token', + { + ownerUserId: 'SY-00000055', + keyword: null, + createdAfter: null, + createdBefore: null, + limit: 80, + cursor: 'cursor-current', + }, + expect.anything(), + ); }); }); @@ -962,6 +1365,28 @@ test('后台素材查询可查看素材详情', async () => { expect(within(dialog).getByText(/"style": "clay"/u)).toBeTruthy(); }); +test('后台素材详情分别展示真实操作和归组 Task ID', async () => { + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries: [ + { + ...generatedAsset, + taskId: 'editor-atlas-split-operation-1', + groupTaskId: 'vector-engine-source-task-1', + }, + ], + nextCursor: null, + }); + render( + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '详情' })); + + const dialog = screen.getByRole('dialog', { name: '素材详情' }); + expect(within(dialog).getByText('editor-atlas-split-operation-1')).toBeTruthy(); + expect(within(dialog).getByText('vector-engine-source-task-1')).toBeTruthy(); +}); + test('后台素材查询可打开弹窗查看完整提示词', async () => { render( , diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx index e23a00aae..ee2337e62 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx @@ -1,6 +1,6 @@ import { ChevronDown, ChevronRight, Eye, FileText, RefreshCcw, X } from 'lucide-react'; import { Fragment, type ReactNode } from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { getAdminAssetReadUrl, @@ -24,6 +24,7 @@ const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300; const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40; const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const; const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px'; +const ADMIN_EDITOR_ASSET_FILTER_DEBOUNCE_MS = 300; const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`; let adminAssetReadDispatchTail = Promise.resolve(); @@ -37,6 +38,9 @@ export function AdminEditorAssetQueryPage({ const [createdAfter, setCreatedAfter] = useState(''); const [createdBefore, setCreatedBefore] = useState(''); const [nextCursor, setNextCursor] = useState(null); + const [committedQueryKey, setCommittedQueryKey] = useState( + null, + ); const [isLoading, setIsLoading] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [errorMessage, setErrorMessage] = useState(''); @@ -51,49 +55,147 @@ export function AdminEditorAssetQueryPage({ title: string; prompt: string; } | null>(null); + const listRequestGenerationRef = useRef(0); + const listRequestAbortControllerRef = useRef(null); + const automaticQueryInitializedRef = useRef(false); + const automaticQueryTokenRef = useRef(token); + const activeQuery = buildListQuery(); + const activeQueryKey = editorAssetListQueryKey(token, activeQuery); + const hasCurrentQuerySnapshot = committedQueryKey === activeQueryKey; + const visibleEntries = hasCurrentQuerySnapshot ? entries : []; + const visibleNextCursor = hasCurrentQuerySnapshot ? nextCursor : null; useEffect(() => { - void refreshPage(); + const runImmediately = + !automaticQueryInitializedRef.current || + automaticQueryTokenRef.current !== token; + automaticQueryInitializedRef.current = true; + automaticQueryTokenRef.current = token; + + if (runImmediately) { + void refreshPage(); + return; + } + + invalidateListRequest(); + setIsLoading(true); + setIsLoadingMore(false); + setErrorMessage(''); + const timer = setTimeout( + () => void refreshPage(), + ADMIN_EDITOR_ASSET_FILTER_DEBOUNCE_MS, + ); + return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [token, ownerUserId, keyword, createdAfter, createdBefore]); + useEffect( + () => () => { + automaticQueryInitializedRef.current = false; + invalidateListRequest(); + }, + [], + ); + async function refreshPage() { + const query = buildListQuery(); + const queryKey = editorAssetListQueryKey(token, query); + const { requestGeneration, abortController } = beginListRequest(); setIsLoading(true); + setIsLoadingMore(false); setErrorMessage(''); try { - const response = await listAdminEditorAssets(token, buildListQuery()); + const response = await listAdminEditorAssets( + token, + query, + abortController.signal, + ); + if (listRequestGenerationRef.current !== requestGeneration) { + return; + } setEntries(response.entries); setNextCursor(response.nextCursor ?? null); + setCommittedQueryKey(queryKey); } catch (error: unknown) { + if ( + listRequestGenerationRef.current !== requestGeneration || + isAbortError(error) + ) { + return; + } handlePageError(error, onUnauthorized, setErrorMessage); } finally { - setIsLoading(false); + if (listRequestGenerationRef.current === requestGeneration) { + if (listRequestAbortControllerRef.current === abortController) { + listRequestAbortControllerRef.current = null; + } + setIsLoading(false); + } } } async function loadMore() { - if (!nextCursor || isLoadingMore) { + if (!visibleNextCursor || isLoading || isLoadingMore) { return; } + const query = buildListQuery(); + const queryKey = editorAssetListQueryKey(token, query); + if (committedQueryKey !== queryKey) { + return; + } + const cursor = visibleNextCursor; + const { requestGeneration, abortController } = beginListRequest(); setIsLoadingMore(true); setErrorMessage(''); try { - const response = await listAdminEditorAssets(token, { - ...buildListQuery(), - cursor: nextCursor, - }); + const response = await listAdminEditorAssets( + token, + { + ...query, + cursor, + }, + abortController.signal, + ); + if (listRequestGenerationRef.current !== requestGeneration) { + return; + } setEntries((current) => mergeAssetEntries(current, response.entries)); setNextCursor(response.nextCursor ?? null); } catch (error: unknown) { + if ( + listRequestGenerationRef.current !== requestGeneration || + isAbortError(error) + ) { + return; + } handlePageError(error, onUnauthorized, setErrorMessage); } finally { - setIsLoadingMore(false); + if (listRequestGenerationRef.current === requestGeneration) { + if (listRequestAbortControllerRef.current === abortController) { + listRequestAbortControllerRef.current = null; + } + setIsLoadingMore(false); + } } } + function beginListRequest() { + invalidateListRequest(); + const abortController = new AbortController(); + listRequestAbortControllerRef.current = abortController; + const requestGeneration = listRequestGenerationRef.current; + return { requestGeneration, abortController }; + } + + function invalidateListRequest() { + listRequestGenerationRef.current += 1; + listRequestAbortControllerRef.current?.abort(); + listRequestAbortControllerRef.current = null; + } + function buildListQuery(): AdminEditorAssetListQuery { return { - ownerUserId: ownerUserId || null, + ownerUserId: ownerUserId.trim() || null, keyword: keyword.trim() || null, createdAfter: dateInputToStartRfc3339(createdAfter), createdBefore: dateInputToEndRfc3339(createdBefore), @@ -143,7 +245,7 @@ export function AdminEditorAssetQueryPage({ />