From be7f0375e566f231af69ff534b21e2723d20359f Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 6 Jul 2026 21:00:21 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E7=B2=BE=E9=80=89=E6=B4=BB?= =?UTF-8?q?=E5=8A=A8=E5=8D=A1=E5=9B=BE=E7=89=87=E5=AE=BD=E9=AB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台上传活动卡图片时读取并保存原始宽高 隐藏活动卡 Object Key 输入,重复上传替换图片引用 公开精选按活动图宽高设置卡片比例 同步 SpacetimeDB schema、bindings 和后端数据契约文档 --- apps/admin-web/src/api/adminApiClient.ts | 72 +++++++++++++++++++ apps/admin-web/src/api/adminApiTypes.ts | 6 ++ .../AdminEditorShowcaseReviewPage.test.tsx | 55 ++++++++++++-- .../pages/AdminEditorShowcaseReviewPage.tsx | 27 +++---- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- server-rs/crates/api-server/src/admin.rs | 54 +++++++------- .../crates/api-server/src/editor_project.rs | 4 ++ .../crates/shared-contracts/src/admin.rs | 4 ++ .../src/mapper/editor_project.rs | 8 +++ ..._showcase_campaign_config_snapshot_type.rs | 2 + .../editor_showcase_campaign_config_type.rs | 6 ++ ...wcase_campaign_config_upsert_input_type.rs | 2 + .../src/editor_project_storage.rs | 17 ++++- .../crates/spacetime-module/src/migration.rs | 6 ++ .../creationShowcaseModel.test.ts | 6 ++ .../creation-home/creationShowcaseModel.ts | 2 + .../image-editor/editorProjectClient.test.ts | 4 ++ .../image-editor/editorProjectClient.ts | 2 + 18 files changed, 226 insertions(+), 53 deletions(-) diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 10e0215ce..3ecc25e7c 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -403,6 +403,7 @@ export async function uploadAdminEditorShowcaseCampaignImage( file: File, ): Promise { const contentType = resolveAdminImageContentType(file); + const dimensions = await readAdminImageFileDimensions(file); const response = await request( '/admin/api/editor-showcase/campaign/image-upload-ticket', { @@ -420,6 +421,8 @@ export async function uploadAdminEditorShowcaseCampaignImage( return { imageSrc: objectKey ? `/${objectKey}` : response.upload.legacyPublicPath, imageObjectKey: objectKey, + imageWidth: dimensions.imageWidth, + imageHeight: dimensions.imageHeight, legacyPublicPath: response.upload.legacyPublicPath, }; } @@ -607,6 +610,75 @@ function resolveAdminImageContentType(file: File) { return declaredType || 'application/octet-stream'; } +function normalizeAdminImageDimensions(width: number, height: number) { + const imageWidth = Math.round(width); + const imageHeight = Math.round(height); + if ( + !Number.isFinite(imageWidth) || + !Number.isFinite(imageHeight) || + imageWidth <= 0 || + imageHeight <= 0 + ) { + return null; + } + return { imageWidth, imageHeight }; +} + +async function readAdminImageFileDimensions(file: File) { + if (typeof createImageBitmap === 'function') { + try { + const bitmap = await createImageBitmap(file); + const dimensions = normalizeAdminImageDimensions( + bitmap.width, + bitmap.height, + ); + bitmap.close(); + if (dimensions) { + return dimensions; + } + } catch { + // Fall back to HTMLImageElement decoding below. + } + } + + if ( + typeof Image === 'undefined' || + typeof URL === 'undefined' || + typeof URL.createObjectURL !== 'function' + ) { + throw new Error('读取活动卡图片尺寸失败,请重新选择图片'); + } + + return new Promise<{ imageWidth: number; imageHeight: number }>( + (resolve, reject) => { + const objectUrl = URL.createObjectURL(file); + const image = new Image(); + const cleanup = () => { + if (typeof URL.revokeObjectURL === 'function') { + URL.revokeObjectURL(objectUrl); + } + }; + image.onload = () => { + cleanup(); + const dimensions = normalizeAdminImageDimensions( + image.naturalWidth || image.width, + image.naturalHeight || image.height, + ); + if (dimensions) { + resolve(dimensions); + return; + } + reject(new Error('读取活动卡图片尺寸失败,请重新选择图片')); + }; + image.onerror = () => { + cleanup(); + reject(new Error('读取活动卡图片尺寸失败,请重新选择图片')); + }; + image.src = objectUrl; + }, + ); +} + function buildAdminDirectUploadFormData( upload: AdminDirectUploadTicketPayload, file: File, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 8119889cb..48f1a7656 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -458,6 +458,8 @@ export interface AdminEditorShowcaseCampaignPayload { title: string; imageSrc: string; imageObjectKey?: string | null; + imageWidth?: number | null; + imageHeight?: number | null; prompt: string; author: string; costText: string; @@ -473,6 +475,8 @@ export interface AdminUpsertEditorShowcaseCampaignRequest { title: string; imageSrc: string; imageObjectKey?: string | null; + imageWidth?: number | null; + imageHeight?: number | null; prompt: string; author: string; costText: string; @@ -500,6 +504,8 @@ export interface AdminCreateEditorShowcaseCampaignImageUploadTicketResponse { export interface AdminUploadedEditorShowcaseCampaignImage { imageSrc: string; imageObjectKey: string; + imageWidth: number; + imageHeight: number; legacyPublicPath: string; } diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx index 4c384daee..766c8b05f 100644 --- a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx @@ -91,6 +91,8 @@ beforeEach(() => { title: '活动卡', imageSrc: '/campaign.png', imageObjectKey: null, + imageWidth: null, + imageHeight: null, prompt: '活动提示词', author: '官方', costText: '12 泥点', @@ -125,6 +127,8 @@ beforeEach(() => { title: '新活动卡', imageSrc: '/new-campaign.png', imageObjectKey: null, + imageWidth: null, + imageHeight: null, prompt: '新活动提示词', author: '官方', costText: '6 泥点', @@ -135,6 +139,8 @@ beforeEach(() => { imageSrc: '/generated-character-drafts/editor/showcase-campaign/card.png', imageObjectKey: 'generated-character-drafts/editor/showcase-campaign/card.png', + imageWidth: 900, + imageHeight: 1200, legacyPublicPath: '/generated-character-drafts/editor/showcase-campaign/card.png', }); @@ -291,7 +297,7 @@ test('后台精选审核可以切换展示和保存活动卡', async () => { }); }); -test('后台精选活动卡可以上传图片并保存 objectKey', async () => { +test('后台精选活动卡上传图片时替换图片引用并隐藏 objectKey', async () => { render( { file, ); }); - expect(await screen.findByDisplayValue('/generated-character-drafts/editor/showcase-campaign/card.png')).toBeTruthy(); - expect(screen.getByDisplayValue('generated-character-drafts/editor/showcase-campaign/card.png')).toBeTruthy(); + expect( + await screen.findByDisplayValue( + '/generated-character-drafts/editor/showcase-campaign/card.png', + ), + ).toBeTruthy(); + expect( + screen.queryByDisplayValue( + 'generated-character-drafts/editor/showcase-campaign/card.png', + ), + ).toBeNull(); + + vi.mocked(uploadAdminEditorShowcaseCampaignImage).mockResolvedValueOnce({ + imageSrc: + '/generated-character-drafts/editor/showcase-campaign/replacement.png', + imageObjectKey: + 'generated-character-drafts/editor/showcase-campaign/replacement.png', + imageWidth: 1024, + imageHeight: 1536, + legacyPublicPath: + '/generated-character-drafts/editor/showcase-campaign/replacement.png', + }); + const replacementFile = new File(['replacement-bytes'], 'replacement.png', { + type: 'image/png', + }); + fireEvent.change(screen.getByLabelText('上传活动卡图片'), { + target: { files: [replacementFile] }, + }); + + await waitFor(() => { + expect(uploadAdminEditorShowcaseCampaignImage).toHaveBeenCalledWith( + 'admin-token', + replacementFile, + ); + }); + expect( + await screen.findByDisplayValue( + '/generated-character-drafts/editor/showcase-campaign/replacement.png', + ), + ).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '保存活动卡' })); @@ -321,9 +364,11 @@ test('后台精选活动卡可以上传图片并保存 objectKey', async () => { 'admin-token', expect.objectContaining({ imageSrc: - '/generated-character-drafts/editor/showcase-campaign/card.png', + '/generated-character-drafts/editor/showcase-campaign/replacement.png', imageObjectKey: - 'generated-character-drafts/editor/showcase-campaign/card.png', + 'generated-character-drafts/editor/showcase-campaign/replacement.png', + imageWidth: 1024, + imageHeight: 1536, }), ); }); diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx index ebf45a2e5..a6fb7d12d 100644 --- a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx @@ -70,6 +70,8 @@ export function AdminEditorShowcaseReviewPage({ author: '', costText: '', imageObjectKey: null, + imageWidth: null, + imageHeight: null, }); const [isSavingCampaign, setIsSavingCampaign] = useState(false); const [isUploadingCampaignImage, setIsUploadingCampaignImage] = @@ -202,6 +204,8 @@ export function AdminEditorShowcaseReviewPage({ title: campaignDraft.title, imageSrc: campaignDraft.imageSrc, imageObjectKey: campaignDraft.imageObjectKey ?? null, + imageWidth: campaignDraft.imageWidth ?? null, + imageHeight: campaignDraft.imageHeight ?? null, prompt: campaignDraft.prompt, author: campaignDraft.author, costText: campaignDraft.costText, @@ -228,6 +232,8 @@ export function AdminEditorShowcaseReviewPage({ ...current, imageSrc: upload.imageSrc, imageObjectKey: upload.imageObjectKey, + imageWidth: upload.imageWidth, + imageHeight: upload.imageHeight, })); } catch (error: unknown) { handlePageError(error, onUnauthorized, setErrorMessage); @@ -514,6 +520,8 @@ export function AdminEditorShowcaseReviewPage({ ...current, imageSrc: event.target.value, imageObjectKey: null, + imageWidth: null, + imageHeight: null, })) } /> @@ -538,25 +546,6 @@ export function AdminEditorShowcaseReviewPage({ -