From 1057c7df3238a0780914183ccc5266b1470354a1 Mon Sep 17 00:00:00 2001 From: zichun <26684461+reporkey@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:23:34 +0800 Subject: [PATCH] feat(formcollect): type-aware fields + FK dropdowns from other tables --- src/main/java/com/xly/service/FormResolverService.java | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------ src/main/java/com/xly/tool/FormCollectTool.java | 38 +++++++++++++++++++++++++++++++++++--- src/main/java/com/xly/tool/ProposeWriteTool.java | 16 ++++++++++++---- src/main/java/com/xly/web/FormController.java | 34 ++++++++++++++++++++++++++++++++++ src/main/resources/templates/chat.html | 44 ++++++++++++++++++++++++++++++++------------ 5 files changed, 183 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/xly/web/FormController.java diff --git a/src/main/java/com/xly/service/FormResolverService.java b/src/main/java/com/xly/service/FormResolverService.java index 075b5f2..1568f22 100644 --- a/src/main/java/com/xly/service/FormResolverService.java +++ b/src/main/java/com/xly/service/FormResolverService.java @@ -134,29 +134,32 @@ public class FormResolverService { // 报价是跨表主-从表单:主表(quoquotationmaster) + 印刷/部件明细(quoquotationslave) + 多数量(quoquotationmanyqtys)。 // 每个字段标 target 表;印刷/颜色/单双面无独立列,作为备注写进从表 sMaterialsMemo(note)。价格由 ERP【核价】算。 List> l = new ArrayList<>(); - l.add(bf("sCustomerId", "客户名称", "elecustomer", "master")); - l.add(bf("sProductId", "产品名称", "eleproduct", "master")); - l.add(bf("sMaterialsId", "物料名称", "elematerials", "master")); - l.add(bf("sProductUnit", "单位", null, "slave")); - l.add(bf("dLength", "长(L)", null, "master")); - l.add(bf("dWidth", "宽(W)", null, "master")); - l.add(bf("dHeight", "高(D)", null, "master")); - l.add(bf("dQty", "数量", null, "master")); - l.add(bf("sPrint", "印刷", null, "note")); - l.add(bf("sColor", "颜色", null, "note")); - l.add(bf("sSingleDouble", "单双面", null, "note")); - l.add(bf("dProductLength", "部件长", null, "slave")); - l.add(bf("dProductWidth", "部件宽", null, "slave")); - l.add(bf("dPrice", "单价", null, "master")); - l.add(bf("dCoefficient", "系数", null, "slave")); - l.add(bf("sMaterialsMemo", "材料备注", null, "slave")); - l.add(bf("dManyQty", "多数量(逗号分隔)", null, "manyqtys")); + // fk → 下拉从对应表取选项;number → 数字;select → 固定选项;text/date 由控件决定 + l.add(bf("sCustomerId", "客户名称", "elecustomer", "master", "fkselect", null)); + l.add(bf("sProductId", "产品名称", "eleproduct", "master", "fkselect", null)); + l.add(bf("sMaterialsId", "物料名称", "elematerials", "master", "fkselect", null)); + l.add(bf("sProductUnit", "单位", null, "slave", "select", "只,个,套,张,本,件,PCS,KG,米,平方米")); + l.add(bf("dLength", "长(L)", null, "master", "number", null)); + l.add(bf("dWidth", "宽(W)", null, "master", "number", null)); + l.add(bf("dHeight", "高(D)", null, "master", "number", null)); + l.add(bf("dQty", "数量", null, "master", "number", null)); + l.add(bf("sPrint", "印刷", null, "note", "select", "胶印,柔印,凹印,丝印,数码印刷,不印刷")); + l.add(bf("sColor", "颜色", null, "note", "select", "彩色,黑白,专色,四色,无")); + l.add(bf("sSingleDouble", "单双面", null, "note", "select", "单面,双面")); + l.add(bf("dProductLength", "部件长", null, "slave", "number", null)); + l.add(bf("dProductWidth", "部件宽", null, "slave", "number", null)); + l.add(bf("dPrice", "单价", null, "master", "number", null)); + l.add(bf("dCoefficient", "系数", null, "slave", "number", null)); + l.add(bf("sMaterialsMemo", "材料备注", null, "slave", "text", null)); + Map many = bf("dManyQty", "多数量", null, "manyqtys", "text", null); + many.put("hint", "多个报价数量用逗号分隔,如 1000,3000,5000"); + l.add(many); return l; } return null; } - private Map bf(String col, String label, String fk, String targetTable) { + private Map bf(String col, String label, String fk, String targetTable, String type, String optionsCsv) { Map m = new LinkedHashMap<>(); m.put("col", col); m.put("label", label); @@ -166,9 +169,58 @@ public class FormResolverService { if (targetTable != null) { m.put("table", targetTable); } + if (type != null) { + m.put("type", type); + } + if (optionsCsv != null && !optionsCsv.isBlank()) { + List opts = new ArrayList<>(); + for (String o : optionsCsv.split(",")) { + if (!o.trim().isEmpty()) { + opts.add(o.trim()); + } + } + m.put("options", opts); + } return m; } + /** FK 名称候选(下拉从对应表取选项):按名称字段模糊匹配,返回名称列表。仅允许字段字典里出现过的外键表。 */ + public List fkOptions(String fkTable, String brand, String q) { + List out = new ArrayList<>(); + try { + // 安全:只允许作为外键目标出现过的表(白名单),避免任意读表 + Integer ok = jdbc.queryForObject( + "SELECT COUNT(*) FROM viw_kg_field_dict WHERE sFkTable=?", Integer.class, fkTable); + if (ok == null || ok == 0) { + return out; + } + String nameField = resolveNameField(fkTable); + if (nameField == null) { + return out; + } + String sql = "SELECT DISTINCT `" + nameField + "` n FROM `" + fkTable + "` " + + "WHERE IFNULL(`" + nameField + "`,'')<>'' " + + (brand != null && !brand.isBlank() ? "AND sBrandsId=? " : "") + + (q != null && !q.isBlank() ? "AND `" + nameField + "` LIKE ? " : "") + + "ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC LIMIT 50"; + List args = new ArrayList<>(); + if (brand != null && !brand.isBlank()) { + args.add(brand); + } + if (q != null && !q.isBlank()) { + args.add("%" + q.trim() + "%"); + } + for (Map r : jdbc.queryForList(sql, args.toArray())) { + Object n = r.get("n"); + if (n != null) { + out.add(n.toString()); + } + } + } catch (Exception ignore) { + } + return out; + } + /** 表列 -> MySQL 数据类型(information_schema)。 */ public Map columnTypes(String table) { Map m = new HashMap<>(); diff --git a/src/main/java/com/xly/tool/FormCollectTool.java b/src/main/java/com/xly/tool/FormCollectTool.java index 4c8f1e1..c457978 100644 --- a/src/main/java/com/xly/tool/FormCollectTool.java +++ b/src/main/java/com/xly/tool/FormCollectTool.java @@ -64,6 +64,7 @@ public class FormCollectTool { // 主字段来源 = 该表的**业务录入字段**(字段字典,排除系统/审计/计算列)——比 gdsconfigformslave 的 // 界面布局字段更贴近"要填的业务参数"(报价的 客户/产品/数量/尺寸/单价,而不是 制单人/单据日期)。 List> biz = resolver.businessFields(table, MAX_FIELDS); + Map types = resolver.columnTypes(table); for (Map b : biz) { String col = str(b.get("col")); if (col == null || col.isBlank()) { @@ -72,11 +73,25 @@ public class FormCollectTool { Map f = new LinkedHashMap<>(); f.put("name", col); f.put("label", firstNonBlank(str(b.get("label")), col)); - f.put("control", "text"); f.put("required", false); Object fk = b.get("fk"); + Object opts = b.get("options"); + String type = str(b.get("type")); if (fk != null && !String.valueOf(fk).isBlank()) { - f.put("fk", true); // 外键字段:让用户填名称,写入时解析成 id + // 外键字段:下拉从对应表实时取选项(客户/产品/物料…),用户填名称、写入时解析成 id + f.put("type", "fkselect"); + f.put("fkTable", String.valueOf(fk)); + } else if (opts instanceof List && !((List) opts).isEmpty()) { + f.put("type", "select"); + f.put("options", opts); + } else if (type != null && !type.isBlank()) { + f.put("type", type); + } else { + f.put("type", inferType(types.get(col))); // 通用表:按列类型推断 number/date/text + } + Object hint = b.get("hint"); + if (hint != null && !String.valueOf(hint).isBlank()) { + f.put("hint", String.valueOf(hint)); } fields.add(f); } @@ -96,7 +111,6 @@ public class FormCollectTool { Map f = new LinkedHashMap<>(); f.put("name", name); f.put("label", firstNonBlank(str(c.get("sChinese")), name)); - f.put("control", firstNonBlank(str(c.get("sControlName")), "text")); f.put("required", truthy(c.get("bNotEmpty"))); String def = str(c.get("sDefault")); if (def != null && !def.isBlank()) { @@ -104,7 +118,10 @@ public class FormCollectTool { } List opts = simpleOptions(str(c.get("sChineseDropDown"))); if (!opts.isEmpty()) { + f.put("type", "select"); f.put("options", opts); + } else { + f.put("type", "text"); } fields.add(f); } @@ -146,6 +163,21 @@ public class FormCollectTool { return out; } + /** 按列的 MySQL 类型推断表单控件类型。 */ + private static String inferType(String dataType) { + if (dataType == null) { + return "text"; + } + String t = dataType.toLowerCase(); + if (t.contains("int") || t.equals("decimal") || t.contains("double") || t.contains("float") || t.contains("numeric")) { + return "number"; + } + if (t.contains("date") || t.contains("time")) { + return "date"; + } + return "text"; + } + private static boolean truthy(Object o) { if (o == null) { return false; diff --git a/src/main/java/com/xly/tool/ProposeWriteTool.java b/src/main/java/com/xly/tool/ProposeWriteTool.java index 7068fbc..84a77c8 100644 --- a/src/main/java/com/xly/tool/ProposeWriteTool.java +++ b/src/main/java/com/xly/tool/ProposeWriteTool.java @@ -221,7 +221,7 @@ public class ProposeWriteTool { // 权威 label -> {col, fk} 映射(与 collectForm 同源:策展/字段字典),保证前端标签能被正确回映射 Map> labelMap = new LinkedHashMap<>(); for (Map bfm : resolver.businessFields(table, 40)) { - labelMap.put(String.valueOf(bfm.get("label")), bfm); + labelMap.put(normLabel(String.valueOf(bfm.get("label"))), bfm); } Map col = new LinkedHashMap<>(); List descParts = new ArrayList<>(); @@ -236,7 +236,7 @@ public class ProposeWriteTool { if (isBlank(v)) { continue; } - Map fm = labelMap.get(zh); + Map fm = labelMap.get(normLabel(zh)); if (fm == null) { fm = resolveColumn(table, zh); // 回退字段字典 } @@ -375,7 +375,7 @@ public class ProposeWriteTool { Map slaveTypes = resolver.columnTypes("quoquotationslave"); Map> labelMap = new LinkedHashMap<>(); for (Map bfm : resolver.businessFields(table, 40)) { - labelMap.put(String.valueOf(bfm.get("label")), bfm); + labelMap.put(normLabel(String.valueOf(bfm.get("label"))), bfm); } Map masterCol = new LinkedHashMap<>(); @@ -395,7 +395,7 @@ public class ProposeWriteTool { if (isBlank(v)) { continue; } - Map fm = labelMap.get(zh); + Map fm = labelMap.get(normLabel(zh)); if (fm == null) { continue; } @@ -582,6 +582,14 @@ public class ProposeWriteTool { return s == null || s.isBlank(); } + /** 归一化标签用于匹配:去掉括号提示与空白("多数量(逗号分隔)" ⇄ "多数量")。 */ + private static String normLabel(String s) { + if (s == null) { + return ""; + } + return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim(); + } + private String err(String msg) { Map m = new LinkedHashMap<>(); m.put("error", msg); diff --git a/src/main/java/com/xly/web/FormController.java b/src/main/java/com/xly/web/FormController.java new file mode 100644 index 0000000..4751ac6 --- /dev/null +++ b/src/main/java/com/xly/web/FormController.java @@ -0,0 +1,34 @@ +package com.xly.web; + +import com.xly.service.FormResolverService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * FormCollect 的**下拉选项来源**端点:外键字段(客户/产品/物料…)的选项从对应表实时取。 + * + *

前端渲染 {@code type=fkselect} 字段时调 {@code GET /api/agent/form/options?table=elecustomer&q=..} + * 拿候选名称填进下拉。安全:{@link FormResolverService#fkOptions} 只允许「在字段字典里作为外键目标出现过」的表, + * 并按名称字段模糊匹配 + 租户过滤 + LIMIT,避免任意读表。 + */ +@RestController +@RequestMapping("/api/agent/form") +public class FormController { + + private final FormResolverService resolver; + + public FormController(FormResolverService resolver) { + this.resolver = resolver; + } + + @GetMapping("/options") + public List options(@RequestParam("table") String table, + @RequestParam(value = "q", required = false) String q, + @RequestParam(value = "brandsid", required = false) String brandsid) { + return resolver.fkOptions(table, brandsid, q); + } +} diff --git a/src/main/resources/templates/chat.html b/src/main/resources/templates/chat.html index 6a7f702..701c9e6 100644 --- a/src/main/resources/templates/chat.html +++ b/src/main/resources/templates/chat.html @@ -806,28 +806,36 @@ card.find('.op-result').text('已取消'); } - // ====================== FormCollect:对话内动态表单(字段来自 ERP 表单元数据) ====================== - // fields = [{name,label,control,required,default,options?}](新版对象)或 ["中文名",...](旧版字符串,兼容) + // ====================== FormCollect:type-aware 动态表单 ====================== + // fields = [{name,label,type,required,default,options?,fkTable?}](新版)或 ["中文名",...](旧版兼容) + // type: text | number | date | select(固定选项) | fkselect(选项从对应表实时取) function renderFormCollect(entity, fields) { hideTypingIndicator(); const fid = 'fc-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6); - const norm = (fields || []).map(f => (typeof f === 'string') - ? { name: f, label: f, required: false } - : f); - const inputs = norm.map(f => { + const norm = (fields || []).map(f => (typeof f === 'string') ? { name: f, label: f, type: 'text' } : f); + const st = 'padding:6px 10px;border:1px solid #ccc;border-radius:8px;width:55%;'; + const inputs = norm.map((f, i) => { const label = escapeHtml(f.label || f.name); const req = f.required ? ' *' : ''; const defv = f.default != null ? escapeHtml(String(f.default)) : ''; + const type = f.type || 'text'; + const ph = f.hint ? ` placeholder="${escapeHtml(String(f.hint))}"` : ''; let control; - if (Array.isArray(f.options) && f.options.length > 0) { + if (type === 'select' && Array.isArray(f.options)) { const opts = [''] - .concat(f.options.map(o => `${escapeHtml(String(o))}`)) - .join(''); - control = ``; + .concat(f.options.map(o => `${escapeHtml(String(o))}`)).join(''); + control = ``; + } else if (type === 'fkselect') { + const dlId = `${fid}-dl-${i}`; + control = ``; + } else if (type === 'number') { + control = ``; + } else if (type === 'date') { + control = ``; } else { - control = ``; + control = ``; } - return `

${control}
`; + return `
${control}
`; }).join(''); const html = `
@@ -842,6 +850,18 @@
`; $('#chatMessages').append(html); + // 外键下拉:聚焦拉一批候选,输入按关键词刷新(选项来自对应表) + $(`#${fid} .fc-fk`).each(function () { + const el = this, dlId = $(el).attr('list'), fkTable = $(el).data('fk'); + const load = (q) => fetch(`${CONFIG.backendUrl}/api/agent/form/options?table=${encodeURIComponent(fkTable)}&brandsid=${encodeURIComponent(brandsid)}&q=${encodeURIComponent(q||'')}`) + .then(r => r.json()).then(list => { + const dl = document.getElementById(dlId); + if (dl) dl.innerHTML = (list||[]).map(o => ``).join(''); + }).catch(() => {}); + let t = null; + $(el).on('focus', () => load('')); + $(el).on('input', function () { clearTimeout(t); const v = this.value; t = setTimeout(() => load(v), 250); }); + }); $(`#${fid} .fc-submit`).on('click', function () { const parts = []; $(`#${fid} .fc-input`).each(function () { -- libgit2 0.22.2