diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md index 8a8701407..5d0efaeac 100644 --- a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -116,7 +116,7 @@ Tab: - 全部、角色、UI、音乐、音效、视频和美宣都必须来自用户素材库中已提交并通过审核的生成素材;不要求素材当前仍保留在某个项目资源中,已通过审核的快照在原素材删除后仍可保留公开展示。 - 未登录用户也可读取公开精选;当没有公开画布生成资源时,不再用公开作品图片补充,只显示简洁空态。 - 上传素材、公开作品图片、mock 资源和假组合都不进入精选。 -- 任意画布左侧素材列表中,单个素材右键打开素材菜单;原外置删除按钮移入该菜单,以文字 `删除` 展示。生成素材菜单内提供 `提交精选审核`,调用 `POST /api/editor/assets/{assetId}/showcase-submissions` 后进入 `pending` 状态;已提交、已通过或已拒绝的素材显示对应状态,不再显示默认打开的公开勾选项。 +- 任意画布左侧素材列表中,单个素材右键打开素材菜单;原外置删除按钮移入该菜单,以文字 `删除` 展示。生成素材菜单内提供 `提交精选审核`,调用 `POST /api/editor/assets/{assetId}/showcase-submissions` 后进入 `pending` 状态;已提交、已通过或已拒绝的素材显示对应状态,不再显示默认打开的公开勾选项。素材不可提交或后端拒绝提交时,菜单必须显示具体原因,例如非生成素材、素材尚未保存、没有媒体内容或审核未通过不能重复提交。 - 后台新增纯素材查询页,读取账号级 `editor_asset` 中 `sourceType="generated"` 的素材,用于查看所有用户生成素材。该页只提供时间、用户 ID 和关键词查询,以及缩略图详情、提示词全文、生成成本、作者展示名和陶泥号查看;不提供分类筛选,也不承载审核、返还、展示开关或活动卡配置操作。 - 不为了填满展示区创建假素材、假作者、假泥点成本或假组合关系。 - 暂无真实数据的 Tab 保留 Tab 入口,但内容区显示简洁空态。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 1334333e5..bef9a9adf 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -5088,6 +5088,11 @@ pub(crate) fn map_editor_project_error(error: SpacetimeClientError) -> AppError "message": message, })) } + SpacetimeClientError::Procedure(message) => AppError::from_status(StatusCode::BAD_REQUEST) + .with_details(json!({ + "provider": "editor-project", + "message": message, + })), SpacetimeClientError::Runtime(message) => AppError::from_status(StatusCode::BAD_REQUEST) .with_details(json!({ "provider": "editor-project", @@ -5121,6 +5126,19 @@ mod tests { use super::*; use crate::config::AppConfig; + #[test] + fn editor_project_procedure_errors_preserve_business_reason_as_bad_request() { + let error = map_editor_project_error(SpacetimeClientError::Procedure( + "只有生成素材可以提交精选审核".to_string(), + )); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + error.details().and_then(|details| details.get("message")), + Some(&json!("只有生成素材可以提交精选审核")), + ); + } + fn apply_editor_canvas_generation_completion( layers: Value, completion: &EditorCanvasGenerationCompletionRequest, diff --git a/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts b/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts index e009d12ae..826ee4ba7 100644 --- a/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts +++ b/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts @@ -21,6 +21,7 @@ import { renameAssetFolderById, replaceLocalAssetFolder, resolveAllAssetSelection, + resolveAssetShowcaseSubmissionState, resolveAssetFolderIdFromPoint, resolveDefaultAssetFolder, resolvePreferredAssetUploadFolder, @@ -89,6 +90,58 @@ describe('ImageCanvasAssetLibraryModel', () => { ]); }); + it('explains why assets cannot be submitted to showcase review', () => { + expect( + resolveAssetShowcaseSubmissionState(createAsset()).reason, + ).toBe('只有生成素材可以提交精选审核'); + expect( + resolveAssetShowcaseSubmissionState( + createAsset({ sourceType: 'generated', persisted: false }), + ).reason, + ).toBe('素材保存完成后才能提交精选审核'); + expect( + resolveAssetShowcaseSubmissionState( + createAsset({ sourceType: 'generated', src: '' }), + ).reason, + ).toBe('没有媒体内容的素材不能提交精选审核'); + expect( + resolveAssetShowcaseSubmissionState( + createAsset({ + sourceType: 'generated', + showcaseReviewStatus: 'rejected', + }), + ), + ).toMatchObject({ + canSubmit: false, + label: '精选未通过', + reason: '该素材审核未通过,不能重复提交', + }); + }); + + it('allows persisted generated assets to be submitted and keeps prior backend errors visible', () => { + expect( + resolveAssetShowcaseSubmissionState( + createAsset({ sourceType: 'generated' }), + ), + ).toMatchObject({ + canSubmit: true, + label: '提交精选审核', + reason: null, + }); + expect( + resolveAssetShowcaseSubmissionState( + createAsset({ + sourceType: 'generated', + showcaseSubmitError: '只有生成素材可以提交精选审核', + }), + ), + ).toMatchObject({ + canSubmit: true, + label: '提交精选审核', + reason: '只有生成素材可以提交精选审核', + }); + }); + it('filters grouped assets by folder and asset search text', () => { const groupedAssets = groupAssetsByFolder( [ diff --git a/src/components/image-editor/ImageCanvasAssetLibraryModel.ts b/src/components/image-editor/ImageCanvasAssetLibraryModel.ts index f97d1ce73..09ba9bd51 100644 --- a/src/components/image-editor/ImageCanvasAssetLibraryModel.ts +++ b/src/components/image-editor/ImageCanvasAssetLibraryModel.ts @@ -288,6 +288,82 @@ export function getSelectableAssets(assets: EditorAsset[]) { return assets.filter((asset) => asset.sourceKind === 'uploaded'); } +export type AssetShowcaseSubmissionState = { + canSubmit: boolean; + label: string; + reason: string | null; +}; + +function normalizeAssetShowcaseReason(value: string | null | undefined) { + return value?.trim() || null; +} + +export function resolveAssetShowcaseSubmissionState( + asset: EditorAsset, +): AssetShowcaseSubmissionState { + const reviewStatus = asset.showcaseReviewStatus ?? null; + + if (reviewStatus === 'pending') { + return { + canSubmit: false, + label: '精选审核中', + reason: '素材正在审核中,请等待审核结果', + }; + } + + if (reviewStatus === 'approved') { + return { + canSubmit: false, + label: + asset.showcaseDisplayEnabled === false + ? '已入选精选,暂未展示' + : '已入选精选', + reason: + asset.showcaseDisplayEnabled === false + ? '素材已通过审核,运营后台开启展示后才会出现在前台' + : '素材已经入选精选', + }; + } + + if (reviewStatus === 'rejected') { + return { + canSubmit: false, + label: '精选未通过', + reason: '该素材审核未通过,不能重复提交', + }; + } + + if (asset.sourceType !== 'generated') { + return { + canSubmit: false, + label: '提交精选审核', + reason: '只有生成素材可以提交精选审核', + }; + } + + if (!asset.persisted) { + return { + canSubmit: false, + label: '提交精选审核', + reason: '素材保存完成后才能提交精选审核', + }; + } + + if (!asset.src.trim()) { + return { + canSubmit: false, + label: '提交精选审核', + reason: '没有媒体内容的素材不能提交精选审核', + }; + } + + return { + canSubmit: true, + label: '提交精选审核', + reason: normalizeAssetShowcaseReason(asset.showcaseSubmitError), + }; +} + export function areAllSelectableAssetsSelected( selectableAssets: EditorAsset[], selectedAssetIds: Set, diff --git a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx index dec4bac5e..10c12ac0b 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx @@ -371,6 +371,35 @@ describe('ImageCanvasAssetRowView', () => { name: /提交精选审核/u, }); expect((submitMenuItem as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText('只有生成素材可以提交精选审核')).toBeTruthy(); + + fireEvent.click(submitMenuItem); + expect(submitAssetToShowcase).not.toHaveBeenCalled(); + }); + + it('shows why generated assets cannot be submitted before they are saved', () => { + const submitAssetToShowcase = vi.fn(); + renderAssetRow({ + asset: createAsset({ + sourceType: 'generated', + persisted: false, + }), + submitAssetToShowcase, + }); + + fireEvent.contextMenu( + screen.getByRole('button', { name: '添加账号素材A' }), + { + clientX: 120, + clientY: 80, + }, + ); + + const submitMenuItem = screen.getByRole('menuitem', { + name: /提交精选审核/u, + }); + expect((submitMenuItem as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText('素材保存完成后才能提交精选审核')).toBeTruthy(); fireEvent.click(submitMenuItem); expect(submitAssetToShowcase).not.toHaveBeenCalled(); diff --git a/src/components/image-editor/ImageCanvasAssetRowView.tsx b/src/components/image-editor/ImageCanvasAssetRowView.tsx index 32f387cbb..ebe252ecc 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.tsx @@ -15,6 +15,7 @@ import { getDraggedAssetId, hasDataTransferType, } from './ImageCanvasEditorModel'; +import { resolveAssetShowcaseSubmissionState } from './ImageCanvasAssetLibraryModel'; import type { AssetPointerDragState, EditorAsset, @@ -85,20 +86,10 @@ export function ImageCanvasAssetRowView({ const isUploadingAsset = asset.uploadStatus === 'uploading'; const isFailedUpload = asset.uploadStatus === 'failed'; const showcaseReviewStatus = asset.showcaseReviewStatus ?? null; - const canSubmitShowcaseAsset = - asset.sourceType === 'generated' && - asset.persisted && - !showcaseReviewStatus; - const showcaseMenuLabel = - showcaseReviewStatus === 'pending' - ? '精选审核中' - : showcaseReviewStatus === 'approved' - ? asset.showcaseDisplayEnabled === false - ? '已入选精选,暂未展示' - : '已入选精选' - : showcaseReviewStatus === 'rejected' - ? '精选未通过' - : '提交精选审核'; + const showcaseSubmissionState = resolveAssetShowcaseSubmissionState(asset); + const canSubmitShowcaseAsset = showcaseSubmissionState.canSubmit; + const showcaseMenuLabel = showcaseSubmissionState.label; + const showcaseMenuReason = showcaseSubmissionState.reason; const closeMenu = useCallback(() => { setMenuPosition(null); @@ -460,16 +451,21 @@ export function ImageCanvasAssetRowView({ role="menuitem" className="image-canvas-editor__asset-context-menu-toggle" disabled={!canSubmitShowcaseAsset} + title={showcaseMenuReason ?? showcaseMenuLabel} onClick={() => { if (!canSubmitShowcaseAsset) { return; } submitAssetToShowcase(asset); - closeMenu(); }} > - {showcaseMenuLabel} + {showcaseMenuLabel} + {showcaseMenuReason ? ( + + {showcaseMenuReason} + + ) : null} {showcaseReviewStatus === 'approved' ? ( + + {assetLibrary.assets + .map((asset) => `${asset.id}:${asset.showcaseSubmitError ?? ''}`) + .join('|')} + {assetLibrary.activeUploadFolderId} @@ -537,4 +542,48 @@ describe('useImageCanvasAssetLibrary', () => { ); }); }); + + it('keeps the backend showcase rejection reason on the asset', async () => { + submitEditorAssetShowcaseMock.mockRejectedValueOnce( + new ApiClientError({ + message: '只有生成素材可以提交精选审核', + status: 400, + code: 'BAD_REQUEST', + }), + ); + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + createAssetSnapshot({ + assetId: 'asset-a', + sourceType: 'generated', + publicShowcaseEnabled: true, + }), + ], + }); + + render(); + + await screen.findByText('素材A'); + act(() => + screen.getByRole('button', { name: 'submit asset to showcase' }).click(), + ); + + await waitFor(() => { + expect(screen.getByTestId('asset-submit-errors').textContent).toBe( + 'asset-a:只有生成素材可以提交精选审核', + ); + }); + expect(screen.getByTestId('assets').textContent).toBe( + 'asset-a:素材A:project:true', + ); + }); }); diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.ts b/src/components/image-editor/useImageCanvasAssetLibrary.ts index 0c5a7db6b..db016cb2a 100644 --- a/src/components/image-editor/useImageCanvasAssetLibrary.ts +++ b/src/components/image-editor/useImageCanvasAssetLibrary.ts @@ -39,6 +39,7 @@ import { renameAssetById, renameAssetFolderById, replaceLocalAssetFolder, + resolveAssetShowcaseSubmissionState, resolveAllAssetSelection, resolveDefaultAssetFolder, resolveAssetFolderIdFromPoint, @@ -63,6 +64,16 @@ function isEditorAuthError(error: unknown) { ); } +function formatShowcaseSubmitError(error: unknown) { + if (error instanceof ApiClientError && error.message.trim()) { + return error.message.trim(); + } + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + return '提交精选审核失败,请稍后重试'; +} + const ASSET_FOLDER_SELECTOR = '[data-asset-folder-id]'; const ASSET_ITEM_SELECTOR = '[data-asset-id]'; const ASSET_MARQUEE_BLOCKED_TARGET_SELECTOR = @@ -356,11 +367,7 @@ export function useImageCanvasAssetLibrary({ const submitAssetToShowcase = useCallback( (asset: EditorAsset) => { - if ( - asset.sourceType !== 'generated' || - !asset.persisted || - asset.showcaseReviewStatus - ) { + if (!resolveAssetShowcaseSubmissionState(asset).canSubmit) { return; } const previousAsset = asset; @@ -372,6 +379,7 @@ export function useImageCanvasAssetLibrary({ publicShowcaseEnabled: false, showcaseReviewStatus: 'pending', showcaseDisplayEnabled: false, + showcaseSubmitError: undefined, } : currentAsset, ), @@ -391,15 +399,22 @@ export function useImageCanvasAssetLibrary({ generationCostMudPoints: showcaseAsset.generationCostMudPoints ?? currentAsset.generationCostMudPoints, + showcaseSubmitError: undefined, } : currentAsset, ), ); }) - .catch(() => { + .catch((error: unknown) => { + const showcaseSubmitError = formatShowcaseSubmitError(error); setAssets((currentAssets) => currentAssets.map((currentAsset) => - currentAsset.id === asset.id ? previousAsset : currentAsset, + currentAsset.id === asset.id + ? { + ...previousAsset, + showcaseSubmitError, + } + : currentAsset, ), ); }); diff --git a/src/index.css b/src/index.css index 1402c9eef..e5f01af6c 100644 --- a/src/index.css +++ b/src/index.css @@ -8399,14 +8399,34 @@ button.image-canvas-editor__reference-chip:disabled { .image-canvas-editor__asset-context-menu button.image-canvas-editor__asset-context-menu-toggle { + min-height: 2.2rem; justify-content: space-between; gap: 1rem; + padding-top: 0.28rem; + padding-bottom: 0.28rem; + white-space: normal; } .image-canvas-editor__asset-context-menu-label { + display: grid; min-width: 0; flex: 1 1 auto; + gap: 0.12rem; + line-height: 1.15; text-align: left; + white-space: normal; +} + +.image-canvas-editor__asset-context-menu-reason { + color: #64748b; + font-size: 0.68rem; + font-weight: 680; + line-height: 1.2; +} + +.image-canvas-editor__asset-context-menu button:disabled + .image-canvas-editor__asset-context-menu-reason { + color: #94a3b8; } .image-canvas-editor__asset-context-menu-check {