fix(client): streamline template selection and show required product fields

This commit is contained in:
WuYongJie 2026-11-11 17:07:22 +08:00
parent 3cb65c4b58
commit cfc1452f8b

View File

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