完善后台精选活动卡上传
后台精选活动卡支持选择图片并通过 OSS 直传凭证上传。 保存活动卡时同步 imageSrc 和 imageObjectKey。 移除后台素材包分类选项,清空分类时不再自动隐藏精选素材。 补充后台活动卡上传和分类交互测试。
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user