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:
2026-05-19 10:02:13 +08:00
parent 5e03b3d2f2
commit 7b37271f17
16 changed files with 653 additions and 73 deletions

View File

@@ -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(