Commit 1057c7df3238a0780914183ccc5266b1470354a1
1 parent
190e64cd
feat(formcollect): type-aware fields + FK dropdowns from other tables
User: form rendered every field as a plain text box; no type (dropdown/date/number), and FK fields didn't pull options from their source tables. - FormCollect schema now carries per-field type: fkselect | select | number | date | text (+ options for select, fkTable for fkselect, hint for placeholder). Types inferred from column data type for generic forms; curated for quote. - New GET /api/agent/form/options?table=&q=&brandsid= -> FK options from the source table (elecustomer/eleproduct/elematerials...), whitelisted to tables that appear as sFkTable in the field dict (rejects arbitrary tables e.g. gdslogininfo), tenant-filtered + LIMIT. - chat.html renderFormCollect renders by type: fkselect = searchable <input list=datalist> that fetches options on focus/typing; select = <select>; number = <input type=number>; date picker; text. - Fix: tolerant label matching (strip parentheticals) so '多数量' maps whether or not the LLM keeps the '(逗号分隔)' hint -> multi-qty rows now reliably created. Verified via agent: quote form shows 客户/产品/物料=fkselect, 单位/印刷/颜色/单双面=select, 尺寸/数量/单价/系数=number; options endpoint returns real names (客户 q=Little -> Little Antelope); non-FK table rejected; full write incl 多数量 (BJD202607086 -> 2000/4000/6000).
Showing
5 changed files
with
183 additions
and
37 deletions
src/main/java/com/xly/service/FormResolverService.java
| @@ -134,29 +134,32 @@ public class FormResolverService { | @@ -134,29 +134,32 @@ public class FormResolverService { | ||
| 134 | // 报价是跨表主-从表单:主表(quoquotationmaster) + 印刷/部件明细(quoquotationslave) + 多数量(quoquotationmanyqtys)。 | 134 | // 报价是跨表主-从表单:主表(quoquotationmaster) + 印刷/部件明细(quoquotationslave) + 多数量(quoquotationmanyqtys)。 |
| 135 | // 每个字段标 target 表;印刷/颜色/单双面无独立列,作为备注写进从表 sMaterialsMemo(note)。价格由 ERP【核价】算。 | 135 | // 每个字段标 target 表;印刷/颜色/单双面无独立列,作为备注写进从表 sMaterialsMemo(note)。价格由 ERP【核价】算。 |
| 136 | List<Map<String, Object>> l = new ArrayList<>(); | 136 | List<Map<String, Object>> l = new ArrayList<>(); |
| 137 | - l.add(bf("sCustomerId", "客户名称", "elecustomer", "master")); | ||
| 138 | - l.add(bf("sProductId", "产品名称", "eleproduct", "master")); | ||
| 139 | - l.add(bf("sMaterialsId", "物料名称", "elematerials", "master")); | ||
| 140 | - l.add(bf("sProductUnit", "单位", null, "slave")); | ||
| 141 | - l.add(bf("dLength", "长(L)", null, "master")); | ||
| 142 | - l.add(bf("dWidth", "宽(W)", null, "master")); | ||
| 143 | - l.add(bf("dHeight", "高(D)", null, "master")); | ||
| 144 | - l.add(bf("dQty", "数量", null, "master")); | ||
| 145 | - l.add(bf("sPrint", "印刷", null, "note")); | ||
| 146 | - l.add(bf("sColor", "颜色", null, "note")); | ||
| 147 | - l.add(bf("sSingleDouble", "单双面", null, "note")); | ||
| 148 | - l.add(bf("dProductLength", "部件长", null, "slave")); | ||
| 149 | - l.add(bf("dProductWidth", "部件宽", null, "slave")); | ||
| 150 | - l.add(bf("dPrice", "单价", null, "master")); | ||
| 151 | - l.add(bf("dCoefficient", "系数", null, "slave")); | ||
| 152 | - l.add(bf("sMaterialsMemo", "材料备注", null, "slave")); | ||
| 153 | - l.add(bf("dManyQty", "多数量(逗号分隔)", null, "manyqtys")); | 137 | + // fk → 下拉从对应表取选项;number → 数字;select → 固定选项;text/date 由控件决定 |
| 138 | + l.add(bf("sCustomerId", "客户名称", "elecustomer", "master", "fkselect", null)); | ||
| 139 | + l.add(bf("sProductId", "产品名称", "eleproduct", "master", "fkselect", null)); | ||
| 140 | + l.add(bf("sMaterialsId", "物料名称", "elematerials", "master", "fkselect", null)); | ||
| 141 | + l.add(bf("sProductUnit", "单位", null, "slave", "select", "只,个,套,张,本,件,PCS,KG,米,平方米")); | ||
| 142 | + l.add(bf("dLength", "长(L)", null, "master", "number", null)); | ||
| 143 | + l.add(bf("dWidth", "宽(W)", null, "master", "number", null)); | ||
| 144 | + l.add(bf("dHeight", "高(D)", null, "master", "number", null)); | ||
| 145 | + l.add(bf("dQty", "数量", null, "master", "number", null)); | ||
| 146 | + l.add(bf("sPrint", "印刷", null, "note", "select", "胶印,柔印,凹印,丝印,数码印刷,不印刷")); | ||
| 147 | + l.add(bf("sColor", "颜色", null, "note", "select", "彩色,黑白,专色,四色,无")); | ||
| 148 | + l.add(bf("sSingleDouble", "单双面", null, "note", "select", "单面,双面")); | ||
| 149 | + l.add(bf("dProductLength", "部件长", null, "slave", "number", null)); | ||
| 150 | + l.add(bf("dProductWidth", "部件宽", null, "slave", "number", null)); | ||
| 151 | + l.add(bf("dPrice", "单价", null, "master", "number", null)); | ||
| 152 | + l.add(bf("dCoefficient", "系数", null, "slave", "number", null)); | ||
| 153 | + l.add(bf("sMaterialsMemo", "材料备注", null, "slave", "text", null)); | ||
| 154 | + Map<String, Object> many = bf("dManyQty", "多数量", null, "manyqtys", "text", null); | ||
| 155 | + many.put("hint", "多个报价数量用逗号分隔,如 1000,3000,5000"); | ||
| 156 | + l.add(many); | ||
| 154 | return l; | 157 | return l; |
| 155 | } | 158 | } |
| 156 | return null; | 159 | return null; |
| 157 | } | 160 | } |
| 158 | 161 | ||
| 159 | - private Map<String, Object> bf(String col, String label, String fk, String targetTable) { | 162 | + private Map<String, Object> bf(String col, String label, String fk, String targetTable, String type, String optionsCsv) { |
| 160 | Map<String, Object> m = new LinkedHashMap<>(); | 163 | Map<String, Object> m = new LinkedHashMap<>(); |
| 161 | m.put("col", col); | 164 | m.put("col", col); |
| 162 | m.put("label", label); | 165 | m.put("label", label); |
| @@ -166,9 +169,58 @@ public class FormResolverService { | @@ -166,9 +169,58 @@ public class FormResolverService { | ||
| 166 | if (targetTable != null) { | 169 | if (targetTable != null) { |
| 167 | m.put("table", targetTable); | 170 | m.put("table", targetTable); |
| 168 | } | 171 | } |
| 172 | + if (type != null) { | ||
| 173 | + m.put("type", type); | ||
| 174 | + } | ||
| 175 | + if (optionsCsv != null && !optionsCsv.isBlank()) { | ||
| 176 | + List<String> opts = new ArrayList<>(); | ||
| 177 | + for (String o : optionsCsv.split(",")) { | ||
| 178 | + if (!o.trim().isEmpty()) { | ||
| 179 | + opts.add(o.trim()); | ||
| 180 | + } | ||
| 181 | + } | ||
| 182 | + m.put("options", opts); | ||
| 183 | + } | ||
| 169 | return m; | 184 | return m; |
| 170 | } | 185 | } |
| 171 | 186 | ||
| 187 | + /** FK 名称候选(下拉从对应表取选项):按名称字段模糊匹配,返回名称列表。仅允许字段字典里出现过的外键表。 */ | ||
| 188 | + public List<String> fkOptions(String fkTable, String brand, String q) { | ||
| 189 | + List<String> out = new ArrayList<>(); | ||
| 190 | + try { | ||
| 191 | + // 安全:只允许作为外键目标出现过的表(白名单),避免任意读表 | ||
| 192 | + Integer ok = jdbc.queryForObject( | ||
| 193 | + "SELECT COUNT(*) FROM viw_kg_field_dict WHERE sFkTable=?", Integer.class, fkTable); | ||
| 194 | + if (ok == null || ok == 0) { | ||
| 195 | + return out; | ||
| 196 | + } | ||
| 197 | + String nameField = resolveNameField(fkTable); | ||
| 198 | + if (nameField == null) { | ||
| 199 | + return out; | ||
| 200 | + } | ||
| 201 | + String sql = "SELECT DISTINCT `" + nameField + "` n FROM `" + fkTable + "` " + | ||
| 202 | + "WHERE IFNULL(`" + nameField + "`,'')<>'' " + | ||
| 203 | + (brand != null && !brand.isBlank() ? "AND sBrandsId=? " : "") + | ||
| 204 | + (q != null && !q.isBlank() ? "AND `" + nameField + "` LIKE ? " : "") + | ||
| 205 | + "ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC LIMIT 50"; | ||
| 206 | + List<Object> args = new ArrayList<>(); | ||
| 207 | + if (brand != null && !brand.isBlank()) { | ||
| 208 | + args.add(brand); | ||
| 209 | + } | ||
| 210 | + if (q != null && !q.isBlank()) { | ||
| 211 | + args.add("%" + q.trim() + "%"); | ||
| 212 | + } | ||
| 213 | + for (Map<String, Object> r : jdbc.queryForList(sql, args.toArray())) { | ||
| 214 | + Object n = r.get("n"); | ||
| 215 | + if (n != null) { | ||
| 216 | + out.add(n.toString()); | ||
| 217 | + } | ||
| 218 | + } | ||
| 219 | + } catch (Exception ignore) { | ||
| 220 | + } | ||
| 221 | + return out; | ||
| 222 | + } | ||
| 223 | + | ||
| 172 | /** 表列 -> MySQL 数据类型(information_schema)。 */ | 224 | /** 表列 -> MySQL 数据类型(information_schema)。 */ |
| 173 | public Map<String, String> columnTypes(String table) { | 225 | public Map<String, String> columnTypes(String table) { |
| 174 | Map<String, String> m = new HashMap<>(); | 226 | Map<String, String> m = new HashMap<>(); |
src/main/java/com/xly/tool/FormCollectTool.java
| @@ -64,6 +64,7 @@ public class FormCollectTool { | @@ -64,6 +64,7 @@ public class FormCollectTool { | ||
| 64 | // 主字段来源 = 该表的**业务录入字段**(字段字典,排除系统/审计/计算列)——比 gdsconfigformslave 的 | 64 | // 主字段来源 = 该表的**业务录入字段**(字段字典,排除系统/审计/计算列)——比 gdsconfigformslave 的 |
| 65 | // 界面布局字段更贴近"要填的业务参数"(报价的 客户/产品/数量/尺寸/单价,而不是 制单人/单据日期)。 | 65 | // 界面布局字段更贴近"要填的业务参数"(报价的 客户/产品/数量/尺寸/单价,而不是 制单人/单据日期)。 |
| 66 | List<Map<String, Object>> biz = resolver.businessFields(table, MAX_FIELDS); | 66 | List<Map<String, Object>> biz = resolver.businessFields(table, MAX_FIELDS); |
| 67 | + Map<String, String> types = resolver.columnTypes(table); | ||
| 67 | for (Map<String, Object> b : biz) { | 68 | for (Map<String, Object> b : biz) { |
| 68 | String col = str(b.get("col")); | 69 | String col = str(b.get("col")); |
| 69 | if (col == null || col.isBlank()) { | 70 | if (col == null || col.isBlank()) { |
| @@ -72,11 +73,25 @@ public class FormCollectTool { | @@ -72,11 +73,25 @@ public class FormCollectTool { | ||
| 72 | Map<String, Object> f = new LinkedHashMap<>(); | 73 | Map<String, Object> f = new LinkedHashMap<>(); |
| 73 | f.put("name", col); | 74 | f.put("name", col); |
| 74 | f.put("label", firstNonBlank(str(b.get("label")), col)); | 75 | f.put("label", firstNonBlank(str(b.get("label")), col)); |
| 75 | - f.put("control", "text"); | ||
| 76 | f.put("required", false); | 76 | f.put("required", false); |
| 77 | Object fk = b.get("fk"); | 77 | Object fk = b.get("fk"); |
| 78 | + Object opts = b.get("options"); | ||
| 79 | + String type = str(b.get("type")); | ||
| 78 | if (fk != null && !String.valueOf(fk).isBlank()) { | 80 | if (fk != null && !String.valueOf(fk).isBlank()) { |
| 79 | - f.put("fk", true); // 外键字段:让用户填名称,写入时解析成 id | 81 | + // 外键字段:下拉从对应表实时取选项(客户/产品/物料…),用户填名称、写入时解析成 id |
| 82 | + f.put("type", "fkselect"); | ||
| 83 | + f.put("fkTable", String.valueOf(fk)); | ||
| 84 | + } else if (opts instanceof List && !((List<?>) opts).isEmpty()) { | ||
| 85 | + f.put("type", "select"); | ||
| 86 | + f.put("options", opts); | ||
| 87 | + } else if (type != null && !type.isBlank()) { | ||
| 88 | + f.put("type", type); | ||
| 89 | + } else { | ||
| 90 | + f.put("type", inferType(types.get(col))); // 通用表:按列类型推断 number/date/text | ||
| 91 | + } | ||
| 92 | + Object hint = b.get("hint"); | ||
| 93 | + if (hint != null && !String.valueOf(hint).isBlank()) { | ||
| 94 | + f.put("hint", String.valueOf(hint)); | ||
| 80 | } | 95 | } |
| 81 | fields.add(f); | 96 | fields.add(f); |
| 82 | } | 97 | } |
| @@ -96,7 +111,6 @@ public class FormCollectTool { | @@ -96,7 +111,6 @@ public class FormCollectTool { | ||
| 96 | Map<String, Object> f = new LinkedHashMap<>(); | 111 | Map<String, Object> f = new LinkedHashMap<>(); |
| 97 | f.put("name", name); | 112 | f.put("name", name); |
| 98 | f.put("label", firstNonBlank(str(c.get("sChinese")), name)); | 113 | f.put("label", firstNonBlank(str(c.get("sChinese")), name)); |
| 99 | - f.put("control", firstNonBlank(str(c.get("sControlName")), "text")); | ||
| 100 | f.put("required", truthy(c.get("bNotEmpty"))); | 114 | f.put("required", truthy(c.get("bNotEmpty"))); |
| 101 | String def = str(c.get("sDefault")); | 115 | String def = str(c.get("sDefault")); |
| 102 | if (def != null && !def.isBlank()) { | 116 | if (def != null && !def.isBlank()) { |
| @@ -104,7 +118,10 @@ public class FormCollectTool { | @@ -104,7 +118,10 @@ public class FormCollectTool { | ||
| 104 | } | 118 | } |
| 105 | List<String> opts = simpleOptions(str(c.get("sChineseDropDown"))); | 119 | List<String> opts = simpleOptions(str(c.get("sChineseDropDown"))); |
| 106 | if (!opts.isEmpty()) { | 120 | if (!opts.isEmpty()) { |
| 121 | + f.put("type", "select"); | ||
| 107 | f.put("options", opts); | 122 | f.put("options", opts); |
| 123 | + } else { | ||
| 124 | + f.put("type", "text"); | ||
| 108 | } | 125 | } |
| 109 | fields.add(f); | 126 | fields.add(f); |
| 110 | } | 127 | } |
| @@ -146,6 +163,21 @@ public class FormCollectTool { | @@ -146,6 +163,21 @@ public class FormCollectTool { | ||
| 146 | return out; | 163 | return out; |
| 147 | } | 164 | } |
| 148 | 165 | ||
| 166 | + /** 按列的 MySQL 类型推断表单控件类型。 */ | ||
| 167 | + private static String inferType(String dataType) { | ||
| 168 | + if (dataType == null) { | ||
| 169 | + return "text"; | ||
| 170 | + } | ||
| 171 | + String t = dataType.toLowerCase(); | ||
| 172 | + if (t.contains("int") || t.equals("decimal") || t.contains("double") || t.contains("float") || t.contains("numeric")) { | ||
| 173 | + return "number"; | ||
| 174 | + } | ||
| 175 | + if (t.contains("date") || t.contains("time")) { | ||
| 176 | + return "date"; | ||
| 177 | + } | ||
| 178 | + return "text"; | ||
| 179 | + } | ||
| 180 | + | ||
| 149 | private static boolean truthy(Object o) { | 181 | private static boolean truthy(Object o) { |
| 150 | if (o == null) { | 182 | if (o == null) { |
| 151 | return false; | 183 | return false; |
src/main/java/com/xly/tool/ProposeWriteTool.java
| @@ -221,7 +221,7 @@ public class ProposeWriteTool { | @@ -221,7 +221,7 @@ public class ProposeWriteTool { | ||
| 221 | // 权威 label -> {col, fk} 映射(与 collectForm 同源:策展/字段字典),保证前端标签能被正确回映射 | 221 | // 权威 label -> {col, fk} 映射(与 collectForm 同源:策展/字段字典),保证前端标签能被正确回映射 |
| 222 | Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>(); | 222 | Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>(); |
| 223 | for (Map<String, Object> bfm : resolver.businessFields(table, 40)) { | 223 | for (Map<String, Object> bfm : resolver.businessFields(table, 40)) { |
| 224 | - labelMap.put(String.valueOf(bfm.get("label")), bfm); | 224 | + labelMap.put(normLabel(String.valueOf(bfm.get("label"))), bfm); |
| 225 | } | 225 | } |
| 226 | Map<String, Object> col = new LinkedHashMap<>(); | 226 | Map<String, Object> col = new LinkedHashMap<>(); |
| 227 | List<String> descParts = new ArrayList<>(); | 227 | List<String> descParts = new ArrayList<>(); |
| @@ -236,7 +236,7 @@ public class ProposeWriteTool { | @@ -236,7 +236,7 @@ public class ProposeWriteTool { | ||
| 236 | if (isBlank(v)) { | 236 | if (isBlank(v)) { |
| 237 | continue; | 237 | continue; |
| 238 | } | 238 | } |
| 239 | - Map<String, Object> fm = labelMap.get(zh); | 239 | + Map<String, Object> fm = labelMap.get(normLabel(zh)); |
| 240 | if (fm == null) { | 240 | if (fm == null) { |
| 241 | fm = resolveColumn(table, zh); // 回退字段字典 | 241 | fm = resolveColumn(table, zh); // 回退字段字典 |
| 242 | } | 242 | } |
| @@ -375,7 +375,7 @@ public class ProposeWriteTool { | @@ -375,7 +375,7 @@ public class ProposeWriteTool { | ||
| 375 | Map<String, String> slaveTypes = resolver.columnTypes("quoquotationslave"); | 375 | Map<String, String> slaveTypes = resolver.columnTypes("quoquotationslave"); |
| 376 | Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>(); | 376 | Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>(); |
| 377 | for (Map<String, Object> bfm : resolver.businessFields(table, 40)) { | 377 | for (Map<String, Object> bfm : resolver.businessFields(table, 40)) { |
| 378 | - labelMap.put(String.valueOf(bfm.get("label")), bfm); | 378 | + labelMap.put(normLabel(String.valueOf(bfm.get("label"))), bfm); |
| 379 | } | 379 | } |
| 380 | 380 | ||
| 381 | Map<String, Object> masterCol = new LinkedHashMap<>(); | 381 | Map<String, Object> masterCol = new LinkedHashMap<>(); |
| @@ -395,7 +395,7 @@ public class ProposeWriteTool { | @@ -395,7 +395,7 @@ public class ProposeWriteTool { | ||
| 395 | if (isBlank(v)) { | 395 | if (isBlank(v)) { |
| 396 | continue; | 396 | continue; |
| 397 | } | 397 | } |
| 398 | - Map<String, Object> fm = labelMap.get(zh); | 398 | + Map<String, Object> fm = labelMap.get(normLabel(zh)); |
| 399 | if (fm == null) { | 399 | if (fm == null) { |
| 400 | continue; | 400 | continue; |
| 401 | } | 401 | } |
| @@ -582,6 +582,14 @@ public class ProposeWriteTool { | @@ -582,6 +582,14 @@ public class ProposeWriteTool { | ||
| 582 | return s == null || s.isBlank(); | 582 | return s == null || s.isBlank(); |
| 583 | } | 583 | } |
| 584 | 584 | ||
| 585 | + /** 归一化标签用于匹配:去掉括号提示与空白("多数量(逗号分隔)" ⇄ "多数量")。 */ | ||
| 586 | + private static String normLabel(String s) { | ||
| 587 | + if (s == null) { | ||
| 588 | + return ""; | ||
| 589 | + } | ||
| 590 | + return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim(); | ||
| 591 | + } | ||
| 592 | + | ||
| 585 | private String err(String msg) { | 593 | private String err(String msg) { |
| 586 | Map<String, Object> m = new LinkedHashMap<>(); | 594 | Map<String, Object> m = new LinkedHashMap<>(); |
| 587 | m.put("error", msg); | 595 | m.put("error", msg); |
src/main/java/com/xly/web/FormController.java
0 → 100644
| 1 | +package com.xly.web; | ||
| 2 | + | ||
| 3 | +import com.xly.service.FormResolverService; | ||
| 4 | +import org.springframework.web.bind.annotation.GetMapping; | ||
| 5 | +import org.springframework.web.bind.annotation.RequestMapping; | ||
| 6 | +import org.springframework.web.bind.annotation.RequestParam; | ||
| 7 | +import org.springframework.web.bind.annotation.RestController; | ||
| 8 | + | ||
| 9 | +import java.util.List; | ||
| 10 | + | ||
| 11 | +/** | ||
| 12 | + * FormCollect 的**下拉选项来源**端点:外键字段(客户/产品/物料…)的选项从对应表实时取。 | ||
| 13 | + * | ||
| 14 | + * <p>前端渲染 {@code type=fkselect} 字段时调 {@code GET /api/agent/form/options?table=elecustomer&q=..} | ||
| 15 | + * 拿候选名称填进下拉。安全:{@link FormResolverService#fkOptions} 只允许「在字段字典里作为外键目标出现过」的表, | ||
| 16 | + * 并按名称字段模糊匹配 + 租户过滤 + LIMIT,避免任意读表。 | ||
| 17 | + */ | ||
| 18 | +@RestController | ||
| 19 | +@RequestMapping("/api/agent/form") | ||
| 20 | +public class FormController { | ||
| 21 | + | ||
| 22 | + private final FormResolverService resolver; | ||
| 23 | + | ||
| 24 | + public FormController(FormResolverService resolver) { | ||
| 25 | + this.resolver = resolver; | ||
| 26 | + } | ||
| 27 | + | ||
| 28 | + @GetMapping("/options") | ||
| 29 | + public List<String> options(@RequestParam("table") String table, | ||
| 30 | + @RequestParam(value = "q", required = false) String q, | ||
| 31 | + @RequestParam(value = "brandsid", required = false) String brandsid) { | ||
| 32 | + return resolver.fkOptions(table, brandsid, q); | ||
| 33 | + } | ||
| 34 | +} |
src/main/resources/templates/chat.html
| @@ -806,28 +806,36 @@ | @@ -806,28 +806,36 @@ | ||
| 806 | card.find('.op-result').text('已取消'); | 806 | card.find('.op-result').text('已取消'); |
| 807 | } | 807 | } |
| 808 | 808 | ||
| 809 | - // ====================== FormCollect:对话内动态表单(字段来自 ERP 表单元数据) ====================== | ||
| 810 | - // fields = [{name,label,control,required,default,options?}](新版对象)或 ["中文名",...](旧版字符串,兼容) | 809 | + // ====================== FormCollect:type-aware 动态表单 ====================== |
| 810 | + // fields = [{name,label,type,required,default,options?,fkTable?}](新版)或 ["中文名",...](旧版兼容) | ||
| 811 | + // type: text | number | date | select(固定选项) | fkselect(选项从对应表实时取) | ||
| 811 | function renderFormCollect(entity, fields) { | 812 | function renderFormCollect(entity, fields) { |
| 812 | hideTypingIndicator(); | 813 | hideTypingIndicator(); |
| 813 | const fid = 'fc-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6); | 814 | const fid = 'fc-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6); |
| 814 | - const norm = (fields || []).map(f => (typeof f === 'string') | ||
| 815 | - ? { name: f, label: f, required: false } | ||
| 816 | - : f); | ||
| 817 | - const inputs = norm.map(f => { | 815 | + const norm = (fields || []).map(f => (typeof f === 'string') ? { name: f, label: f, type: 'text' } : f); |
| 816 | + const st = 'padding:6px 10px;border:1px solid #ccc;border-radius:8px;width:55%;'; | ||
| 817 | + const inputs = norm.map((f, i) => { | ||
| 818 | const label = escapeHtml(f.label || f.name); | 818 | const label = escapeHtml(f.label || f.name); |
| 819 | const req = f.required ? ' <span style="color:#e00;">*</span>' : ''; | 819 | const req = f.required ? ' <span style="color:#e00;">*</span>' : ''; |
| 820 | const defv = f.default != null ? escapeHtml(String(f.default)) : ''; | 820 | const defv = f.default != null ? escapeHtml(String(f.default)) : ''; |
| 821 | + const type = f.type || 'text'; | ||
| 822 | + const ph = f.hint ? ` placeholder="${escapeHtml(String(f.hint))}"` : ''; | ||
| 821 | let control; | 823 | let control; |
| 822 | - if (Array.isArray(f.options) && f.options.length > 0) { | 824 | + if (type === 'select' && Array.isArray(f.options)) { |
| 823 | const opts = ['<option value=""></option>'] | 825 | const opts = ['<option value=""></option>'] |
| 824 | - .concat(f.options.map(o => `<option${String(o)===defv?' selected':''}>${escapeHtml(String(o))}</option>`)) | ||
| 825 | - .join(''); | ||
| 826 | - control = `<select data-label="${label}" class="fc-input" style="padding:6px 10px;border:1px solid #ccc;border-radius:8px;width:55%;">${opts}</select>`; | 826 | + .concat(f.options.map(o => `<option${String(o)===defv?' selected':''}>${escapeHtml(String(o))}</option>`)).join(''); |
| 827 | + control = `<select data-label="${label}" class="fc-input" style="${st}">${opts}</select>`; | ||
| 828 | + } else if (type === 'fkselect') { | ||
| 829 | + const dlId = `${fid}-dl-${i}`; | ||
| 830 | + control = `<input data-label="${label}" class="fc-input fc-fk" list="${dlId}" data-fk="${escapeHtml(f.fkTable||'')}" placeholder="输入以搜索…" autocomplete="off" style="${st}"><datalist id="${dlId}"></datalist>`; | ||
| 831 | + } else if (type === 'number') { | ||
| 832 | + control = `<input type="number" step="any" data-label="${label}" value="${defv}" class="fc-input" style="${st}"${ph}>`; | ||
| 833 | + } else if (type === 'date') { | ||
| 834 | + control = `<input type="date" data-label="${label}" value="${defv}" class="fc-input" style="${st}">`; | ||
| 827 | } else { | 835 | } else { |
| 828 | - control = `<input data-label="${label}" value="${defv}" class="fc-input" style="padding:6px 10px;border:1px solid #ccc;border-radius:8px;width:55%;">`; | 836 | + control = `<input type="text" data-label="${label}" value="${defv}" class="fc-input" style="${st}"${ph}>`; |
| 829 | } | 837 | } |
| 830 | - return `<div style="margin-bottom:8px;"><label style="display:inline-block;width:130px;color:#555;">${label}${req}</label>${control}</div>`; | 838 | + return `<div style="margin-bottom:8px;"><label style="display:inline-block;width:130px;color:#555;vertical-align:middle;">${label}${req}</label>${control}</div>`; |
| 831 | }).join(''); | 839 | }).join(''); |
| 832 | const html = ` | 840 | const html = ` |
| 833 | <div class="message ai-message" id="${fid}"> | 841 | <div class="message ai-message" id="${fid}"> |
| @@ -842,6 +850,18 @@ | @@ -842,6 +850,18 @@ | ||
| 842 | </div> | 850 | </div> |
| 843 | </div>`; | 851 | </div>`; |
| 844 | $('#chatMessages').append(html); | 852 | $('#chatMessages').append(html); |
| 853 | + // 外键下拉:聚焦拉一批候选,输入按关键词刷新(选项来自对应表) | ||
| 854 | + $(`#${fid} .fc-fk`).each(function () { | ||
| 855 | + const el = this, dlId = $(el).attr('list'), fkTable = $(el).data('fk'); | ||
| 856 | + const load = (q) => fetch(`${CONFIG.backendUrl}/api/agent/form/options?table=${encodeURIComponent(fkTable)}&brandsid=${encodeURIComponent(brandsid)}&q=${encodeURIComponent(q||'')}`) | ||
| 857 | + .then(r => r.json()).then(list => { | ||
| 858 | + const dl = document.getElementById(dlId); | ||
| 859 | + if (dl) dl.innerHTML = (list||[]).map(o => `<option value="${escapeHtml(String(o))}"></option>`).join(''); | ||
| 860 | + }).catch(() => {}); | ||
| 861 | + let t = null; | ||
| 862 | + $(el).on('focus', () => load('')); | ||
| 863 | + $(el).on('input', function () { clearTimeout(t); const v = this.value; t = setTimeout(() => load(v), 250); }); | ||
| 864 | + }); | ||
| 845 | $(`#${fid} .fc-submit`).on('click', function () { | 865 | $(`#${fid} .fc-submit`).on('click', function () { |
| 846 | const parts = []; | 866 | const parts = []; |
| 847 | $(`#${fid} .fc-input`).each(function () { | 867 | $(`#${fid} .fc-input`).each(function () { |