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 | 134 | // 报价是跨表主-从表单:主表(quoquotationmaster) + 印刷/部件明细(quoquotationslave) + 多数量(quoquotationmanyqtys)。 |
| 135 | 135 | // 每个字段标 target 表;印刷/颜色/单双面无独立列,作为备注写进从表 sMaterialsMemo(note)。价格由 ERP【核价】算。 |
| 136 | 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 | 157 | return l; |
| 155 | 158 | } |
| 156 | 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 | 163 | Map<String, Object> m = new LinkedHashMap<>(); |
| 161 | 164 | m.put("col", col); |
| 162 | 165 | m.put("label", label); |
| ... | ... | @@ -166,9 +169,58 @@ public class FormResolverService { |
| 166 | 169 | if (targetTable != null) { |
| 167 | 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 | 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 | 224 | /** 表列 -> MySQL 数据类型(information_schema)。 */ |
| 173 | 225 | public Map<String, String> columnTypes(String table) { |
| 174 | 226 | Map<String, String> m = new HashMap<>(); | ... | ... |
src/main/java/com/xly/tool/FormCollectTool.java
| ... | ... | @@ -64,6 +64,7 @@ public class FormCollectTool { |
| 64 | 64 | // 主字段来源 = 该表的**业务录入字段**(字段字典,排除系统/审计/计算列)——比 gdsconfigformslave 的 |
| 65 | 65 | // 界面布局字段更贴近"要填的业务参数"(报价的 客户/产品/数量/尺寸/单价,而不是 制单人/单据日期)。 |
| 66 | 66 | List<Map<String, Object>> biz = resolver.businessFields(table, MAX_FIELDS); |
| 67 | + Map<String, String> types = resolver.columnTypes(table); | |
| 67 | 68 | for (Map<String, Object> b : biz) { |
| 68 | 69 | String col = str(b.get("col")); |
| 69 | 70 | if (col == null || col.isBlank()) { |
| ... | ... | @@ -72,11 +73,25 @@ public class FormCollectTool { |
| 72 | 73 | Map<String, Object> f = new LinkedHashMap<>(); |
| 73 | 74 | f.put("name", col); |
| 74 | 75 | f.put("label", firstNonBlank(str(b.get("label")), col)); |
| 75 | - f.put("control", "text"); | |
| 76 | 76 | f.put("required", false); |
| 77 | 77 | Object fk = b.get("fk"); |
| 78 | + Object opts = b.get("options"); | |
| 79 | + String type = str(b.get("type")); | |
| 78 | 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 | 96 | fields.add(f); |
| 82 | 97 | } |
| ... | ... | @@ -96,7 +111,6 @@ public class FormCollectTool { |
| 96 | 111 | Map<String, Object> f = new LinkedHashMap<>(); |
| 97 | 112 | f.put("name", name); |
| 98 | 113 | f.put("label", firstNonBlank(str(c.get("sChinese")), name)); |
| 99 | - f.put("control", firstNonBlank(str(c.get("sControlName")), "text")); | |
| 100 | 114 | f.put("required", truthy(c.get("bNotEmpty"))); |
| 101 | 115 | String def = str(c.get("sDefault")); |
| 102 | 116 | if (def != null && !def.isBlank()) { |
| ... | ... | @@ -104,7 +118,10 @@ public class FormCollectTool { |
| 104 | 118 | } |
| 105 | 119 | List<String> opts = simpleOptions(str(c.get("sChineseDropDown"))); |
| 106 | 120 | if (!opts.isEmpty()) { |
| 121 | + f.put("type", "select"); | |
| 107 | 122 | f.put("options", opts); |
| 123 | + } else { | |
| 124 | + f.put("type", "text"); | |
| 108 | 125 | } |
| 109 | 126 | fields.add(f); |
| 110 | 127 | } |
| ... | ... | @@ -146,6 +163,21 @@ public class FormCollectTool { |
| 146 | 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 | 181 | private static boolean truthy(Object o) { |
| 150 | 182 | if (o == null) { |
| 151 | 183 | return false; | ... | ... |
src/main/java/com/xly/tool/ProposeWriteTool.java
| ... | ... | @@ -221,7 +221,7 @@ public class ProposeWriteTool { |
| 221 | 221 | // 权威 label -> {col, fk} 映射(与 collectForm 同源:策展/字段字典),保证前端标签能被正确回映射 |
| 222 | 222 | Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>(); |
| 223 | 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 | 226 | Map<String, Object> col = new LinkedHashMap<>(); |
| 227 | 227 | List<String> descParts = new ArrayList<>(); |
| ... | ... | @@ -236,7 +236,7 @@ public class ProposeWriteTool { |
| 236 | 236 | if (isBlank(v)) { |
| 237 | 237 | continue; |
| 238 | 238 | } |
| 239 | - Map<String, Object> fm = labelMap.get(zh); | |
| 239 | + Map<String, Object> fm = labelMap.get(normLabel(zh)); | |
| 240 | 240 | if (fm == null) { |
| 241 | 241 | fm = resolveColumn(table, zh); // 回退字段字典 |
| 242 | 242 | } |
| ... | ... | @@ -375,7 +375,7 @@ public class ProposeWriteTool { |
| 375 | 375 | Map<String, String> slaveTypes = resolver.columnTypes("quoquotationslave"); |
| 376 | 376 | Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>(); |
| 377 | 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 | 381 | Map<String, Object> masterCol = new LinkedHashMap<>(); |
| ... | ... | @@ -395,7 +395,7 @@ public class ProposeWriteTool { |
| 395 | 395 | if (isBlank(v)) { |
| 396 | 396 | continue; |
| 397 | 397 | } |
| 398 | - Map<String, Object> fm = labelMap.get(zh); | |
| 398 | + Map<String, Object> fm = labelMap.get(normLabel(zh)); | |
| 399 | 399 | if (fm == null) { |
| 400 | 400 | continue; |
| 401 | 401 | } |
| ... | ... | @@ -582,6 +582,14 @@ public class ProposeWriteTool { |
| 582 | 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 | 593 | private String err(String msg) { |
| 586 | 594 | Map<String, Object> m = new LinkedHashMap<>(); |
| 587 | 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 | 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 | 812 | function renderFormCollect(entity, fields) { |
| 812 | 813 | hideTypingIndicator(); |
| 813 | 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 | 818 | const label = escapeHtml(f.label || f.name); |
| 819 | 819 | const req = f.required ? ' <span style="color:#e00;">*</span>' : ''; |
| 820 | 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 | 823 | let control; |
| 822 | - if (Array.isArray(f.options) && f.options.length > 0) { | |
| 824 | + if (type === 'select' && Array.isArray(f.options)) { | |
| 823 | 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 | 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 | 839 | }).join(''); |
| 832 | 840 | const html = ` |
| 833 | 841 | <div class="message ai-message" id="${fid}"> |
| ... | ... | @@ -842,6 +850,18 @@ |
| 842 | 850 | </div> |
| 843 | 851 | </div>`; |
| 844 | 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 | 865 | $(`#${fid} .fc-submit`).on('click', function () { |
| 846 | 866 | const parts = []; |
| 847 | 867 | $(`#${fid} .fc-input`).each(function () { | ... | ... |