Merge branch 'master' into hide-obj-id

This commit is contained in:
2026-07-07 13:53:44 +08:00
27 changed files with 507 additions and 78 deletions
+72
View File
@@ -403,6 +403,7 @@ export async function uploadAdminEditorShowcaseCampaignImage(
file: File,
): Promise<AdminUploadedEditorShowcaseCampaignImage> {
const contentType = resolveAdminImageContentType(file);
const dimensions = await readAdminImageFileDimensions(file);
const response = await request<AdminCreateEditorShowcaseCampaignImageUploadTicketResponse>(
'/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,
+6
View File
@@ -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;
}
@@ -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(
<AdminEditorShowcaseReviewPage
token="admin-token"
@@ -311,8 +317,45 @@ test('后台精选活动卡可以上传图片并保存 objectKey', async () => {
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,
}),
);
});
@@ -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({
</button>
</div>
</label>
<label className="admin-field">
<span>Object Key</span>
<input
value={campaignDraft.imageObjectKey ?? ''}
onChange={(event) =>
setCampaignDraft((current) => {
const imageObjectKey =
event.target.value.trim().replace(/^\/+/u, '') || null;
return {
...current,
imageObjectKey,
imageSrc: imageObjectKey
? `/${imageObjectKey}`
: current.imageSrc,
};
})
}
/>
</label>
<label className="admin-field">
<span></span>
<input
@@ -516,7 +516,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`EditorShowcaseCampaignConfig`
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
- 说明:`陶泥儿精选` 首位固定活动卡配置表,当前使用固定 `config_id = global`。后台可配置启用状态、标题、图片地址、图片 OSS `image_object_key`提示词、作者和成本文案;上传按钮通过后台受控上传票据把图片写入 OSS,保存时同时落 `image_src` `image_object_key`。公开精选接口只在启用时返回该配置,前端优先用 `image_object_key` 走签名读地址展示。
- 说明:`陶泥儿精选` 首位固定活动卡配置表,当前使用固定 `config_id = global`。后台可配置启用状态、标题、图片地址、提示词、作者和成本文案;上传按钮通过后台受控上传票据把图片写入 OSS,保存时同时落 `image_src`、内部图片 OSS `image_object_key``image_width``image_height`。公开精选接口只在启用时返回该配置,前端优先用 `image_object_key` 走签名读地址展示,并按记录的图片宽高决定活动卡比例
- 索引:主键 `config_id`
### `inventory_slot`
@@ -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 入口,但内容区显示简洁空态。
+27 -27
View File
@@ -19,17 +19,16 @@ use serde::Deserialize;
use serde_json::{Map, Value};
use shared_contracts::admin::{
AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
AdminCreateEditorShowcaseCampaignImageUploadTicketResponse,
AdminDirectUploadTicketPayload,
AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload,
AdminDashboardBreakdownRowPayload, AdminDashboardChartBucketPayload,
AdminDashboardChartPayload, AdminDashboardMetricsPayload, AdminDashboardOperationMetricPayload,
AdminDashboardOperationsPayload, AdminDashboardQuery, AdminDashboardRangePayload,
AdminDashboardResponse, AdminDatabaseOverviewPayload, AdminDatabaseTableListResponse,
AdminDatabaseTableRowPayload, AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse,
AdminDatabaseTableStatPayload, AdminDebugHeaderInput, AdminDebugHttpRequest,
AdminDebugHttpResponse, AdminEditorAssetListQuery, AdminEditorAssetListResponse,
AdminEditorAssetPayload, AdminEditorShowcaseAssetPayload, AdminEditorShowcaseAssetResponse,
AdminCreateEditorShowcaseCampaignImageUploadTicketResponse, AdminCreationEntryConfigResponse,
AdminCreationEntryTypeConfigPayload, AdminDashboardBreakdownRowPayload,
AdminDashboardChartBucketPayload, AdminDashboardChartPayload, AdminDashboardMetricsPayload,
AdminDashboardOperationMetricPayload, AdminDashboardOperationsPayload, AdminDashboardQuery,
AdminDashboardRangePayload, AdminDashboardResponse, AdminDatabaseOverviewPayload,
AdminDatabaseTableListResponse, AdminDatabaseTableRowPayload, AdminDatabaseTableRowsQuery,
AdminDatabaseTableRowsResponse, AdminDatabaseTableStatPayload, AdminDebugHeaderInput,
AdminDebugHttpRequest, AdminDebugHttpResponse, AdminDirectUploadTicketPayload,
AdminEditorAssetListQuery, AdminEditorAssetListResponse, AdminEditorAssetPayload,
AdminEditorShowcaseAssetPayload, AdminEditorShowcaseAssetResponse,
AdminEditorShowcaseCampaignPayload, AdminEditorShowcaseCampaignResponse,
AdminEditorShowcaseDisplayRequest, AdminEditorShowcaseListQuery,
AdminEditorShowcaseListResponse, AdminEditorShowcaseReviewRequest, AdminLoginRequest,
@@ -41,9 +40,7 @@ use shared_contracts::admin::{
AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertPublicWorkInteractionConfigRequest,
AdminWorkVisibilityListResponse,
};
use shared_contracts::assets::{
CreateDirectUploadTicketRequest, DirectUploadObjectAccess,
};
use shared_contracts::assets::{CreateDirectUploadTicketRequest, DirectUploadObjectAccess};
use shared_contracts::creation_entry_config::{
encode_unified_creation_spec_response, validate_unified_creation_spec_for_play,
};
@@ -587,6 +584,8 @@ pub async fn admin_upsert_editor_showcase_campaign(
title: payload.title,
image_src: payload.image_src,
image_object_key: payload.image_object_key,
image_width: payload.image_width,
image_height: payload.image_height,
prompt: payload.prompt,
author: payload.author,
cost_text: payload.cost_text,
@@ -620,14 +619,11 @@ pub async fn admin_create_editor_showcase_campaign_image_upload_ticket(
);
}
if payload.content_length == 0 {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("图片文件不能为空")
);
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")
AppError::from_status(StatusCode::BAD_REQUEST).with_message("活动卡图片不能超过 20MB")
);
}
@@ -650,7 +646,10 @@ pub async fn admin_create_editor_showcase_campaign_image_upload_ticket(
"asset_kind".to_string(),
"editor_showcase_campaign_image".to_string(),
),
("source".to_string(), "admin_editor_showcase_campaign".to_string()),
(
"source".to_string(),
"admin_editor_showcase_campaign".to_string(),
),
]),
max_size_bytes: Some(payload.content_length),
expire_seconds: None,
@@ -662,9 +661,11 @@ pub async fn admin_create_editor_showcase_campaign_image_upload_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}"),
}))
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(
serde_json::json!({
"message": format!("活动卡上传凭证响应解析失败:{error}"),
}),
)
})?;
Ok(json_success_body(
Some(&request_context),
@@ -799,6 +800,8 @@ fn admin_editor_showcase_campaign_payload_from_record(
title: record.title,
image_src: record.image_src,
image_object_key: record.image_object_key,
image_width: record.image_width,
image_height: record.image_height,
prompt: record.prompt,
author: record.author,
cost_text: record.cost_text,
@@ -821,10 +824,7 @@ fn admin_direct_upload_ticket_payload_from_asset_upload(
upload.form_fields.credential,
),
("x-oss-date".to_string(), upload.form_fields.date),
(
"x-oss-signature".to_string(),
upload.form_fields.signature,
),
("x-oss-signature".to_string(), upload.form_fields.signature),
(
"success_action_status".to_string(),
upload.form_fields.success_action_status,
@@ -618,6 +618,8 @@ pub struct EditorShowcaseCampaignPayload {
title: String,
image_src: String,
image_object_key: Option<String>,
image_width: Option<u32>,
image_height: Option<u32>,
prompt: String,
author: String,
cost_text: String,
@@ -3352,6 +3354,8 @@ fn editor_showcase_campaign_payload_from_record(
title: record.title,
image_src: record.image_src,
image_object_key: record.image_object_key,
image_width: record.image_width,
image_height: record.image_height,
prompt: record.prompt,
author: record.author,
cost_text: record.cost_text,
@@ -5084,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",
@@ -5117,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,
@@ -257,6 +257,8 @@ pub struct AdminEditorShowcaseCampaignPayload {
pub title: String,
pub image_src: String,
pub image_object_key: Option<String>,
pub image_width: Option<u32>,
pub image_height: Option<u32>,
pub prompt: String,
pub author: String,
pub cost_text: String,
@@ -276,6 +278,8 @@ pub struct AdminUpsertEditorShowcaseCampaignRequest {
pub title: String,
pub image_src: String,
pub image_object_key: Option<String>,
pub image_width: Option<u32>,
pub image_height: Option<u32>,
pub prompt: String,
pub author: String,
pub cost_text: String,
@@ -181,6 +181,8 @@ pub struct EditorShowcaseCampaignConfigRecord {
pub title: String,
pub image_src: String,
pub image_object_key: Option<String>,
pub image_width: Option<u32>,
pub image_height: Option<u32>,
pub prompt: String,
pub author: String,
pub cost_text: String,
@@ -428,6 +430,8 @@ pub struct EditorShowcaseCampaignConfigUpsertRecordInput {
pub cost_text: String,
pub updated_at_micros: i64,
pub image_object_key: Option<String>,
pub image_width: Option<u32>,
pub image_height: Option<u32>,
}
impl From<EditorProjectCreateRecordInput> for crate::module_bindings::EditorProjectCreateInput {
@@ -786,6 +790,8 @@ impl From<EditorShowcaseCampaignConfigUpsertRecordInput>
cost_text: input.cost_text,
updated_at_micros: input.updated_at_micros,
image_object_key: input.image_object_key,
image_width: input.image_width,
image_height: input.image_height,
}
}
}
@@ -1218,6 +1224,8 @@ fn map_editor_showcase_campaign_config_snapshot(
title: snapshot.title,
image_src: snapshot.image_src,
image_object_key: snapshot.image_object_key,
image_width: snapshot.image_width,
image_height: snapshot.image_height,
prompt: snapshot.prompt,
author: snapshot.author,
cost_text: snapshot.cost_text,
@@ -16,6 +16,8 @@ pub struct EditorShowcaseCampaignConfigSnapshot {
pub updated_by_admin_user_id: Option<String>,
pub updated_at_micros: i64,
pub image_object_key: Option<String>,
pub image_width: Option<u32>,
pub image_height: Option<u32>,
}
impl __sdk::InModule for EditorShowcaseCampaignConfigSnapshot {
@@ -17,6 +17,8 @@ pub struct EditorShowcaseCampaignConfig {
pub updated_by_admin_user_id: Option<String>,
pub updated_at: __sdk::Timestamp,
pub image_object_key: Option<String>,
pub image_width: Option<u32>,
pub image_height: Option<u32>,
}
impl __sdk::InModule for EditorShowcaseCampaignConfig {
@@ -38,6 +40,8 @@ pub struct EditorShowcaseCampaignConfigCols {
__sdk::__query_builder::Col<EditorShowcaseCampaignConfig, Option<String>>,
pub updated_at: __sdk::__query_builder::Col<EditorShowcaseCampaignConfig, __sdk::Timestamp>,
pub image_object_key: __sdk::__query_builder::Col<EditorShowcaseCampaignConfig, Option<String>>,
pub image_width: __sdk::__query_builder::Col<EditorShowcaseCampaignConfig, Option<u32>>,
pub image_height: __sdk::__query_builder::Col<EditorShowcaseCampaignConfig, Option<u32>>,
}
impl __sdk::__query_builder::HasCols for EditorShowcaseCampaignConfig {
@@ -57,6 +61,8 @@ impl __sdk::__query_builder::HasCols for EditorShowcaseCampaignConfig {
),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
image_object_key: __sdk::__query_builder::Col::new(table_name, "image_object_key"),
image_width: __sdk::__query_builder::Col::new(table_name, "image_width"),
image_height: __sdk::__query_builder::Col::new(table_name, "image_height"),
}
}
}
@@ -16,6 +16,8 @@ pub struct EditorShowcaseCampaignConfigUpsertInput {
pub cost_text: String,
pub updated_at_micros: i64,
pub image_object_key: Option<String>,
pub image_width: Option<u32>,
pub image_height: Option<u32>,
}
impl __sdk::InModule for EditorShowcaseCampaignConfigUpsertInput {
@@ -207,6 +207,10 @@ pub struct EditorShowcaseCampaignConfig {
updated_at: Timestamp,
#[default(None::<String>)]
image_object_key: Option<String>,
#[default(None::<u32>)]
image_width: Option<u32>,
#[default(None::<u32>)]
image_height: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
@@ -466,6 +470,8 @@ pub struct EditorShowcaseCampaignConfigSnapshot {
pub updated_by_admin_user_id: Option<String>,
pub updated_at_micros: i64,
pub image_object_key: Option<String>,
pub image_width: Option<u32>,
pub image_height: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -621,6 +627,8 @@ pub struct EditorShowcaseCampaignConfigUpsertInput {
pub cost_text: String,
pub updated_at_micros: i64,
pub image_object_key: Option<String>,
pub image_width: Option<u32>,
pub image_height: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
@@ -2202,7 +2210,10 @@ fn upsert_editor_showcase_campaign_config(
let title = input.title.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());
let image_src =
normalize_campaign_image_src(input.image_src.as_str(), image_object_key.as_deref());
let image_width = input.image_width.filter(|value| *value > 0);
let image_height = input.image_height.filter(|value| *value > 0);
if input.enabled {
if title.is_empty() {
return Err("editor_showcase_campaign_config.title 不能为空".to_string());
@@ -2238,6 +2249,8 @@ fn upsert_editor_showcase_campaign_config(
updated_by_admin_user_id: Some(admin_user_id),
updated_at: now,
image_object_key,
image_width,
image_height,
});
ctx.db
.editor_showcase_campaign_config()
@@ -2713,6 +2726,8 @@ fn showcase_campaign_config_snapshot_from_row(
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,
image_width: row.image_width,
image_height: row.image_height,
}
}
@@ -1271,6 +1271,12 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
object
.entry("image_object_key".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("image_width".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("image_height".to_string())
.or_insert(serde_json::Value::Null);
}
}
if table_name == "big_fish_creation_session" {
@@ -83,6 +83,8 @@ describe('creationShowcaseModel', () => {
enabled: true,
title: '活动精选',
imageSrc: '/campaign.png',
imageWidth: 900,
imageHeight: 1200,
prompt: '活动提示词',
author: '运营',
costText: '50泥点',
@@ -108,6 +110,10 @@ describe('creationShowcaseModel', () => {
campaign: true,
cost: '50泥点',
});
expect(items[0]?.previews[0]).toMatchObject({
width: 900,
height: 1200,
});
expect(items[1]).toMatchObject({
id: 'asset:asset-1',
label: '审核通过素材',
@@ -805,6 +805,8 @@ function campaignToShowcaseItem(
src: imageSrc,
objectKey: objectKey || null,
mediaType: 'image',
width: campaign.imageWidth ?? undefined,
height: campaign.imageHeight ?? undefined,
},
],
prompt: campaign.prompt.trim() || UNKNOWN_META_VALUE,
@@ -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 -1
View File
@@ -3913,7 +3913,6 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
display: grid;
width: 100%;
min-height: 9.5rem;
max-height: 23rem;
overflow: hidden;
place-items: stretch;
background: linear-gradient(180deg, #fffdfa, #fff4ea);
@@ -8399,14 +8398,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 {

Some files were not shown because too many files have changed in this diff Show More