fix(client): streamline template selection and show required product fields
This commit is contained in:
parent
3cb65c4b58
commit
cfc1452f8b
@ -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() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user