显示精选审核提交失败原因

统一素材精选审核提交资格判断

在素材菜单显示不可提交和后端拒绝原因

将编辑器业务拒绝映射为 400 并保留中文原因

补充精选审核原因展示测试和文档口径
This commit is contained in:
2026-07-06 21:35:10 +08:00
parent be7f0375e5
commit 2d631becc7
10 changed files with 281 additions and 24 deletions
@@ -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 入口,但内容区显示简洁空态。
@@ -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,
@@ -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(
[
@@ -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<string>,
@@ -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();
@@ -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();
}}
>
<span className="image-canvas-editor__asset-context-menu-label">
{showcaseMenuLabel}
<span>{showcaseMenuLabel}</span>
{showcaseMenuReason ? (
<span className="image-canvas-editor__asset-context-menu-reason">
{showcaseMenuReason}
</span>
) : null}
</span>
{showcaseReviewStatus === 'approved' ? (
<span
@@ -54,6 +54,7 @@ export type EditorAsset = {
showcaseReviewStatus?: string | null;
showcaseDisplayEnabled?: boolean | null;
showcaseLikeCount?: number | null;
showcaseSubmitError?: string | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
@@ -117,6 +117,11 @@ function AssetLibraryHarness({
)
.join('|')}
</span>
<span data-testid="asset-submit-errors">
{assetLibrary.assets
.map((asset) => `${asset.id}:${asset.showcaseSubmitError ?? ''}`)
.join('|')}
</span>
<span data-testid="active-upload-folder">
{assetLibrary.activeUploadFolderId}
</span>
@@ -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(<AssetLibraryHarness />);
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',
);
});
});
@@ -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,
),
);
});
+20
View File
@@ -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 {