From 0b766c8bf9c2d6916569bfeb1938bf26b6d28f96 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 6 Jul 2026 14:10:16 +0800 Subject: [PATCH 01/24] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=9F=B3=E9=A2=91?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E5=B0=81=E9=9D=A2=E7=BC=A9=E7=95=A5=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增音频素材统一封面图。 素材库、创作主页精选和后台缩略图改为使用音频封面。 补充音频封面展示回归测试。 --- .../pages/AdminEditorAssetQueryPage.test.tsx | 107 ++++++---- .../src/pages/AdminEditorAssetQueryPage.tsx | 43 ++-- .../AdminEditorShowcaseReviewPage.test.tsx | 73 +++++-- .../pages/AdminEditorShowcaseReviewPage.tsx | 65 +++--- public/creation-home/audio-asset-cover.svg | 19 ++ .../CreationLandingView.test.tsx | 97 ++++++--- .../creation-home/CreationLandingView.tsx | 199 +++++++++++------- .../creationShowcaseModel.test.ts | 3 + .../creation-home/creationShowcaseModel.ts | 127 ++++++++--- .../ImageCanvasAssetRowView.test.tsx | 118 +++++++---- .../image-editor/ImageCanvasAssetRowView.tsx | 35 +-- src/index.css | 192 +++++++++++------ 12 files changed, 717 insertions(+), 361 deletions(-) create mode 100644 public/creation-home/audio-asset-cover.svg diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index 9c7612f3d..3d075d2ed 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -1,14 +1,20 @@ /* @vitest-environment jsdom */ -import {fireEvent, render, screen, waitFor, within} from '@testing-library/react'; -import {beforeEach, expect, test, vi} from 'vitest'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { beforeEach, expect, test, vi } from 'vitest'; import { getAdminAssetReadUrl, listAdminEditorAssets, } from '../api/adminApiClient'; -import type {AdminEditorAssetPayload} from '../api/adminApiTypes'; -import {AdminEditorAssetQueryPage} from './AdminEditorAssetQueryPage'; +import type { AdminEditorAssetPayload } from '../api/adminApiTypes'; +import { AdminEditorAssetQueryPage } from './AdminEditorAssetQueryPage'; vi.mock('../api/adminApiClient', () => ({ getAdminAssetReadUrl: vi.fn(), @@ -34,7 +40,7 @@ const generatedAsset: AdminEditorAssetPayload = { provider: 'character', taskId: 'task-1', assetKind: 'character', - generationInputs: {style: 'clay'}, + generationInputs: { style: 'clay' }, sourceResourceId: 'resource-1', thumbnailSrc: null, generationCostMudPoints: 12, @@ -60,10 +66,7 @@ beforeEach(() => { test('后台素材查询展示作者昵称和陶泥号', async () => { render( - , + , ); expect(await screen.findByText('作者昵称')).toBeTruthy(); @@ -73,25 +76,22 @@ test('后台素材查询展示作者昵称和陶泥号', async () => { test('后台素材查询按用户、搜索和时间调用查询接口', async () => { render( - , + , ); - await screen.findByRole('img', {name: '素材:角色形象 1'}); + await screen.findByRole('img', { name: '素材:角色形象 1' }); fireEvent.change(screen.getByLabelText('用户 ID'), { - target: {value: 'user-1'}, + target: { value: 'user-1' }, }); fireEvent.change(screen.getByLabelText('搜索'), { - target: {value: '陶泥角色'}, + target: { value: '陶泥角色' }, }); fireEvent.change(screen.getByLabelText('开始时间'), { - target: {value: '2026-07-01'}, + target: { value: '2026-07-01' }, }); fireEvent.change(screen.getByLabelText('结束时间'), { - target: {value: '2026-07-04'}, + target: { value: '2026-07-04' }, }); await waitFor(() => { @@ -107,26 +107,20 @@ test('后台素材查询按用户、搜索和时间调用查询接口', async () test('后台素材查询不展示分类筛选和分类列', async () => { render( - , + , ); expect(await screen.findByText('角色形象 1')).toBeTruthy(); expect(screen.queryByLabelText('分类')).toBeNull(); - expect(screen.queryByRole('columnheader', {name: '分类'})).toBeNull(); + expect(screen.queryByRole('columnheader', { name: '分类' })).toBeNull(); }); test('后台素材查询缩略图使用 objectKey 换签后展示', async () => { render( - , + , ); - const image = await screen.findByRole('img', {name: '素材:角色形象 1'}); + const image = await screen.findByRole('img', { name: '素材:角色形象 1' }); await waitFor(() => { expect(image.getAttribute('src')).toBe( @@ -139,6 +133,32 @@ test('后台素材查询缩略图使用 objectKey 换签后展示', async () => }); }); +test('后台素材查询音频素材使用统一封面缩略图', async () => { + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries: [ + { + ...generatedAsset, + assetId: 'asset-audio-1', + label: '胜利音效', + imageSrc: '/generated-editor-audios/sfx.mp3', + objectKey: 'generated-editor-audios/sfx.mp3', + assetKind: 'sound-effect', + }, + ], + nextCursor: null, + }); + + render( + , + ); + + const image = await screen.findByRole('img', { name: '素材:胜利音效' }); + expect(image.getAttribute('src')).toBe( + '/creation-home/audio-asset-cover.svg', + ); + expect(getAdminAssetReadUrl).not.toHaveBeenCalled(); +}); + test('后台素材查询格式化微秒时间文本', async () => { vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ entries: [ @@ -152,10 +172,7 @@ test('后台素材查询格式化微秒时间文本', async () => { }); render( - , + , ); expect(await screen.findByText('角色形象 1')).toBeTruthy(); @@ -164,15 +181,12 @@ test('后台素材查询格式化微秒时间文本', async () => { test('后台素材查询可查看素材详情', async () => { render( - , + , ); - fireEvent.click(await screen.findByRole('button', {name: '详情'})); + fireEvent.click(await screen.findByRole('button', { name: '详情' })); - const dialog = screen.getByRole('dialog', {name: '素材详情'}); + const dialog = screen.getByRole('dialog', { name: '素材详情' }); expect(within(dialog).getByText('asset-1')).toBeTruthy(); expect(within(dialog).getByText('1024 x 1024')).toBeTruthy(); expect(within(dialog).getByText('12 泥点')).toBeTruthy(); @@ -181,20 +195,21 @@ test('后台素材查询可查看素材详情', async () => { test('后台素材查询可打开弹窗查看完整提示词', async () => { render( - , + , ); - fireEvent.click(await screen.findByRole('button', {name: '完整提示词内容'})); + fireEvent.click( + await screen.findByRole('button', { name: '完整提示词内容' }), + ); - const dialog = screen.getByRole('dialog', {name: '完整提示词'}); + const dialog = screen.getByRole('dialog', { name: '完整提示词' }); expect(within(dialog).getByText('完整提示词内容')).toBeTruthy(); - fireEvent.click(within(dialog).getByRole('button', {name: '关闭完整提示词'})); + fireEvent.click( + within(dialog).getByRole('button', { name: '关闭完整提示词' }), + ); await waitFor(() => { - expect(screen.queryByRole('dialog', {name: '完整提示词'})).toBeNull(); + expect(screen.queryByRole('dialog', { name: '完整提示词' })).toBeNull(); }); }); diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx index b5b705e9a..50f588c25 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx @@ -1,17 +1,17 @@ -import {Eye, FileText, RefreshCcw, X} from 'lucide-react'; -import type {ReactNode} from 'react'; -import {useEffect, useState} from 'react'; +import { Eye, FileText, RefreshCcw, X } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useEffect, useState } from 'react'; import { getAdminAssetReadUrl, listAdminEditorAssets, } from '../api/adminApiClient'; -import type {AdminAssetReadUrlResponse} from '../api/adminApiClient'; +import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; import type { AdminEditorAssetListQuery, AdminEditorAssetPayload, } from '../api/adminApiTypes'; -import {handlePageError} from './pageUtils'; +import { handlePageError } from './pageUtils'; interface AdminEditorAssetQueryPageProps { token: string; @@ -19,6 +19,7 @@ interface AdminEditorAssetQueryPageProps { } const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300; +const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.svg'; export function AdminEditorAssetQueryPage({ token, @@ -274,10 +275,11 @@ export function AdminEditorAssetQueryPage({ ); } -function AdminAssetThumbnail({entry}: {entry: AdminEditorAssetPayload}) { +function AdminAssetThumbnail({ entry }: { entry: AdminEditorAssetPayload }) { + const isAudio = isAdminAudioAsset(entry); const imageSrc = useAdminResolvedAssetImageSrc( - entry.thumbnailSrc || entry.imageSrc, - entry.objectKey, + isAudio ? AUDIO_ASSET_COVER_SRC : entry.thumbnailSrc || entry.imageSrc, + isAudio ? null : entry.objectKey, ); const alt = `素材:${entry.label || entry.assetId}`; @@ -288,6 +290,16 @@ function AdminAssetThumbnail({entry}: {entry: AdminEditorAssetPayload}) { ); } +function isAdminAudioAsset(entry: AdminEditorAssetPayload) { + const assetKind = entry.assetKind?.trim() ?? ''; + return ( + assetKind === 'sound-effect' || + assetKind === 'background-music' || + assetKind === 'editor_uploaded_audio' || + /\.(?:mp3|wav|m4a|aac|ogg)(?:$|[?#])/iu.test(entry.imageSrc.trim()) + ); +} + function AdminAssetDetailDialog({ entry, onClose, @@ -334,7 +346,9 @@ function AdminAssetDetailDialog({ {entry.generationCostMudPoints} 泥点 {entry.model || '-'} - {entry.provider || '-'} + + {entry.provider || '-'} + {entry.taskId || '-'} {entry.objectKey || '-'} @@ -463,7 +477,8 @@ function mergeAssetEntries( [...current, ...incoming].forEach((entry) => byId.set(entry.assetId, entry)); return [...byId.values()].sort( (left, right) => - parseAdminTimestamp(right.createdAt) - parseAdminTimestamp(left.createdAt) || + parseAdminTimestamp(right.createdAt) - + parseAdminTimestamp(left.createdAt) || right.assetId.localeCompare(left.assetId), ); } @@ -505,13 +520,13 @@ function parseAdminTimestamp(value: string | null | undefined) { function authorDisplayName(entry: AdminEditorAssetPayload) { return ( - entry.authorDisplayName?.trim() || - entry.authorPublicUserCode?.trim() || - '-' + entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-' ); } -function formatGenerationInputs(value: Record | null | undefined) { +function formatGenerationInputs( + value: Record | null | undefined, +) { if (!value) { return '-'; } diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx index f67e00914..ac11c94bd 100644 --- a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx @@ -1,7 +1,13 @@ /* @vitest-environment jsdom */ -import {fireEvent, render, screen, waitFor, within} from '@testing-library/react'; -import {beforeEach, expect, test, vi} from 'vitest'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { beforeEach, expect, test, vi } from 'vitest'; import { getAdminAssetReadUrl, @@ -11,8 +17,8 @@ import { updateAdminEditorShowcaseDisplay, upsertAdminEditorShowcaseCampaign, } from '../api/adminApiClient'; -import type {AdminEditorShowcaseAssetPayload} from '../api/adminApiTypes'; -import {AdminEditorShowcaseReviewPage} from './AdminEditorShowcaseReviewPage'; +import type { AdminEditorShowcaseAssetPayload } from '../api/adminApiTypes'; +import { AdminEditorShowcaseReviewPage } from './AdminEditorShowcaseReviewPage'; vi.mock('../api/adminApiClient', () => ({ getAdminAssetReadUrl: vi.fn(), @@ -40,7 +46,7 @@ const pendingShowcaseAsset: AdminEditorShowcaseAssetPayload = { provider: 'character', taskId: 'task-1', assetKind: 'character', - generationInputs: {style: 'clay'}, + generationInputs: { style: 'clay' }, generationCostMudPoints: 12, refundMudPoints: 6, reviewStatus: 'pending', @@ -169,6 +175,36 @@ test('后台精选审核格式化微秒时间并显示素材名', async () => { expect(screen.queryByText('1783231493.573727Z')).toBeNull(); }); +test('后台精选审核音频素材使用统一封面缩略图', async () => { + vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({ + entries: [ + { + ...pendingShowcaseAsset, + showcaseId: 'showcase-audio-1', + assetId: 'asset-audio-1', + label: '胜利音效', + imageSrc: '/generated-editor-audios/sfx.mp3', + objectKey: 'generated-editor-audios/sfx.mp3', + assetKind: 'sound-effect', + }, + ], + nextCursor: null, + }); + + render( + , + ); + + const image = await screen.findByRole('img', { name: '精选素材:胜利音效' }); + expect(image.getAttribute('src')).toBe( + '/creation-home/audio-asset-cover.svg', + ); + expect(getAdminAssetReadUrl).not.toHaveBeenCalled(); +}); + test('后台精选审核可以通过素材并查看完整提示词', async () => { render( />, ); - fireEvent.change( - await screen.findByLabelText('审核备注:角色形象 1'), - {target: {value: '质量通过'}}, - ); - fireEvent.click(screen.getByRole('button', {name: '通过'})); + fireEvent.change(await screen.findByLabelText('审核备注:角色形象 1'), { + target: { value: '质量通过' }, + }); + fireEvent.click(screen.getByRole('button', { name: '通过' })); await waitFor(() => { expect(reviewAdminEditorShowcaseAsset).toHaveBeenCalledWith('admin-token', { @@ -191,8 +226,8 @@ test('后台精选审核可以通过素材并查看完整提示词', async () => }); }); - fireEvent.click(screen.getByRole('button', {name: '完整提示词内容'})); - const dialog = screen.getByRole('dialog', {name: '完整提示词'}); + fireEvent.click(screen.getByRole('button', { name: '完整提示词内容' })); + const dialog = screen.getByRole('dialog', { name: '完整提示词' }); expect(within(dialog).getByText('完整提示词内容')).toBeTruthy(); }); @@ -209,7 +244,7 @@ test('后台精选审核可以切换展示和保存活动卡', async () => { />, ); - fireEvent.click(await screen.findByRole('button', {name: '隐藏'})); + fireEvent.click(await screen.findByRole('button', { name: '隐藏' })); await waitFor(() => { expect(updateAdminEditorShowcaseDisplay).toHaveBeenCalledWith( 'admin-token', @@ -222,15 +257,15 @@ test('后台精选审核可以切换展示和保存活动卡', async () => { }); fireEvent.change(await screen.findByLabelText('标题'), { - target: {value: '新活动卡'}, + target: { value: '新活动卡' }, }); fireEvent.change(screen.getByLabelText('图片地址'), { - target: {value: '/new-campaign.png'}, + target: { value: '/new-campaign.png' }, }); fireEvent.change(screen.getByLabelText('成本文案'), { - target: {value: '6 泥点'}, + target: { value: '6 泥点' }, }); - fireEvent.click(screen.getByRole('button', {name: '保存活动卡'})); + fireEvent.click(screen.getByRole('button', { name: '保存活动卡' })); await waitFor(() => { expect(upsertAdminEditorShowcaseCampaign).toHaveBeenCalledWith( @@ -271,7 +306,7 @@ test('后台精选审核已通过素材可以设置精选分类', async () => { ); fireEvent.change(await screen.findByLabelText('精选分类:角色形象 2'), { - target: {value: 'ui'}, + target: { value: 'ui' }, }); await waitFor(() => { @@ -307,7 +342,7 @@ test('后台精选审核清空精选分类时自动隐藏素材', async () => { ); fireEvent.change(await screen.findByLabelText('精选分类:角色形象 2'), { - target: {value: ''}, + target: { value: '' }, }); await waitFor(() => { diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx index f3fbd561c..302d4d074 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 type {ReactNode} from 'react'; -import {useEffect, useState} from 'react'; +import { Eye, FileText, RefreshCcw, X } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useEffect, useState } from 'react'; import { getAdminAssetReadUrl, @@ -10,13 +10,13 @@ import { updateAdminEditorShowcaseDisplay, upsertAdminEditorShowcaseCampaign, } from '../api/adminApiClient'; -import type {AdminAssetReadUrlResponse} from '../api/adminApiClient'; +import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; import type { AdminEditorShowcaseAssetPayload, AdminEditorShowcaseCampaignPayload, AdminEditorShowcaseListQuery, } from '../api/adminApiTypes'; -import {handlePageError} from './pageUtils'; +import { handlePageError } from './pageUtils'; interface AdminEditorShowcaseReviewPageProps { token: string; @@ -24,20 +24,21 @@ interface AdminEditorShowcaseReviewPageProps { } const ADMIN_SHOWCASE_READ_EXPIRE_SECONDS = 300; +const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.svg'; const showcaseCategoryOptions = [ - {value: 'packs', label: '素材包'}, - {value: 'characters', label: '角色'}, - {value: 'ui', label: 'UI'}, - {value: 'music', label: '音乐'}, - {value: 'marketing', label: '美宣'}, + { value: 'packs', label: '素材包' }, + { value: 'characters', label: '角色' }, + { value: 'ui', label: 'UI' }, + { value: 'music', label: '音乐' }, + { value: 'marketing', label: '美宣' }, ]; const reviewStatusOptions = [ - {value: '', label: '全部'}, - {value: 'pending', label: '待审核'}, - {value: 'approved', label: '已通过'}, - {value: 'rejected', label: '已拒绝'}, + { value: '', label: '全部' }, + { value: 'pending', label: '待审核' }, + { value: 'approved', label: '已通过' }, + { value: 'rejected', label: '已拒绝' }, ]; export function AdminEditorShowcaseReviewPage({ @@ -314,9 +315,7 @@ export function AdminEditorShowcaseReviewPage({ {entry.label || '-'} - - {formatDateTime(entry.submittedAt)} - + {formatDateTime(entry.submittedAt)} {authorDisplayName(entry)} {entry.authorPublicUserCode?.trim() || '-'} @@ -342,7 +341,9 @@ export function AdminEditorShowcaseReviewPage({ )} - + {reviewStatusLabel(entry.reviewStatus)} {entry.reviewStatus === 'approved' ? ( @@ -589,7 +590,11 @@ function AdminShowcaseThumbnail({ }: { entry: AdminEditorShowcaseAssetPayload; }) { - const imageSrc = useAdminResolvedAssetImageSrc(entry.imageSrc, entry.objectKey); + const isAudio = isAdminShowcaseAudioAsset(entry); + const imageSrc = useAdminResolvedAssetImageSrc( + isAudio ? AUDIO_ASSET_COVER_SRC : entry.imageSrc, + isAudio ? null : entry.objectKey, + ); const alt = `精选素材:${entry.label || entry.showcaseId}`; return imageSrc ? ( @@ -599,6 +604,16 @@ function AdminShowcaseThumbnail({ ); } +function isAdminShowcaseAudioAsset(entry: AdminEditorShowcaseAssetPayload) { + const assetKind = entry.assetKind?.trim() ?? ''; + return ( + assetKind === 'sound-effect' || + assetKind === 'background-music' || + assetKind === 'editor_uploaded_audio' || + /\.(?:mp3|wav|m4a|aac|ogg)(?:$|[?#])/iu.test(entry.imageSrc.trim()) + ); +} + function AdminShowcaseDetailDialog({ entry, onClose, @@ -659,7 +674,9 @@ function AdminShowcaseDetailDialog({ {entry.likeCount} {entry.model || '-'} - {entry.provider || '-'} + + {entry.provider || '-'} + {entry.taskId || '-'} {entry.objectKey || '-'} @@ -869,13 +886,13 @@ function reviewStatusClassName(value: string) { function authorDisplayName(entry: AdminEditorShowcaseAssetPayload) { return ( - entry.authorDisplayName?.trim() || - entry.authorPublicUserCode?.trim() || - '-' + entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-' ); } -function formatGenerationInputs(value: Record | null | undefined) { +function formatGenerationInputs( + value: Record | null | undefined, +) { if (!value) { return '-'; } diff --git a/public/creation-home/audio-asset-cover.svg b/public/creation-home/audio-asset-cover.svg new file mode 100644 index 000000000..0e96909aa --- /dev/null +++ b/public/creation-home/audio-asset-cover.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index 82449027e..b80a8d7f6 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -125,12 +125,10 @@ function installIntersectionObserverMock() { return observers; } -function triggerIntersection( - observer: { - callback: IntersectionObserverCallback; - instance: IntersectionObserver; - }, -) { +function triggerIntersection(observer: { + callback: IntersectionObserverCallback; + instance: IntersectionObserver; +}) { act(() => { observer.callback( [{ isIntersecting: true } as IntersectionObserverEntry], @@ -157,15 +155,15 @@ describe('CreationLandingView', () => { renderCreationLanding(); - expect( - screen.getByRole('main', { name: '陶泥儿创作主页' }), - ).toBeTruthy(); + expect(screen.getByRole('main', { name: '陶泥儿创作主页' })).toBeTruthy(); expect( screen.getByRole('heading', { name: '陶泥儿 - 开启全民精品游戏创作', }), ).toBeTruthy(); - expect(screen.getByText('登录即送100泥点,可以免费制作50个素材')).toBeTruthy(); + expect( + screen.getByText('登录即送100泥点,可以免费制作50个素材'), + ).toBeTruthy(); expect(screen.getByRole('heading', { name: '创作工具' })).toBeTruthy(); expect(screen.getByText('游戏视觉规范')).toBeTruthy(); expect(screen.getByRole('heading', { name: '陶泥儿精选' })).toBeTruthy(); @@ -193,7 +191,9 @@ describe('CreationLandingView', () => { , ); - expect(await screen.findByRole('heading', { name: '最近项目' })).toBeTruthy(); + expect( + await screen.findByRole('heading', { name: '最近项目' }), + ).toBeTruthy(); expect(screen.getByText('最新项目')).toBeTruthy(); expect(screen.getByText('旧项目')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '查看全部' })); @@ -302,10 +302,7 @@ describe('CreationLandingView', () => { render( - + , ); @@ -375,9 +372,13 @@ describe('CreationLandingView', () => { const assetCard = await screen.findByText('角色英雄'); const card = assetCard.closest('.creation-landing__asset-card'); expect(card).toBeTruthy(); - expect(within(card as HTMLElement).getByText('character hero prompt')).toBeTruthy(); + expect( + within(card as HTMLElement).getByText('character hero prompt'), + ).toBeTruthy(); expect(within(card as HTMLElement).getByText('创作者A')).toBeTruthy(); - expect(within(card as HTMLElement).queryByText('user-internal-author')).toBeNull(); + expect( + within(card as HTMLElement).queryByText('user-internal-author'), + ).toBeNull(); expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy(); }); @@ -500,18 +501,56 @@ describe('CreationLandingView', () => { expect(within(dialog).getByText('生成总成本')).toBeTruthy(); expect(within(dialog).getByText('60泥点')).toBeTruthy(); expect(within(dialog).getByText('创作者A')).toBeTruthy(); - expect( - within(dialog).getByLabelText('素材缩略图导航'), - ).toBeTruthy(); + expect(within(dialog).getByLabelText('素材缩略图导航')).toBeTruthy(); - await user.click(within(dialog).getByRole('button', { name: '查看待机动画' })); + await user.click( + within(dialog).getByRole('button', { name: '查看待机动画' }), + ); expect( - within(dialog).getByRole('button', { name: '查看待机动画' }).getAttribute( - 'aria-current', - ), + within(dialog) + .getByRole('button', { name: '查看待机动画' }) + .getAttribute('aria-current'), ).toBe('true'); }); + it('uses the unified audio cover image for featured audio assets', async () => { + const user = userEvent.setup(); + listEditorProjectsMock.mockResolvedValueOnce(projectItems); + listPublicEditorProjectResourcesMock.mockResolvedValueOnce([ + { + resourceId: 'resource-music', + projectId: 'project-newest', + label: '背景音乐', + imageSrc: '/generated-editor-audios/music.mp3', + objectKey: 'generated-editor-audios/music.mp3', + width: 1, + height: 1, + sourceType: 'generated', + prompt: '舒缓背景音乐', + actualPrompt: null, + model: 'audio1.0', + provider: 'ark', + taskId: 'task-audio-1', + assetKind: 'background-music', + priceMudPoints: 5, + }, + ]); + + renderCreationLanding(); + + await user.click(screen.getByRole('tab', { name: '音乐' })); + const card = (await screen.findByText('背景音乐')).closest( + '.creation-landing__asset-card', + ); + expect(card).toBeTruthy(); + const image = within(card as HTMLElement).getByRole('img', { + name: '背景音乐', + }); + expect(image.getAttribute('src')).toBe( + '/creation-home/audio-asset-cover.svg', + ); + }); + it('uses Taonier featured as the generated project resource waterfall instead of creation entries', async () => { listEditorProjectsMock.mockResolvedValueOnce(projectItems); listPublicEditorProjectResourcesMock.mockResolvedValueOnce([ @@ -564,9 +603,9 @@ describe('CreationLandingView', () => { expect(await screen.findByText('素材包 A')).toBeTruthy(); expect(screen.getByText('素材包 B')).toBeTruthy(); expect(screen.queryByText('上传素材')).toBeNull(); - expect( - screen.getByLabelText('用户素材瀑布流').className, - ).toContain('creation-landing__asset-waterfall'); + expect(screen.getByLabelText('用户素材瀑布流').className).toContain( + 'creation-landing__asset-waterfall', + ); const firstPreview = container.querySelector( '.creation-landing__asset-preview', ) as HTMLElement | null; @@ -665,7 +704,9 @@ describe('CreationLandingView', () => { await waitFor(() => expect(observers).toHaveLength(1)); triggerIntersection(observers[0]!); - expect(await screen.findByRole('button', { name: '读取更多素材失败,点击重试' })).toBeTruthy(); + expect( + await screen.findByRole('button', { name: '读取更多素材失败,点击重试' }), + ).toBeTruthy(); expect(screen.getByText('素材包 A')).toBeTruthy(); expect(screen.getByLabelText('用户素材瀑布流')).toBeTruthy(); }); diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index 3a50a5a70..833e6f490 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -61,8 +61,7 @@ const CREATION_HOME_ASSETS = { projectNew: '/creation-home/project-new.png', } as const; -const SHOWCASE_LIKE_ICON_SRC = - '/creation-home/showcase-like-thumb.png'; +const SHOWCASE_LIKE_ICON_SRC = '/creation-home/showcase-like-thumb.png'; const CREATION_FEATURES: CreationFeatureItem[] = [ { @@ -137,7 +136,9 @@ function mergeShowcaseResources( current: EditorProjectResourceSnapshot[], incoming: EditorProjectResourceSnapshot[], ) { - const seenResourceIds = new Set(current.map((resource) => resource.resourceId)); + const seenResourceIds = new Set( + current.map((resource) => resource.resourceId), + ); return [ ...current, ...incoming.filter((resource) => { @@ -189,10 +190,17 @@ function ShowcaseAudioPreview({ const { resolvedUrl } = useResolvedAssetReadUrl(preview.src, { objectKey: preview.objectKey, }); + const coverSrc = + preview.coverSrc?.trim() || '/creation-home/audio-asset-cover.svg'; return (
-
@@ -418,7 +424,8 @@ function CreationShowcaseModal({ onClick={() => onSelectIndex(index)} > - {preview.mediaType === 'image' ? ( + {preview.mediaType === 'image' || + preview.mediaType === 'audio' ? ( ) : ( getPreviewIcon(preview) @@ -455,9 +462,13 @@ export function CreationLandingView({ >([]); const [showcaseCampaign, setShowcaseCampaign] = useState(null); - const [showcaseNextCursor, setShowcaseNextCursor] = useState(null); + const [showcaseNextCursor, setShowcaseNextCursor] = useState( + null, + ); const [assetError, setAssetError] = useState(null); - const [showcaseLoadMoreError, setShowcaseLoadMoreError] = useState(null); + const [showcaseLoadMoreError, setShowcaseLoadMoreError] = useState< + string | null + >(null); const [activeShowcaseTab, setActiveShowcaseTab] = useState('packs'); const [selectedShowcaseItem, setSelectedShowcaseItem] = @@ -499,9 +510,7 @@ export function CreationLandingView({ } const message = error instanceof Error ? error.message : '读取最近项目失败'; - setProjectError( - message, - ); + setProjectError(message); }) .finally(() => setIsLoadingProjects(false)); }, [authUi, isAuthenticated]); @@ -540,47 +549,58 @@ export function CreationLandingView({ refreshShowcaseResources(); }, [refreshShowcaseResources]); - const loadMoreShowcaseResources = useCallback((options: { force?: boolean } = {}) => { - const cursor = showcaseNextCursor; - if (!cursor || isLoadingShowcase || isLoadingMoreShowcase) { - return; - } - if (showcaseLoadingMoreCursorRef.current === cursor) { - return; - } - if (!options.force && showcaseFailedLoadMoreCursorRef.current === cursor) { - return; - } - showcaseLoadingMoreCursorRef.current = cursor; - setIsLoadingMoreShowcase(true); - setShowcaseLoadMoreError(null); - listPublicEditorProjectResources({ cursor }) - .then((page) => { - const normalizedPage = normalizeShowcaseResourcePage(page); - setProjectShowcaseResources((current) => - mergeShowcaseResources(current, normalizedPage.resources), - ); - setShowcaseCampaign((current) => normalizedPage.campaign ?? current); - setShowcaseNextCursor(normalizedPage.nextCursor); - showcaseFailedLoadMoreCursorRef.current = null; - }) - .catch((error: unknown) => { - showcaseFailedLoadMoreCursorRef.current = cursor; - setShowcaseLoadMoreError( - error instanceof Error ? error.message : '读取陶泥儿精选失败', - ); - }) - .finally(() => { - if (showcaseLoadingMoreCursorRef.current === cursor) { - showcaseLoadingMoreCursorRef.current = null; - } - setIsLoadingMoreShowcase(false); - }); - }, [isLoadingMoreShowcase, isLoadingShowcase, showcaseNextCursor]); + const loadMoreShowcaseResources = useCallback( + (options: { force?: boolean } = {}) => { + const cursor = showcaseNextCursor; + if (!cursor || isLoadingShowcase || isLoadingMoreShowcase) { + return; + } + if (showcaseLoadingMoreCursorRef.current === cursor) { + return; + } + if ( + !options.force && + showcaseFailedLoadMoreCursorRef.current === cursor + ) { + return; + } + showcaseLoadingMoreCursorRef.current = cursor; + setIsLoadingMoreShowcase(true); + setShowcaseLoadMoreError(null); + listPublicEditorProjectResources({ cursor }) + .then((page) => { + const normalizedPage = normalizeShowcaseResourcePage(page); + setProjectShowcaseResources((current) => + mergeShowcaseResources(current, normalizedPage.resources), + ); + setShowcaseCampaign((current) => normalizedPage.campaign ?? current); + setShowcaseNextCursor(normalizedPage.nextCursor); + showcaseFailedLoadMoreCursorRef.current = null; + }) + .catch((error: unknown) => { + showcaseFailedLoadMoreCursorRef.current = cursor; + setShowcaseLoadMoreError( + error instanceof Error ? error.message : '读取陶泥儿精选失败', + ); + }) + .finally(() => { + if (showcaseLoadingMoreCursorRef.current === cursor) { + showcaseLoadingMoreCursorRef.current = null; + } + setIsLoadingMoreShowcase(false); + }); + }, + [isLoadingMoreShowcase, isLoadingShowcase, showcaseNextCursor], + ); useEffect(() => { const element = showcaseLoadMoreRef.current; - if (!element || !showcaseNextCursor || isLoadingShowcase || isLoadingMoreShowcase) { + if ( + !element || + !showcaseNextCursor || + isLoadingShowcase || + isLoadingMoreShowcase + ) { return; } if (showcaseFailedLoadMoreCursorRef.current === showcaseNextCursor) { @@ -608,28 +628,31 @@ export function CreationLandingView({ showcaseNextCursor, ]); - const createProject = useCallback((tool?: CreationFeatureTool) => { - if (authUi && !authUi.user) { - authUi.openLoginModal(() => createProject(tool)); - return; - } - setIsCreatingProject(true); - setProjectError(null); - createEditorProject() - .then((project) => - onOpenProject(project.projectId, tool ? { tool } : { guide: true }), - ) - .catch((error: unknown) => { - if (isUnauthorizedError(error)) { - authUi?.openLoginModal(() => createProject(tool)); - return; - } - setProjectError( - error instanceof Error ? error.message : '创建项目失败', - ); - }) - .finally(() => setIsCreatingProject(false)); - }, [authUi, onOpenProject]); + const createProject = useCallback( + (tool?: CreationFeatureTool) => { + if (authUi && !authUi.user) { + authUi.openLoginModal(() => createProject(tool)); + return; + } + setIsCreatingProject(true); + setProjectError(null); + createEditorProject() + .then((project) => + onOpenProject(project.projectId, tool ? { tool } : { guide: true }), + ) + .catch((error: unknown) => { + if (isUnauthorizedError(error)) { + authUi?.openLoginModal(() => createProject(tool)); + return; + } + setProjectError( + error instanceof Error ? error.message : '创建项目失败', + ); + }) + .finally(() => setIsCreatingProject(false)); + }, + [authUi, onOpenProject], + ); const handleFeatureClick = useCallback( (feature: CreationFeatureItem) => { @@ -646,13 +669,16 @@ export function CreationLandingView({ (showcaseId: string, likeCount: number) => { setProjectShowcaseResources((current) => current.map((resource) => - resource.showcaseId === showcaseId || resource.resourceId === showcaseId + resource.showcaseId === showcaseId || + resource.resourceId === showcaseId ? { ...resource, likeCount } : resource, ), ); setSelectedShowcaseItem((current) => - current?.showcaseId === showcaseId ? { ...current, likeCount } : current, + current?.showcaseId === showcaseId + ? { ...current, likeCount } + : current, ); }, [], @@ -783,7 +809,10 @@ export function CreationLandingView({ return ( <> -
+
{showcaseItems.map((item) => { const showcaseId = item.showcaseId?.trim(); const isLiked = Boolean( @@ -830,7 +859,11 @@ export function CreationLandingView({ disabled={isLikePending} onClick={() => handleToggleShowcaseLike(item)} > - + {item.likeCount ?? 0} ) : null} @@ -1015,7 +1048,11 @@ export function CreationLandingView({

陶泥儿精选

-
+
{SHOWCASE_TABS.map((tab) => (
); }, })); beforeEach(() => { useResolvedAssetReadUrlMock.mockImplementation( - (source: string | null | undefined, options?: { objectKey?: string | null }) => ({ + ( + source: string | null | undefined, + options?: { objectKey?: string | null }, + ) => ({ resolvedUrl: options?.objectKey ? `https://signed.example.com/${options.objectKey}` : (source ?? ''), @@ -138,9 +143,7 @@ describe('ImageCanvasAssetRowView', () => { const onDownloadAsset = vi.fn(); renderAssetRow({ asset, onDownloadAsset }); - fireEvent.click( - screen.getByRole('button', { name: '下载素材账号素材A' }), - ); + fireEvent.click(screen.getByRole('button', { name: '下载素材账号素材A' })); expect(onDownloadAsset).toHaveBeenCalledWith(asset); }); @@ -154,20 +157,26 @@ describe('ImageCanvasAssetRowView', () => { const onDownloadAsset = vi.fn(); renderAssetRow({ asset, addAssetLayer, onDownloadAsset }); - fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), { - clientX: 120, - clientY: 80, - }); + fireEvent.contextMenu( + screen.getByRole('button', { name: '添加账号素材A' }), + { + clientX: 120, + clientY: 80, + }, + ); expect(screen.getByRole('menu')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '下载素材账号素材A' })); expect(onDownloadAsset).toHaveBeenCalledWith(asset); expect(screen.queryByRole('menu')).toBeNull(); - fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), { - clientX: 120, - clientY: 80, - }); + fireEvent.contextMenu( + screen.getByRole('button', { name: '添加账号素材A' }), + { + clientX: 120, + clientY: 80, + }, + ); fireEvent.click(screen.getByRole('button', { name: '添加账号素材A' })); expect(addAssetLayer).toHaveBeenCalledWith(asset); expect(screen.queryByRole('menu')).toBeNull(); @@ -199,7 +208,9 @@ describe('ImageCanvasAssetRowView', () => { fireEvent.keyDown(input, { key: 'Enter' }); expect(input.className).toContain('platform-text-field'); - expect(input.className).toContain('image-canvas-editor__asset-rename-input'); + expect(input.className).toContain( + 'image-canvas-editor__asset-rename-input', + ); expect(commitAssetRename).toHaveBeenCalledWith( expect.objectContaining({ id: 'asset-1' }), ); @@ -226,7 +237,7 @@ describe('ImageCanvasAssetRowView', () => { ).toBe('42'); }); - it('uses icon thumbnails for audio assets instead of rendering media as images', () => { + it('uses the audio cover thumbnail for audio assets', () => { renderAssetRow({ asset: createAsset({ label: '胜利音效.mp3', @@ -236,13 +247,13 @@ describe('ImageCanvasAssetRowView', () => { }); expect(screen.getByText('音频')).toBeTruthy(); - expect(screen.queryByRole('img', { name: '素材:胜利音效.mp3' })).toBeNull(); + const image = screen.getByRole('img', { name: '素材:胜利音效.mp3' }); + expect(image.getAttribute('src')).toBe( + '/creation-home/audio-asset-cover.svg', + ); expect( document.querySelector('.image-canvas-editor__asset-media-overlay'), - ).toBeTruthy(); - expect( - document.querySelector('.image-canvas-editor__media-preview--audio'), - ).toBeTruthy(); + ).toBeNull(); }); it('uses a stable visual preview for video asset cards without image thumbnails', () => { @@ -250,13 +261,16 @@ describe('ImageCanvasAssetRowView', () => { asset: createAsset({ label: '生成视频.mp4', src: '/generated-character-drafts/editor/asset-library/video/demo.mp4', - objectKey: 'generated-character-drafts/editor/asset-library/video/demo.mp4', + objectKey: + 'generated-character-drafts/editor/asset-library/video/demo.mp4', mediaType: 'video', }), }); expect(screen.getByText('视频')).toBeTruthy(); - expect(screen.queryByRole('img', { name: '素材:生成视频.mp4' })).toBeNull(); + expect( + screen.queryByRole('img', { name: '素材:生成视频.mp4' }), + ).toBeNull(); const video = screen.getByLabelText( '视频素材预览:生成视频.mp4', ) as HTMLVideoElement; @@ -315,10 +329,13 @@ describe('ImageCanvasAssetRowView', () => { screen.queryByRole('button', { name: '删除素材账号素材A' }), ).toBeNull(); - fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), { - clientX: 120, - clientY: 80, - }); + fireEvent.contextMenu( + screen.getByRole('button', { name: '添加账号素材A' }), + { + clientX: 120, + clientY: 80, + }, + ); const submitMenuItem = screen.getByRole('menuitem', { name: /提交精选审核/u, @@ -327,10 +344,13 @@ describe('ImageCanvasAssetRowView', () => { fireEvent.click(submitMenuItem); expect(submitAssetToShowcase).toHaveBeenCalledWith(asset); - fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), { - clientX: 120, - clientY: 80, - }); + fireEvent.contextMenu( + screen.getByRole('button', { name: '添加账号素材A' }), + { + clientX: 120, + clientY: 80, + }, + ); fireEvent.click(screen.getByRole('menuitem', { name: '删除' })); expect(deleteUploadedAsset).toHaveBeenCalledWith(asset); }); @@ -339,10 +359,13 @@ describe('ImageCanvasAssetRowView', () => { const submitAssetToShowcase = vi.fn(); renderAssetRow({ submitAssetToShowcase }); - fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), { - clientX: 120, - clientY: 80, - }); + fireEvent.contextMenu( + screen.getByRole('button', { name: '添加账号素材A' }), + { + clientX: 120, + clientY: 80, + }, + ); const submitMenuItem = screen.getByRole('menuitem', { name: /提交精选审核/u, @@ -360,10 +383,13 @@ describe('ImageCanvasAssetRowView', () => { const submitAssetToShowcase = vi.fn(); renderAssetRow({ asset, submitAssetToShowcase }); - fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), { - clientX: 120, - clientY: 80, - }); + fireEvent.contextMenu( + screen.getByRole('button', { name: '添加账号素材A' }), + { + clientX: 120, + clientY: 80, + }, + ); const submitMenuItem = screen.getByRole('menuitem', { name: /提交精选审核/u, diff --git a/src/components/image-editor/ImageCanvasAssetRowView.tsx b/src/components/image-editor/ImageCanvasAssetRowView.tsx index 393500139..c6bd2093a 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.tsx @@ -1,4 +1,4 @@ -import { Check, Download, Music2, Pencil, Video, X } from 'lucide-react'; +import { Check, Download, Pencil, Video, X } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; @@ -24,6 +24,8 @@ import { getCanvasMediaPreviewMarker, } from './ImageCanvasMediaModel'; +const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.svg'; + export type ImageCanvasAssetRowViewProps = { asset: EditorAsset; assetPointerDragRef: { current: AssetPointerDragState | null }; @@ -38,7 +40,10 @@ export type ImageCanvasAssetRowViewProps = { setAssetPointerDrag: Dispatch>; setSelectedAssetIds: Dispatch>>; updateAssetMoveDropFolder: (folderId: string | null) => void; - addUploadedFiles: (files: FileList | File[], options?: UploadFilesOptions) => void; + addUploadedFiles: ( + files: FileList | File[], + options?: UploadFilesOptions, + ) => void; moveAssetToFolder: (assetId: string, folderId: string) => void; startRenamingAsset: (asset: EditorAsset) => void; commitAssetRename: (asset: EditorAsset) => void; @@ -81,7 +86,9 @@ export function ImageCanvasAssetRowView({ const isFailedUpload = asset.uploadStatus === 'failed'; const showcaseReviewStatus = asset.showcaseReviewStatus ?? null; const canSubmitShowcaseAsset = - asset.sourceType === 'generated' && asset.persisted && !showcaseReviewStatus; + asset.sourceType === 'generated' && + asset.persisted && + !showcaseReviewStatus; const showcaseMenuLabel = showcaseReviewStatus === 'pending' ? '精选审核中' @@ -153,13 +160,7 @@ export function ImageCanvasAssetRowView({ ? '视频' : `${asset.width} x ${asset.height}`; const mediaOverlay = - asset.mediaType === 'audio' ? ( -
- -
- ) : asset.mediaType === 'video' ? ( + asset.mediaType === 'video' ? (
@@ -279,9 +280,7 @@ export function ImageCanvasAssetRowView({ rowClassName={[ 'image-canvas-editor__asset-row', isUploadingAsset ? 'image-canvas-editor__asset-row--uploading' : '', - isFailedUpload - ? 'image-canvas-editor__asset-row--upload-failed' - : '', + isFailedUpload ? 'image-canvas-editor__asset-row--upload-failed' : '', ] .filter(Boolean) .join(' ')} @@ -291,12 +290,18 @@ export function ImageCanvasAssetRowView({ imageSrc={ asset.mediaType === 'image' ? asset.src - : (asset.thumbnailSrc ?? '') + : asset.mediaType === 'audio' + ? AUDIO_ASSET_COVER_SRC + : (asset.thumbnailSrc ?? '') } objectKey={asset.mediaType === 'image' ? asset.objectKey : undefined} previewNode={videoPreview} titleNode={ - isUploadingAsset || isFailedUpload ? {asset.label} : titleNode + isUploadingAsset || isFailedUpload ? ( + {asset.label} + ) : ( + titleNode + ) } actions={actions} draggable={!isRenaming && !isUploadingAsset && !isFailedUpload} diff --git a/src/index.css b/src/index.css index 7d873657e..f1022184c 100644 --- a/src/index.css +++ b/src/index.css @@ -1585,7 +1585,9 @@ html[data-mobile-keyboard-open='true'] #root { background: rgba(255, 252, 248, 0.97); box-shadow: 0 18px 36px rgba(74, 34, 15, 0.18); pointer-events: none; - transform: translateX(calc(-50% + var(--floating-feedback-contact-offset, 0px))); + transform: translateX( + calc(-50% + var(--floating-feedback-contact-offset, 0px)) + ); } .floating-feedback-entry-contact-panel img { @@ -3183,8 +3185,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { width: 100%; height: 100%; overflow: hidden; - background: - linear-gradient(135deg, rgba(15, 23, 42, 0.06), transparent 56%), + background: linear-gradient(135deg, rgba(15, 23, 42, 0.06), transparent 56%), #f3f4f6; } @@ -3397,7 +3398,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { overflow: auto; background: linear-gradient(rgba(255, 250, 244, 0.56), rgba(255, 250, 244, 0.56)), - url('/creation-home/background-clay-workspace.png') center top / 64rem 64rem repeat, + url('/creation-home/background-clay-workspace.png') center top / 64rem 64rem + repeat, linear-gradient(180deg, #fff9f3 0%, #fffdf9 52%, #fff8f0 100%); color: var(--creation-ink); } @@ -3434,8 +3436,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { margin: 0; max-width: 62rem; color: var(--creation-ink); - font-family: - "Songti SC", "STSong", "Noto Serif SC", "SimSun", serif; + font-family: 'Songti SC', 'STSong', 'Noto Serif SC', 'SimSun', serif; font-size: clamp(2.35rem, 4.25vw, 3.85rem); font-weight: 950; letter-spacing: 0; @@ -3512,8 +3513,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { top: 14%; width: 58%; aspect-ratio: 4 / 3; - background: - linear-gradient(135deg, rgba(75, 181, 170, 0.24), transparent 52%), + background: linear-gradient(135deg, rgba(75, 181, 170, 0.24), transparent 52%), #ffffff; } @@ -3522,8 +3522,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { top: 28%; width: 34%; aspect-ratio: 1; - background: - linear-gradient(135deg, rgba(246, 171, 76, 0.24), transparent 56%), + background: linear-gradient(135deg, rgba(246, 171, 76, 0.24), transparent 56%), #ffffff; } @@ -3532,8 +3531,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { bottom: 10%; width: 36%; aspect-ratio: 16 / 9; - background: - linear-gradient(135deg, rgba(129, 140, 248, 0.2), transparent 56%), + background: linear-gradient(135deg, rgba(129, 140, 248, 0.2), transparent 56%), #ffffff; } @@ -3585,9 +3583,16 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { min-height: 9.55rem; border: 1px solid rgba(220, 189, 163, 0.78); border-radius: 1.12rem; - background: - radial-gradient(circle at 21% 42%, rgba(222, 130, 66, 0.12), transparent 9rem), - linear-gradient(135deg, rgba(255, 254, 251, 0.92), rgba(255, 247, 238, 0.82)); + background: radial-gradient( + circle at 21% 42%, + rgba(222, 130, 66, 0.12), + transparent 9rem + ), + linear-gradient( + 135deg, + rgba(255, 254, 251, 0.92), + rgba(255, 247, 238, 0.82) + ); padding: 1.05rem 1.3rem; color: inherit; font: inherit; @@ -3646,7 +3651,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { border-radius: 999px; background: rgba(112, 62, 32, 0.22); filter: blur(0.16rem); - content: ""; + content: ''; } .creation-landing__feature-art { @@ -3961,15 +3966,25 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { place-items: center; gap: 1rem; padding: 1rem; - background: - linear-gradient(135deg, rgba(244, 187, 129, 0.4), rgba(255, 247, 236, 0.92)), - radial-gradient(circle at 30% 24%, rgba(255, 255, 255, 0.9), transparent 42%); - color: #9a5428; + background: linear-gradient( + 135deg, + rgba(244, 187, 129, 0.4), + rgba(255, 247, 236, 0.92) + ), + radial-gradient( + circle at 30% 24%, + rgba(255, 255, 255, 0.9), + transparent 42% + ); } -.creation-landing__asset-audio-preview svg { - width: 2.6rem; - height: 2.6rem; +.creation-landing__asset-audio-cover { + display: block; + width: min(48%, 11rem); + min-width: 3.2rem; + height: auto; + object-fit: contain; + object-position: center; } .creation-landing__asset-audio-preview audio { @@ -4054,8 +4069,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { min-height: 0; grid-template-rows: minmax(0, 1fr) auto; overflow: hidden; - background: - radial-gradient(circle at 18% 12%, rgba(186, 117, 65, 0.18), transparent 32%), + background: radial-gradient( + circle at 18% 12%, + rgba(186, 117, 65, 0.18), + transparent 32% + ), #100d0b; } @@ -4376,7 +4394,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { --platform-button-primary-fill, linear-gradient(135deg, #df7f40, #b95d3a) ); - --image-canvas-brand-text-on-fill: var(--platform-button-primary-text, #fffaf5); + --image-canvas-brand-text-on-fill: var( + --platform-button-primary-text, + #fffaf5 + ); --image-canvas-brand-border: rgba(204, 117, 76, 0.34); --image-canvas-brand-border-strong: rgba(199, 101, 61, 0.64); --image-canvas-brand-border-soft: rgba(226, 203, 184, 0.82); @@ -4881,8 +4902,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__media-preview { position: relative; isolation: isolate; - background: - radial-gradient(circle at 25% 24%, rgba(255, 255, 255, 0.22), transparent 0.72rem), + background: radial-gradient( + circle at 25% 24%, + rgba(255, 255, 255, 0.22), + transparent 0.72rem + ), linear-gradient(135deg, rgba(15, 23, 42, 0.84), rgba(20, 83, 45, 0.58)); } @@ -4895,8 +4919,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__media-preview--audio::before { - background: - repeating-linear-gradient( + background: repeating-linear-gradient( 90deg, rgba(103, 232, 249, 0.34) 0 0.18rem, transparent 0.18rem 0.52rem @@ -4905,8 +4928,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__media-preview--video::before { - background: - linear-gradient(90deg, rgba(15, 23, 42, 0.42) 0 14%, transparent 14% 86%, rgba(15, 23, 42, 0.42) 86%), + background: linear-gradient( + 90deg, + rgba(15, 23, 42, 0.42) 0 14%, + transparent 14% 86%, + rgba(15, 23, 42, 0.42) 86% + ), repeating-linear-gradient( 0deg, rgba(248, 250, 252, 0.34) 0 0.2rem, @@ -5840,7 +5867,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { rgba(250, 245, 255, 0.94), transparent 30% ), - linear-gradient(135deg, rgba(250, 245, 255, 0.84), rgba(243, 232, 255, 0.72)); + linear-gradient( + 135deg, + rgba(250, 245, 255, 0.84), + rgba(243, 232, 255, 0.72) + ); } .image-canvas-editor__generation-frame--character.image-canvas-editor__generation-frame--focused { @@ -5853,9 +5884,16 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__generation-frame--character-animation { - background: - radial-gradient(circle at 50% 36%, rgba(255, 247, 237, 0.96), transparent 32%), - linear-gradient(135deg, rgba(255, 247, 237, 0.92), rgba(254, 215, 170, 0.74)); + background: radial-gradient( + circle at 50% 36%, + rgba(255, 247, 237, 0.96), + transparent 32% + ), + linear-gradient( + 135deg, + rgba(255, 247, 237, 0.92), + rgba(254, 215, 170, 0.74) + ); } .image-canvas-editor__generation-frame--character-animation.image-canvas-editor__generation-frame--focused { @@ -5868,8 +5906,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__generation-frame--video { - background: - linear-gradient(90deg, rgba(15, 23, 42, 0.12) 10%, transparent 10% 90%, rgba(15, 23, 42, 0.12) 90%), + background: linear-gradient( + 90deg, + rgba(15, 23, 42, 0.12) 10%, + transparent 10% 90%, + rgba(15, 23, 42, 0.12) 90% + ), linear-gradient(135deg, rgba(239, 246, 255, 0.9), rgba(219, 234, 254, 0.82)); } @@ -5884,8 +5926,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__generation-frame--audio-sound-effect, .image-canvas-editor__generation-frame--audio-background-music { - background: - repeating-linear-gradient( + background: repeating-linear-gradient( 90deg, rgba(79, 70, 229, 0.14) 0 0.22rem, transparent 0.22rem 0.58rem @@ -6091,8 +6132,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__generation-frame--quick-edit { border-color: var(--image-canvas-brand-border); - background: - linear-gradient(135deg, rgba(204, 117, 76, 0.12), rgba(240, 203, 169, 0.12)), + background: linear-gradient( + 135deg, + rgba(204, 117, 76, 0.12), + rgba(240, 203, 169, 0.12) + ), rgba(255, 255, 255, 0.72); } @@ -6109,8 +6153,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__generation-frame--publication { border-color: rgba(236, 72, 153, 0.28); - background: - radial-gradient(circle at 30% 20%, rgba(244, 114, 182, 0.18), transparent 38%), + background: radial-gradient( + circle at 30% 20%, + rgba(244, 114, 182, 0.18), + transparent 38% + ), linear-gradient(135deg, rgba(236, 72, 153, 0.12), rgba(251, 146, 60, 0.08)), rgba(255, 247, 252, 0.78); } @@ -6311,7 +6358,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { transform: translate(-50%, -100%); } -.image-canvas-editor__floating-toolbar button.image-canvas-editor__floating-toolbar-text-button { +.image-canvas-editor__floating-toolbar + button.image-canvas-editor__floating-toolbar-text-button { width: auto; min-width: 0; gap: 0.35rem; @@ -8779,8 +8827,11 @@ button.image-canvas-editor__reference-chip:disabled { z-index: 999; box-sizing: border-box; border: 2px solid var(--image-canvas-brand-accent); - background: - linear-gradient(90deg, rgba(204, 117, 76, 0.2) 1px, transparent 1px), + background: linear-gradient( + 90deg, + rgba(204, 117, 76, 0.2) 1px, + transparent 1px + ), linear-gradient(rgba(204, 117, 76, 0.2) 1px, transparent 1px); background-size: 33.333% 33.333%; box-shadow: @@ -9335,7 +9386,8 @@ button.image-canvas-editor__reference-chip:disabled { height: 2rem; } - .image-canvas-editor__floating-toolbar button.image-canvas-editor__floating-toolbar-text-button { + .image-canvas-editor__floating-toolbar + button.image-canvas-editor__floating-toolbar-text-button { width: auto; } @@ -9632,8 +9684,11 @@ button.image-canvas-editor__reference-chip:disabled { } .platform-mobile-home-welcome-overlay { - background: - radial-gradient(circle at 50% 18%, rgba(255, 254, 252, 0.54), transparent 32%), + background: radial-gradient( + circle at 50% 18%, + rgba(255, 254, 252, 0.54), + transparent 32% + ), rgba(61, 31, 16, 0.24); } @@ -9642,8 +9697,11 @@ button.image-canvas-editor__reference-chip:disabled { overflow: hidden; border-radius: 1.65rem; border: 1px solid rgba(255, 255, 255, 0.68); - background: - radial-gradient(circle at 18% 0%, rgba(240, 203, 169, 0.28), transparent 38%), + background: radial-gradient( + circle at 18% 0%, + rgba(240, 203, 169, 0.28), + transparent 38% + ), var(--platform-modal-fill); box-shadow: 0 24px 58px rgba(112, 57, 30, 0.2), @@ -12521,7 +12579,8 @@ button.image-canvas-editor__reference-chip:disabled { padding: 0 !important; background: linear-gradient(rgba(255, 250, 244, 0.72), rgba(255, 250, 244, 0.72)), - url('/creation-home/background-clay-workspace.png') center top / 64rem 64rem repeat, + url('/creation-home/background-clay-workspace.png') center top / 64rem 64rem + repeat, linear-gradient(180deg, #fffaf4 0%, #fffdf9 52%, #fff8f0 100%); box-shadow: 0 24px 58px rgba(112, 57, 30, 0.08); } @@ -12656,7 +12715,7 @@ button.image-canvas-editor__reference-chip:disabled { width: 1px; height: 1.66rem; background: rgba(217, 190, 166, 0.82); - content: ""; + content: ''; transform: translateY(-50%); } @@ -12691,7 +12750,9 @@ button.image-canvas-editor__reference-chip:disabled { display: none; } -.platform-desktop-shell--workbench .platform-desktop-topbar button.platform-desktop-search { +.platform-desktop-shell--workbench + .platform-desktop-topbar + button.platform-desktop-search { width: auto; max-width: none !important; min-width: 7.35rem; @@ -12702,12 +12763,17 @@ button.image-canvas-editor__reference-chip:disabled { gap: 0.38rem !important; } -.platform-desktop-shell--workbench .platform-desktop-topbar button.platform-desktop-search:hover { +.platform-desktop-shell--workbench + .platform-desktop-topbar + button.platform-desktop-search:hover { border-color: rgba(214, 184, 159, 0.52); background: rgba(255, 250, 244, 0.56); } -.platform-desktop-shell--workbench .platform-desktop-topbar button.platform-desktop-search > span:first-child { +.platform-desktop-shell--workbench + .platform-desktop-topbar + button.platform-desktop-search + > span:first-child { width: 2.06rem; height: 2.06rem; } @@ -12750,12 +12816,16 @@ button.image-canvas-editor__reference-chip:disabled { box-shadow: none; } -.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__icon-shell { +.platform-desktop-shell--workbench + .platform-desktop-rail__button--active + .platform-desktop-rail__icon-shell { background: #fff2e7; box-shadow: none; } -.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__icon { +.platform-desktop-shell--workbench + .platform-desktop-rail__button--active + .platform-desktop-rail__icon { color: #c7653d; } @@ -12795,7 +12865,9 @@ button.image-canvas-editor__reference-chip:disabled { box-shadow: none; } -.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__label { +.platform-desktop-shell--workbench + .platform-desktop-rail__button--active + .platform-desktop-rail__label { color: #c7653d; } From 7c449f0596d8c7bb0226767c7c31836d2662e506 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 6 Jul 2026 14:44:43 +0800 Subject: [PATCH 02/24] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=9F=B3=E9=A2=91?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E5=B0=81=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用用户提供的透明 PNG 替换自绘 SVG 音频封面 统一后台素材查询、精选审核、创作主页和画板素材库的音频封面引用 补齐画板图层列表音频图层封面展示 更新相关测试覆盖 --- .../pages/AdminEditorAssetQueryPage.test.tsx | 2 +- .../src/pages/AdminEditorAssetQueryPage.tsx | 2 +- .../AdminEditorShowcaseReviewPage.test.tsx | 2 +- .../pages/AdminEditorShowcaseReviewPage.tsx | 2 +- public/creation-home/audio-asset-cover.png | Bin 0 -> 6968 bytes public/creation-home/audio-asset-cover.svg | 19 --------------- .../CreationLandingView.test.tsx | 2 +- .../creation-home/CreationLandingView.tsx | 2 +- .../creationShowcaseModel.test.ts | 2 +- .../creation-home/creationShowcaseModel.ts | 2 +- .../ImageCanvasAssetRowView.test.tsx | 2 +- .../image-editor/ImageCanvasAssetRowView.tsx | 2 +- .../ImageCanvasLayerPanelView.tsx | 10 ++++++-- .../ImageCanvasSidebarView.test.tsx | 22 ++++++++++++++++++ 14 files changed, 40 insertions(+), 31 deletions(-) create mode 100644 public/creation-home/audio-asset-cover.png delete mode 100644 public/creation-home/audio-asset-cover.svg diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index 3d075d2ed..346a7335d 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -154,7 +154,7 @@ test('后台素材查询音频素材使用统一封面缩略图', async () => { const image = await screen.findByRole('img', { name: '素材:胜利音效' }); expect(image.getAttribute('src')).toBe( - '/creation-home/audio-asset-cover.svg', + '/creation-home/audio-asset-cover.png', ); expect(getAdminAssetReadUrl).not.toHaveBeenCalled(); }); diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx index 50f588c25..177865f24 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx @@ -19,7 +19,7 @@ interface AdminEditorAssetQueryPageProps { } const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300; -const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.svg'; +const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png'; export function AdminEditorAssetQueryPage({ token, diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx index ac11c94bd..90cd99289 100644 --- a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx @@ -200,7 +200,7 @@ test('后台精选审核音频素材使用统一封面缩略图', async () => { const image = await screen.findByRole('img', { name: '精选素材:胜利音效' }); expect(image.getAttribute('src')).toBe( - '/creation-home/audio-asset-cover.svg', + '/creation-home/audio-asset-cover.png', ); expect(getAdminAssetReadUrl).not.toHaveBeenCalled(); }); diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx index 302d4d074..1988dfab4 100644 --- a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx @@ -24,7 +24,7 @@ interface AdminEditorShowcaseReviewPageProps { } const ADMIN_SHOWCASE_READ_EXPIRE_SECONDS = 300; -const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.svg'; +const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png'; const showcaseCategoryOptions = [ { value: 'packs', label: '素材包' }, diff --git a/public/creation-home/audio-asset-cover.png b/public/creation-home/audio-asset-cover.png new file mode 100644 index 0000000000000000000000000000000000000000..893e476ab76a2037e5068a53d8c31901f26474cc GIT binary patch literal 6968 zcmXAucOaYH_rPx=Awo$iR*fdH)rz)8tyo31HM;CQy3{JQC8+VD_UN!_tEf$@MT{D) z7Jb!tQEC^pYmeXK`}-^Bx%WQjex7shIrrR?D8s8dC(x(R0Du#Ex?08nz#tI@Q1sA` zg~w|r05CvLOZ~bJY|-+b@rZ8P*D%w_LGZ&$y+xS$h<-V@&Q?=i3d`mNarIGgLyTJNSPp_-0`6vDVktUK69MJ%2Q(oviu0<9@gKjuc z#vajd0C!spPl5;PzO8Bq<1ToTY@FhE)Vzfrt}=EeqU14%8@m;`Y`E)&U-(P z4vl7zGOjDb^k|xEyxS*ZjA-l>pKoOvQ|(p`xG&JWtW7ABNo4)ThqRG#P{zw6bk}UT z3zx6JUgtG+4-v_U5qc+(c;>efUI_RH-G+vn{PP!fP|{y6guxAOs#eB1iFOXiw>q32 zu;9xFUFSWy_pT()K7ZlTEeeJsSNx?ReO5A#jciE#9009cW3K={Zrb5qR_fSf(1H{ z-qjsd)g5k5uk0>uwhSm$?KTDE0z_zkaFgKs;LCI=gO@z2W=MKJIRuc|Co&Mjx@(B+ zxFQcZL06_Ycu)9Mnv2h+=;uE7c#Ej6^n#^+{v*uSKPt-DF z*NmO+n$!JLwT9qS&T1uWGT{AGi~fV4aluZV@j-7CM8wBB3~e?3e@uS zY`^tCrLFHi>QDrO<8&%qaTJFhPU}^r#L52tU6&HYQRZs_!e;P%P(?ZG^>2|}@Ks5H z^-2Gv0I!2T`g6l(XWPZ2{4G^-~(K2RkbS?v>F83<$?`$vBp5%wS5m+{Wwvu z^DEvmIoU9R*~B z{m?Y7J|AYRC<2Bi6QUfrv}~B+I11_YDi_fZ1xXqwJ7rR1X@hfOz&>HpT`Lh$M7-Wzt{Q8I*3}~`dObGb$>5;V)h!pZ#ji533ujf}yE*5&lQ+THjVFX?hJ0>eHLj96Y zr(78Se|#kwdQO;X`Bxjp^@KZ4mjbEt6l||v%NXbsu+q>6p@L+Ipkue_q}pho(&26M+lvTs85q7`n99QnWYu z<8Q5W8LRa&zuLa%Y|4fgd64dP9x1e2aECc@W5!M6cbh;M5_=KhGWe=SU3>@sF^t`AIS0 zGUsNlhr+3Qj4|eb?Tg9N1zHrr(~+u38@$bkiXvR_eLKz&D2H_Bek70+{X;A2@o2J= z>c}Y;>X_c!zpHlx+s(V4+crO*tDV9W7y*s!&$4{*7Tv?YOTBy@TME!VpBT6BkQCgB zsnmz1yRQ-Jt|sarl$>thDCxInEz^YF8_jbxYW#aKxLLaQRQuH8+oJSRLoY}Z;kons z48fzx`9rp#$KF1HkD{$}r@86t*B%2TjWcm4Su=4qDSA;Q2RLNqX0RkqDcSz|oC8f5 zPULV6PE_XaHyyxzS45L_AHGU<>5_a9{wqBI9jRQy1UUt##>iC!`r`jW1NSNH9OO|w zk1@4u)G$=o+h=ghDce*7ge=q_@9r^o3X6&bNc_2Gi7b>u3?xlX^;A0wUESBdl_vB1p`vvE6}R-+zU(Xf`WlVDyB{oVMipxBBSIMi14RSFzApVJcNQl z>yu14hR3~M2O$fg#~aDcp|O$2ka82naVQ5U7~u09dQuz(;)xI>y3bSu+i@HWgrl-~ zwD3p>4fne8Dlwk%81kgLJOud$f-H=8uGhlT9YYFIr7+a%5SlTe+b@bte+;P$w=|K1 zi$Re2T3KFf)K;kRqzL5)8#VcOU~YF^6xj}XIf{)opB77=Kh}7bwi`v(fEpQ_oPTKH z&qEXTaoZed;R%owHeNXoF=}q$D7+Qq(~)H2@!J&VYArkiBq=&jRWVep<5^(ccOi{Z zz1M(8C*lAPXN2q{2r_P*q*_4IR3hW9I^rZ0Hg&;;A`3aVqP797LyTwRF-i+sL=3mv zO&x^Cv8Arb%`sskj~7vPy!gQoYKlVziLZ_6IZec1A^T{RnX+R$PC(KF?i@8OJQ0G7 zw5fapLE0ZfR%&ELk_92O1d7vnExgV#q|;+nEL9hRB*MPlg$()j81gIg0TGUc(6qqQ z8yKnu7J_`ny{nBNLXeR_wg@t$q7DS9sI(D_LL5VK*>GgZ!^e&;loTHX`fWhbmT9nY zRzzN{+kpSYfr6T^$4tNNX|Bzqot+KlP0P=eX}(qe<^N?|wISHoY@>;{BI|2iGmE*` zv!NvIPQkYrW1Vk^#LrL7BLZy($8LMl4JXOkV3<(Id5TO&`H&Qja{roUdjKAMAHHd3 zwsE0X(l-TOeuHugnM3Bo&|?t-n^OqFD=Rn>Pu?_jFhlVEe*BO=zqX{xSV#O%kw?h5 zA0y^vm6Vt^Og978zl@}w`t6d&pTA}7_D(~5Ta@VNPhaSV#8KQBojS1?x7J6J_18_K zzTPQU3H2)lq)%w*vzhMnPqM?}5tehOR_jxj^UAP}*XP)EFSACvw62D-<^bmK7>4|d zU<#9{OqD1MWW3(U4rgZ7U_BuUsInb)2LeErf zJ;+pzGbAY(w0a1TqJ32dSO$$~%hwC0_Jl*H>V={OQUxS_#XS8@Hx~}vx_Z6nhI=f7n7*YNy|^(EJSFKJP= z+iZHGz!ENNBg!_~iR=geo=QO@KFnVg!gZ9jiSDgh~K z%@+JU)L$#D5wZ5+UwA$BKfxSxzD1QNK-uOBR{F00a4z$UdA~lA8e-D)sLIaN|9PP& z?Q29w=>EH^)H~@(BNfDzx~csqF-*dYO?7F;>(WR zS+*X#80_~gc0W(xjsk|bvX-mXIg{N&6@`Nz46D5%m&hq5V)~EMF+8m$$=Mohgl4s! zimaF&^_chvPDXNCG!_Q9AKP`%^P|1%g76Q)@Jq#g1yGPo*FWKL*WxxRKjzZ?4dRrV z)Sa#|WEeZFB<{l|&j*!skgctCYpmVJ^}ZY9lOn+xhStpNrNyF=;2TLD()AJTLJ8I- z){)%hrHy`p>r&u0tam<7dt?500J;dH6pl=+>Kq;22^;3Ms6@T-#NY~ zu7=#+2tM`M;@dpn^1*_KJK8z3ex|O9B8JVnq;oHf555$AUfbFbpIC4-@NE=SFa_iw z?;Un)OVobasj=On0Ba+aJ++iH;BYC3a$Ww@z&@5XUACh`k__IRnf5{9g_a=ViWKS{;2Fl=6os>E0VpKPPj^ThRx%*Xsx8#u%hb z@;NCTQ^DR2)=B%ImO7DkEuFi1v)l5FsNuKRz5kDuM)~2Xt|9QeQ5&-C5$F+cW zJCY0thse%fEf=s2~a|cBQ298-@r%qH7nGKcR9XWy-wx- z%X~f(7I1QGM3Xmyqv=3}AoEww&fq~;9oLKQVb}%-cz7^9zB%pBm$Tyw|53w`t%T?x#U zbMR|u9ci2SW%SFw(|abZ;_IiAgvY3gWnzjr1sFw=56==G30-n7bwv@Q8feW zd&{WHeDu0en>lCj;Q?1GTHoMEemh5ul8waskb|qdq-R=78(edp^nlXb4-L*X{%nl{ zm=zgj@|~NAl^h4UoxpRnE2F_S1LENBL~H1E!~K8Fh1$MWKsW!##4@V=EW@z5@!jtZ z*?#^veCR(7x33-U){R;rra02&34P+gV+%T4prP%d)%T_i=g_|2oTwl@!@$$~-$PSl*K)F~rjX~i_d$7ukopd%*!@8d0 z#hwy~S(uIEysbX4;IvMkMXFB;$4oR5R_}}CkEzxky5NA~o0B}%x2v}r@BWe=ZkFT?eY-+U zn9U*Ae@2&x3-^)bMtaT06lT`@>@A|DRk^q2sz1J*jJVuT%SgVqld&+gTd$7G_VPdT zF1rx}dwTAKZ*>Q{DU-yip*(yBVC~f->+s~)N;c7rj5-}^bJnl^;DAECn0qUOpjWG{ zvO^Avx9vmJ{1xWo%1P87N@jhj@)1}vY5o!>_;z|g*BWWy+U*`Z8kYe#9HVBn3#|ON z!q&Aal6XAV_>iskD|2k8p%OL z=^8;k71uzS`u&Yq3IEd4;LocT$r?quZ67uEd0vx(T2;e|GQgdCJdgOr|D`l;EEc{}S6jQzZ5N3Cq}q@v7*;Xk zbo+o1Vt$;PgvHOKaZw78$_lgZQm}hr$m*gd0g3h476yQ z^i`^<)%uGk25+9`(%rX8y9fhwW`%lNccjZXWvc()TAN=#BQ@mW&Qw>=v~WUs@+WGn zB zc-1O2rCwO6+Pg$L4G({Z#K}ipeyksUmG@{1lp7Mw=bFw;@B_IQIU*zGV7qC8x)(m~icW&wI)3r_%c3FL@LW4^_y;fH{F4eVh z3T+~K)+SZm?z|Tn+rv9nsnW1J*?%t57-0`$-0krGGs?EN^;oS*ZE;run%bl?;FQ(Z zA~#Ch#vbYsDI~R^eL`!-{wb5iM1H>t&HU$s3OEty1W=% z5W+RiasdTjVUa+0zox6)etiv9`sqHW+AHO)lK{+d7Z0g+vX>mPVr4d*_Fv?>7Pu`Q zg==;+V(8*oXU`s9OW~0sbK%i4@f_jnqffWPa$4I){65-S$Me$XwuH^4F21&h&M&6< zWZ4P}KSrR;x-$2&mOzbXED@XU80xWaG6^1?zhxDeqOYo;SfAS2>_-42wh?nek<==g z$k4Nd4fWAug<~oI)982OMd#IeB*3c^=(a6$&TAJv%fnE%ea1t(%Y*ndtB4A|^9$RG zDscVP>{1rajg2$d{0J`fWO4xEw`rq5#4>xf^Z5}*{&Eup zJtHtQi@QPMZyvxnWql5LD~Cc6+c0!Ge#2kKU5R|{nih6gtAH^H zyju9R=j{9O>~MV#r(^)KwTP5A3r^bVH?QMmnJ2xtI$|Gj^|zE|nS9sYet^qItq_!Ysn@2Dfn9p4-st7#l8aM$~0!dCNFpU5thZpc`u2>?$-6%_HY|9;+Kd zbAV9>c(CytEMs?;F=Ymxzrx0pE>|2>oNNDx0_0HCAjcCTP$=%)ZxL9+3+zbp?k5r8 zKM}M~^w{_>VGIg8qjkuJVe7zOH+zw7-{PIaD34&|#kmuMN+TSDJd~B39}yTh()(T43@EZi`j9{a3MHTC|9X zQRp2|;3Xj5(OVhD(Q3Bwhrj;vou2xA^6Qo<0docLieo}jELbBj+0(7*LoUyoK4gr9 zF5^|r_qiQnq-uy5OYlM-6QX%Xk>o_5BV!O>$n`P7YQa7}NN55%X2}~MO-C)bm~a`) zQ$h{#scdu`%re&Tx|mFtdJiOBa~0uW;Dg?gP$9px8XHsr>Cgc4He_a&0%yt&Tmv(<7w-I;)xdUK1vT#Bc$?xt6E-BF@Z!S#D|IvY(zDD$HD6s? z_MT1o+!k1*CGbt>W&YXS$v`E?kT>Byhz9oX;H6A;v-p9bOJ++hO1M?G@Pp@{r*<8Q zWmB)6&j9_ar|d=G4bXLYTO)BAL*<4lR_V6i=VY-f|6i+ - - - - - - - diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index b80a8d7f6..e54762ba5 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -547,7 +547,7 @@ describe('CreationLandingView', () => { name: '背景音乐', }); expect(image.getAttribute('src')).toBe( - '/creation-home/audio-asset-cover.svg', + '/creation-home/audio-asset-cover.png', ); }); diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index 833e6f490..64f8a2cf1 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -191,7 +191,7 @@ function ShowcaseAudioPreview({ objectKey: preview.objectKey, }); const coverSrc = - preview.coverSrc?.trim() || '/creation-home/audio-asset-cover.svg'; + preview.coverSrc?.trim() || '/creation-home/audio-asset-cover.png'; return (
diff --git a/src/components/creation-home/creationShowcaseModel.test.ts b/src/components/creation-home/creationShowcaseModel.test.ts index 99a53de5d..e108cb496 100644 --- a/src/components/creation-home/creationShowcaseModel.test.ts +++ b/src/components/creation-home/creationShowcaseModel.test.ts @@ -459,7 +459,7 @@ describe('creationShowcaseModel', () => { expect(items).toHaveLength(1); expect(items[0]?.previews[0]?.mediaType).toBe('audio'); expect(items[0]?.previews[0]?.coverSrc).toBe( - '/creation-home/audio-asset-cover.svg', + '/creation-home/audio-asset-cover.png', ); expect(items[0]?.author).toBe('当前用户'); expect(items[0]?.cost).toBe('5泥点'); diff --git a/src/components/creation-home/creationShowcaseModel.ts b/src/components/creation-home/creationShowcaseModel.ts index 4001a2f0a..68dd913a3 100644 --- a/src/components/creation-home/creationShowcaseModel.ts +++ b/src/components/creation-home/creationShowcaseModel.ts @@ -56,7 +56,7 @@ type ShowcaseAssetGroup = { }; const UNKNOWN_META_VALUE = '-'; -const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.svg'; +const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png'; const SHOWCASE_COST_KEYS = [ 'priceMudPoints', diff --git a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx index 2ca914786..dec4bac5e 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx @@ -249,7 +249,7 @@ describe('ImageCanvasAssetRowView', () => { expect(screen.getByText('音频')).toBeTruthy(); const image = screen.getByRole('img', { name: '素材:胜利音效.mp3' }); expect(image.getAttribute('src')).toBe( - '/creation-home/audio-asset-cover.svg', + '/creation-home/audio-asset-cover.png', ); expect( document.querySelector('.image-canvas-editor__asset-media-overlay'), diff --git a/src/components/image-editor/ImageCanvasAssetRowView.tsx b/src/components/image-editor/ImageCanvasAssetRowView.tsx index c6bd2093a..32f387cbb 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.tsx @@ -24,7 +24,7 @@ import { getCanvasMediaPreviewMarker, } from './ImageCanvasMediaModel'; -const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.svg'; +const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png'; export type ImageCanvasAssetRowViewProps = { asset: EditorAsset; diff --git a/src/components/image-editor/ImageCanvasLayerPanelView.tsx b/src/components/image-editor/ImageCanvasLayerPanelView.tsx index aa03a6150..bf4a9941d 100644 --- a/src/components/image-editor/ImageCanvasLayerPanelView.tsx +++ b/src/components/image-editor/ImageCanvasLayerPanelView.tsx @@ -7,6 +7,8 @@ import type { ImageContextMenuState, } from './ImageCanvasEditorTypes'; +const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png'; + export type ImageCanvasLayerPanelViewProps = { layers: CanvasLayer[]; selectedLayerId: string | null; @@ -52,8 +54,12 @@ export function ImageCanvasLayerPanelView({ ] .filter(Boolean) .join(' · ')} - imageSrc={layer.src} - objectKey={layer.mediaType === 'image' ? layer.objectKey : undefined} + imageSrc={ + layer.mediaType === 'audio' ? AUDIO_ASSET_COVER_SRC : layer.src + } + objectKey={ + layer.mediaType === 'image' ? layer.objectKey : undefined + } imageAlt={`图层缩略图:${layer.title}`} selected={selectedLayerId === layer.id} primaryLabel={`选择图层${layer.title}`} diff --git a/src/components/image-editor/ImageCanvasSidebarView.test.tsx b/src/components/image-editor/ImageCanvasSidebarView.test.tsx index 83300590f..5b479b6e8 100644 --- a/src/components/image-editor/ImageCanvasSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSidebarView.test.tsx @@ -297,6 +297,28 @@ describe('ImageCanvasSidebarView', () => { ).toBeTruthy(); }); + it('uses the transparent audio cover for audio layer thumbnails', () => { + render( + , + ); + + const image = screen.getByAltText('图层缩略图:胜利音效'); + expect(image.getAttribute('src')).toBe( + '/creation-home/audio-asset-cover.png', + ); + }); + it('filters canvas layers from the sidebar search field', () => { render( Date: Mon, 6 Jul 2026 18:51:11 +0800 Subject: [PATCH 03/24] =?UTF-8?q?=E6=8E=A7=E5=88=B6=E7=94=BB=E5=B8=83=20Ag?= =?UTF-8?q?ent=20=E5=85=A5=E5=8F=A3=E9=BB=98=E8=AE=A4=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR 控制画布 Agent 入口显示。 默认 .env 关闭、本地 .env.local 开启,并补充示例和类型声明。 禁用时隐藏 dock 按钮和收起态 Agent 面板,开启时保留右侧任务列表互斥逻辑。 修正右侧 Agent 和任务列表切换不影响左侧资源栏。 补充画布 Agent 开关、面板独立性和 dock 隐藏测试。 --- .env | 2 + .env.example | 4 + .env.local | 2 + .../ImageCanvasEditorShellView.test.tsx | 1 + .../ImageCanvasEditorView.test.tsx | 175 ++++++++++++++++-- .../image-editor/ImageCanvasEditorView.tsx | 58 +++--- .../ImageCanvasPanelDockView.test.tsx | 7 + .../image-editor/ImageCanvasPanelDockView.tsx | 18 +- .../ImageCanvasSidebarView.test.tsx | 1 + .../image-editor/ImageCanvasStageView.tsx | 21 ++- .../useEditorAgentConversation.ts | 3 +- src/vite-env.d.ts | 1 + 12 files changed, 242 insertions(+), 51 deletions(-) diff --git a/.env b/.env index e1ed925f9..72324e587 100644 --- a/.env +++ b/.env @@ -1,5 +1,7 @@ # 微信小程序 web-view 登录配置。 # 留空时不覆盖已有微信网页 OAuth 配置;正式联调时再填小程序 AppID / AppSecret。 +VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=false + WECHAT_MINI_PROGRAM_APP_ID="" WECHAT_MINI_PROGRAM_APP_SECRET="" WECHAT_JS_CODE_SESSION_ENDPOINT="" diff --git a/.env.example b/.env.example index df656b892..32a49b796 100644 --- a/.env.example +++ b/.env.example @@ -199,6 +199,10 @@ VITE_LLM_DEBUG_LOG="false" # Set to "true" to expose local diagnostic panels, or "false" to hide them. VITE_DEBUG_MODE="" +# Optional: show the image editor right-side Agent entry. +# Keep this off by default outside local development. +VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false" + # Optional: official VikingDB credentials for regenerating build-tag similarities # with the Python embedding script. The script auto-loads `.env.local` and uses # the fixed `bge-large-zh` embedding model. diff --git a/.env.local b/.env.local index 311781f60..cbc3ab9a0 100644 --- a/.env.local +++ b/.env.local @@ -42,6 +42,8 @@ LLM_DEBUG_LOG="true" # 注意:不要在客户端启用调试日志,避免敏感数据泄露 # VITE_LLM_DEBUG_LOG="false" +VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=true + ALIYUN_OSS_BUCKET="xushi-dev" ALIYUN_OSS_REGION="oss-cn-beijing" ALIYUN_OSS_ENDPOINT="oss-cn-beijing.aliyuncs.com" diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 818db33cb..dc5fa045a 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -139,6 +139,7 @@ function createStageProps(): ImageCanvasStageViewProps { editorAgentAssets: [], taskListRefreshKey: 0, isTaskSidebarOpen: false, + isAgentConversationEnabled: false, isAgentConversationOpen: false, generateDialog: null, cropExpandPanel: null, diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index aca59ee66..49f1b47a7 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -12,6 +12,7 @@ import userEvent from '@testing-library/user-event'; import JSZip from 'jszip'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { EditorAgentConversationClient } from './useEditorAgentConversation'; import { ApiClientError, AuthUiContext, @@ -25,11 +26,73 @@ import { setupImageCanvasEditorViewTestLifecycle, } from './ImageCanvasEditorView.test-utils'; -const listEditorAgentConversationsMock = vi.hoisted(() => vi.fn()); -const createEditorAgentConversationMock = vi.hoisted(() => vi.fn()); -const getEditorAgentConversationMock = vi.hoisted(() => vi.fn()); -const deleteEditorAgentConversationMock = vi.hoisted(() => vi.fn()); -const streamEditorAgentMessageMock = vi.hoisted(() => vi.fn()); +type EditorAgentListConversations = + EditorAgentConversationClient['listConversations']; +type EditorAgentCreateConversation = + EditorAgentConversationClient['createConversation']; +type EditorAgentGetConversation = + EditorAgentConversationClient['getConversation']; +type EditorAgentDeleteConversation = + EditorAgentConversationClient['deleteConversation']; +type EditorAgentStreamMessage = EditorAgentConversationClient['streamMessage']; +type EditorAgentConversationSummary = Awaited< + ReturnType +>[number]; +type EditorAgentConversationDetail = Awaited< + ReturnType +>; +const EDITOR_AGENT_SIDEBAR_ENV = 'VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR'; + +const listEditorAgentConversationsMock = vi.hoisted(() => + vi.fn< + Parameters, + ReturnType + >(async () => []), +); +const createEditorAgentConversationMock = vi.hoisted(() => + vi.fn< + Parameters, + ReturnType + >(async () => ({ + conversationId: 'editor-agent-conv-created', + projectId: 'editor-project-default', + title: '画布 Agent', + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + messages: [], + })), +); +const getEditorAgentConversationMock = vi.hoisted(() => + vi.fn< + Parameters, + ReturnType + >(async () => ({ + conversationId: 'editor-agent-conv-test', + projectId: 'editor-project-default', + title: '画布 Agent', + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + messages: [], + })), +); +const deleteEditorAgentConversationMock = vi.hoisted(() => + vi.fn< + Parameters, + ReturnType + >(async () => ({ + conversationId: 'editor-agent-conv-test', + projectId: 'editor-project-default', + title: '画布 Agent', + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + })), +); +const streamEditorAgentMessageMock = vi.hoisted(() => + vi.fn< + Parameters, + ReturnType + >(async () => undefined), +); const generateEditorImageMock = vi.hoisted(() => vi.fn()); const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); @@ -85,7 +148,9 @@ vi.mock('../../services/image-editor/editorAgentClient', () => ({ streamEditorAgentMessage: streamEditorAgentMessageMock, })); -function createEditorAgentConversationSummary(overrides = {}) { +function createEditorAgentConversationSummary( + overrides: Partial = {}, +): EditorAgentConversationSummary { return { conversationId: 'editor-agent-conv-test', projectId: 'editor-project-default', @@ -96,7 +161,9 @@ function createEditorAgentConversationSummary(overrides = {}) { }; } -function createEditorAgentDetailWithGeneration(resourceId: string) { +function createEditorAgentDetailWithGeneration( + resourceId: string, +): EditorAgentConversationDetail { const summary = createEditorAgentConversationSummary(); return { ...summary, @@ -146,6 +213,18 @@ function openMinimap() { return screen.getByRole('button', { name: '画布小地图' }); } +async function ensureAgentConversationOpen() { + const agentButton = screen.getByRole('button', { name: '画布 Agent' }); + if (agentButton.getAttribute('aria-pressed') !== 'true') { + fireEvent.click(agentButton); + } + return screen.findByLabelText('发送给画布 Agent'); +} + +function enableEditorAgentSidebarForTest() { + vi.stubEnv(EDITOR_AGENT_SIDEBAR_ENV, 'true'); +} + describe('ImageCanvasEditorView', () => { setupImageCanvasEditorViewTestLifecycle({ generateEditorImageMock, @@ -167,6 +246,7 @@ describe('ImageCanvasEditorView', () => { }); beforeEach(() => { + vi.stubEnv(EDITOR_AGENT_SIDEBAR_ENV, 'false'); listEditorAgentConversationsMock.mockResolvedValue([]); createEditorAgentConversationMock.mockResolvedValue({ ...createEditorAgentConversationSummary({ @@ -197,9 +277,11 @@ describe('ImageCanvasEditorView', () => { deleteEditorAgentConversationMock.mockReset(); streamEditorAgentMessageMock.mockReset(); getPlatformProfileDashboardMock.mockReset(); + vi.unstubAllEnvs(); }); it('loads the project from projectid query before falling back to recent project', async () => { + listEditorAgentConversationsMock.mockResolvedValue([]); loadEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-query', title: '查询项目', @@ -481,6 +563,7 @@ describe('ImageCanvasEditorView', () => { }); it('does not inject built-in mock assets when the persisted library is empty', async () => { + listEditorAgentConversationsMock.mockResolvedValue([]); loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-empty', title: '空画布', @@ -939,10 +1022,10 @@ describe('ImageCanvasEditorView', () => { }); it('moves focus from the Agent input back to the canvas layer before handling shortcuts', async () => { + enableEditorAgentSidebarForTest(); render(); - fireEvent.click(screen.getByRole('button', { name: '画布 Agent' })); - const agentInput = await screen.findByLabelText('发送给画布 Agent'); + const agentInput = await ensureAgentConversationOpen(); agentInput.focus(); expect(document.activeElement).toBe(agentInput); @@ -1341,6 +1424,74 @@ describe('ImageCanvasEditorView', () => { expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy(); }); + it('hides the right Agent conversation entry by default', () => { + render(); + + expect(screen.queryByRole('button', { name: '画布 Agent' })).toBeNull(); + expect( + screen.queryByRole('button', { name: '打开画布 Agent' }), + ).toBeNull(); + expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull(); + expect(listEditorAgentConversationsMock).not.toHaveBeenCalled(); + }); + + it('opens the right Agent conversation panel by default when enabled', async () => { + enableEditorAgentSidebarForTest(); + render(); + + expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy(); + expect( + screen.queryByRole('complementary', { name: '画布任务列表' }), + ).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '画布 Agent' })); + + expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull(); + }); + + it('keeps the left resource sidebar independent from right-side task and Agent panels', async () => { + enableEditorAgentSidebarForTest(); + render(); + + const assetSidebar = openAssetSidebar(); + expect(within(assetSidebar).getByText('素材')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '任务列表' })); + expect( + screen.getByRole('complementary', { name: '画布任务列表' }), + ).toBeTruthy(); + expect( + screen.getByRole('complementary', { name: '图片资源栏' }), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '打开图层' })); + const layerSidebar = screen.getByRole('complementary', { + name: '图片资源栏', + }); + expect(within(layerSidebar).getByText('图层')).toBeTruthy(); + expect( + screen.getByRole('complementary', { name: '画布任务列表' }), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '画布 Agent' })); + expect( + screen.getByRole('complementary', { name: '图片资源栏' }), + ).toBeTruthy(); + expect( + screen.queryByRole('complementary', { name: '画布任务列表' }), + ).toBeNull(); + expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '任务列表' })); + expect( + screen.getByRole('complementary', { name: '图片资源栏' }), + ).toBeTruthy(); + expect( + screen.getByRole('complementary', { name: '画布任务列表' }), + ).toBeTruthy(); + expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull(); + }); + it('adds assets from the sidebar and supports zoom buttons', () => { render(); @@ -1557,6 +1708,7 @@ describe('ImageCanvasEditorView', () => { }); it('renders Agent generation thumbnails as passive previews', async () => { + enableEditorAgentSidebarForTest(); const detail = createEditorAgentDetailWithGeneration('resource-puzzle'); listEditorAgentConversationsMock.mockResolvedValueOnce([ createEditorAgentConversationSummary(), @@ -1565,7 +1717,7 @@ describe('ImageCanvasEditorView', () => { render(); - fireEvent.click(screen.getByRole('button', { name: '画布 Agent' })); + await ensureAgentConversationOpen(); const messageLog = await screen.findByRole('log', { name: '画布 Agent 消息流', }); @@ -1578,6 +1730,7 @@ describe('ImageCanvasEditorView', () => { }); it('refreshes canvas and asset library when Agent generation finishes', async () => { + enableEditorAgentSidebarForTest(); let showGeneratedAsset = false; loadEditorAssetLibraryMock.mockImplementation(async () => ({ folders: [ @@ -1692,7 +1845,7 @@ describe('ImageCanvasEditorView', () => { render(); - fireEvent.click(screen.getByRole('button', { name: '画布 Agent' })); + await ensureAgentConversationOpen(); await waitFor(() => { expect(getEditorAgentConversationMock).toHaveBeenCalledWith( 'editor-agent-conv-test', diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index cce084c0f..687740c30 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -117,6 +117,12 @@ type ImageCanvasEditorViewProps = { onProjectAccessLost?: () => void; }; +function resolveEditorAgentSidebarEnabled( + rawValue = import.meta.env.VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR, +): boolean { + return rawValue === 'true' || rawValue === '1'; +} + function isCanvasStartupTool(value: string | null): value is CanvasStartupTool { return CANVAS_STARTUP_TOOLS.includes(value as CanvasStartupTool); } @@ -1161,44 +1167,49 @@ export function ImageCanvasEditorView({ applyProjectSnapshot: applyGeneratedProjectSnapshot, onWalletBalanceMayHaveChanged: refreshEditorWalletBalance, }); - const [isAgentConversationOpen, setIsAgentConversationOpen] = useState(false); + const isAgentConversationEnabled = resolveEditorAgentSidebarEnabled(); + const [isAgentConversationOpen, setIsAgentConversationOpen] = useState( + () => isAgentConversationEnabled, + ); + const effectiveIsAgentConversationOpen = + isAgentConversationEnabled && isAgentConversationOpen; const toggleAgentConversation = useCallback(() => { + if (!isAgentConversationEnabled) { + return; + } const nextOpen = !isAgentConversationOpen; - if (nextOpen) { - setActiveSidebarPanel(null); - if (generationSurface.isTaskSidebarOpen) { - generationSurface.toggleTaskSidebar(); - } + if (nextOpen && generationSurface.isTaskSidebarOpen) { + generationSurface.toggleTaskSidebar(); } setIsAgentConversationOpen(nextOpen); - }, [generationSurface, isAgentConversationOpen, setActiveSidebarPanel]); + }, [generationSurface, isAgentConversationEnabled, isAgentConversationOpen]); const toggleTaskSidebar = useCallback(() => { - if (!generationSurface.isTaskSidebarOpen) { - setActiveSidebarPanel(null); + if ( + !generationSurface.isTaskSidebarOpen && + effectiveIsAgentConversationOpen + ) { setIsAgentConversationOpen(false); } generationSurface.toggleTaskSidebar(); - }, [generationSurface, setActiveSidebarPanel]); + }, [effectiveIsAgentConversationOpen, generationSurface]); const toggleCanvasSidebarPanel = useCallback( (panel: SidebarPanel) => { - setIsAgentConversationOpen(false); - if (generationSurface.isTaskSidebarOpen) { - generationSurface.toggleTaskSidebar(); - } toggleSidebarPanel(panel); }, - [generationSurface, toggleSidebarPanel], + [toggleSidebarPanel], ); useEffect(() => { - if (generationSurface.isTaskSidebarOpen && isAgentConversationOpen) { + if ( + (!isAgentConversationEnabled || generationSurface.isTaskSidebarOpen) && + isAgentConversationOpen + ) { setIsAgentConversationOpen(false); } - }, [generationSurface.isTaskSidebarOpen, isAgentConversationOpen]); - useEffect(() => { - if (activeSidebarPanel && isAgentConversationOpen) { - setIsAgentConversationOpen(false); - } - }, [activeSidebarPanel, isAgentConversationOpen]); + }, [ + generationSurface.isTaskSidebarOpen, + isAgentConversationEnabled, + isAgentConversationOpen, + ]); const { setQuickEditPanel, setCropExpandPanel, @@ -1923,7 +1934,8 @@ export function ImageCanvasEditorView({ isToolbarGuideVisible, taskListRefreshKey: generationSurface.taskListRefreshKey, isTaskSidebarOpen: generationSurface.isTaskSidebarOpen, - isAgentConversationOpen, + isAgentConversationEnabled, + isAgentConversationOpen: effectiveIsAgentConversationOpen, generateDialog, cropExpandPanel: generationSurface.cropExpandPanel, cropExpandSourceLayer: generationSurface.cropExpandSourceLayer, diff --git a/src/components/image-editor/ImageCanvasPanelDockView.test.tsx b/src/components/image-editor/ImageCanvasPanelDockView.test.tsx index 5810d71a1..d033bf634 100644 --- a/src/components/image-editor/ImageCanvasPanelDockView.test.tsx +++ b/src/components/image-editor/ImageCanvasPanelDockView.test.tsx @@ -18,6 +18,7 @@ function renderPanelDock( isZoomMenuOpen: false, isBackgroundSettingsOpen: false, activeSidebarPanel: null, + isAgentConversationEnabled: true, isAgentConversationOpen: false, isMinimapOpen: false, minimapModel: null, @@ -91,6 +92,12 @@ describe('ImageCanvasPanelDockView', () => { expect(props.onToggleMinimap).toHaveBeenCalledTimes(1); }); + it('hides the Agent entry when the Agent sidebar is disabled', () => { + renderPanelDock({ isAgentConversationEnabled: false }); + + expect(screen.queryByRole('button', { name: '画布 Agent' })).toBeNull(); + }); + it('renders zoom and background settings with callback wiring', () => { const props = renderPanelDock({ viewport: { x: 0, y: 0, scale: 0.5 }, diff --git a/src/components/image-editor/ImageCanvasPanelDockView.tsx b/src/components/image-editor/ImageCanvasPanelDockView.tsx index 9c7c041d7..38143ee91 100644 --- a/src/components/image-editor/ImageCanvasPanelDockView.tsx +++ b/src/components/image-editor/ImageCanvasPanelDockView.tsx @@ -35,6 +35,7 @@ type ImageCanvasPanelDockViewProps = { isZoomMenuOpen: boolean; isBackgroundSettingsOpen: boolean; activeSidebarPanel: SidebarPanel | null; + isAgentConversationEnabled: boolean; isAgentConversationOpen: boolean; isMinimapOpen: boolean; minimapModel: StageMinimapModel | null; @@ -62,6 +63,7 @@ export function ImageCanvasPanelDockView({ isZoomMenuOpen, isBackgroundSettingsOpen, activeSidebarPanel, + isAgentConversationEnabled, isAgentConversationOpen, isMinimapOpen, minimapModel, @@ -300,13 +302,15 @@ export function ImageCanvasPanelDockView({ pressed={activeSidebarPanel === 'layers'} onClick={() => onToggleSidebarPanel('layers')} /> - + {isAgentConversationEnabled ? ( + + ) : null} - + {isAgentConversationEnabled ? ( + + ) : null} {isToolbarGuideVisible ? (
{ if (disposed) { return; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 81879c63e..0cf010431 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -2,6 +2,7 @@ interface ImportMetaEnv { readonly VITE_DEBUG_MODE?: string; + readonly VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR?: string; } interface Window { From b5aa68fc6a01f2160fdf65470b67af9205819d86 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 6 Jul 2026 18:56:49 +0800 Subject: [PATCH 04/24] =?UTF-8?q?=E8=B0=83=E6=95=B4=E7=B2=BE=E9=80=89?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=85=AC=E5=BC=80=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增精选活动卡图片 objectKey 存储字段和迁移兼容。 新增后台活动卡图片 OSS 直传凭证接口。 移除素材包精选分类约束,允许未设置分类素材进入全部精选。 同步 admin 和前台读取活动卡 objectKey 的类型契约。 --- apps/admin-web/src/api/adminApiTypes.ts | 27 ++++ server-rs/crates/api-server/src/admin.rs | 124 ++++++++++++++++++ .../crates/api-server/src/editor_project.rs | 2 + .../crates/api-server/src/modules/admin.rs | 7 + .../crates/shared-contracts/src/admin.rs | 28 ++++ .../src/mapper/editor_project.rs | 4 + ..._showcase_campaign_config_snapshot_type.rs | 1 + .../editor_showcase_campaign_config_type.rs | 3 + ...wcase_campaign_config_upsert_input_type.rs | 1 + .../src/editor_project_storage.rs | 43 +++--- .../crates/spacetime-module/src/migration.rs | 8 ++ .../image-editor/editorProjectClient.ts | 1 + 12 files changed, 234 insertions(+), 15 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index f63ac4588..8119889cb 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -457,6 +457,7 @@ export interface AdminEditorShowcaseCampaignPayload { enabled: boolean; title: string; imageSrc: string; + imageObjectKey?: string | null; prompt: string; author: string; costText: string; @@ -471,11 +472,37 @@ export interface AdminUpsertEditorShowcaseCampaignRequest { enabled: boolean; title: string; imageSrc: string; + imageObjectKey?: string | null; prompt: string; author: string; costText: string; } +export interface AdminDirectUploadTicketPayload { + bucket: string; + host: string; + objectKey: string; + legacyPublicPath: string; + contentType?: string | null; + formFields: Record; +} + +export interface AdminCreateEditorShowcaseCampaignImageUploadTicketRequest { + fileName: string; + contentType: string; + contentLength: number; +} + +export interface AdminCreateEditorShowcaseCampaignImageUploadTicketResponse { + upload: AdminDirectUploadTicketPayload; +} + +export interface AdminUploadedEditorShowcaseCampaignImage { + imageSrc: string; + imageObjectKey: string; + legacyPublicPath: string; +} + export interface AdminUpsertProfileRedeemCodeRequest { code: string; mode: ProfileRedeemCodeMode; diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 2f39d312a..de103847d 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -18,6 +18,9 @@ use reqwest::Client; use serde::Deserialize; use serde_json::{Map, Value}; use shared_contracts::admin::{ + AdminCreateEditorShowcaseCampaignImageUploadTicketRequest, + AdminCreateEditorShowcaseCampaignImageUploadTicketResponse, + AdminDirectUploadTicketPayload, AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload, AdminDashboardBreakdownRowPayload, AdminDashboardChartBucketPayload, AdminDashboardChartPayload, AdminDashboardMetricsPayload, AdminDashboardOperationMetricPayload, @@ -38,6 +41,9 @@ use shared_contracts::admin::{ AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertPublicWorkInteractionConfigRequest, AdminWorkVisibilityListResponse, }; +use shared_contracts::assets::{ + CreateDirectUploadTicketRequest, DirectUploadObjectAccess, +}; use shared_contracts::creation_entry_config::{ encode_unified_creation_spec_response, validate_unified_creation_spec_for_play, }; @@ -52,6 +58,7 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{ api_response::json_success_body, + assets::create_direct_upload_ticket_for_owner, editor_generation_config::EditorGenerationPricingConfig, editor_project::current_utc_micros, http_error::AppError, @@ -83,6 +90,7 @@ const ADMIN_DASHBOARD_MAX_PERIOD_DAYS: i64 = 366; const ADMIN_EDITOR_ASSET_DEFAULT_LIMIT: u32 = 80; const ADMIN_EDITOR_ASSET_MAX_LIMIT: u32 = 200; const ADMIN_DASHBOARD_ACTIVE_WINDOW_MICROS: i64 = 5 * 60 * 1_000_000; +const ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_MAX_BYTES: u64 = 20 * 1024 * 1024; #[derive(Clone, Debug)] pub struct AuthenticatedAdmin { @@ -578,6 +586,7 @@ pub async fn admin_upsert_editor_showcase_campaign( enabled: payload.enabled, title: payload.title, image_src: payload.image_src, + image_object_key: payload.image_object_key, prompt: payload.prompt, author: payload.author, cost_text: payload.cost_text, @@ -592,6 +601,79 @@ pub async fn admin_upsert_editor_showcase_campaign( )) } +pub async fn admin_create_editor_showcase_campaign_image_upload_ticket( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result, AppError> { + let file_name = payload.file_name.trim(); + if file_name.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_message("图片文件名不能为空") + ); + } + let content_type = payload.content_type.trim(); + if !content_type.starts_with("image/") { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_message("活动卡只能上传图片") + ); + } + if payload.content_length == 0 { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_message("图片文件不能为空") + ); + } + if payload.content_length > ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_MAX_BYTES { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("活动卡图片不能超过 20MB") + ); + } + + let ticket = create_direct_upload_ticket_for_owner( + &state, + &request_context, + admin.session().subject.as_str(), + CreateDirectUploadTicketRequest { + legacy_prefix: "generated-character-drafts".to_string(), + path_segments: vec![ + "editor".to_string(), + "showcase-campaign".to_string(), + current_utc_micros().to_string(), + ], + file_name: file_name.to_string(), + content_type: Some(content_type.to_string()), + access: Some(DirectUploadObjectAccess::Private), + metadata: BTreeMap::from([ + ( + "asset_kind".to_string(), + "editor_showcase_campaign_image".to_string(), + ), + ("source".to_string(), "admin_editor_showcase_campaign".to_string()), + ]), + max_size_bytes: Some(payload.content_length), + expire_seconds: None, + success_action_status: Some(200), + }, + ) + .await?; + let axum::Json(value) = ticket; + let payload_value = value.get("data").cloned().unwrap_or(value); + let response: shared_contracts::assets::CreateDirectUploadTicketResponse = + serde_json::from_value(payload_value).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(serde_json::json!({ + "message": format!("活动卡上传凭证响应解析失败:{error}"), + })) + })?; + Ok(json_success_body( + Some(&request_context), + AdminCreateEditorShowcaseCampaignImageUploadTicketResponse { + upload: admin_direct_upload_ticket_payload_from_asset_upload(response.upload), + }, + )) +} + fn build_admin_creation_entry_config_response( config: shared_contracts::creation_entry_config::CreationEntryConfigResponse, ) -> AdminCreationEntryConfigResponse { @@ -716,6 +798,7 @@ fn admin_editor_showcase_campaign_payload_from_record( enabled: record.enabled, title: record.title, image_src: record.image_src, + image_object_key: record.image_object_key, prompt: record.prompt, author: record.author, cost_text: record.cost_text, @@ -723,6 +806,47 @@ fn admin_editor_showcase_campaign_payload_from_record( } } +fn admin_direct_upload_ticket_payload_from_asset_upload( + upload: shared_contracts::assets::DirectUploadTicketPayload, +) -> AdminDirectUploadTicketPayload { + let mut form_fields = BTreeMap::from([ + ("key".to_string(), upload.form_fields.key), + ("policy".to_string(), upload.form_fields.policy), + ( + "x-oss-signature-version".to_string(), + upload.form_fields.signature_version, + ), + ( + "x-oss-credential".to_string(), + upload.form_fields.credential, + ), + ("x-oss-date".to_string(), upload.form_fields.date), + ( + "x-oss-signature".to_string(), + upload.form_fields.signature, + ), + ( + "success_action_status".to_string(), + upload.form_fields.success_action_status, + ), + ]); + if let Some(content_type) = upload.form_fields.content_type { + form_fields.insert("Content-Type".to_string(), content_type); + } + if let Some(cache_control) = upload.form_fields.cache_control { + form_fields.insert("Cache-Control".to_string(), cache_control); + } + form_fields.extend(upload.form_fields.metadata); + + AdminDirectUploadTicketPayload { + bucket: upload.bucket, + host: upload.host, + object_key: upload.object_key, + legacy_public_path: upload.legacy_public_path, + form_fields, + } +} + fn editor_showcase_admin_cursor(record: &EditorShowcaseAssetRecord) -> Option { let submitted_at_micros = parse_timestamp_text_to_micros(record.submitted_at.as_str())?; Some(format!("{submitted_at_micros}:{}", record.showcase_id)) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 2435ea990..c675aff21 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -617,6 +617,7 @@ pub struct EditorShowcaseCampaignPayload { enabled: bool, title: String, image_src: String, + image_object_key: Option, prompt: String, author: String, cost_text: String, @@ -3350,6 +3351,7 @@ fn editor_showcase_campaign_payload_from_record( enabled: record.enabled, title: record.title, image_src: record.image_src, + image_object_key: record.image_object_key, prompt: record.prompt, author: record.author, cost_text: record.cost_text, diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index 025500d85..44e6b5afd 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -6,6 +6,7 @@ use axum::{ use crate::{ admin::{ admin_dashboard, admin_debug_http, admin_get_creation_entry_config, + admin_create_editor_showcase_campaign_image_upload_ticket, admin_get_editor_generation_pricing, admin_get_editor_showcase_campaign, admin_list_database_table_rows, admin_list_database_tables, admin_list_editor_assets, admin_list_editor_showcase_assets, admin_list_tracking_event_keys, @@ -152,6 +153,12 @@ pub fn router(state: AppState) -> Router { require_admin_auth, )), ) + .route( + "/admin/api/editor-showcase/campaign/image-upload-ticket", + post(admin_create_editor_showcase_campaign_image_upload_ticket).route_layer( + middleware::from_fn_with_state(state.clone(), require_admin_auth), + ), + ) .route( "/admin/api/works/visibility", get(admin_list_work_visibility) diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index cb9b83d7b..3f157b185 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -254,6 +256,7 @@ pub struct AdminEditorShowcaseCampaignPayload { pub enabled: bool, pub title: String, pub image_src: String, + pub image_object_key: Option, pub prompt: String, pub author: String, pub cost_text: String, @@ -272,11 +275,36 @@ pub struct AdminUpsertEditorShowcaseCampaignRequest { pub enabled: bool, pub title: String, pub image_src: String, + pub image_object_key: Option, pub prompt: String, pub author: String, pub cost_text: String, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminCreateEditorShowcaseCampaignImageUploadTicketRequest { + pub file_name: String, + pub content_type: String, + pub content_length: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminCreateEditorShowcaseCampaignImageUploadTicketResponse { + pub upload: AdminDirectUploadTicketPayload, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminDirectUploadTicketPayload { + pub bucket: String, + pub host: String, + pub object_key: String, + pub legacy_public_path: String, + pub form_fields: BTreeMap, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AdminLoginResponse { diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index 38865c15f..5dce27889 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -180,6 +180,7 @@ pub struct EditorShowcaseCampaignConfigRecord { pub enabled: bool, pub title: String, pub image_src: String, + pub image_object_key: Option, pub prompt: String, pub author: String, pub cost_text: String, @@ -426,6 +427,7 @@ pub struct EditorShowcaseCampaignConfigUpsertRecordInput { pub author: String, pub cost_text: String, pub updated_at_micros: i64, + pub image_object_key: Option, } impl From for crate::module_bindings::EditorProjectCreateInput { @@ -783,6 +785,7 @@ impl From author: input.author, cost_text: input.cost_text, updated_at_micros: input.updated_at_micros, + image_object_key: input.image_object_key, } } } @@ -1214,6 +1217,7 @@ fn map_editor_showcase_campaign_config_snapshot( enabled: snapshot.enabled, title: snapshot.title, image_src: snapshot.image_src, + image_object_key: snapshot.image_object_key, prompt: snapshot.prompt, author: snapshot.author, cost_text: snapshot.cost_text, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_campaign_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_campaign_config_snapshot_type.rs index 5f71e9552..2850679db 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_campaign_config_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_campaign_config_snapshot_type.rs @@ -15,6 +15,7 @@ pub struct EditorShowcaseCampaignConfigSnapshot { pub cost_text: String, pub updated_by_admin_user_id: Option, pub updated_at_micros: i64, + pub image_object_key: Option, } impl __sdk::InModule for EditorShowcaseCampaignConfigSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_campaign_config_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_campaign_config_type.rs index 44b188966..dbf215e0d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_campaign_config_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_campaign_config_type.rs @@ -16,6 +16,7 @@ pub struct EditorShowcaseCampaignConfig { pub cost_text: String, pub updated_by_admin_user_id: Option, pub updated_at: __sdk::Timestamp, + pub image_object_key: Option, } impl __sdk::InModule for EditorShowcaseCampaignConfig { @@ -36,6 +37,7 @@ pub struct EditorShowcaseCampaignConfigCols { pub updated_by_admin_user_id: __sdk::__query_builder::Col>, pub updated_at: __sdk::__query_builder::Col, + pub image_object_key: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for EditorShowcaseCampaignConfig { @@ -54,6 +56,7 @@ impl __sdk::__query_builder::HasCols for EditorShowcaseCampaignConfig { "updated_by_admin_user_id", ), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + image_object_key: __sdk::__query_builder::Col::new(table_name, "image_object_key"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_campaign_config_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_campaign_config_upsert_input_type.rs index 813101fd1..b093fc4e0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_campaign_config_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_showcase_campaign_config_upsert_input_type.rs @@ -15,6 +15,7 @@ pub struct EditorShowcaseCampaignConfigUpsertInput { pub author: String, pub cost_text: String, pub updated_at_micros: i64, + pub image_object_key: Option, } impl __sdk::InModule for EditorShowcaseCampaignConfigUpsertInput { diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index ba6ff8e43..a330706c0 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -13,7 +13,7 @@ const EDITOR_SHOWCASE_STATUS_PENDING: &str = "pending"; const EDITOR_SHOWCASE_STATUS_APPROVED: &str = "approved"; const EDITOR_SHOWCASE_STATUS_REJECTED: &str = "rejected"; const EDITOR_SHOWCASE_CAMPAIGN_CONFIG_ID: &str = "global"; -const EDITOR_SHOWCASE_CATEGORIES: [&str; 5] = ["packs", "characters", "ui", "music", "marketing"]; +const EDITOR_SHOWCASE_CATEGORIES: [&str; 4] = ["characters", "ui", "music", "marketing"]; #[spacetimedb::table( accessor = editor_project, @@ -205,6 +205,8 @@ pub struct EditorShowcaseCampaignConfig { cost_text: String, updated_by_admin_user_id: Option, updated_at: Timestamp, + #[default(None::)] + image_object_key: Option, } #[derive(Clone, Debug, PartialEq, SpacetimeType)] @@ -463,6 +465,7 @@ pub struct EditorShowcaseCampaignConfigSnapshot { pub cost_text: String, pub updated_by_admin_user_id: Option, pub updated_at_micros: i64, + pub image_object_key: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -617,6 +620,7 @@ pub struct EditorShowcaseCampaignConfigUpsertInput { pub author: String, pub cost_text: String, pub updated_at_micros: i64, + pub image_object_key: Option, } #[derive(Clone, Debug, PartialEq, SpacetimeType)] @@ -1931,10 +1935,6 @@ fn list_public_editor_showcase_assets( && asset.display_enabled && !asset.asset_deleted_while_pending && (asset.refund_mud_points == 0 || asset.refund_completed_at.is_some()) - && asset - .showcase_category - .as_deref() - .is_some_and(is_valid_showcase_category) && !asset.image_src.trim().is_empty() && is_after_public_showcase_cursor( approved_at_micros, @@ -2077,14 +2077,11 @@ fn update_editor_showcase_asset_display( if asset.asset_deleted_while_pending { return Err("素材已删除,不能公开展示".to_string()); } - let showcase_category = input - .showcase_category - .map(|value| normalize_showcase_category(value.as_str())) - .transpose()? - .or_else(|| asset.showcase_category.clone()); - if input.display_enabled && showcase_category.is_none() { - return Err("公开展示前必须先设置精选分类".to_string()); - } + let showcase_category = match input.showcase_category { + Some(value) if value.trim().is_empty() => None, + Some(value) => Some(normalize_showcase_category(value.as_str())?), + None => asset.showcase_category.clone(), + }; if input.display_enabled && asset.refund_mud_points > 0 && asset.refund_completed_at.is_none() { return Err("精选返还完成前不能公开展示".to_string()); } @@ -2203,7 +2200,9 @@ fn upsert_editor_showcase_campaign_config( "editor_showcase_campaign_config.updated_by_admin_user_id", )?; let title = input.title.trim().to_string(); - let image_src = input.image_src.trim().to_string(); + let image_object_key = normalize_optional(input.image_object_key) + .map(|value| value.trim_start_matches('/').to_string()); + let image_src = normalize_campaign_image_src(input.image_src.as_str(), image_object_key.as_deref()); if input.enabled { if title.is_empty() { return Err("editor_showcase_campaign_config.title 不能为空".to_string()); @@ -2238,6 +2237,7 @@ fn upsert_editor_showcase_campaign_config( cost_text: input.cost_text.trim().to_string(), updated_by_admin_user_id: Some(admin_user_id), updated_at: now, + image_object_key, }); ctx.db .editor_showcase_campaign_config() @@ -2614,7 +2614,7 @@ fn normalize_showcase_category(value: &str) -> Result { if is_valid_showcase_category(normalized) { return Ok(normalized.to_string()); } - Err("精选分类只支持 packs、characters、ui、music 或 marketing".to_string()) + Err("精选分类只支持 characters、ui、music 或 marketing".to_string()) } fn is_valid_showcase_category(value: &str) -> bool { @@ -2712,9 +2712,22 @@ fn showcase_campaign_config_snapshot_from_row( cost_text: row.cost_text, updated_by_admin_user_id: row.updated_by_admin_user_id, updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + image_object_key: row.image_object_key, } } +fn normalize_campaign_image_src(raw_image_src: &str, image_object_key: Option<&str>) -> String { + let image_src = raw_image_src.trim(); + if !image_src.is_empty() { + return image_src.to_string(); + } + image_object_key + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("/{}", value.trim_start_matches('/'))) + .unwrap_or_default() +} + fn timestamp_option_micros(value: Option) -> Option { value.map(|timestamp| timestamp.to_micros_since_unix_epoch()) } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index ad2897b97..1be3b8d9d 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1265,6 +1265,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde .or_insert(serde_json::Value::Null); } } + if table_name == "editor_showcase_campaign_config" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:活动卡图片 Object Key 晚于活动卡表加入,旧迁移包按仅有图片地址兼容。 + object + .entry("image_object_key".to_string()) + .or_insert(serde_json::Value::Null); + } + } if table_name == "big_fish_creation_session" { if let Some(object) = next_value.as_object_mut() { // 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。 diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 18b9020f3..26b6c9320 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -138,6 +138,7 @@ export type EditorShowcaseCampaignSnapshot = { enabled: boolean; title: string; imageSrc: string; + imageObjectKey?: string | null; prompt: string; author: string; costText: string; From 68e90875523798bb425e113e1cf996fbc332d291 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 6 Jul 2026 18:57:20 +0800 Subject: [PATCH 05/24] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E7=B2=BE=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); } From 51c47c952b7be974660e7cc4bc3079d95d5be1af Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 6 Jul 2026 18:58:58 +0800 Subject: [PATCH 06/24] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=89=8D=E5=8F=B0?= =?UTF-8?q?=E7=B2=BE=E9=80=89=E5=88=86=E7=B1=BB=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前台精选分类新增全部并作为默认 Tab。 移除素材包 Tab 和素材包组合逻辑,未设置分类素材保留在全部中。 活动卡支持从 imageObjectKey 兜底图片地址。 精选卡片改为网格布局并让图片素材铺满预览区域。 更新前台精选模型和创作主页测试。 --- .../CreationLandingView.test.tsx | 29 ++++---- .../creation-home/CreationLandingView.tsx | 6 +- .../creationShowcaseModel.test.ts | 72 ++++++++++--------- .../creation-home/creationShowcaseModel.ts | 64 ++++------------- src/index.css | 27 ++++--- 5 files changed, 89 insertions(+), 109 deletions(-) diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index e54762ba5..a403c7f45 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -167,7 +167,8 @@ describe('CreationLandingView', () => { expect(screen.getByRole('heading', { name: '创作工具' })).toBeTruthy(); expect(screen.getByText('游戏视觉规范')).toBeTruthy(); expect(screen.getByRole('heading', { name: '陶泥儿精选' })).toBeTruthy(); - expect(screen.getByRole('tab', { name: '素材包' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: '全部' })).toBeTruthy(); + expect(screen.queryByRole('tab', { name: '素材包' })).toBeNull(); expect(screen.queryByRole('tab', { name: '特效' })).toBeNull(); expect(screen.queryByRole('tab', { name: '场景' })).toBeNull(); expect(screen.queryByRole('tab', { name: '游戏' })).toBeNull(); @@ -557,12 +558,12 @@ describe('CreationLandingView', () => { { resourceId: 'resource-pack-a', projectId: 'project-newest', - label: '素材包 A', + label: '精选素材 A', imageSrc: 'data:image/png;base64,a', width: 512, height: 640, sourceType: 'generated', - prompt: '素材包提示词', + prompt: '精选素材提示词', actualPrompt: null, model: null, provider: null, @@ -585,7 +586,7 @@ describe('CreationLandingView', () => { { resourceId: 'resource-pack-b', projectId: 'project-old', - label: '素材包 B', + label: '精选素材 B', imageSrc: 'data:image/png;base64,b', width: 512, height: 512, @@ -600,8 +601,8 @@ describe('CreationLandingView', () => { const { container } = renderCreationLanding(); - expect(await screen.findByText('素材包 A')).toBeTruthy(); - expect(screen.getByText('素材包 B')).toBeTruthy(); + expect(await screen.findByText('精选素材 A')).toBeTruthy(); + expect(screen.getByText('精选素材 B')).toBeTruthy(); expect(screen.queryByText('上传素材')).toBeNull(); expect(screen.getByLabelText('用户素材瀑布流').className).toContain( 'creation-landing__asset-waterfall', @@ -623,7 +624,7 @@ describe('CreationLandingView', () => { { resourceId: 'resource-pack-a', projectId: 'project-newest', - label: '素材包 A', + label: '精选素材 A', imageSrc: 'data:image/png;base64,a', width: 512, height: 640, @@ -642,7 +643,7 @@ describe('CreationLandingView', () => { { resourceId: 'resource-pack-b', projectId: 'project-old', - label: '素材包 B', + label: '精选素材 B', imageSrc: 'data:image/png;base64,b', width: 512, height: 512, @@ -659,12 +660,12 @@ describe('CreationLandingView', () => { renderCreationLanding(); - expect(await screen.findByText('素材包 A')).toBeTruthy(); + expect(await screen.findByText('精选素材 A')).toBeTruthy(); await waitFor(() => expect(observers).toHaveLength(1)); - expect(screen.queryByText('素材包 B')).toBeNull(); + expect(screen.queryByText('精选素材 B')).toBeNull(); triggerIntersection(observers[0]!); triggerIntersection(observers[0]!); - expect(await screen.findByText('素材包 B')).toBeTruthy(); + expect(await screen.findByText('精选素材 B')).toBeTruthy(); await waitFor(() => expect(listPublicEditorProjectResourcesMock).toHaveBeenLastCalledWith({ cursor: 'cursor-page-2', @@ -682,7 +683,7 @@ describe('CreationLandingView', () => { { resourceId: 'resource-pack-a', projectId: 'project-newest', - label: '素材包 A', + label: '精选素材 A', imageSrc: 'data:image/png;base64,a', width: 512, height: 640, @@ -700,14 +701,14 @@ describe('CreationLandingView', () => { renderCreationLanding(); - expect(await screen.findByText('素材包 A')).toBeTruthy(); + expect(await screen.findByText('精选素材 A')).toBeTruthy(); await waitFor(() => expect(observers).toHaveLength(1)); triggerIntersection(observers[0]!); expect( await screen.findByRole('button', { name: '读取更多素材失败,点击重试' }), ).toBeTruthy(); - expect(screen.getByText('素材包 A')).toBeTruthy(); + expect(screen.getByText('精选素材 A')).toBeTruthy(); expect(screen.getByLabelText('用户素材瀑布流')).toBeTruthy(); }); diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index 64f8a2cf1..ec07c8e04 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -247,7 +247,7 @@ function ShowcaseImagePreview({ className={ variant === 'modal' ? 'creation-landing__showcase-media' - : 'h-full w-full object-contain' + : 'creation-landing__asset-preview-media' } /> ); @@ -269,7 +269,7 @@ function ShowcaseVideoPreview({ className={ variant === 'modal' ? 'creation-landing__showcase-media' - : 'h-full w-full object-contain' + : 'creation-landing__asset-preview-media' } controls={controls} muted={!controls} @@ -470,7 +470,7 @@ export function CreationLandingView({ string | null >(null); const [activeShowcaseTab, setActiveShowcaseTab] = - useState('packs'); + useState('all'); const [selectedShowcaseItem, setSelectedShowcaseItem] = useState(null); const [selectedShowcaseIndex, setSelectedShowcaseIndex] = useState(0); diff --git a/src/components/creation-home/creationShowcaseModel.test.ts b/src/components/creation-home/creationShowcaseModel.test.ts index e108cb496..53afccd73 100644 --- a/src/components/creation-home/creationShowcaseModel.test.ts +++ b/src/components/creation-home/creationShowcaseModel.test.ts @@ -26,7 +26,7 @@ function createResource( describe('creationShowcaseModel', () => { it('only builds showcase items from generated project resources', () => { const items = buildCreationShowcaseItems({ - activeTab: 'packs', + activeTab: 'all', projectResources: [ createResource({ resourceId: 'generated-1', @@ -55,7 +55,7 @@ describe('creationShowcaseModel', () => { it('filters out generated showcase records with display disabled', () => { const items = buildCreationShowcaseItems({ - activeTab: 'packs', + activeTab: 'all', projectResources: [ createResource({ resourceId: 'public-generated', @@ -78,7 +78,7 @@ describe('creationShowcaseModel', () => { it('prepends enabled campaign cards and keeps showcase metrics', () => { const items = buildCreationShowcaseItems({ - activeTab: 'packs', + activeTab: 'all', campaign: { enabled: true, title: '活动精选', @@ -109,7 +109,7 @@ describe('creationShowcaseModel', () => { cost: '50泥点', }); expect(items[1]).toMatchObject({ - id: 'standalone:asset-1', + id: 'asset:asset-1', label: '审核通过素材', likeCount: 12, showcaseId: 'showcase-1', @@ -215,9 +215,9 @@ describe('creationShowcaseModel', () => { expect(items[0]?.previews[0]?.id).toBe('resource-original'); }); - it('groups pack items by the same referenced spec image and sums known cost', () => { + it('shows every real asset in the all tab without requiring a category', () => { const items = buildCreationShowcaseItems({ - activeTab: 'packs', + activeTab: 'all', projectResources: [ createResource({ resourceId: 'spec-1', @@ -247,51 +247,55 @@ describe('creationShowcaseModel', () => { ], }); - expect(items).toHaveLength(1); - expect(items[0]?.label).toBe('角色规范图'); - expect(items[0]?.previews.map((preview) => preview.label)).toEqual([ - '角色规范图', - '角色主图', - ]); + expect(items.map((item) => item.label)).toEqual(['角色规范图', '角色主图']); expect(items[0]?.prompt).toBe('规范提示词'); expect(items[0]?.author).toBe('创作者A'); - expect(items[0]?.cost).toBe('17泥点'); + expect(items[0]?.cost).toBe('5泥点'); + expect(items[1]?.cost).toBe('12泥点'); }); - it('groups assets when references point to the source project resource', () => { + it('keeps unset category visible in all tab and out of concrete category tabs', () => { const items = buildCreationShowcaseItems({ - activeTab: 'packs', + activeTab: 'all', projectResources: [ createResource({ resourceId: 'spec-1', - label: '项目规范图', + label: '未设置分类素材', assetKind: 'spec', - sourceResourceId: 'resource-spec-1', + showcaseCategory: null, }), createResource({ - resourceId: 'character-1', - label: '项目角色图', + resourceId: 'ui-1', + label: '人工 UI 素材', assetKind: 'character', - generationInputs: { - fields: [{ title: '角色设定', value: '项目角色提示词' }], - references: [ - { - title: '角色规范', - label: '项目规范图', - refType: 'project-resource', - refId: 'resource-spec-1', - }, - ], - }, + showcaseCategory: 'ui', }), ], }); - expect(items).toHaveLength(1); - expect(items[0]?.previews.map((preview) => preview.label)).toEqual([ - '项目规范图', - '项目角色图', + expect(items.map((item) => item.label)).toEqual([ + '未设置分类素材', + '人工 UI 素材', ]); + expect( + buildCreationShowcaseItems({ + activeTab: 'ui', + projectResources: [ + createResource({ + resourceId: 'spec-1', + label: '未设置分类素材', + assetKind: 'spec', + showcaseCategory: null, + }), + createResource({ + resourceId: 'ui-1', + label: '人工 UI 素材', + assetKind: 'character', + showcaseCategory: 'ui', + }), + ], + }).map((item) => item.label), + ).toEqual(['人工 UI 素材']); }); it('groups character animations under the referenced character image', () => { diff --git a/src/components/creation-home/creationShowcaseModel.ts b/src/components/creation-home/creationShowcaseModel.ts index 68dd913a3..fe17d6407 100644 --- a/src/components/creation-home/creationShowcaseModel.ts +++ b/src/components/creation-home/creationShowcaseModel.ts @@ -17,12 +17,7 @@ import { DEFAULT_VIDEO_MODEL, } from '../image-editor/ImageCanvasGenerationModel'; -export type ShowcaseTabId = - | 'packs' - | 'characters' - | 'ui' - | 'music' - | 'marketing'; +export type ShowcaseTabId = 'all' | 'characters' | 'ui' | 'music' | 'marketing'; export type ShowcaseAssetMediaType = 'image' | 'video' | 'audio'; @@ -71,15 +66,15 @@ const SHOWCASE_AUTHOR_KEYS = [ ] as const; export const SHOWCASE_TABS: Array<{ id: ShowcaseTabId; label: string }> = [ - { id: 'packs', label: '素材包' }, + { id: 'all', label: '全部' }, { id: 'characters', label: '角色' }, { id: 'ui', label: 'UI' }, { id: 'music', label: '音乐' }, { id: 'marketing', label: '美宣' }, ]; -const SHOWCASE_TAB_IDS = new Set( - SHOWCASE_TABS.map((tab) => tab.id), +const SHOWCASE_CATEGORY_IDS = new Set( + SHOWCASE_TABS.filter((tab) => tab.id !== 'all').map((tab) => tab.id), ); function assetRecord(asset: EditorAssetSnapshot) { @@ -218,7 +213,7 @@ function projectResourceToShowcaseAsset( } function normalizeShowcaseCategory(value: string): ShowcaseTabId | null { - return SHOWCASE_TAB_IDS.has(value as ShowcaseTabId) + return SHOWCASE_CATEGORY_IDS.has(value as Exclude) ? (value as ShowcaseTabId) : null; } @@ -470,41 +465,6 @@ function sortGroupAssets(assets: EditorAssetSnapshot[]) { }); } -function groupPackAssets(assets: EditorAssetSnapshot[]) { - const groups = new Map(); - assets.forEach((asset) => { - const specReference = findSpecReference(asset); - if (specReference) { - addAssetToGroup( - groups, - getReferenceKey(specReference), - asset, - specReference.label, - ); - return; - } - if (isSpecAsset(asset)) { - addAssetToGroup(groups, getSelfAssetKey(asset), asset, asset.label); - return; - } - addAssetToGroup(groups, `standalone:${asset.assetId}`, asset, asset.label); - }); - const specAssets = assets.filter(isSpecAsset); - specAssets.forEach((asset) => { - const group = mergeAssetAliasGroups(groups, asset, asset.label); - if ( - group && - !group.assets.some((groupAsset) => groupAsset.assetId === asset.assetId) - ) { - group.assets.unshift(asset); - } - }); - return [...groups.values()].map((group) => ({ - ...group, - assets: sortGroupAssets(group.assets), - })); -} - function groupCharacterAssets(assets: EditorAssetSnapshot[]) { const groups = new Map(); assets @@ -613,15 +573,15 @@ function groupSingleAssets( } function getGroupsForTab(assets: EditorAssetSnapshot[], tab: ShowcaseTabId) { + if (tab === 'all') { + return groupSingleAssets(assets, () => true); + } if (assets.some((asset) => resolveAssetShowcaseCategory(asset))) { return groupSingleAssets( assets, (asset) => resolveAssetShowcaseCategory(asset) === tab, ); } - if (tab === 'packs') { - return groupPackAssets(assets); - } if (tab === 'characters') { return groupCharacterAssets(assets); } @@ -829,7 +789,10 @@ function toLibraryShowcaseItem( function campaignToShowcaseItem( campaign?: EditorShowcaseCampaignSnapshot | null, ): ShowcaseAssetItem | null { - if (!campaign?.enabled || !campaign.imageSrc.trim()) { + const objectKey = campaign?.imageObjectKey?.trim().replace(/^\/+/u, '') ?? ''; + const imageSrc = + campaign?.imageSrc.trim() || (objectKey ? `/${objectKey}` : ''); + if (!campaign?.enabled || !imageSrc) { return null; } return { @@ -839,7 +802,8 @@ function campaignToShowcaseItem( { id: 'campaign:global:image', label: campaign.title.trim() || '陶泥儿精选', - src: campaign.imageSrc, + src: imageSrc, + objectKey: objectKey || null, mediaType: 'image', }, ], diff --git a/src/index.css b/src/index.css index f1022184c..1402c9eef 100644 --- a/src/index.css +++ b/src/index.css @@ -3801,20 +3801,20 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .creation-landing__asset-waterfall { + display: grid; width: 100%; min-width: 0; - column-count: 3; - column-gap: 0.92rem; + grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); + gap: 0.92rem; + align-items: start; } .creation-landing__asset-card { position: relative; - display: inline-block; + display: block; width: 100%; min-width: 0; overflow: hidden; - margin: 0 0 0.92rem; - break-inside: avoid; border: 1px solid rgba(220, 189, 163, 0.78); border-radius: 1rem; background: rgba(255, 254, 251, 0.88); @@ -3911,10 +3911,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .creation-landing__asset-preview { position: relative; display: grid; + width: 100%; min-height: 9.5rem; max-height: 23rem; overflow: hidden; - place-items: center; + place-items: stretch; background: linear-gradient(180deg, #fffdfa, #fff4ea); } @@ -3923,6 +3924,16 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { object-position: center; } +.creation-landing__asset-preview-media { + display: block; + width: calc(100% + 2px); + max-width: none; + height: calc(100% + 2px); + max-height: none; + object-fit: cover; + object-position: center; +} + .creation-landing__asset-preview--bundle { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: stretch; @@ -4291,7 +4302,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .creation-landing__asset-waterfall { - column-count: 2; + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); } .creation-landing__showcase-modal-panel { @@ -4351,7 +4362,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .creation-landing__asset-waterfall { - column-count: 1; + grid-template-columns: 1fr; } .creation-landing__showcase-modal-overlay { From fcd353f12f32f063bb32638db4f450b69f8e0e8c Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 6 Jul 2026 18:59:40 +0800 Subject: [PATCH 07/24] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E7=B2=BE=E9=80=89?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E4=B8=8E=E6=B4=BB=E5=8A=A8=E5=8D=A1=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新精选审核公开口径,说明未设置分类进入全部。 更新创作主页文档,移除素材包分类并说明全部 Tab。 更新后端表目录,记录活动卡 imageObjectKey 和 OSS 上传方式。 --- docs/project-memory/shared-memory/decision-log.md | 2 +- ...架构】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 ++-- ...法创作】创作主页与项目入口改版计划-2026-06-18.md | 14 +++++++------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f74984a1b..1ad4a8b7d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -27,7 +27,7 @@ ## 2026-07-04 陶泥儿精选改为素材提交审核后公开 - 背景:`/creation` 的 `陶泥儿精选` 过去依赖 `editor_project_resource.public_showcase_enabled`,生成画布资源默认可公开,和“作品公开默认关闭、由用户主动投稿精选”的运营要求冲突,也无法在后台审核、返还泥点和配置固定活动卡。 -- 决策:`陶泥儿精选` 的公开事实改为独立 `editor_showcase_asset` 审核表。生成素材默认不公开;用户在账号级素材库对 `sourceType="generated"` 且有媒体内容的素材提交审核,后端快照素材信息并写入 `pending`。后台审核通过后写入 `approved`,但默认 `display_enabled=false` 且 `showcase_category=null`,运营需按前台 Tab 手动设置分类并开启展示;审核通过时按 `generation_cost_mud_points` 返还 50% 泥点;拒绝后写入 `rejected`。公开接口 `GET /api/editor/showcase/resources` 只返回已通过、展示开启且分类合法的快照,按通过时间和 `showcaseId` 倒序分页,并可携带后台配置的固定活动卡。旧 `editor_project_resource.public_showcase_enabled` 和旧 PATCH 接口只保留兼容,不再驱动精选公开。 +- 决策:`陶泥儿精选` 的公开事实改为独立 `editor_showcase_asset` 审核表。生成素材默认不公开;用户在账号级素材库对 `sourceType="generated"` 且有媒体内容的素材提交审核,后端快照素材信息并写入 `pending`。后台审核通过后写入 `approved`,但默认 `display_enabled=false` 且 `showcase_category=null`,运营可按前台具体 Tab 手动设置分类并开启展示;未设置分类的素材展示开启后进入前台“全部”,但不进入角色 / UI / 音乐 / 美宣具体分类。审核通过时按 `generation_cost_mud_points` 返还 50% 泥点;拒绝后写入 `rejected`。公开接口 `GET /api/editor/showcase/resources` 返回已通过、展示开启且媒体非空的快照,按通过时间和 `showcaseId` 倒序分页,并可携带后台配置的固定活动卡。旧 `editor_project_resource.public_showcase_enabled` 和旧 PATCH 接口只保留兼容,不再驱动精选公开。 - 影响范围:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`spacetime-client` 绑定与 mapper、`api-server` 编辑器和后台路由、admin-web 精选审核页、素材库右键菜单、`/creation` 精选瀑布流、图片画布文档和后端表目录。 - 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo check --manifest-path server-rs/Cargo.toml -p spacetime-module -p spacetime-client -p api-server`、前端 / 后台 typecheck 与精选相关组件测试,确认默认不公开、提交后 pending、审核通过后展示和返还、展示开关与点赞生效。 - 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index a04d9d8ab..a508390a9 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -502,7 +502,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorShowcaseAsset` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:`陶泥儿精选` 的独立审核与公开快照表。用户从账号级生成素材提交后,后端把素材媒体、提示词、生成输入、素材类型和 `generation_cost_mud_points` 快照到该表,初始 `review_status = pending`、`display_enabled = false` 且 `showcase_category = null`。后台审核通过后写入 `approved`,但仍保持不展示;运营需在后台按前台 Tab 手动设置 `showcase_category`(`packs`、`characters`、`ui`、`music`、`marketing`)并开启展示后,素材才进入公开精选。审核通过时生成确定性返还流水 `editor-showcase-refund:{showcase_id}`,BFF 按 50% 生成成本返还泥点后回写 `refund_completed_at`;拒绝后写入 `rejected`。公开精选 `GET /api/editor/showcase/resources` 只读取 `review_status = approved`、`display_enabled = true`、`showcase_category` 合法且媒体非空的记录,按通过时间 / `showcase_id` 倒序 cursor 分页。素材删除时,待审核记录标记 `asset_deleted_while_pending`,已拒绝记录删除,已通过记录保留快照继续展示。 +- 说明:`陶泥儿精选` 的独立审核与公开快照表。用户从账号级生成素材提交后,后端把素材媒体、提示词、生成输入、素材类型和 `generation_cost_mud_points` 快照到该表,初始 `review_status = pending`、`display_enabled = false` 且 `showcase_category = null`。后台审核通过后写入 `approved`,但仍保持不展示;运营可在后台按前台具体 Tab 手动设置 `showcase_category`(`characters`、`ui`、`music`、`marketing`)并开启展示。未设置分类的素材不归入具体 Tab,但展示开启后仍进入前台“全部”。审核通过时生成确定性返还流水 `editor-showcase-refund:{showcase_id}`,BFF 按 50% 生成成本返还泥点后回写 `refund_completed_at`;拒绝后写入 `rejected`。公开精选 `GET /api/editor/showcase/resources` 读取 `review_status = approved`、`display_enabled = true` 且媒体非空的记录,按通过时间 / `showcase_id` 倒序 cursor 分页。素材删除时,待审核记录标记 `asset_deleted_while_pending`,已拒绝记录删除,已通过记录保留快照继续展示。 - 索引:`by_editor_showcase_asset_owner_user_id`、`by_editor_showcase_asset_review_status`。 ### `editor_showcase_asset_like` @@ -516,7 +516,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorShowcaseCampaignConfig` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:`陶泥儿精选` 首位固定活动卡配置表,当前使用固定 `config_id = default`。后台可配置启用状态、标题、图片 URL、提示词、作者和成本文案;固定活动卡不包含副标题或 Object Key,公开精选接口只在启用时返回该配置。 +- 说明:`陶泥儿精选` 首位固定活动卡配置表,当前使用固定 `config_id = global`。后台可配置启用状态、标题、图片地址、图片 OSS `image_object_key`、提示词、作者和成本文案;上传按钮通过后台受控上传票据把图片写入 OSS,保存时同时落 `image_src` 和 `image_object_key`。公开精选接口只在启用时返回该配置,前端优先用 `image_object_key` 走签名读地址展示。 - 索引:主键 `config_id`。 ### `inventory_slot` diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md index f93f7bfba..8a8701407 100644 --- a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -15,7 +15,7 @@ - 顶级导航中“创作”进入 `/creation`,“草稿”改为“项目”并进入 `/project`。 - 移动端隐藏“创作”和“项目”入口,只保留浏览和个人相关入口;移动端直达 `/creation` 时提示用户使用桌面端打开。 - 登录后在创作主页展示最近项目,并通过新建项目进入 `/editor/canvas?projectid=xxx`。 -- `陶泥儿精选` 展示全站用户图片画布项目中允许公开展示的画布生成素材包和素材,不使用账号级非项目素材、公开作品补充或 mock 素材。 +- `陶泥儿精选` 展示全站用户图片画布项目中允许公开展示的画布生成素材,不使用账号级非项目素材、公开作品补充或 mock 素材。 - 现有 `/creation/` 玩法工作台、草稿、作品架、生成恢复和发布链路保持不变。 ## 页面结构 @@ -73,13 +73,13 @@ `陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/` 工作台使用,但不作为本页精选区内容。 -精选内容只使用用户从账号级素材库主动提交、后台审核通过、手动设置精选分类且展示状态开启的 `editor_showcase_asset` 快照。新生成素材不会默认公开,审核通过后也不会自动展示;运营需在后台按前台 Tab 设置分类(素材包、角色、UI、音乐、美宣)并开启展示。旧 `editor_project_resource.public_showcase_enabled` 只保留历史兼容,不再作为 `/creation` 精选事实源。账号级 `editor_asset` 仍是素材库私有事实;只有 `sourceType="generated"`、有媒体内容、提交审核并通过的素材才可进入精选,上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。审核通过时按生成成本返还 50% 泥点,返还流水使用确定性 `editor-showcase-refund:{showcaseId}` 保证幂等。公开 BFF 必须返回作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。 +精选内容只使用用户从账号级素材库主动提交、后台审核通过且展示状态开启的 `editor_showcase_asset` 快照。新生成素材不会默认公开,审核通过后也不会自动展示;运营可在后台按前台具体 Tab 设置分类(角色、UI、音乐、美宣)并开启展示,未设置分类的素材不会隐藏,会进入前台“全部”。旧 `editor_project_resource.public_showcase_enabled` 只保留历史兼容,不再作为 `/creation` 精选事实源。账号级 `editor_asset` 仍是素材库私有事实;只有 `sourceType="generated"`、有媒体内容、提交审核并通过的素材才可进入精选,上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。审核通过时按生成成本返还 50% 泥点,返还流水使用确定性 `editor-showcase-refund:{showcaseId}` 保证幂等。公开 BFF 必须返回作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。 瀑布流通过 `GET /api/editor/showcase/resources` 按通过审核时间 / `showcaseId` 倒序 cursor 分页读取,每页最多 36 条;响应有 `nextCursor` 时,页面滚动到底部继续请求 `?cursor=...` 并追加到现有瀑布流,而不是固定只展示首屏数量。响应可以额外携带后台配置的固定活动卡,用于在列表首位展示运营精选。 Tab: -- 素材包 +- 全部 - 角色 - UI - 音乐 @@ -90,7 +90,7 @@ Tab: 展示口径: -- 素材包:每个列表项展示同一规范图生成的规范图和相关素材组合。 +- 全部:展示所有已公开精选素材,包含未设置分类的素材。 - 角色:每个列表项展示游戏角色图和该角色生成的角色动画素材组合。 - UI:每个列表项展示游戏 UI 图和该 UI 图拆出的子素材组合。 - 音乐:展示背景音乐和音效。 @@ -112,8 +112,8 @@ Tab: 数据来源: -- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_showcase_asset` 快照,快照包含 `showcaseId`、`assetId`、审核状态、展示状态、`showcaseCategory`、点赞数、生成成本、返还泥点、`authorDisplayName` / `display_name` 和 `authorPublicUserCode` / 陶泥号用于展示;前端按 `showcaseCategory` 放入素材包、角色、UI、音乐或美宣 Tab,只展示后端已经筛过的公开结果。作者展示优先展示名,没有展示名时展示陶泥号,绝不能展示内部 `ownerUserId` / `user_id`。 -- 素材包、角色、UI、音乐、音效、视频和美宣都必须来自用户素材库中已提交并通过审核的生成素材;不要求素材当前仍保留在某个项目资源中,已通过审核的快照在原素材删除后仍可保留公开展示。 +- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_showcase_asset` 快照,快照包含 `showcaseId`、`assetId`、审核状态、展示状态、`showcaseCategory`、点赞数、生成成本、返还泥点、`authorDisplayName` / `display_name` 和 `authorPublicUserCode` / 陶泥号用于展示;前端默认进入“全部”,展示所有后端已经筛过的公开结果;角色、UI、音乐或美宣 Tab 只展示对应 `showcaseCategory` 的素材。作者展示优先展示名,没有展示名时展示陶泥号,绝不能展示内部 `ownerUserId` / `user_id`。 +- 全部、角色、UI、音乐、音效、视频和美宣都必须来自用户素材库中已提交并通过审核的生成素材;不要求素材当前仍保留在某个项目资源中,已通过审核的快照在原素材删除后仍可保留公开展示。 - 未登录用户也可读取公开精选;当没有公开画布生成资源时,不再用公开作品图片补充,只显示简洁空态。 - 上传素材、公开作品图片、mock 资源和假组合都不进入精选。 - 任意画布左侧素材列表中,单个素材右键打开素材菜单;原外置删除按钮移入该菜单,以文字 `删除` 展示。生成素材菜单内提供 `提交精选审核`,调用 `POST /api/editor/assets/{assetId}/showcase-submissions` 后进入 `pending` 状态;已提交、已通过或已拒绝的素材显示对应状态,不再显示默认打开的公开勾选项。 @@ -128,7 +128,7 @@ Tab: - 最近项目继续使用编辑器项目接口:`listEditorProjects`、`createEditorProject` 和 `/editor/canvas?projectid=xxx`。 - 最近项目和项目页打开画布时,浏览器 history 只保留带 `projectid` 的最终画布路由;新建画布引导使用 `guide=toolbar` 一次性 query,主站九宫格直达生成器使用 `tool=` 一次性 query,画布消费后通过 `history.replaceState` 清理这两个参数。 - 项目封面逻辑复用 `/project` 项目卡已有的画布中心缩略图算法。 -- `陶泥儿精选` 读取后端公开精选接口返回的审核通过素材快照;公开事实以后端 `editor_showcase_asset.review_status`、`display_enabled` 和 `showcase_category` 为准,前端只做展示组合、Tab 归类、点赞交互和提交审核的乐观状态回滚。 +- `陶泥儿精选` 读取后端公开精选接口返回的审核通过素材快照;公开事实以后端 `editor_showcase_asset.review_status` 和 `display_enabled` 为准,`showcase_category` 只用于前端具体 Tab 归类,前端只做展示组合、Tab 归类、点赞交互和提交审核的乐观状态回滚。 - `/creation/` 的玩法工作台、草稿、生成页、结果页、发布、运行态和作品架链路保持原状。 ## 路由与导航 From ab61390121039b1693cf6fb8aeb2264be1f0f55c Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 6 Jul 2026 19:15:03 +0800 Subject: [PATCH 08/24] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83=20Ag?= =?UTF-8?q?ent=20=E9=BB=98=E8=AE=A4=E8=A2=AB=E4=BB=BB=E5=8A=A1=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E9=9A=90=E8=97=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 打开画布时 Agent 开关启用则保持 Agent 默认展开。 恢复任务自动打开任务列表时不再抢占 Agent 默认态。 用户手动打开任务列表时仍按右侧面板互斥关闭 Agent。 补充恢复任务场景下 Agent 默认显示的回归测试。 --- .../ImageCanvasEditorView.test.tsx | 34 +++++++++++++++++++ .../image-editor/ImageCanvasEditorView.tsx | 10 ++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 49f1b47a7..67b9f73a7 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1449,6 +1449,40 @@ describe('ImageCanvasEditorView', () => { expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull(); }); + it('keeps the Agent conversation panel open when restored tasks auto-open the task list', async () => { + enableEditorAgentSidebarForTest(); + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-with-restored-task', + title: '恢复任务项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + ...defaultEditorProjectLayers, + { + layerId: 'generation-dialog:restored-task', + resourceId: 'generation-dialog:restored-task', + itemType: 'generation-dialog', + dialog: { + id: 'restored-task', + mode: 'generate', + prompt: '继续生成', + status: 'idle', + }, + }, + ], + resources: defaultEditorProjectResources, + updatedAt: '2026-07-06T00:00:00.000Z', + }); + + render(); + + expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy(); + await waitFor(() => { + expect( + screen.queryByRole('complementary', { name: '画布任务列表' }), + ).toBeNull(); + }); + }); + it('keeps the left resource sidebar independent from right-side task and Agent panels', async () => { enableEditorAgentSidebarForTest(); render(); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 687740c30..504334bf5 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1199,13 +1199,19 @@ export function ImageCanvasEditorView({ [toggleSidebarPanel], ); useEffect(() => { + if (!isAgentConversationEnabled && isAgentConversationOpen) { + setIsAgentConversationOpen(false); + return; + } if ( - (!isAgentConversationEnabled || generationSurface.isTaskSidebarOpen) && + isAgentConversationEnabled && + generationSurface.isTaskSidebarOpen && isAgentConversationOpen ) { - setIsAgentConversationOpen(false); + generationSurface.toggleTaskSidebar(); } }, [ + generationSurface, generationSurface.isTaskSidebarOpen, isAgentConversationEnabled, isAgentConversationOpen, From 29db6365e16d8a9cd27566657f5e0772ed4c0229 Mon Sep 17 00:00:00 2001 From: kvtodev Date: Mon, 6 Jul 2026 18:29:10 +0800 Subject: [PATCH 09/24] =?UTF-8?q?=E9=9A=90=E8=97=8F=E7=94=BB=E6=9D=BF?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E4=BF=A1=E6=81=AF=E4=B8=ADObject=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/image-editor/ImageCanvasMetadataModalView.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.tsx index 13e1552b5..4ae10f503 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.tsx @@ -111,8 +111,6 @@ export function ImageCanvasMetadataModalView({ )}
Task
{formatTaskIdForDisplay(layer.taskId)}
-
Object
-
{layer.objectKey ?? layer.assetObjectId ?? '-'}
) : null} From be7f0375e566f231af69ff534b21e2723d20359f Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 6 Jul 2026 21:00:21 +0800 Subject: [PATCH 10/24] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E7=B2=BE=E9=80=89?= =?UTF-8?q?=E6=B4=BB=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({
-