From cfc1452f8b9d2c12ba14fdf622c9bc0d72ea8a1a Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Wed, 11 Nov 2026 17:07:22 +0800 Subject: [PATCH] fix(client): streamline template selection and show required product fields --- .../crates/ui-egui/src/jiansun/creation.rs | 207 +++++++++++++----- 1 file changed, 151 insertions(+), 56 deletions(-) diff --git a/apps/detail-photocraft/crates/ui-egui/src/jiansun/creation.rs b/apps/detail-photocraft/crates/ui-egui/src/jiansun/creation.rs index 6edff736..848d25e4 100644 --- a/apps/detail-photocraft/crates/ui-egui/src/jiansun/creation.rs +++ b/apps/detail-photocraft/crates/ui-egui/src/jiansun/creation.rs @@ -321,6 +321,15 @@ fn template_identity(value: &Value) -> bool { && value["contentHash"].as_str().is_some_and(|hash| hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))) } +fn default_resource_component(parts: &[Value]) -> Option<&Value> { + if let [only] = parts { + return Some(only); + } + let mut detail = parts.iter().filter(|part| part["kind"] == "detail"); + let candidate = detail.next()?; + detail.next().is_none().then_some(candidate) +} + fn selected_template(brief: &Value, workflow: &Value, canonical_brief: &Value) -> Value { let selected = &brief["templateSelection"]; if template_identity(selected) @@ -372,10 +381,18 @@ fn store_image_rows(brief: &mut Value, rows: Vec<(Value, String)>) -> Result<(), Ok(()) } -fn canvas_entry_ready(brief: &Value, template: &Value) -> bool { - ["name", "brandId", "categoryId"].iter().all(|field| brief[*field].as_str().is_some_and(|value| !value.trim().is_empty())) - && template_identity(template) - && image_rows(brief).iter().any(|(image, _)| image["purpose"] == "product") +fn canvas_entry_missing(brief: &Value, template: &Value) -> Vec<&'static str> { + let filled = |field: &str| brief[field].as_str().is_some_and(|value| !value.trim().is_empty()); + [ + (filled("brandId"), "品牌"), + (filled("categoryId"), "品类"), + (template_identity(template), "模板"), + (filled("name"), "产品名称"), + (image_rows(brief).iter().any(|(image, _)| image["purpose"] == "product"), "产品图"), + ] + .into_iter() + .filter_map(|(present, label)| (!present).then_some(label)) + .collect() } fn edit(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: &mut Value, width: f32, multiline: bool, name: &str) -> bool { @@ -1230,8 +1247,33 @@ impl Workspace { self.references_ui(app, ui, width); brief_section_break(ui); let template = selected_template(&self.creation_brief, &self.template_workflow, &self.creation["brief"]); - let ready = canvas_entry_ready(&self.creation_brief, &template); - let hint = help_icon(ui, if self.creation_dirty { "资料自动保存中,也可手动保存;进入画布前需补齐必填项。" } else { "资料已保存,准备好后可进入模板画布。" }); + let missing = canvas_entry_missing(&self.creation_brief, &template); + let blocked = if self.unknown { + Some("请先核查原请求,再进入模板画布。".into()) + } else if self.busy.is_some() { + Some("请等待当前操作完成。".into()) + } else if missing == ["模板"] && self.creation_reads.resource_set["id"].is_string() { + if self.creation_catalog_loading("resource-set") || self.creation_catalog_loading("resource-component") { + Some("正在核对模板资料,请稍候。".into()) + } else if self.creation_reads.resource_component.is_empty() + && self.creation_catalog.get("resource-set").is_some_and(|parts| parts.len() > 1) + { + Some("请选择要使用的模板组件。".into()) + } else { + Some("所选模板暂不可用,请查看模板区的提示。".into()) + } + } else if missing == ["产品图"] { + Some("请添加至少一张参考图,并将用途设为「产品图」。".into()) + } else if !missing.is_empty() { + Some(format!("进入画布前,请补齐:{}。", missing.join("、"))) + } else { + None + }; + let hint = if let Some(message) = blocked.as_deref() { + stitch_ui::status_label(ui, message, stitch_ui::StatusTone::Warning) + } else { + help_icon(ui, if self.creation_dirty { "资料将自动保存,也可手动保存。" } else { "可进入模板画布。" }) + }; self.control_geometry("entry-hint", hint.rect); ui.add_space(6.0); ui.horizontal_wrapped(|ui| { @@ -1243,7 +1285,7 @@ impl Workspace { if save.clicked() { self.creation_submit(app, "creation-save", json!({})); } - self.brief_enter_button(app, ui, &template, ready); + self.brief_enter_button(app, ui, &template, blocked.is_none()); }); } @@ -1423,15 +1465,8 @@ impl Workspace { ui, "保存资料并进入模板画布", stitch_ui::ButtonRole::Primary, - ready && self.busy.is_none() && !self.unknown, - ) - .on_disabled_hover_text(if self.unknown { - "先核查原请求,不会重发" - } else if self.busy.is_some() { - "请等待当前操作完成" - } else { - "补齐产品名称、品牌、品类、模板和产品图" - }); + ready, + ); self.control_geometry("enter-canvas", enter.rect); if enter.clicked() { let mut brief = self.creation_brief.clone(); @@ -1476,14 +1511,14 @@ impl Workspace { let mut rows = image_rows(&self.creation_brief); let mut changed = false; let mut remove = None; + ui.label(egui::RichText::new("产品参考图 *").size(13.0).color(t.text_dim)); if rows.is_empty() { stitch_ui::material_frame(ui, stitch_ui::surface_frame(ui.ctx(), stitch_ui::SurfaceRole::Input).corner_radius(10).inner_margin(18), |ui| { ui.set_width((width - 38.0).max(100.0)); - ui.horizontal(|ui| { - ui.label(egui::RichText::new("尚未添加产品参考图").size(14.0).color(t.text_dim)); - help_icon(ui, "添加产品图或 CAD 导出的图片,至少一张需标记为产品图。"); - }); + ui.label(egui::RichText::new("至少添加一张用途为「产品图」的参考图").size(14.0).color(t.text_dim)); }); + } else if !rows.iter().any(|(image, _)| image["purpose"] == "product") { + stitch_ui::status_label(ui, "请将至少一张参考图的用途设为「产品图」。", stitch_ui::StatusTone::Warning); } // Keep real purpose and ownership controls visible alongside each image. let columns = if width >= 780.0 { 3 } else if width >= 600.0 { 2 } else { 1 }; @@ -1672,6 +1707,10 @@ impl Workspace { let item = template_picker_row(ui, active, &text, ready, None); geometry.push(json!({"id":row["id"],"key":row["key"],"ready":ready,"rect":rect_value(item.rect)})); if item.clicked() { + ui.close(); + if active { + continue; + } self.creation_reads.resource_set = row.clone(); self.creation_reads.resource_component.clear(); self.creation_reads.resource_image = None; @@ -1680,7 +1719,6 @@ impl Workspace { self.creation_catalog.remove("resource-set"); self.creation_catalog.remove("resource-component"); self.catalog(app, "resource-set", row["id"].as_str().unwrap_or("").into()); - ui.close(); } } }).response; @@ -1704,23 +1742,25 @@ impl Workspace { if picker_notice(ui, self.creation_catalog_loading("resource-set"), error.as_deref(), parts.is_empty(), "该资源组没有可用的模板组件") { self.catalog(app, "resource-set", set.clone()); } + if ui.is_enabled() + && self.creation_reads.resource_component.is_empty() + && !self.creation_catalog_loading("resource-set") + && let Some(part) = default_resource_component(&parts) + { + self.select_resource_component(app, &set, part); + } for part in &parts { let Some(id) = part["id"].as_str() else { continue; }; let name = part["name"].as_str().unwrap_or("模板组件"); - let issues = part["issues"].as_array().cloned().unwrap_or_default(); - let preview = part["previews"].as_array().and_then(|images| images.first()).map(|image| &image["id"]); - let action = stitch_ui::action_button(ui, name, stitch_ui::ButtonRole::Secondary, !self.creation_catalog_loading("resource-component")); + let selected = self.creation_reads.resource_component == id; + let loading = self.creation_catalog_loading("resource-component"); + let action = stitch_ui::segmented_option_sized(ui, name, selected, !loading, vec2(0.0, 34.0)); self.control_geometry(&format!("resource-component-{id}"), action.rect); - if action.clicked() { - self.creation_reads.resource_component = id.to_owned(); - self.creation_reads.resource_image = None; - self.creation_catalog.remove("resource-component"); - self.creation_brief["templateSelection"] = Value::Null; - self.creation_dirty = true; - self.catalog(app, "resource-component", json!({"resourceSetId":set,"componentId":id,"previewId":preview}).to_string()); + if action.clicked() && (!selected || !template_identity(&self.creation_brief["templateSelection"])) { + self.select_resource_component(app, &set, part); } - if self.creation_reads.resource_component != id || self.creation_catalog_loading("resource-component") { - for issue in issues.iter().filter_map(Value::as_str) { + if !selected || loading { + for issue in part["issues"].as_array().into_iter().flatten().filter_map(Value::as_str) { stitch_ui::status_label(ui, issue, stitch_ui::StatusTone::Error); } } @@ -1742,11 +1782,41 @@ impl Workspace { self.creation_view_geometry["templateSelection"] = selected_template(&self.creation_brief, &self.template_workflow, &self.creation["brief"]); } + fn select_resource_component(&mut self, app: &mut PhotocraftApp, set: &str, part: &Value) { + let Some(id) = part["id"].as_str() else { return; }; + let preview = part["previews"].as_array().and_then(|images| images.first()).map(|image| &image["id"]); + self.creation_reads.resource_component = id.to_owned(); + self.creation_reads.resource_image = None; + self.creation_catalog.remove("resource-component"); + self.creation_dirty |= !self.creation_brief["templateSelection"].is_null(); + self.creation_brief["templateSelection"] = Value::Null; + self.catalog(app, "resource-component", json!({"resourceSetId":set,"componentId":id,"previewId":preview}).to_string()); + } + fn resource_component_result(&mut self, app: &mut PhotocraftApp, ui: &mut Ui, width: f32, set: &str, result: &Value) { use base64::Engine as _; let component = result["id"].as_str().unwrap_or(""); + let choice = &result["choice"]; + if ui.is_enabled() + && self.creation_brief["templateSelection"].is_null() + && template_identity(choice) + && choice["enterprise"]["resourceSetId"] == set + && choice["enterprise"]["componentId"] == component + && choice["brandId"] == self.creation_brief["brandId"] + && choice["categoryId"] == self.creation_brief["categoryId"] + { + let mut choice = choice.clone(); + if let Some(width) = choice["width"].as_u64().filter(|width| matches!(width, 750 | 1440)) { + self.creation_width = width as u32; + } + if let Some(fields) = choice.as_object_mut() { + fields.remove("width"); + } + self.creation_brief["templateSelection"] = choice; + self.creation_dirty = true; + } if let Some(mapping) = result["mapping"].as_str() { - let label = if template_identity(&result["choice"]) { "字段映射已核对" } else { "字段映射" }; + let label = if template_identity(choice) { "字段映射已核对" } else { "字段映射" }; ui.weak(format!("{label} · {mapping}")); } for issue in result["issues"].as_array().into_iter().flatten().filter_map(Value::as_str) { @@ -1775,29 +1845,14 @@ impl Workspace { let Some(id) = preview["id"].as_str() else { continue; }; if stitch_ui::select_option(ui, preview["name"].as_str().unwrap_or("预览"), result["previewId"] == id, true).clicked() { self.creation_reads.resource_image = None; - self.catalog(app, "resource-component", json!({"resourceSetId":set,"componentId":component,"previewId":id}).to_string()); + self.catalog(app, "resource-component", json!({"resourceSetId":set,"componentId":component,"previewId":id,"previewOnly":true}).to_string()); ui.close(); } } }); } - if template_identity(&result["choice"]) { - if let Some(width) = result["choice"]["width"].as_u64() { - ui.weak(format!("原稿宽度 {width} px")); - } - let applied = self.creation_brief["templateSelection"]["contentHash"] == result["choice"]["contentHash"]; - let use_template = stitch_ui::action_button(ui, if applied {"已选择此组件"} else {"使用此组件"}, stitch_ui::ButtonRole::Primary, - !applied && !self.creation_catalog_loading("resource-component")); - self.control_geometry("resource-use", use_template.rect); - if use_template.clicked() { - let mut choice = result["choice"].clone(); - if let Some(width) = choice["width"].as_u64().filter(|width| matches!(width, 750 | 1440)) { - self.creation_width = width as u32; - } - choice.as_object_mut().map(|value| value.remove("width")); - self.creation_brief["templateSelection"] = choice; - self.creation_dirty = true; - } + if let Some(width) = result["choice"]["width"].as_u64() { + ui.weak(format!("原稿宽度 {width} px")); } } @@ -2759,6 +2814,44 @@ mod tests { } } + #[test] + fn resource_template_defaults_to_detail_or_a_single_component() { + let detail = json!({"id":"detail","kind":"detail"}); + let main = json!({"id":"main_images","kind":"main_images"}); + let sku = json!({"id":"800","kind":"sku_images"}); + assert_eq!(default_resource_component(&[main, detail.clone(), sku.clone()]), Some(&detail)); + assert_eq!(default_resource_component(std::slice::from_ref(&sku)), Some(&sku)); + assert!(default_resource_component(&[sku, json!({"id":"750","kind":"sku_images"})]).is_none()); + assert!(default_resource_component(&[]).is_none()); + } + + #[test] + fn resource_template_result_binds_the_component_without_an_extra_click() { + let (mut w, mut app, _) = brief_fixture(); + w.creation_brief["templateSelection"] = Value::Null; + w.creation_dirty = false; + let result = json!({"id":"detail","choice":{ + "id":"desk","version":"map-version","contentHash":"a".repeat(64), + "brandId":"brand","categoryId":"category","width":750, + "enterprise":{"resourceSetId":"selected-set","componentId":"detail"} + }}); + let ctx = egui::Context::default(); + let mut render = |set, result: &Value| { + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + w.resource_component_result(&mut app, ui, 500.0, set, result); + }); + output.textures_delta.clear(); + (w.creation_brief["templateSelection"].clone(), w.creation_width, w.creation_dirty) + }; + assert!(render("previous-set", &result).0.is_null()); + let (selected, width, dirty) = render("selected-set", &result); + assert_eq!(selected["enterprise"]["componentId"], "detail"); + assert_eq!(width, 750); + assert!(dirty); + let (after_preview, _, _) = render("selected-set", &json!({"id":"detail","previewId":"another"})); + assert_eq!(after_preview, selected); + } + #[test] fn canvas_entry_uses_product_and_exact_template_without_ai_stage_confirmation() { let mut brief = empty_brief(); @@ -2767,13 +2860,15 @@ mod tests { brief["categoryId"] = json!("desk"); brief["images"] = json!([{"name":"产品实拍","source":"local:unit-test","purpose":"product"}]); let template = json!({"id":"real-template-identity-contract","version":"2","contentHash":"a".repeat(64),"brandId":"brand","categoryId":"desk"}); - assert!(canvas_entry_ready(&brief, &template), "facts/selling points/AI plan are not prerequisites for opening the structure"); + assert!(canvas_entry_missing(&brief, &template).is_empty(), "facts/selling points/AI plan are not prerequisites for opening the structure"); assert_eq!(selected_template(&brief, &Value::Null, &brief), Value::Null, "a directory's first item cannot silently become the user's selection"); brief["templateSelection"] = template.clone(); assert_eq!(selected_template(&brief, &Value::Null, &brief), template); - assert!(!canvas_entry_ready(&brief, &json!({"id":"template","version":"2","contentHash":"not-a-hash"}))); - brief["images"] = json!([]); - assert!(!canvas_entry_ready(&brief, &template)); + assert_eq!(canvas_entry_missing(&brief, &json!({"id":"template","version":"2","contentHash":"not-a-hash"})), ["模板"]); + brief["images"][0]["purpose"] = json!("scene"); + assert_eq!(canvas_entry_missing(&brief, &template), ["产品图"]); + brief["name"] = json!(" "); + assert_eq!(canvas_entry_missing(&brief, &Value::Null), ["模板", "产品名称", "产品图"]); } #[test] fn installed_legacy_template_is_pinned_but_not_relabelled_for_a_changed_brand() {