/* 数据字典 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();
})();