/* 数据字典 v2 原型 mock(纯前端,无真实后端调用) * 口径来源:.scratch/dict-alignment/issues/08-prototype-v2-contract.md(20260908 拍板) * Q1 /page 行内嵌 children(含停用);Q2 扩展 saveOrUpdate,children 非空即复合; * 子项全量替换(带 id 更新 / 不带 id 新建 / 缺失删除,63009/63003 拦); * Q3 键值=名称仅复合路径新建行(单项新增与编辑维持现状 63001); * Q4 isDefault 任意行可带,落 DictGroupDefault,同载荷仅一行 true。 */ 'use strict'; /* ================= mock 数据 ================= */ let _id = 9000; const nid = () => 'n' + (++_id); const genCode = () => 'dict_' + Math.random().toString(16).slice(2, 10); const STORE = { groups: [ { id: 'g1', name: '行业', code: 'industry', status: 1 }, { id: 'g2', name: '客户类型', code: 'customer_type', status: 1 }, ], defaultItemId: { g1: 'i1', g2: 'i2' }, // groupId -> 默认项 id(DictGroupDefault 模拟,一组一个) items: [ // 顶层只挂一级项;二级项全部内嵌在 children(= /page v2 契约) { id: 'i1', groupId: 'g1', name: '互联网/信息技术', code: 'internet', value: '互联网/信息技术', sortNo: 1, status: 1, description: '', builtin: false, referenced: false, children: [ { id: 'i101', name: '电商', code: 'elec', value: '电商', sortNo: 1, status: 1, referenced: false }, { id: 'i102', name: '游戏', code: 'game', value: '游戏', sortNo: 2, status: 1, referenced: true }, { id: 'i103', name: '社交(停用示例)', code: 'social', value: '社交', sortNo: 3, status: 0, referenced: false }, ] }, { id: 'i3', groupId: 'g1', name: '金融', code: 'finance', value: '金融', sortNo: 2, status: 1, description: '', builtin: false, referenced: false, children: [ { id: 'i301', name: '银行', code: 'bank', value: '银行', sortNo: 1, status: 1, referenced: false }, { id: 'i302', name: '保险', code: 'insurance', value: '保险', sortNo: 2, status: 1, referenced: false }, ] }, { id: 'i4', groupId: 'g1', name: '制造业(无子项示例)', code: 'manufacture', value: '制造业', sortNo: 3, status: 1, description: '', builtin: false, referenced: false, children: [] }, { id: 'i2', groupId: 'g2', name: '潜在客户', code: 'potential', value: '潜在客户', sortNo: 1, status: 1, description: '', builtin: false, referenced: true, children: [] }, { id: 'i5', groupId: 'g2', name: '重点客户', code: 'key_account', value: '重点客户', sortNo: 2, status: 1, description: '', builtin: false, referenced: false, children: [] }, { id: 'i6', groupId: 'g2', name: '一般客户', code: 'normal', value: '一般客户', sortNo: 3, status: 1, description: '', builtin: false, referenced: false, children: [] }, ], }; const $ = id => document.getElementById(id); const esc = s => s == null ? '' : String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const expanded = new Set(); const groupOf = id => STORE.groups.find(g => g.id === id); const isDefaultItem = it => STORE.defaultItemId[it.groupId] === it.id; function toast(msg, type) { const d = document.createElement('div'); d.className = 'toast-item toast-' + (type === 'ok' ? 'ok' : type === 'err' ? 'err' : 'info'); d.textContent = msg; $('toast').appendChild(d); setTimeout(() => d.remove(), 6000); } /* ================= 列表(只出一级 + 行内嵌子项) ================= */ function visibleRows() { const g = $('iGroup').value, kw = ($('iKeyword').value || '').trim(), st = $('iStatus').value; return STORE.items.filter(i => (!g || i.groupId === g) && (!st || String(i.status) === st) && (!kw || (i.name || '').includes(kw) || (i.code || '').includes(kw))); // 筛选只作用一级行 } const statusBadge = s => s === 1 ? '启用' : '停用'; function marks(it) { const g = groupOf(it.groupId); return (it.referenced ? '被引用' : '') + (it.status === 1 && g && g.status === 0 ? '失活' : ''); } function renderItems() { const rows = visibleRows(); $('iBody').innerHTML = rows.length ? rows.map(i => { const kids = i.children || []; const open = expanded.has(i.id); const chev = '' + (open ? '▼' : '▶') + ''; let html = '' + '' + chev + '' + '' + esc(groupOf(i.groupId).name) + '' + '' + esc(i.name) + (kids.length ? '' + kids.length + ' 个子项' : '') + '' + '' + esc(i.code) + '' + esc(i.value) + '' + esc(i.sortNo) + '' + '' + statusBadge(i.status) + '' + '' + (isDefaultItem(i) ? '默认' : '') + '' + '' + marks(i) + '' + '' + '' + '' + '' + ''; if (open) { html += kids.length ? kids.map(c => '' + '' + '└ ' + esc(c.name) + ' 二级' + '' + esc(c.code) + '' + esc(c.value) + '' + esc(c.sortNo) + '' + '' + statusBadge(c.status) + '' + '' + (isDefaultItem(c) ? '默认' : '') + '' + '' + (c.referenced ? '被引用' : '') + '' + '随一级项「编辑」弹窗全量维护').join('') : '(无二级子项)'; } return html; }).join('') : '暂无字典项'; } function toggleExpand(id) { expanded.has(id) ? expanded.delete(id) : expanded.add(id); renderItems(); } /* ================= 子项行(弹窗内列表) ================= */ function kidRowHtml(k) { const exist = !k._new; return '' + '' + '' + '' + '' + '' + (exist ? '现值 ' + esc(k.value) + '(更新行键值不变)' : '= 名称(后端落值)') + '' + '' + (exist ? '带 id 更新 ' : '新建 ') + ''; } function readKidRows() { return [...document.querySelectorAll('#kidsBody tr')] .filter(tr => tr.querySelector('input')) .map(tr => ({ _tmp: tr.dataset.kid, _new: tr.dataset.new === '1', name: tr.querySelectorAll('input')[0].value.trim(), code: tr.querySelectorAll('input')[1].value.trim(), _def: tr.querySelector('.kdef').checked, sortNo: tr.querySelector('.ksort').value.trim(), _del: tr.classList.contains('ghost'), })); } function repaintKids(kids) { $('kidsBody').innerHTML = kids.map(kidRowHtml).join('') || '暂无二级子项,点上方按钮添加'; syncValueField(); } function addKidRow() { const kids = readKidRows(); kids.push({ _tmp: nid(), _new: true, name: '', code: '', _def: false, sortNo: kids.filter(k => !k._del).length + 1, _del: false }); repaintKids(kids); } function delKidRow(btn) { const tr = btn.closest('tr'); const kids = readKidRows(); const idx = [...document.querySelectorAll('#kidsBody tr')].indexOf(tr); kids[idx]._del = !kids[idx]._del; repaintKids(kids); } /* ================= 键值字段联动(Q3 口径的可视化) ================= */ function syncValueField() { const v = $('fiValue'); if (!v) return; const hint = $('fiValueHint'); const liveKids = readKidRows().filter(k => !k._del).length; if (window._editId) { v.disabled = false; hint.textContent = '编辑:维持现状,传了就改(63001 照走);子项新建行不传、后端取名称'; } else if (liveKids > 0) { v.disabled = true; v.value = ''; v.placeholder = '复合新增:不传键值,后端取字典名称落值'; hint.textContent = '已有 ' + liveKids + ' 个二级子项 → 复合新增:不传键值,后端直接取字典名称落值'; } else { v.disabled = false; v.placeholder = '必填,空白报 63001(单项新增现状)'; hint.textContent = '无子项 → 单项新增现状:键值必填(空白 63001)'; } } /* ================= 复合新增/编辑弹窗 ================= */ function dlgItem(id) { window._editId = id || ''; const it = id ? STORE.items.find(x => x.id === id) : null; const gOpts = STORE.groups.map(g => '').join(''); const kids = it ? (it.children || []).map(c => Object.assign({}, c, { _def: STORE.defaultItemId[it.groupId] === c.id })) : []; $('dlgTitle').textContent = it ? '编辑字典项(children 全量替换:带 id 更新 / 不带 id 新建 / 缺失删除)' : '新建字典项(有子项=复合新增,键值=名称;无子项=单项现状)'; $('dlgBody').innerHTML = `
二级字典项列表形式随本接口一次提交(children[i].xxx 表单绑定)
${kids.map(kidRowHtml).join('') || ''}
字典名称*字典编码是否默认值排序键值
暂无二级子项,点上方按钮添加
「删行」置灰划线 = 全量替换中的「缺失即删除」;被引用(63009)/内置(63003) 项后端拦截。新建行键值=名称;带 id 更新行键值不变。
`; $('dlgFoot').innerHTML = ''; $('dlg').showModal(); syncValueField(); } function dlgClose() { $('dlg').close(); } /* ================= 保存(复合语义 + 守卫演示) ================= */ function payloadPreview(id, p, singleMode) { const g = groupOf(p.groupId); const L = []; L.push('POST /api/dict/item/saveOrUpdate' + (id ? '?id=' + id : '') + (singleMode ? ' (children 空 → 单项保存现状)' : ' (children 非空 → 复合保存)')); L.push('groupId=' + p.groupId + ' # ' + g.name + '(' + g.code + ')'); L.push('name=' + p.name); if (singleMode) L.push('value=' + p.value + ' # 单项新增:键值照传(现状)'); else if (id) L.push('value=' + p.value + ' # 编辑:键值照传,传了就改(现状)'); else L.push('(复合新增:不传顶层 value——新建行键值=名称)'); L.push('code=' + (p.code || '') + (p.code || id ? '' : ' # 空 → 后端自动生成 dict_*')); L.push('sortNo=' + p.sortNo); L.push('status=' + p.status); L.push('isDefault=' + p.isDefault); L.push('description=' + p.description); p.children.forEach((k, i) => { L.push('children[' + i + ']' + (k.id ? '.id=' + k.id + ' # 带 id → 更新(键值维持现值)' : ' # 无 id → 新建(后端落 键值=名称)')); L.push('children[' + i + '].name=' + k.name); L.push('children[' + i + '].code=' + (k.code || '') + (k.code || k.id ? '' : ' # 空 → 后端自动生成')); L.push('children[' + i + '].sortNo=' + k.sortNo); L.push('children[' + i + '].isDefault=' + k.isDefault); }); const cur = id ? STORE.items.find(x => x.id === id) : null; if (cur) { const missing = (cur.children || []).filter(c => !p.children.some(k => k.id === c.id)); L.push(missing.length ? '(本载荷缺失的既有子项 → 删除:' + missing.map(c => c.id + ' ' + c.name).join('、') + ';被引用/内置则 63009/63003 拦)' : '(既有子项全部在载荷中,无删除)'); } const box = $('payloadBox'); box.style.display = 'block'; box.textContent = L.join('\n'); $('payloadHint').textContent = '↑ 上一条保存的请求载荷(仅预览,mock 未真正发请求)。Spring 表单绑定集合语法:children[0].name=…'; } function saveItem(id) { const it = id ? STORE.items.find(x => x.id === id) : null; const err = m => { $('fiOut').innerHTML = '
失败: ' + esc(m) + '
'; }; const name = ($('fiName').value || '').trim(); if (!name) return err('字典名称不能为空'); const kidsRaw = readKidRows(); // 全量替换守卫:缺失(含删行置灰)的既有子项先过 63009/63003 if (it) { const kept = kidsRaw.filter(k => !k._del).map(k => k._tmp); const missing = (it.children || []).filter(c => !kept.includes(c.id)); for (const c of missing) { if (c.referenced) return err('63009 该字典已被引用,无法删除(子项 ' + c.name + ')——全量替换守卫生效'); if (c.builtin) return err('63003 内置字典不可删除(子项 ' + c.name + ')'); } } const kids = kidsRaw.filter(k => !k._del).map((k, i) => ({ id: k._new ? undefined : k._tmp, name: k.name, code: k.code, sortNo: k.sortNo === '' ? i + 1 : Number(k.sortNo), isDefault: k._def, })); for (let i = 0; i < kids.length; i++) if (!kids[i].name) return err('二级字典项名称不能为空(第 ' + (i + 1) + ' 行)'); const selfDefault = $('fiDefault').checked; const defCount = (selfDefault ? 1 : 0) + kids.filter(k => k.isDefault).length; if (defCount > 1) return err('同一载荷只允许一行「是否默认值」=是(一组仅一个默认)——当前 ' + defCount + ' 行为 true'); if (it && $('fiStatus').value === '0' && STORE.defaultItemId[it.groupId] === it.id) { return err('63013 该字典为分组默认项,请先取消默认或指定其他默认项'); } const singleMode = kids.length === 0; // children 空 → 单项保存现状(键值必填) const value = ($('fiValue').value || '').trim(); if (it && !value) return err('63001 字典键值不能为空(编辑维持现状口径)'); if (!it && singleMode && !value) return err('63001 字典键值不能为空(单项新增现状口径)'); const groupId = $('fiGroup').value; const code = ($('fiCode').value || '').trim(); const p = { groupId, name, code, value, sortNo: ($('fiSort').value || '').trim(), status: $('fiStatus').value, isDefault: selfDefault, description: ($('fiDesc').value || '').trim(), children: kids, }; payloadPreview(id, p, singleMode); // ---- mock 落库(children 全量替换) ---- const lvl1Id = it ? it.id : nid(); const newKids = kids.map(k => { const old = k.id && it ? (it.children || []).find(c => c.id === k.id) : null; return { id: old ? old.id : nid(), name: k.name, code: old ? old.code : (k.code || genCode()), value: old ? old.value : k.name, // 新建行键值=名称;更新行键值不变 sortNo: k.sortNo, status: 1, referenced: old ? old.referenced : false, builtin: old ? old.builtin : false, }; }); if (it) { Object.assign(it, { groupId, name, sortNo: p.sortNo === '' ? it.sortNo : Number(p.sortNo), status: Number(p.status), description: p.description, value: value, // 编辑:传了就改(现状口径) children: newKids, }); } else { STORE.items.push({ id: lvl1Id, groupId, name, code: code || genCode(), value: singleMode ? value : name, // 单项现状传值;复合新建=名称 sortNo: p.sortNo === '' ? undefined : Number(p.sortNo), status: Number(p.status), description: p.description, builtin: false, referenced: false, children: newKids, }); } const defWinner = selfDefault ? lvl1Id : (kids.find(k => k.isDefault) || {}).id; if (defWinner) STORE.defaultItemId[groupId] = defWinner; toast('保存成功(mock)——children 已按全量替换落库', 'ok'); dlgClose(); expanded.add(lvl1Id); renderItems(); } /* ================= 行内操作(守卫口径沿用) ================= */ function itemStatus(id) { const it = STORE.items.find(x => x.id === id); if (it.status === 1 && STORE.defaultItemId[it.groupId] === it.id) return toast('63013 该字典为分组默认项,请先取消默认或指定其他默认项', 'err'); it.status = it.status === 1 ? 0 : 1; toast('状态已更新(mock)', 'ok'); renderItems(); } function itemSetDefault(id) { const it = STORE.items.find(x => x.id === id); STORE.defaultItemId[it.groupId] = it.id; toast('已设为分组默认项(同组原默认自动取消,mock)', 'ok'); renderItems(); } function itemDelete(id) { const it = STORE.items.find(x => x.id === id); if (it.referenced) return toast('63009 该字典已被引用,无法删除(force-delete 为计数飘高兜底入口,不在本原型展开)', 'err'); if ((it.children || []).length) return toast('有存活子项,先在「编辑」弹窗删行保存后再删除本项(口径沿用)', 'err'); if (!confirm('确认删除字典项 ' + it.name + '?')) return; STORE.items = STORE.items.filter(x => x.id !== id); toast('已删除(mock)', 'ok'); renderItems(); } /* ================= 初始化 ================= */ (function init() { $('iGroup').innerHTML = '' + STORE.groups.map(g => '').join(''); expanded.add('i1'); renderItems(); })();