diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 85f029e37..68fbc4a54 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -7,6 +7,7 @@ import { listAdminRechargeOrders, resolveAdminRechargeRefundManualReview, updateAdminAccount, + uploadAdminEditorShowcaseCampaignImage, } from './adminApiClient'; afterEach(() => { @@ -16,7 +17,7 @@ afterEach(() => { test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( - new Response(JSON.stringify({account: {accountId: 'member-1'}}), { + new Response(JSON.stringify({ account: { accountId: 'member-1' } }), { status: 200, }), ), @@ -40,7 +41,7 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => expect(fetchMock.mock.calls[0]?.[1]).toEqual( expect.objectContaining({ method: 'POST', - headers: expect.objectContaining({Authorization: 'Bearer owner-token'}), + headers: expect.objectContaining({ Authorization: 'Bearer owner-token' }), }), ); expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/accounts/member%2F1'); @@ -56,6 +57,86 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => ); }); +test('活动卡图片上传成功后先确认正式私有对象再返回图片引用', async () => { + const closeBitmap = vi.fn(); + vi.stubGlobal( + 'createImageBitmap', + vi + .fn() + .mockResolvedValue({ width: 1024, height: 1536, close: closeBitmap }), + ); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + upload: { + bucket: 'genarrative-release', + host: 'https://genarrative-release.oss.example.com', + objectKey: + 'generated-character-drafts/editor/showcase-campaign/current/card.png', + legacyPublicPath: + '/generated-character-drafts/editor/showcase-campaign/current/card.png', + contentType: 'image/png', + formFields: { key: 'campaign-key', policy: 'signed-policy' }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + .mockResolvedValueOnce(new Response('', { status: 200 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ assetObject: { assetObjectId: 'assetobj-1' } }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + const file = new File(['image-bytes'], 'card.png', { type: 'image/png' }); + + const uploaded = await uploadAdminEditorShowcaseCampaignImage( + 'admin-token', + file, + ); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/admin/api/editor-showcase/campaign/image-upload-ticket', + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + 'https://genarrative-release.oss.example.com', + ); + expect(fetchMock.mock.calls[2]?.[0]).toBe( + '/admin/api/editor-showcase/campaign/image-upload-confirm', + ); + expect(fetchMock.mock.calls[2]?.[1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }), + body: JSON.stringify({ + bucket: 'genarrative-release', + objectKey: + 'generated-character-drafts/editor/showcase-campaign/current/card.png', + contentType: 'image/png', + contentLength: file.size, + }), + }), + ); + expect(uploaded).toEqual({ + imageSrc: + '/generated-character-drafts/editor/showcase-campaign/current/card.png', + imageObjectKey: + 'generated-character-drafts/editor/showcase-campaign/current/card.png', + imageWidth: 1024, + imageHeight: 1536, + legacyPublicPath: + '/generated-character-drafts/editor/showcase-campaign/current/card.png', + }); + expect(closeBitmap).toHaveBeenCalledOnce(); +}); + test('充值订单查询按后台契约序列化筛选参数', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ entries: [] }), { @@ -110,13 +191,11 @@ test('用户详情只发送实际提供的用户定位字段', async () => { }); test('退款执行使用独立 execute 管理员路由', async () => { - const fetchMock = vi - .fn() - .mockResolvedValue( - new Response(JSON.stringify({ outRefundNo: 'refund-1' }), { - status: 200, - }), - ); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ outRefundNo: 'refund-1' }), { + status: 200, + }), + ); vi.stubGlobal('fetch', fetchMock); await executeAdminRechargeRefund('token-1', { @@ -143,13 +222,11 @@ test('退款执行使用独立 execute 管理员路由', async () => { }); test('退款人工复核使用独立 resolve 管理员路由', async () => { - const fetchMock = vi - .fn() - .mockResolvedValue( - new Response(JSON.stringify({ outRefundNo: 'refund-1' }), { - status: 200, - }), - ); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ outRefundNo: 'refund-1' }), { + status: 200, + }), + ); vi.stubGlobal('fetch', fetchMock); await resolveAdminRechargeRefundManualReview('token-1', { diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 17e3ba244..c5f17d5b5 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -1,5 +1,6 @@ import type { AdminAccountListResponse, + AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminCreateAccountRequest, AdminCreateAccountResponse, AdminCreateEditorShowcaseCampaignImageUploadTicketRequest, @@ -388,6 +389,19 @@ export async function uploadAdminEditorShowcaseCampaignImage( ); await postAdminDirectUploadFile(response.upload, file); const objectKey = response.upload.objectKey.trim().replace(/^\/+/u, ''); + await request( + '/admin/api/editor-showcase/campaign/image-upload-confirm', + { + method: 'POST', + token, + body: { + bucket: response.upload.bucket, + objectKey, + contentType, + contentLength: file.size, + } satisfies AdminConfirmEditorShowcaseCampaignImageUploadRequest, + }, + ); return { imageSrc: objectKey ? `/${objectKey}` : response.upload.legacyPublicPath, imageObjectKey: objectKey, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 65c416d50..879af5c51 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -372,6 +372,7 @@ export interface AdminEditorShowcaseAssetPayload { taskId?: string | null; assetKind?: string | null; generationInputs?: Record | null; + thumbnailSrc?: string | null; generationCostMudPoints: number; refundMudPoints: number; reviewStatus: 'pending' | 'approved' | 'rejected' | string; @@ -459,6 +460,13 @@ export interface AdminCreateEditorShowcaseCampaignImageUploadTicketResponse { upload: AdminDirectUploadTicketPayload; } +export interface AdminConfirmEditorShowcaseCampaignImageUploadRequest { + bucket: string; + objectKey: string; + contentType: string; + contentLength: number; +} + export interface AdminUploadedEditorShowcaseCampaignImage { imageSrc: string; imageObjectKey: string; diff --git a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx new file mode 100644 index 000000000..9e93be572 --- /dev/null +++ b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx @@ -0,0 +1,436 @@ +import { X } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; +import { getAdminAssetReadUrl, isAdminApiError } from '../api/adminApiClient'; + +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 AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`; +let adminAssetReadDispatchTail = Promise.resolve(); + +export interface AdminPreviewableEditorAsset { + assetId: string; + label: string; + imageSrc: string; + objectKey?: string | null; + assetKind?: string | null; + thumbnailSrc?: string | null; +} + +export function AdminEditorAssetThumbnail({ + entry, + token, + altPrefix = '素材', +}: { + entry: AdminPreviewableEditorAsset; + token: string; + altPrefix?: string; +}) { + const thumbnailSource = resolveAdminAssetThumbnailSource(entry); + const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility(); + const imageSrc = useAdminResolvedAssetUrl( + token, + thumbnailSource.src, + thumbnailSource.objectKey, + shouldLoad, + ); + const alt = `${altPrefix}:${entry.label || entry.assetId}`; + + return imageSrc ? ( + {alt} + ) : ( +
+ ); +} + +export function AdminEditorAssetPreviewDialog({ + entry, + token, + onClose, +}: { + entry: AdminPreviewableEditorAsset; + token: string; + onClose: () => void; +}) { + return ( +
+
+
+
+

{entry.label || entry.assetId}

+ {entry.assetId} +
+ +
+ +
+
+ ); +} + +function AdminEditorAssetPreviewMedia({ + entry, + token, +}: { + entry: AdminPreviewableEditorAsset; + token: string; +}) { + const mediaKind = resolveAdminAssetMediaKind(entry); + const isAudio = mediaKind === 'audio'; + const isVideo = mediaKind === 'video'; + const mediaSrc = useAdminResolvedAssetUrl( + token, + entry.imageSrc, + entry.objectKey, + ); + const posterSrc = useAdminResolvedAssetUrl( + token, + isVideo ? (entry.thumbnailSrc ?? '') : '', + null, + ); + const label = entry.label || entry.assetId; + + if (isAudio) { + return ( +
+ {`音频封面:${label}`} + {mediaSrc ? ( +