From 68e90875523798bb425e113e1cf996fbc332d291 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 6 Jul 2026 18:57:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=90=8E=E5=8F=B0=E7=B2=BE?= =?UTF-8?q?=E9=80=89=E6=B4=BB=E5=8A=A8=E5=8D=A1=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台精选活动卡支持选择图片并通过 OSS 直传凭证上传。 保存活动卡时同步 imageSrc 和 imageObjectKey。 移除后台素材包分类选项,清空分类时不再自动隐藏精选素材。 补充后台活动卡上传和分类交互测试。 --- apps/admin-web/src/api/adminApiClient.ts | 78 +++++++++++++++ .../AdminEditorShowcaseReviewPage.test.tsx | 58 ++++++++++- .../pages/AdminEditorShowcaseReviewPage.tsx | 95 ++++++++++++++++--- apps/admin-web/src/styles/admin.css | 14 +++ 4 files changed, 226 insertions(+), 19 deletions(-) diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index ff5306b42..10e0215ce 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -11,8 +11,11 @@ import type { AdminDatabaseTableListResponse, AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse, + AdminCreateEditorShowcaseCampaignImageUploadTicketRequest, + AdminCreateEditorShowcaseCampaignImageUploadTicketResponse, AdminEditorAssetListQuery, AdminEditorAssetListResponse, + AdminDirectUploadTicketPayload, AdminEditorShowcaseAssetResponse, AdminEditorShowcaseCampaignResponse, AdminEditorShowcaseDisplayRequest, @@ -27,6 +30,7 @@ import type { AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest, AdminUpdateWorkVisibilityResponse, + AdminUploadedEditorShowcaseCampaignImage, AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRechargeProductRequest, @@ -394,6 +398,32 @@ export function upsertAdminEditorShowcaseCampaign( ); } +export async function uploadAdminEditorShowcaseCampaignImage( + token: string, + file: File, +): Promise { + const contentType = resolveAdminImageContentType(file); + const response = await request( + '/admin/api/editor-showcase/campaign/image-upload-ticket', + { + method: 'POST', + token, + body: { + fileName: file.name.trim() || 'showcase-campaign.png', + contentType, + contentLength: file.size, + } satisfies AdminCreateEditorShowcaseCampaignImageUploadTicketRequest, + }, + ); + await postAdminDirectUploadFile(response.upload, file); + const objectKey = response.upload.objectKey.trim().replace(/^\/+/u, ''); + return { + imageSrc: objectKey ? `/${objectKey}` : response.upload.legacyPublicPath, + imageObjectKey: objectKey, + legacyPublicPath: response.upload.legacyPublicPath, + }; +} + export function listProfileRedeemCodes(token: string) { return request( '/admin/api/profile/redeem-codes', @@ -556,6 +586,54 @@ function buildAssetReadUrlQuery(query: AdminAssetReadUrlQuery) { return queryString ? `?${queryString}` : ''; } +function resolveAdminImageContentType(file: File) { + const declaredType = file.type.trim(); + if (declaredType.startsWith('image/')) { + return declaredType; + } + const extension = file.name.trim().toLowerCase().match(/\.([a-z0-9]+)$/u)?.[1]; + if (extension === 'jpg' || extension === 'jpeg') { + return 'image/jpeg'; + } + if (extension === 'png') { + return 'image/png'; + } + if (extension === 'webp') { + return 'image/webp'; + } + if (extension === 'gif') { + return 'image/gif'; + } + return declaredType || 'application/octet-stream'; +} + +function buildAdminDirectUploadFormData( + upload: AdminDirectUploadTicketPayload, + file: File, +) { + const formData = new FormData(); + Object.entries(upload.formFields).forEach(([key, value]) => { + if (value !== null && value !== undefined) { + formData.append(key, value); + } + }); + formData.append('file', file, file.name); + return formData; +} + +async function postAdminDirectUploadFile( + upload: AdminDirectUploadTicketPayload, + file: File, +) { + const response = await fetch(upload.host, { + method: 'POST', + body: buildAdminDirectUploadFormData(upload, file), + }); + if (!response.ok) { + throw new Error(`上传活动卡图片失败:HTTP ${response.status}`); + } +} + function buildQueryString(query: AdminTrackingEventListQuery) { const params = new URLSearchParams(); appendQueryParam(params, 'eventKey', query.eventKey); diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx index 90cd99289..4c384daee 100644 --- a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx @@ -15,6 +15,7 @@ import { listAdminEditorShowcaseAssets, reviewAdminEditorShowcaseAsset, updateAdminEditorShowcaseDisplay, + uploadAdminEditorShowcaseCampaignImage, upsertAdminEditorShowcaseCampaign, } from '../api/adminApiClient'; import type { AdminEditorShowcaseAssetPayload } from '../api/adminApiTypes'; @@ -26,6 +27,7 @@ vi.mock('../api/adminApiClient', () => ({ listAdminEditorShowcaseAssets: vi.fn(), reviewAdminEditorShowcaseAsset: vi.fn(), updateAdminEditorShowcaseDisplay: vi.fn(), + uploadAdminEditorShowcaseCampaignImage: vi.fn(), upsertAdminEditorShowcaseCampaign: vi.fn(), })); @@ -88,6 +90,7 @@ beforeEach(() => { enabled: true, title: '活动卡', imageSrc: '/campaign.png', + imageObjectKey: null, prompt: '活动提示词', author: '官方', costText: '12 泥点', @@ -121,12 +124,20 @@ beforeEach(() => { enabled: false, title: '新活动卡', imageSrc: '/new-campaign.png', + imageObjectKey: null, prompt: '新活动提示词', author: '官方', costText: '6 泥点', updatedAt: '2026-07-04T10:20:00Z', }, }); + vi.mocked(uploadAdminEditorShowcaseCampaignImage).mockResolvedValue({ + imageSrc: '/generated-character-drafts/editor/showcase-campaign/card.png', + imageObjectKey: + 'generated-character-drafts/editor/showcase-campaign/card.png', + legacyPublicPath: + '/generated-character-drafts/editor/showcase-campaign/card.png', + }); }); test('后台精选审核展示待审核素材和活动卡配置', async () => { @@ -273,12 +284,51 @@ test('后台精选审核可以切换展示和保存活动卡', async () => { expect.objectContaining({ title: '新活动卡', imageSrc: '/new-campaign.png', + imageObjectKey: null, costText: '6 泥点', }), ); }); }); +test('后台精选活动卡可以上传图片并保存 objectKey', async () => { + render( + , + ); + + await screen.findByDisplayValue('活动卡'); + const file = new File(['image-bytes'], 'card.png', { type: 'image/png' }); + fireEvent.change(screen.getByLabelText('上传活动卡图片'), { + target: { files: [file] }, + }); + + await waitFor(() => { + expect(uploadAdminEditorShowcaseCampaignImage).toHaveBeenCalledWith( + 'admin-token', + 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(); + + fireEvent.click(screen.getByRole('button', { name: '保存活动卡' })); + + await waitFor(() => { + expect(upsertAdminEditorShowcaseCampaign).toHaveBeenCalledWith( + 'admin-token', + expect.objectContaining({ + imageSrc: + '/generated-character-drafts/editor/showcase-campaign/card.png', + imageObjectKey: + 'generated-character-drafts/editor/showcase-campaign/card.png', + }), + ); + }); +}); + test('后台精选审核已通过素材可以设置精选分类', async () => { vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({ entries: [ @@ -321,7 +371,7 @@ test('后台精选审核已通过素材可以设置精选分类', async () => { }); }); -test('后台精选审核清空精选分类时自动隐藏素材', async () => { +test('后台精选审核清空精选分类时不会自动隐藏素材', async () => { vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({ entries: [approvedShowcaseAsset], nextCursor: null, @@ -330,7 +380,7 @@ test('后台精选审核清空精选分类时自动隐藏素材', async () => { entry: { ...approvedShowcaseAsset, showcaseCategory: null, - displayEnabled: false, + displayEnabled: true, }, }); @@ -350,8 +400,8 @@ test('后台精选审核清空精选分类时自动隐藏素材', async () => { 'admin-token', { showcaseId: 'showcase-2', - displayEnabled: false, - showcaseCategory: null, + displayEnabled: true, + showcaseCategory: '', }, ); }); diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx index 1988dfab4..ebf45a2e5 100644 --- a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx @@ -1,6 +1,6 @@ -import { Eye, FileText, RefreshCcw, X } from 'lucide-react'; +import { Eye, FileText, RefreshCcw, Upload, X } from 'lucide-react'; import type { ReactNode } from 'react'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { getAdminAssetReadUrl, @@ -8,6 +8,7 @@ import { listAdminEditorShowcaseAssets, reviewAdminEditorShowcaseAsset, updateAdminEditorShowcaseDisplay, + uploadAdminEditorShowcaseCampaignImage, upsertAdminEditorShowcaseCampaign, } from '../api/adminApiClient'; import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; @@ -27,7 +28,6 @@ const ADMIN_SHOWCASE_READ_EXPIRE_SECONDS = 300; const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png'; const showcaseCategoryOptions = [ - { value: 'packs', label: '素材包' }, { value: 'characters', label: '角色' }, { value: 'ui', label: 'UI' }, { value: 'music', label: '音乐' }, @@ -69,8 +69,12 @@ export function AdminEditorShowcaseReviewPage({ prompt: '', author: '', costText: '', + imageObjectKey: null, }); const [isSavingCampaign, setIsSavingCampaign] = useState(false); + const [isUploadingCampaignImage, setIsUploadingCampaignImage] = + useState(false); + const campaignImageInputRef = useRef(null); useEffect(() => { void refreshPage(); @@ -176,11 +180,11 @@ export function AdminEditorShowcaseReviewPage({ showcaseCategory: string, ) { setErrorMessage(''); - const nextCategory = showcaseCategory.trim() || null; + const nextCategory = showcaseCategory.trim(); try { const response = await updateAdminEditorShowcaseDisplay(token, { showcaseId: entry.showcaseId, - displayEnabled: nextCategory ? entry.displayEnabled : false, + displayEnabled: entry.displayEnabled, showcaseCategory: nextCategory, }); replaceEntry(response.entry); @@ -197,6 +201,7 @@ export function AdminEditorShowcaseReviewPage({ enabled: campaignDraft.enabled, title: campaignDraft.title, imageSrc: campaignDraft.imageSrc, + imageObjectKey: campaignDraft.imageObjectKey ?? null, prompt: campaignDraft.prompt, author: campaignDraft.author, costText: campaignDraft.costText, @@ -211,6 +216,29 @@ export function AdminEditorShowcaseReviewPage({ } } + async function handleCampaignImageFile(file: File | null | undefined) { + if (!file) { + return; + } + setIsUploadingCampaignImage(true); + setErrorMessage(''); + try { + const upload = await uploadAdminEditorShowcaseCampaignImage(token, file); + setCampaignDraft((current) => ({ + ...current, + imageSrc: upload.imageSrc, + imageObjectKey: upload.imageObjectKey, + })); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsUploadingCampaignImage(false); + if (campaignImageInputRef.current) { + campaignImageInputRef.current.value = ''; + } + } + } + function replaceEntry(entry: AdminEditorShowcaseAssetPayload) { setEntries((current) => current.map((item) => @@ -348,11 +376,7 @@ export function AdminEditorShowcaseReviewPage({ {entry.reviewStatus === 'approved' ? ( - {entry.displayEnabled - ? '展示中' - : entry.showcaseCategory - ? '已隐藏' - : '待设置分类'} + {entry.displayEnabled ? '展示中' : '未展示'} ) : null} @@ -482,13 +506,54 @@ export function AdminEditorShowcaseReviewPage({ + diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index b281b1dec..08dc8b68d 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -1091,6 +1091,20 @@ button:disabled { min-width: 160px; } +.admin-hidden-file-input { + display: none; +} + +.admin-showcase-campaign-image-row { + display: flex; + align-items: center; + gap: 8px; +} + +.admin-showcase-campaign-image-row input { + min-width: 0; +} + .admin-showcase-campaign-prompt { min-width: min(100%, 320px); }