Puzzle: support history images & partial generation
Allow history-generated image paths to be submitted where Data URLs were previously required and avoid treating partial/result-page generations as blocking the whole draft. Backend: resolve history /generated-* references via resolve_puzzle_reference_image_as_data_url and convert to PuzzleDownloadedImage; add PuzzleDownloadedImage::from_resolved_reference_image; extend draft handling to apply generated level metadata (auto-naming) and normalize generation_status to treat levels with images as ready. API: add shouldAutoNameLevel to action contracts and use it to request/refine generated level names. Spacetime/module and mappers: normalize completed level statuses when saving/reading so result-page background or per-level generation doesn't mask completed drafts. Frontend: expose resolver helpers, only mark a work as generating when no usable cover or ready level exists, keep level controls enabled during UI-background regeneration, and add tests covering history-image submission, auto-naming, and UI-background/partial-generation behaviors.
This commit is contained in:
@@ -1063,6 +1063,7 @@ fn save_puzzle_generated_images_tx(
|
||||
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
|
||||
// 中文注释:结果页新增关卡可能还没等到自动保存,生成图时以本次 action 携带的关卡快照作为写回目标。
|
||||
draft.levels = levels;
|
||||
draft = normalize_completed_puzzle_level_generation_status(draft);
|
||||
module_puzzle::sync_primary_level_fields(&mut draft);
|
||||
// 中文注释:入口直创会在 api-server 生成首关名后随 levels_json 写入;作品名仍是旧首关名或空值时才跟随首关名,避免覆盖用户手动命名。
|
||||
sync_generated_primary_level_name_as_default_work_title(
|
||||
@@ -1092,6 +1093,7 @@ fn save_puzzle_generated_images_tx(
|
||||
next_level.cover_asset_id = Some(selected.asset_id);
|
||||
}
|
||||
draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
|
||||
draft = normalize_completed_puzzle_level_generation_status(draft);
|
||||
|
||||
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
|
||||
let next_stage = if build_result_preview(&draft, Some("陶泥儿主")).publish_ready {
|
||||
@@ -1143,6 +1145,7 @@ fn save_puzzle_ui_background_tx(
|
||||
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
|
||||
// 中文注释:UI 背景可以在自动保存前立即生成,写回前优先使用本次 action 携带的关卡快照。
|
||||
draft.levels = levels;
|
||||
draft = normalize_completed_puzzle_level_generation_status(draft);
|
||||
module_puzzle::sync_primary_level_fields(&mut draft);
|
||||
}
|
||||
let target_level = selected_puzzle_level(&draft, input.level_id.as_deref())
|
||||
@@ -1155,6 +1158,7 @@ fn save_puzzle_ui_background_tx(
|
||||
(!trimmed.is_empty()).then_some(trimmed)
|
||||
});
|
||||
let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
|
||||
let draft = normalize_completed_puzzle_level_generation_status(draft);
|
||||
|
||||
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
|
||||
let next_stage = if build_result_preview(&draft, Some("陶泥儿主")).publish_ready {
|
||||
@@ -1208,6 +1212,52 @@ fn sync_generated_primary_level_name_as_default_work_title(
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_completed_puzzle_level_generation_status(
|
||||
mut draft: PuzzleResultDraft,
|
||||
) -> PuzzleResultDraft {
|
||||
draft.levels = normalize_completed_puzzle_levels_generation_status(draft.levels);
|
||||
module_puzzle::sync_primary_level_fields(&mut draft);
|
||||
draft
|
||||
}
|
||||
|
||||
fn normalize_completed_puzzle_levels_generation_status(
|
||||
mut levels: Vec<module_puzzle::PuzzleDraftLevel>,
|
||||
) -> Vec<module_puzzle::PuzzleDraftLevel> {
|
||||
for level in &mut levels {
|
||||
if level.generation_status.trim() == "generating" && has_completed_puzzle_level_image(level)
|
||||
{
|
||||
level.generation_status = "ready".to_string();
|
||||
}
|
||||
}
|
||||
levels
|
||||
}
|
||||
|
||||
fn has_completed_puzzle_level_image(level: &module_puzzle::PuzzleDraftLevel) -> bool {
|
||||
let has_cover = level
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
let has_selected_candidate = level
|
||||
.selected_candidate_id
|
||||
.as_deref()
|
||||
.and_then(|candidate_id| {
|
||||
level
|
||||
.candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.candidate_id == candidate_id)
|
||||
})
|
||||
.map(|candidate| candidate.image_src.trim())
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
let has_fallback_candidate = level
|
||||
.candidates
|
||||
.last()
|
||||
.map(|candidate| candidate.image_src.trim())
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
|
||||
has_cover || has_selected_candidate || has_fallback_candidate
|
||||
}
|
||||
|
||||
fn select_puzzle_cover_image_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleSelectCoverImageInput,
|
||||
@@ -1391,12 +1441,13 @@ fn update_puzzle_work_tx(
|
||||
if theme_tags.len() > PUZZLE_MAX_TAG_COUNT {
|
||||
return Err("拼图标签数量不合法".to_string());
|
||||
}
|
||||
let levels = deserialize_optional_levels_input(input.levels_json.as_deref())?
|
||||
let mut levels = deserialize_optional_levels_input(input.levels_json.as_deref())?
|
||||
.map(|levels| {
|
||||
normalize_puzzle_levels(levels, &theme_tags).map_err(|error| error.to_string())
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| build_profile_levels_from_row(&row).unwrap_or_default());
|
||||
levels = normalize_completed_puzzle_levels_generation_status(levels);
|
||||
let preview_draft = PuzzleResultDraft {
|
||||
work_title: input.work_title.clone(),
|
||||
work_description: input.work_description.clone(),
|
||||
@@ -2547,6 +2598,10 @@ fn build_puzzle_gallery_card_view_row(
|
||||
fn resolve_puzzle_gallery_generation_status(
|
||||
levels: &[module_puzzle::PuzzleDraftLevel],
|
||||
) -> Option<String> {
|
||||
if levels.iter().any(has_completed_puzzle_level_image) {
|
||||
return Some("ready".to_string());
|
||||
}
|
||||
|
||||
levels
|
||||
.iter()
|
||||
.map(|level| level.generation_status.trim())
|
||||
@@ -2822,6 +2877,7 @@ fn replace_puzzle_work_profile(
|
||||
}
|
||||
|
||||
fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Result<(), String> {
|
||||
let levels = normalize_completed_puzzle_levels_generation_status(profile.levels);
|
||||
if let Some(existing) = ctx
|
||||
.db
|
||||
.puzzle_work_profile()
|
||||
@@ -2844,7 +2900,7 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re
|
||||
theme_tags_json: serialize_json(&profile.theme_tags),
|
||||
cover_image_src: profile.cover_image_src,
|
||||
cover_asset_id: profile.cover_asset_id,
|
||||
levels_json: serialize_json(&profile.levels),
|
||||
levels_json: serialize_json(&levels),
|
||||
publication_status: profile.publication_status,
|
||||
// 二次编辑发布同一个 profile 时,作品内容可以覆盖,但历史游玩数属于
|
||||
// 广场消费数据,不能因为重新发布被清零。
|
||||
@@ -2882,7 +2938,7 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re
|
||||
theme_tags_json: serialize_json(&profile.theme_tags),
|
||||
cover_image_src: profile.cover_image_src,
|
||||
cover_asset_id: profile.cover_asset_id,
|
||||
levels_json: serialize_json(&profile.levels),
|
||||
levels_json: serialize_json(&levels),
|
||||
publication_status: profile.publication_status,
|
||||
play_count: profile.play_count,
|
||||
remix_count: profile.remix_count,
|
||||
@@ -3532,7 +3588,9 @@ fn deserialize_levels_json(value: &str) -> Result<Vec<module_puzzle::PuzzleDraft
|
||||
if value.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
json_from_str(value).map_err(|error| format!("拼图 levels JSON 非法: {error}"))
|
||||
json_from_str(value)
|
||||
.map(normalize_completed_puzzle_levels_generation_status)
|
||||
.map_err(|error| format!("拼图 levels JSON 非法: {error}"))
|
||||
}
|
||||
|
||||
fn deserialize_optional_levels_input(
|
||||
|
||||
Reference in New Issue
Block a user