完善后台精选活动卡上传

后台精选活动卡支持选择图片并通过 OSS 直传凭证上传。

保存活动卡时同步 imageSrc 和 imageObjectKey。

移除后台素材包分类选项,清空分类时不再自动隐藏精选素材。

补充后台活动卡上传和分类交互测试。
This commit is contained in:
2026-07-06 18:57:20 +08:00
parent b5aa68fc6a
commit 68e9087552
4 changed files with 226 additions and 19 deletions
+78
View File
@@ -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<AdminUploadedEditorShowcaseCampaignImage> {
const contentType = resolveAdminImageContentType(file);
const response = await request<AdminCreateEditorShowcaseCampaignImageUploadTicketResponse>(
'/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<ProfileRedeemCodeAdminListResponse>(
'/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);
@@ -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(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
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: '',
},
);
});
@@ -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<HTMLInputElement | null>(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({
</span>
{entry.reviewStatus === 'approved' ? (
<small>
{entry.displayEnabled
? '展示中'
: entry.showcaseCategory
? '已隐藏'
: '待设置分类'}
{entry.displayEnabled ? '展示中' : '未展示'}
</small>
) : null}
</td>
@@ -482,13 +506,54 @@ export function AdminEditorShowcaseReviewPage({
</label>
<label className="admin-field">
<span></span>
<div className="admin-showcase-campaign-image-row">
<input
value={campaignDraft.imageSrc}
onChange={(event) =>
setCampaignDraft((current) => ({
...current,
imageSrc: event.target.value,
imageObjectKey: null,
}))
}
/>
<input
ref={campaignImageInputRef}
aria-label="上传活动卡图片"
className="admin-hidden-file-input"
type="file"
accept="image/*"
onChange={(event) =>
handleCampaignImageFile(event.currentTarget.files?.[0])
}
/>
<button
className="admin-secondary-button"
disabled={isUploadingCampaignImage}
type="button"
onClick={() => campaignImageInputRef.current?.click()}
>
<Upload size={16} aria-hidden="true" />
<span>{isUploadingCampaignImage ? '上传中' : '上传'}</span>
</button>
</div>
</label>
<label className="admin-field">
<span>Object Key</span>
<input
value={campaignDraft.imageSrc}
value={campaignDraft.imageObjectKey ?? ''}
onChange={(event) =>
setCampaignDraft((current) => ({
...current,
imageSrc: event.target.value,
}))
setCampaignDraft((current) => {
const imageObjectKey =
event.target.value.trim().replace(/^\/+/u, '') || null;
return {
...current,
imageObjectKey,
imageSrc: imageObjectKey
? `/${imageObjectKey}`
: current.imageSrc,
};
})
}
/>
</label>
+14
View File
@@ -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);
}