1008 lines
68 KiB
HTML
1008 lines
68 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>FMS 表单设计器原型 · 选字段 / 主表Form + 子表Table / 分组</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
|
||
<style>
|
||
/* ===================== 设计 token(对齐 FMS --fms-*) ===================== */
|
||
:root {
|
||
--fms-primary: #2f6bff;
|
||
--fms-primary-soft: color-mix(in srgb, var(--fms-primary) 10%, transparent);
|
||
--fms-primary-text: #ffffff;
|
||
--fms-bg: #f4f5f7;
|
||
--fms-card: #ffffff;
|
||
--fms-border: #e6e8ec;
|
||
--fms-border-strong: #d3d7de;
|
||
--fms-text: #1f2329;
|
||
--fms-text-secondary: #646a73;
|
||
--fms-text-placeholder: #9ca3af;
|
||
--fms-text-disabled: #9ca3af;
|
||
--fms-secondary: #eef0f3;
|
||
--fms-secondary-hover: #f2f3f5;
|
||
--fms-control-height: 32px;
|
||
--fms-control-radius: 8px;
|
||
--fms-shadow: 0 1px 3px rgba(16,24,40,.08), 0 1px 2px rgba(16,24,40,.04);
|
||
--fms-shadow-lg: 0 8px 24px rgba(16,24,40,.12);
|
||
--fms-font-size: 13px;
|
||
--fms-line-height: 1.6;
|
||
--fms-success: #18a058;
|
||
--fms-warn: #e6930a;
|
||
--fms-danger: #e5484d;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body { height: 100%; }
|
||
body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
|
||
font-size:var(--fms-font-size); line-height:var(--fms-line-height); color:var(--fms-text); background:var(--fms-bg); -webkit-font-smoothing:antialiased; }
|
||
button { font-family: inherit; }
|
||
svg { display:block; }
|
||
|
||
/* 顶部条 */
|
||
.topbar { display:flex; align-items:center; gap:12px; height:54px; padding:0 16px; background:var(--fms-card); border-bottom:1px solid var(--fms-border); box-shadow:var(--fms-shadow); position:sticky; top:0; z-index:30; flex-wrap:wrap; }
|
||
.topbar__brand { display:flex; align-items:center; gap:8px; font-weight:700; font-size:14px; white-space:nowrap; }
|
||
.topbar__brand .dot { width:10px; height:10px; border-radius:3px; background:var(--fms-primary); }
|
||
.topbar__module { display:flex; align-items:center; gap:8px; padding:4px 10px; background:var(--fms-secondary); border-radius:var(--fms-control-radius); font-weight:600; }
|
||
.topbar__src { display:flex; align-items:center; gap:6px; color:var(--fms-text-secondary); font-size:12px; }
|
||
.topbar__src code { background:var(--fms-secondary-hover); padding:1px 6px; border-radius:5px; color:var(--fms-text); font-size:12px; font-family:ui-monospace,monospace; }
|
||
.topbar__spacer { flex:1; }
|
||
.topbar__sep { width:1px; height:24px; background:var(--fms-border); }
|
||
|
||
/* 分段控件 */
|
||
.segmented { display:inline-flex; padding:3px; background:var(--fms-secondary); border-radius:10px; gap:2px; }
|
||
.segmented button { border:none; background:transparent; cursor:pointer; padding:5px 12px; border-radius:7px; font-size:13px; font-weight:600; color:var(--fms-text-secondary); transition:all .15s; display:inline-flex; align-items:center; gap:6px; }
|
||
.segmented button.active { background:var(--fms-card); color:var(--fms-primary); box-shadow:var(--fms-shadow); }
|
||
|
||
/* 通用按钮 */
|
||
.btn { height:var(--fms-control-height); padding:0 12px; border:1px solid var(--fms-border); border-radius:var(--fms-control-radius); background:var(--fms-card); color:var(--fms-text); cursor:pointer; font-size:13px; display:inline-flex; align-items:center; gap:6px; transition:all .15s; }
|
||
.btn:hover { background:var(--fms-secondary); border-color:var(--fms-border-strong); }
|
||
.btn--primary { background:var(--fms-primary); border-color:var(--fms-primary); color:var(--fms-primary-text); }
|
||
.btn--primary:hover { background:color-mix(in srgb,var(--fms-primary) 88%,#000); }
|
||
.btn--ghost { border-color:transparent; background:transparent; color:var(--fms-text-secondary); }
|
||
.btn--ghost:hover { background:var(--fms-secondary); color:var(--fms-text); }
|
||
.icon-btn { display:inline-flex; align-items:center; justify-content:center; width:30px; height:30px; border:1px solid transparent; border-radius:7px; background:transparent; color:var(--fms-text-secondary); cursor:pointer; transition:all .15s; }
|
||
.icon-btn:hover { background:var(--fms-secondary); color:var(--fms-text); border-color:var(--fms-border); }
|
||
.icon-btn.is-danger:hover { background:#fdecec; color:var(--fms-danger); }
|
||
.icon-btn:disabled { opacity:.4; cursor:not-allowed; }
|
||
|
||
/* 三栏 */
|
||
.layout { display:grid; grid-template-columns:260px 1fr 300px; height:calc(100vh - 54px); min-height:0; }
|
||
.col { min-height:0; display:flex; flex-direction:column; background:var(--fms-card); }
|
||
.col--left { border-right:1px solid var(--fms-border); }
|
||
.col--right { border-left:1px solid var(--fms-border); }
|
||
.col--center { background:var(--fms-bg); }
|
||
.col__head { flex:none; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:10px 12px; border-bottom:1px solid var(--fms-border); }
|
||
.col__title { font-weight:700; font-size:13px; display:flex; align-items:center; gap:6px; }
|
||
.col__title .count { font-size:11px; font-weight:600; color:var(--fms-text-secondary); background:var(--fms-secondary); border-radius:999px; padding:0 7px; line-height:18px; }
|
||
.col__body { flex:1; min-height:0; overflow:auto; padding:10px; }
|
||
|
||
/* 搜索 + 过滤 tab */
|
||
.search { flex:none; display:flex; align-items:center; gap:6px; height:var(--fms-control-height); padding:0 10px; margin:10px 10px 0; border:1px solid var(--fms-border); border-radius:var(--fms-control-radius); background:var(--fms-card); color:var(--fms-text-secondary); }
|
||
.search input { flex:1; border:none; outline:none; background:transparent; font-size:13px; color:var(--fms-text); }
|
||
.filter-tabs { display:flex; gap:4px; padding:8px 10px 0; }
|
||
.filter-tabs button { flex:1; border:1px solid var(--fms-border); background:var(--fms-card); border-radius:7px; padding:4px 0; font-size:11px; color:var(--fms-text-secondary); cursor:pointer; }
|
||
.filter-tabs button.active { background:var(--fms-primary-soft); color:var(--fms-primary); border-color:var(--fms-primary); font-weight:600; }
|
||
|
||
/* 字段库卡片 */
|
||
.field-card { display:flex; align-items:center; gap:8px; padding:8px 10px; margin-bottom:6px; border:1px solid var(--fms-border); border-radius:var(--fms-control-radius); background:var(--fms-card); cursor:grab; transition:all .15s; }
|
||
.field-card:hover { border-color:var(--fms-primary); box-shadow:var(--fms-shadow); }
|
||
.field-card.is-added { opacity:.5; cursor:pointer; background:var(--fms-secondary-hover); }
|
||
.field-card.is-added:hover { border-color:var(--fms-border); }
|
||
.field-card.dragging { opacity:.4; }
|
||
.field-card__main { flex:1; min-width:0; }
|
||
.field-card__name { font-weight:600; font-size:13px; display:flex; align-items:center; gap:5px; }
|
||
.field-card__code { font-size:11px; color:var(--fms-text-placeholder); font-family:ui-monospace,monospace; }
|
||
.field-card__key { color:var(--fms-warn); font-size:11px; }
|
||
.field-card__src { font-size:9px; padding:0 4px; border-radius:4px; background:var(--fms-secondary); color:var(--fms-text-secondary); }
|
||
.field-card__src.view { background:var(--fms-primary-soft); color:var(--fms-primary); }
|
||
.field-card__stale { font-size:9px; padding:0 4px; border-radius:4px; background:#fdecec; color:var(--fms-danger); }
|
||
.type-badge { display:inline-flex; align-items:center; gap:4px; font-size:10px; font-weight:600; line-height:16px; padding:0 6px; border-radius:5px; background:var(--fms-secondary); color:var(--fms-text-secondary); border:1px solid var(--fms-border); white-space:nowrap; }
|
||
|
||
/* 工具条 */
|
||
.canvas-toolbar { flex:none; display:flex; align-items:center; gap:12px; flex-wrap:wrap; padding:8px 12px; background:var(--fms-card); border-bottom:1px solid var(--fms-border); }
|
||
.tool-group { display:inline-flex; align-items:center; gap:6px; }
|
||
.tool-group > label { color:var(--fms-text-secondary); font-size:12px; }
|
||
.chip-group { display:inline-flex; border:1px solid var(--fms-border); border-radius:var(--fms-control-radius); overflow:hidden; }
|
||
.chip-group button { border:none; background:var(--fms-card); cursor:pointer; padding:4px 10px; font-size:12px; color:var(--fms-text-secondary); border-right:1px solid var(--fms-border); }
|
||
.chip-group button:last-child { border-right:none; }
|
||
.chip-group button.active { background:var(--fms-primary-soft); color:var(--fms-primary); font-weight:600; }
|
||
|
||
.canvas { flex:1; min-height:0; overflow:auto; padding:16px; }
|
||
.canvas-hint { color:var(--fms-text-secondary); font-size:12px; margin-bottom:10px; display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
|
||
.legend { display:flex; gap:12px; flex-wrap:wrap; font-size:11px; color:var(--fms-text-secondary); }
|
||
.legend span { display:inline-flex; align-items:center; gap:4px; }
|
||
.legend i { width:10px; height:10px; border-radius:3px; display:inline-block; }
|
||
|
||
/* 分组卡片 */
|
||
.group { background:var(--fms-card); border:1px solid var(--fms-border); border-radius:10px; margin-bottom:14px; box-shadow:var(--fms-shadow); overflow:hidden; }
|
||
.group.selected { border-color:var(--fms-primary); box-shadow:0 0 0 2px var(--fms-primary-soft); }
|
||
.group__head { display:flex; align-items:center; gap:8px; padding:9px 12px; border-bottom:1px solid var(--fms-border); cursor:pointer; user-select:none; }
|
||
.group__head:hover { background:var(--fms-secondary-hover); }
|
||
.group__caret { color:var(--fms-text-secondary); transition:transform .15s; }
|
||
.group.collapsed .group__caret { transform:rotate(-90deg); }
|
||
.group__title { font-weight:700; flex:1; }
|
||
.group__title input { width:100%; border:1px solid var(--fms-primary); border-radius:6px; padding:2px 8px; font-size:13px; font-weight:700; outline:none; }
|
||
.group__meta { font-size:11px; color:var(--fms-text-placeholder); }
|
||
.group__actions { display:inline-flex; gap:2px; }
|
||
.group__body { padding:12px; }
|
||
.group.collapsed .group__body { display:none; }
|
||
.group.drop-active { outline:2px dashed var(--fms-primary); outline-offset:-3px; }
|
||
|
||
/* 设计视图:字段/列 列表 */
|
||
.fld-list, .col-list { display:flex; flex-wrap:wrap; gap:8px; min-height:42px; align-content:flex-start; }
|
||
.col-list { flex-direction:row; }
|
||
.fld-card, .col-card { display:flex; align-items:center; gap:8px; padding:7px 10px; border:1px solid var(--fms-border); border-radius:8px; background:var(--fms-card); cursor:pointer; transition:all .15s; }
|
||
.fld-card { width:100%; }
|
||
.col-card { width:auto; min-width:130px; }
|
||
.fld-card:hover, .col-card:hover { border-color:var(--fms-border-strong); background:var(--fms-secondary-hover); }
|
||
.fld-card.selected, .col-card.selected { border-color:var(--fms-primary); background:var(--fms-primary-soft); }
|
||
.fld-grip, .col-grip { color:var(--fms-text-placeholder); cursor:grab; font-size:13px; }
|
||
.fld-main { flex:1; min-width:0; }
|
||
.fld-label { font-weight:600; font-size:12px; display:flex; align-items:center; gap:5px; }
|
||
.fld-label .req { color:var(--fms-danger); }
|
||
.fld-label .ro { font-size:10px; color:var(--fms-text-placeholder); }
|
||
.fld-sub { font-size:10px; color:var(--fms-text-placeholder); font-family:ui-monospace,monospace; display:flex; align-items:center; gap:5px; margin-top:2px; }
|
||
.auto-tag { font-size:9px; color:var(--fms-primary); background:var(--fms-primary-soft); border-radius:4px; padding:0 5px; white-space:nowrap; }
|
||
.stale-tag { font-size:9px; color:var(--fms-danger); background:#fdecec; border-radius:4px; padding:0 5px; }
|
||
.drag-ghost { opacity:.35; }
|
||
.empty-hint { color:var(--fms-text-placeholder); font-size:12px; padding:8px 2px; width:100%; }
|
||
|
||
/* 预览视图:表单 */
|
||
.form-grid { display:grid; gap:14px 16px; }
|
||
.form-grid.cols-1 { grid-template-columns:1fr; }
|
||
.form-grid.cols-2 { grid-template-columns:repeat(2,1fr); }
|
||
.form-grid.cols-3 { grid-template-columns:repeat(3,1fr); }
|
||
.form-grid.cols-4 { grid-template-columns:repeat(4,1fr); }
|
||
.group.dir-horizontal > .group__body > .form-grid { display:flex; flex-wrap:wrap; gap:14px 16px; }
|
||
.group.dir-horizontal > .group__body > .form-grid > .field-block { flex:1 1 220px; min-width:220px; }
|
||
.field-block { border:1px solid transparent; border-radius:8px; padding:2px; }
|
||
.field-block__label { display:flex; align-items:center; gap:6px; font-size:12px; font-weight:600; margin-bottom:5px; }
|
||
.field-block__label .req { color:var(--fms-danger); }
|
||
.field-block__label .ro { font-size:10px; color:var(--fms-text-placeholder); margin-left:auto; }
|
||
.field-block__help { font-size:11px; color:var(--fms-text-placeholder); margin-top:4px; }
|
||
.field-block__auto { font-size:10px; color:var(--fms-primary); background:var(--fms-primary-soft); border-radius:4px; padding:0 5px; }
|
||
|
||
/* mock 控件(字段类型→控件,自动决定,禁手选) */
|
||
.mock { width:100%; height:30px; border:1px solid var(--fms-border); border-radius:6px; background:var(--fms-card); padding:0 9px; color:var(--fms-text-placeholder); font-size:12px; display:flex; align-items:center; }
|
||
.mock--area { height:54px; align-items:flex-start; padding-top:6px; }
|
||
.mock--ref { gap:6px; justify-content:space-between; }
|
||
.mock--ref button { border:1px solid var(--fms-border); background:var(--fms-secondary); border-radius:5px; padding:0 8px; height:22px; color:var(--fms-text-secondary); }
|
||
|
||
/* 预览视图:子表 */
|
||
.subtable-wrap { background:var(--fms-card); border:1px solid var(--fms-border); border-radius:10px; overflow:hidden; box-shadow:var(--fms-shadow); }
|
||
table.subtable { border-collapse:collapse; width:100%; }
|
||
table.subtable th, table.subtable td { border:1px solid var(--fms-border); padding:8px 10px; text-align:left; font-size:12px; }
|
||
table.subtable thead tr.band th { background:var(--fms-secondary); font-weight:700; text-align:center; }
|
||
table.subtable thead tr.col th { background:#fafbfc; }
|
||
table.subtable tbody tr td { color:var(--fms-text-placeholder); }
|
||
.col-cell { display:flex; align-items:center; gap:6px; cursor:pointer; border-radius:5px; padding:2px 4px; margin:-2px -4px; }
|
||
.col-cell:hover { background:var(--fms-secondary-hover); }
|
||
.col-cell.selected { background:var(--fms-primary-soft); outline:1px solid var(--fms-primary); }
|
||
.col-cell .type-badge { margin-left:auto; }
|
||
|
||
/* 属性面板 */
|
||
.insp-empty { color:var(--fms-text-secondary); font-size:12px; text-align:center; padding:40px 16px; }
|
||
.insp-section { margin-bottom:16px; }
|
||
.insp-section__title { font-weight:700; font-size:12px; color:var(--fms-text-secondary); margin-bottom:8px; text-transform:uppercase; letter-spacing:.04em; }
|
||
.form-row { display:flex; align-items:center; gap:8px; margin-bottom:10px; }
|
||
.form-row > label { flex:none; width:60px; color:var(--fms-text-secondary); font-size:12px; }
|
||
.form-row .control { flex:1; min-width:0; }
|
||
.form-row.col2 { align-items:flex-start; }
|
||
.form-row.col2 > label { padding-top:6px; }
|
||
.form-row textarea.inp { height:auto; padding:6px 9px; resize:vertical; min-height:54px; line-height:1.5; }
|
||
.inp { width:100%; height:var(--fms-control-height); border:1px solid var(--fms-border); border-radius:var(--fms-control-radius); padding:0 10px; font-size:13px; background:var(--fms-card); color:var(--fms-text); outline:none; }
|
||
.inp:focus, .sel:focus { border-color:var(--fms-primary); box-shadow:0 0 0 2px var(--fms-primary-soft); }
|
||
.sel { width:100%; height:var(--fms-control-height); border:1px solid var(--fms-border); border-radius:var(--fms-control-radius); padding:0 8px; font-size:13px; background:var(--fms-card); color:var(--fms-text); outline:none; }
|
||
.switch { position:relative; width:38px; height:22px; flex:none; }
|
||
.switch input { display:none; }
|
||
.switch span { position:absolute; inset:0; background:var(--fms-border-strong); border-radius:999px; transition:.15s; cursor:pointer; }
|
||
.switch span::before { content:""; position:absolute; width:18px; height:18px; left:2px; top:2px; background:#fff; border-radius:50%; transition:.15s; box-shadow:var(--fms-shadow); }
|
||
.switch input:checked + span { background:var(--fms-primary); }
|
||
.switch input:checked + span::before { transform:translateX(16px); }
|
||
.field-meta { font-size:11px; color:var(--fms-text-placeholder); background:var(--fms-secondary-hover); border-radius:6px; padding:8px 10px; line-height:1.9; }
|
||
.field-meta code { color:var(--fms-text); font-family:ui-monospace,monospace; }
|
||
.field-meta.warn { background:#fdecec; color:var(--fms-danger); }
|
||
|
||
/* 模态框 */
|
||
.mask { position:fixed; inset:0; background:rgba(16,24,40,.45); display:none; align-items:center; justify-content:center; z-index:50; }
|
||
.mask.open { display:flex; }
|
||
.modal { width:560px; max-width:92vw; max-height:86vh; background:var(--fms-card); border-radius:12px; box-shadow:var(--fms-shadow-lg); display:flex; flex-direction:column; overflow:hidden; }
|
||
.modal.wide { width:680px; }
|
||
.modal__head { display:flex; align-items:center; justify-content:space-between; padding:14px 16px; border-bottom:1px solid var(--fms-border); font-weight:700; }
|
||
.modal__body { padding:14px 16px; overflow:auto; }
|
||
.modal__foot { display:flex; justify-content:flex-end; gap:8px; padding:12px 16px; border-top:1px solid var(--fms-border); }
|
||
|
||
.grp-add { display:flex; gap:6px; margin-bottom:10px; }
|
||
.grp-add input { flex:1; }
|
||
.grp-tree { border:1px solid var(--fms-border); border-radius:var(--fms-control-radius); padding:6px; max-height:320px; overflow:auto; }
|
||
.grp-node { display:flex; align-items:center; gap:6px; padding:4px 6px; border-radius:6px; }
|
||
.grp-node:hover { background:var(--fms-secondary-hover); }
|
||
.grp-node__name { flex:1; }
|
||
.grp-node__name input { width:100%; border:1px solid var(--fms-primary); border-radius:5px; padding:1px 6px; font-size:12px; outline:none; }
|
||
.grp-node__actions { display:inline-flex; gap:2px; }
|
||
.grp-node.is-disabled .grp-node__name, .grp-node.is-disabled .grp-node__name span { color:var(--fms-text-disabled); }
|
||
.grp-divider { width:1px; height:14px; background:var(--fms-border); margin:0 2px; }
|
||
|
||
/* 同步差异 */
|
||
.diff-row { display:flex; align-items:center; gap:10px; padding:7px 10px; border:1px solid var(--fms-border); border-radius:8px; margin-bottom:6px; }
|
||
.diff-row .tag { font-size:10px; font-weight:700; padding:1px 7px; border-radius:5px; }
|
||
.diff-row .tag.add { background:#e7f6ee; color:var(--fms-success); }
|
||
.diff-row .tag.del { background:#fdecec; color:var(--fms-danger); }
|
||
.diff-row .tag.chg { background:#fef3e0; color:var(--fms-warn); }
|
||
.diff-row .name { font-weight:600; }
|
||
.diff-row .desc { font-size:11px; color:var(--fms-text-secondary); margin-left:auto; }
|
||
|
||
/* JSON 预览 */
|
||
pre.json { background:#0f172a; color:#cbd5e1; padding:14px; border-radius:8px; font-size:12px; line-height:1.6; overflow:auto; max-height:60vh; font-family:ui-monospace,monospace; }
|
||
|
||
/* toast */
|
||
.toast { position:fixed; bottom:24px; left:50%; transform:translateX(-50%) translateY(20px); background:#1f2329; color:#fff; padding:9px 16px; border-radius:8px; font-size:13px; box-shadow:var(--fms-shadow-lg); opacity:0; transition:.25s; z-index:80; pointer-events:none; }
|
||
.toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- ============ 顶部条 ============ -->
|
||
<header class="topbar">
|
||
<div class="topbar__brand"><span class="dot"></span>表单设计器</div>
|
||
<div class="topbar__module">订单管理</div>
|
||
<div class="topbar__src">
|
||
数据源 <code id="srcTable">b_order</code>
|
||
<button class="btn btn--ghost" id="syncBtn" title="从数据源同步字段">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 4v5h-5"/></svg>同步字段
|
||
</button>
|
||
</div>
|
||
|
||
<div class="topbar__spacer"></div>
|
||
|
||
<div class="segmented" id="modeSwitch">
|
||
<button data-mode="form" class="active">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 8h10M7 12h10M7 16h6"/></svg>表单·主表
|
||
</button>
|
||
<button data-mode="table">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M3 14h18M9 4v16"/></svg>表格·子表
|
||
</button>
|
||
</div>
|
||
|
||
<div class="topbar__sep"></div>
|
||
|
||
<div class="segmented" id="viewSwitch">
|
||
<button data-view="design" class="active">设计</button>
|
||
<button data-view="preview">预览</button>
|
||
</div>
|
||
|
||
<div class="topbar__sep"></div>
|
||
|
||
<button class="icon-btn" id="undoBtn" title="撤销" disabled>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15"><path d="M9 14 4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 0 10h-3"/></svg>
|
||
</button>
|
||
<button class="icon-btn" id="redoBtn" title="重做" disabled>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15"><path d="m15 14 5-5-5-5"/><path d="M20 9H9a5 5 0 0 0 0 10h3"/></svg>
|
||
</button>
|
||
<button class="icon-btn" id="groupBtn" title="分组管理">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||
</button>
|
||
<button class="icon-btn" id="jsonBtn" title="导出 JSON">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15"><path d="M8 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3"/><path d="M16 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3"/><path d="M8 9l-3 3 3 3M16 9l3 3-3 3"/></svg>
|
||
</button>
|
||
<button class="icon-btn" id="resetBtn" title="重置为示例">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15"><path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-6.7 3"/><path d="M3 4v5h5"/></svg>
|
||
</button>
|
||
<button class="btn btn--primary" id="saveBtn">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8M7 3v5h8"/></svg>保存
|
||
</button>
|
||
</header>
|
||
|
||
<!-- ============ 三栏 ============ -->
|
||
<div class="layout">
|
||
<!-- 左:字段库 -->
|
||
<aside class="col col--left">
|
||
<div class="search">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
|
||
<input id="libSearch" placeholder="搜索字段名 / 编码" />
|
||
</div>
|
||
<div class="filter-tabs" id="filterTabs">
|
||
<button data-f="all" class="active">全部</button>
|
||
<button data-f="unsel">未选</button>
|
||
<button data-f="sel">已选</button>
|
||
</div>
|
||
<div class="col__head">
|
||
<div class="col__title">字段库 <span class="count" id="libCount">0</span></div>
|
||
<button class="btn btn--ghost" id="addAllBtn" title="全部加入首个分组">全选</button>
|
||
</div>
|
||
<div class="col__body" id="libBody"></div>
|
||
</aside>
|
||
|
||
<!-- 中:画布 -->
|
||
<main class="col col--center">
|
||
<div class="canvas-toolbar" id="canvasToolbar">
|
||
<div class="tool-group" data-only="design">
|
||
<label>视图</label>
|
||
<span style="color:var(--fms-text-secondary);font-size:12px" id="designHint">拖拽字段排序 / 跨分组移动</span>
|
||
</div>
|
||
<div class="tool-group" data-only="form">
|
||
<label>列数</label>
|
||
<div class="chip-group" id="colChips">
|
||
<button data-cols="1">1</button><button data-cols="2" class="active">2</button><button data-cols="3">3</button><button data-cols="4">4</button>
|
||
</div>
|
||
</div>
|
||
<div class="tool-group" data-only="form">
|
||
<label>分组方向</label>
|
||
<div class="chip-group" id="dirChips">
|
||
<button data-dir="vertical" class="active">上下</button><button data-dir="horizontal">左右</button>
|
||
</div>
|
||
</div>
|
||
<div class="tool-group" data-only="design">
|
||
<button class="btn btn--ghost" id="addGroupBtn">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 5v14M5 12h14"/></svg>新增分组
|
||
</button>
|
||
</div>
|
||
<div class="topbar__spacer" style="flex:1"></div>
|
||
<div class="legend">
|
||
<span><i style="background:var(--fms-primary-soft)"></i>选的是字段</span>
|
||
<span><i style="background:var(--fms-secondary)"></i>控件由字段类型自动决定</span>
|
||
<span><i style="background:#fdecec"></i>源已移除</span>
|
||
</div>
|
||
</div>
|
||
<div class="canvas" id="canvas"></div>
|
||
</main>
|
||
|
||
<!-- 右:属性 -->
|
||
<aside class="col col--right">
|
||
<div class="col__head"><div class="col__title">属性 <span class="count" id="inspCtx">未选中</span></div></div>
|
||
<div class="col__body" id="inspBody">
|
||
<div class="insp-empty">选择中间画布的一个<strong>字段</strong>或<strong>分组</strong>即可编辑。<br/><br/>左侧字段库即数据源表的列——<br/>你选<strong>字段</strong>,控件由字段类型自动生成。</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
|
||
<!-- 分组管理 -->
|
||
<div class="mask" id="grpMask"><div class="modal">
|
||
<div class="modal__head"><span>分组管理</span><button class="icon-btn" id="grpClose"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
|
||
<div class="modal__body">
|
||
<div class="grp-add"><input class="inp" id="grpNewTitle" placeholder="输入分组名称,回车新增" /><button class="btn btn--primary" id="grpNewAdd">新增</button></div>
|
||
<div class="grp-tree" id="grpTree"></div>
|
||
</div>
|
||
<div class="modal__foot"><button class="btn" id="grpCancel">关闭</button></div>
|
||
</div></div>
|
||
|
||
<!-- 同步差异 -->
|
||
<div class="mask" id="syncMask"><div class="modal">
|
||
<div class="modal__head"><span>字段同步 · 数据源差异</span><button class="icon-btn" id="syncClose"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
|
||
<div class="modal__body" id="syncBody"></div>
|
||
<div class="modal__foot"><button class="btn" id="syncCancel">取消</button><button class="btn btn--primary" id="syncApply">应用同步</button></div>
|
||
</div></div>
|
||
|
||
<!-- JSON 导出 -->
|
||
<div class="mask" id="jsonMask"><div class="modal wide">
|
||
<div class="modal__head"><span>配置 JSON(当前模式)</span><button class="icon-btn" id="jsonClose"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
|
||
<div class="modal__body"><pre class="json" id="jsonOut"></pre></div>
|
||
<div class="modal__foot"><button class="btn" id="jsonCopy">复制</button><button class="btn btn--primary" id="jsonDone">关闭</button></div>
|
||
</div></div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
/* ===================== 数据模型 =====================
|
||
* 字段来自数据源表/视图(b_viewtable / b_savetable)的列;控件由字段类型(ui)自动决定。
|
||
* 主表(form) 与 子表(table) 各自维护分组与字段布局。 */
|
||
const TYPE_BADGE = { input:'文本', textarea:'多行', number:'数值', date:'日期', datetime:'时间', select:'枚举', ref:'关联' };
|
||
|
||
function F(code, name, ui, opt = {}) {
|
||
return { code, name, ui, src: opt.src || 'view', key: opt.key ? 1 : 0, required: opt.required ? 1 : 0,
|
||
nullable: opt.nullable ?? 1, length: opt.length ?? null, def: opt.def ?? null, stale: 0,
|
||
options: opt.options || null }; // options: 枚举可选项数组
|
||
}
|
||
|
||
/* 模拟「数据库当前结构」(用于同步差异演示) */
|
||
const SERVER = {
|
||
form: [
|
||
F('order_no','订单编号','input',{key:true,required:true}),
|
||
F('order_date','订单日期','date'),
|
||
F('customer_id','客户ID','ref'),
|
||
F('customer_name','客户名称','input',{required:true}),
|
||
F('amount','订单金额','number',{required:true,length:18}),
|
||
F('qty','数量','number'),
|
||
F('status','状态','select',{required:true,options:['待付款','已付款','已发货','已完成']}),
|
||
F('remark','备注','textarea'),
|
||
F('created_at','创建时间','datetime'),
|
||
F('discount','折扣','number',{src:'save'}),
|
||
F('logistics_no','物流单号','input',{src:'save'}),
|
||
],
|
||
table: [
|
||
F('item_no','行号','input',{key:true}),
|
||
F('product_code','产品编码','input',{required:true}),
|
||
F('product_name','产品名称','input',{required:true}),
|
||
F('price','单价','number',{required:true}),
|
||
F('qty','数量','number',{required:true}),
|
||
F('subtotal','小计','number'),
|
||
F('remark','备注','textarea'),
|
||
F('batch_no','批次号','input',{src:'save'}),
|
||
],
|
||
};
|
||
|
||
const DEFAULT_DB = () => ({
|
||
form: {
|
||
table:'b_order',
|
||
fields:[
|
||
F('order_no','订单编号','input',{key:true,required:true}),
|
||
F('order_date','订单日期','date'),
|
||
F('customer_id','客户ID','ref'),
|
||
F('customer_name','客户名称','input',{required:true}),
|
||
F('amount','订单金额','number',{required:true}),
|
||
F('qty','数量','number'),
|
||
F('status','状态','select',{required:true,options:['待付款','已付款','已发货','已完成']}),
|
||
F('remark','备注','textarea'),
|
||
F('created_by','创建人','input'),
|
||
F('created_at','创建时间','datetime'),
|
||
],
|
||
groups:[
|
||
{ id:'g1', title:'基础信息', direction:'vertical', canuse:1, fields:['order_no','order_date','customer_name','status'] },
|
||
{ id:'g2', title:'金额与数量', direction:'vertical', canuse:1, fields:['amount','qty'] },
|
||
{ id:'g3', title:'其他信息', direction:'vertical', canuse:1, fields:['remark','created_by','created_at'] },
|
||
],
|
||
ungrouped:['customer_id'],
|
||
props:{},
|
||
},
|
||
table: {
|
||
table:'b_order_item',
|
||
fields:[
|
||
F('item_no','行号','input',{key:true}),
|
||
F('product_code','产品编码','input',{required:true}),
|
||
F('product_name','产品名称','input',{required:true}),
|
||
F('spec','规格','input'),
|
||
F('price','单价','number',{required:true}),
|
||
F('qty','数量','number',{required:true}),
|
||
F('subtotal','小计','number'),
|
||
F('remark','备注','textarea'),
|
||
],
|
||
groups:[
|
||
{ id:'t1', title:'基本信息', direction:'vertical', canuse:1, fields:['item_no','product_code','product_name','spec'] },
|
||
{ id:'t2', title:'价格信息', direction:'vertical', canuse:1, fields:['price','qty','subtotal'] },
|
||
{ id:'t3', title:'备注', direction:'vertical', canuse:1, fields:['remark'] },
|
||
],
|
||
ungrouped:[],
|
||
props:{},
|
||
},
|
||
});
|
||
|
||
let DB = DEFAULT_DB();
|
||
const LS_KEY = 'fms_form_designer_v1';
|
||
|
||
const state = {
|
||
mode:'form', view:'design', columns:2, direction:'vertical',
|
||
selectedField:null, selectedGroup:null, editingGroupTitle:null, libFilter:'all',
|
||
};
|
||
const history = { past:[], future:[] };
|
||
|
||
const cur = () => DB[state.mode];
|
||
const fieldMap = () => Object.fromEntries(cur().fields.map(f => [f.code, f]));
|
||
const addedCodes = () => { const d=cur(); const s=new Set(d.ungrouped); d.groups.forEach(g=>g.fields.forEach(c=>s.add(c))); return s; };
|
||
function fp(code) {
|
||
const f = fieldMap()[code];
|
||
const base = { label:f?.name||code, visible:1, required:f?.required?1:0, readonly:0, placeholder:'',
|
||
defVal:'', help:'', pattern:'', span:state.columns, width:120, align:'left', sortable:0, frozen:0, i18n:{en:''}, options:null };
|
||
return Object.assign(base, cur().props[code] || {});
|
||
}
|
||
function setFp(code, patch) { cur().props[code] = Object.assign({}, fp(code), patch); }
|
||
|
||
/* 历史快照 */
|
||
function snapshot() { history.past.push(JSON.stringify(cur())); if(history.past.length>50) history.past.shift(); history.future.length=0; updateHistBtns(); }
|
||
function updateHistBtns() { document.getElementById('undoBtn').disabled = !history.past.length; document.getElementById('redoBtn').disabled = !history.future.length; }
|
||
function undo() { if(!history.past.length) return; history.future.push(JSON.stringify(cur())); Object.assign(cur(), JSON.parse(history.past.pop())); state.selectedField=null; state.selectedGroup=null; persist(); renderAll(); updateHistBtns(); }
|
||
function redo() { if(!history.future.length) return; history.past.push(JSON.stringify(cur())); Object.assign(cur(), JSON.parse(history.future.pop())); persist(); renderAll(); updateHistBtns(); }
|
||
|
||
/* localStorage */
|
||
function persist() { try { localStorage.setItem(LS_KEY, JSON.stringify(DB)); } catch(e){} }
|
||
function load() { try { const s=localStorage.getItem(LS_KEY); if(s) DB=JSON.parse(s); } catch(e){} }
|
||
|
||
/* ===================== 渲染:字段库 ===================== */
|
||
function renderLibrary() {
|
||
const body = document.getElementById('libBody');
|
||
const kw = document.getElementById('libSearch').value.trim().toLowerCase();
|
||
const added = addedCodes();
|
||
let list = cur().fields.filter(f => !kw || f.name.toLowerCase().includes(kw) || f.code.toLowerCase().includes(kw));
|
||
if (state.libFilter==='unsel') list = list.filter(f=>!added.has(f.code));
|
||
else if (state.libFilter==='sel') list = list.filter(f=>added.has(f.code));
|
||
document.getElementById('libCount').textContent = cur().fields.length;
|
||
|
||
body.innerHTML = list.map(f => {
|
||
const isAdded = added.has(f.code);
|
||
return `<div class="field-card ${isAdded?'is-added':''}" data-code="${f.code}">
|
||
<div class="field-card__main">
|
||
<div class="field-card__name">${f.name} ${f.key?'<span class="field-card__key">★PK</span>':''} ${f.stale?'<span class="field-card__stale">源已移除</span>':''}</div>
|
||
<div class="field-card__code">${f.code}</div>
|
||
</div>
|
||
<span class="field-card__src ${f.src==='view'?'view':''}">${f.src==='view'?'视图':'保存表'}</span>
|
||
<span class="type-badge">${TYPE_BADGE[f.ui]}</span>
|
||
</div>`;
|
||
}).join('') || '<div class="insp-empty">无匹配字段</div>';
|
||
|
||
body.querySelectorAll('.field-card').forEach(el => {
|
||
el.addEventListener('click', () => addFieldToFirstGroup(el.dataset.code));
|
||
});
|
||
}
|
||
|
||
function addFieldToFirstGroup(code) {
|
||
const d = cur();
|
||
if (addedCodes().has(code)) { selectField(code); renderAll(); return; }
|
||
snapshot();
|
||
if (d.groups.length) d.groups[0].fields.push(code); else d.ungrouped.push(code);
|
||
selectField(code); persist(); renderAll(); toast(`已添加字段「${fieldMap()[code]?.name}」`);
|
||
}
|
||
|
||
/* ===================== 渲染:画布 ===================== */
|
||
function renderCanvas() {
|
||
const canvas = document.getElementById('canvas');
|
||
const d = cur();
|
||
document.getElementById('srcTable').textContent = d.table;
|
||
// 工具条可见性
|
||
document.querySelectorAll('#canvasToolbar [data-only]').forEach(el => {
|
||
const only = el.dataset.only;
|
||
el.style.display = (only==='design' && state.view!=='design') || (only==='form' && !(state.mode==='form' && state.view==='design')) ? 'none' : '';
|
||
});
|
||
|
||
if (state.view==='preview') {
|
||
canvas.innerHTML = state.mode==='form'
|
||
? `<div class="canvas-hint">预览(只读):控件由字段类型自动渲染,不可手选。</div>` + renderFormPreview(d)
|
||
: `<div class="canvas-hint">预览(只读):子表列按分组归并到表头分组带。</div>` + renderTable(d);
|
||
bindPreviewEvents();
|
||
} else {
|
||
const cards = d.groups.map(g=>designGroupCard(g)).join('')
|
||
+ (d.ungrouped.length ? designGroupCard({ id:'__ungrouped', title:'未分组', direction:'vertical', canuse:1, fields:d.ungrouped, _ungrouped:true }) : '');
|
||
canvas.innerHTML = `<div class="canvas-hint">设计:点击左侧字段加入首个分组,再拖拽排序 / 跨分组移动。双击分组标题可重命名。</div><div id="groupList">${cards}</div>`;
|
||
bindDesignEvents();
|
||
initDesignSortable();
|
||
}
|
||
}
|
||
|
||
/* 设计视图:分组卡片 + 可排序字段/列列表 */
|
||
function designGroupCard(g) {
|
||
const isCol = state.mode==='table';
|
||
const listCls = isCol ? 'col-list' : 'fld-list';
|
||
const items = g.fields.length
|
||
? g.fields.map(c => isCol ? colCardHtml(c) : fldCardHtml(c)).join('')
|
||
: `<div class="empty-hint">拖入或点击左侧字段添加到此分组</div>`;
|
||
const meta = `${g.fields.length} ${isCol?'列':'字段'}`;
|
||
return `<div class="group ${g._collapsed?'collapsed':''} ${state.selectedGroup===g.id?'selected':''}" data-group="${g.id}">
|
||
<div class="group__head" data-ghead="${g.id}">
|
||
<span class="group__caret">▾</span>
|
||
<span class="group__title">${g._ungrouped?'未分组':(state.editingGroupTitle===g.id?`<input data-gid="${g.id}" value="${g.title}">`:g.title)}</span>
|
||
<span class="group__meta">${meta}</span>
|
||
${g._ungrouped?'':`<span class="group__actions">
|
||
<button class="icon-btn" data-gact="up" title="上移"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="m18 15-6-6-6 6"/></svg></button>
|
||
<button class="icon-btn" data-gact="down" title="下移"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="m6 9 6 6 6-6"/></svg></button>
|
||
<button class="icon-btn" data-gact="enable" title="${g.canuse?'停用':'启用'}">${g.canuse?'●':'○'}</button>
|
||
<button class="icon-btn is-danger" data-gact="del" title="删除分组"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg></button>
|
||
</span>`}
|
||
</div>
|
||
<div class="group__body"><div class="${listCls}" data-group="${g.id}">${items}</div></div>
|
||
</div>`;
|
||
}
|
||
function fldCardHtml(code) {
|
||
const f = fieldMap()[code], p = fp(code);
|
||
return `<div class="fld-card ${state.selectedField===code?'selected':''}" data-code="${code}" data-group="${currentGroupOf(code)}">
|
||
<span class="fld-grip">⠿</span>
|
||
<div class="fld-main">
|
||
<div class="fld-label">${p.required?'<span class="req">*</span>':''}${p.label}${p.readonly?' <span class="ro">只读</span>':''}</div>
|
||
<div class="fld-sub">${f.code} <span class="type-badge">${TYPE_BADGE[f.ui]}</span>${f.stale?'<span class="stale-tag">源已移除</span>':''}</div>
|
||
</div>
|
||
<span class="auto-tag">自动</span>
|
||
<button class="icon-btn is-danger fld-del" title="移除"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||
</div>`;
|
||
}
|
||
function colCardHtml(code) {
|
||
const f = fieldMap()[code], p = fp(code);
|
||
return `<div class="col-card ${state.selectedField===code?'selected':''}" data-code="${code}" data-group="${currentGroupOf(code)}">
|
||
<span class="col-grip">⠿</span>
|
||
<div class="fld-main">
|
||
<div class="fld-label">${p.required?'<span class="req">*</span>':''}${p.label}</div>
|
||
<div class="fld-sub">${f.code} <span class="type-badge">${TYPE_BADGE[f.ui]}</span></div>
|
||
</div>
|
||
<span class="auto-tag">自动</span>
|
||
<button class="icon-btn is-danger fld-del" title="移除"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||
</div>`;
|
||
}
|
||
|
||
/* 预览:表单 */
|
||
function renderFormPreview(d) {
|
||
const gridCls = 'form-grid cols-'+state.columns;
|
||
const groupsHtml = d.groups.map(g=>{
|
||
const items = g.fields.length ? `<div class="${gridCls}">${g.fields.map(c=>previewFieldBlock(c)).join('')}</div>` : '<div class="empty-hint">(无字段)</div>';
|
||
return `<div class="group dir-${g.direction} ${g._collapsed?'collapsed':''}" data-group="${g.id}">
|
||
<div class="group__head"><span class="group__caret">▾</span><span class="group__title">${g.title}</span><span class="group__meta">${g.fields.length} 字段</span></div>
|
||
<div class="group__body">${items}</div></div>`;
|
||
}).join('');
|
||
const un = d.ungrouped.length ? `<div class="group" data-group="__ungrouped"><div class="group__head"><span class="group__caret">▾</span><span class="group__title">未分组</span><span class="group__meta">${d.ungrouped.length} 字段</span></div><div class="group__body"><div class="${gridCls}">${d.ungrouped.map(c=>previewFieldBlock(c)).join('')}</div></div></div>` : '';
|
||
return groupsHtml + un;
|
||
}
|
||
function previewFieldBlock(code) {
|
||
const f = fieldMap()[code], p = fp(code);
|
||
const span = Math.min(p.span||1, state.columns);
|
||
const help = p.help ? `<div class="field-block__help">${p.help}</div>` : '';
|
||
return `<div class="field-block" style="grid-column:span ${span}">
|
||
<div class="field-block__label">${p.required?'<span class="req">*</span>':''}${p.label}${p.readonly?'<span class="ro">只读</span>':''}<span class="field-block__auto">自动</span></div>
|
||
${mockControl(f,p)}${help}</div>`;
|
||
}
|
||
function mockControl(f, p) {
|
||
const ph = p.placeholder || '';
|
||
switch(f.ui){
|
||
case 'textarea': return `<textarea class="mock mock--area" disabled placeholder="${ph||'请输入'+f.name}"></textarea>`;
|
||
case 'number': return `<div class="mock">${p.defVal||'0.00'}</div>`;
|
||
case 'date': return `<div class="mock">${p.defVal||'YYYY-MM-DD'}</div>`;
|
||
case 'datetime': return `<div class="mock">${p.defVal||'YYYY-MM-DD HH:mm'}</div>`;
|
||
case 'select': return `<div class="mock">${ph||'请选择'+f.name} ▾</div>`;
|
||
case 'ref': return `<div class="mock mock--ref"><span>${ph||'请选择'+f.name}</span><button disabled>…</button></div>`;
|
||
default: return `<div class="mock">${p.defVal||ph||'请输入'+f.name}</div>`;
|
||
}
|
||
}
|
||
/* 预览:子表 */
|
||
function renderTable(d) {
|
||
const bands = d.groups.map(g=>`<th colspan="${g.fields.length||1}">${g.title}</th>`).join('');
|
||
const heads = d.groups.map(g=>g.fields.map(c=>colHeadHtml(c)).join('')).join('');
|
||
const sampleRow = '<tr>'+d.groups.map(g=>g.fields.map(c=>`<td>${sampleCell(c)}</td>`).join('')).join('')+'</tr>';
|
||
return `<div class="subtable-wrap"><table class="subtable"><thead><tr class="band">${bands}</tr><tr class="col">${heads}</tr></thead><tbody>${sampleRow}</tbody></table></div>`;
|
||
}
|
||
function colHeadHtml(code) {
|
||
const f = fieldMap()[code], p = fp(code);
|
||
return `<th><div class="col-cell ${state.selectedField===code?'selected':''}" data-code="${code}" data-group="${currentGroupOf(code)}">
|
||
${p.required?'<span class="req" style="color:var(--fms-danger)">*</span>':''}${p.label}<span class="type-badge">${TYPE_BADGE[f.ui]}</span></div></th>`;
|
||
}
|
||
function sampleCell(code) {
|
||
const f = fieldMap()[code];
|
||
if (f.ui==='number') return fp(code).defVal || '0.00';
|
||
if (f.ui==='date') return '2026-08-26';
|
||
if (f.ui==='datetime') return '2026-08-26 10:00';
|
||
if (f.ui==='select') return '—';
|
||
return (fp(code).defVal || f.name)+'示例';
|
||
}
|
||
function currentGroupOf(code) { const g = cur().groups.find(g=>g.fields.includes(code)); return g?g.id:'__ungrouped'; }
|
||
|
||
/* ===================== DnD(SortableJS) ===================== */
|
||
function initDesignSortable() {
|
||
if (!window.Sortable) { toast('SortableJS 未加载,拖拽排序不可用(点击仍可选/加)'); return; }
|
||
const name = state.mode==='form' ? 'fields' : 'cols';
|
||
document.querySelectorAll('.fld-list, .col-list').forEach(list => {
|
||
Sortable.create(list, { group:name, animation:140, ghostClass:'drag-ghost',
|
||
onEnd: () => { syncFromDOM(); renderLibrary(); renderInspector(); },
|
||
});
|
||
});
|
||
}
|
||
function syncFromDOM() {
|
||
const d = cur();
|
||
const map = new Map(d.groups.map(g=>[g.id, []]));
|
||
let un = [];
|
||
document.querySelectorAll('.fld-list, .col-list').forEach(list => {
|
||
const gid = list.dataset.group;
|
||
const codes = [...list.querySelectorAll('[data-code]')].map(el=>el.dataset.code);
|
||
if (gid==='__ungrouped') un = codes; else if (map.has(gid)) map.set(gid, codes);
|
||
});
|
||
d.groups.forEach(g => g.fields = map.get(g.id) || []);
|
||
d.ungrouped = un;
|
||
persist();
|
||
}
|
||
|
||
/* ===================== 设计视图事件 ===================== */
|
||
function bindDesignEvents() {
|
||
document.querySelectorAll('[data-ghead]').forEach(h => {
|
||
h.addEventListener('click', e => {
|
||
if (e.target.closest('[data-gact]') || e.target.closest('input')) return;
|
||
const id = h.dataset.ghead;
|
||
const g = cur().groups.find(x=>x.id===id);
|
||
if (g && state.editingGroupTitle!==id) g._collapsed = !g._collapsed;
|
||
selectGroup(id); renderAll();
|
||
});
|
||
});
|
||
document.querySelectorAll('[data-gact]').forEach(b => b.addEventListener('click', e => {
|
||
e.stopPropagation(); groupAction(b.closest('[data-group]').dataset.group, b.dataset.gact);
|
||
}));
|
||
document.querySelectorAll('.group__title').forEach(t => t.addEventListener('dblclick', () => {
|
||
if (t.querySelector('input')) return;
|
||
const id = t.closest('[data-group]').dataset.group;
|
||
if (id==='__ungrouped') return;
|
||
state.editingGroupTitle = id; renderAll();
|
||
setTimeout(()=>{ const inp=document.querySelector('.group__title input'); if(inp){inp.focus();inp.select();} },0);
|
||
}));
|
||
document.querySelectorAll('.group__title input').forEach(inp => {
|
||
inp.addEventListener('click', e=>e.stopPropagation());
|
||
inp.addEventListener('change', ()=>{ const g=cur().groups.find(x=>x.id===inp.dataset.gid); if(g) g.title=inp.value; renderAll(); });
|
||
inp.addEventListener('blur', ()=>{ state.editingGroupTitle=null; renderAll(); });
|
||
inp.addEventListener('keydown', e=>{ if(e.key==='Enter'){ state.editingGroupTitle=null; renderAll(); } });
|
||
});
|
||
// 字段/列卡片:选中 + 删除
|
||
document.querySelectorAll('.fld-card, .col-card').forEach(el => {
|
||
el.addEventListener('click', e => {
|
||
if (e.target.closest('.fld-del')) return;
|
||
selectField(el.dataset.code); renderAll();
|
||
});
|
||
el.querySelector('.fld-del')?.addEventListener('click', e => { e.stopPropagation(); removeField(el.dataset.code); });
|
||
});
|
||
// 未分组卡片点击
|
||
const un = document.querySelector('[data-group="__ungrouped"]');
|
||
if (un) un.querySelector('.group__head').addEventListener('click', e => { if(e.target.closest('[data-gact]')) return; selectGroup('__ungrouped'); renderAll(); });
|
||
}
|
||
|
||
/* ===================== 预览视图事件 ===================== */
|
||
function bindPreviewEvents() {
|
||
document.querySelectorAll('.field-block').forEach(el => el.addEventListener('click', () => { selectField(el.dataset.code); renderInspector(); }));
|
||
document.querySelectorAll('.col-cell').forEach(el => el.addEventListener('click', () => { selectField(el.dataset.code); renderInspector(); }));
|
||
document.querySelectorAll('.group__head').forEach(h => h.addEventListener('click', () => {
|
||
const g = h.closest('.group'); const id = g?.dataset.group;
|
||
if (id && id!=='__ungrouped') { selectGroup(id); renderInspector(); }
|
||
}));
|
||
}
|
||
|
||
/* ===================== 分组操作 ===================== */
|
||
function groupAction(id, act) {
|
||
const d = cur(); const i = d.groups.findIndex(g=>g.id===id);
|
||
if (act==='del') {
|
||
if (i<0) return; snapshot();
|
||
d.ungrouped.push(...d.groups[i].fields); d.groups.splice(i,1);
|
||
if (state.selectedGroup===id) state.selectedGroup=null;
|
||
persist(); renderAll(); toast('分组已删除,字段移至未分组');
|
||
} else if (act==='enable') { snapshot(); d.groups[i].canuse = d.groups[i].canuse?0:1; persist(); renderAll(); }
|
||
else if (act==='up' && i>0) { snapshot(); [d.groups[i-1],d.groups[i]]=[d.groups[i],d.groups[i-1]]; persist(); renderAll(); }
|
||
else if (act==='down' && i<d.groups.length-1) { snapshot(); [d.groups[i+1],d.groups[i]]=[d.groups[i],d.groups[i+1]]; persist(); renderAll(); }
|
||
}
|
||
function moveFieldToGroup(code, gid) {
|
||
const d = cur(); snapshot();
|
||
d.groups.forEach(g=>g.fields=g.fields.filter(c=>c!==code));
|
||
d.ungrouped = d.ungrouped.filter(c=>c!==code);
|
||
if (gid==='__ungrouped') d.ungrouped.push(code);
|
||
else { const g=d.groups.find(x=>x.id===gid); if(g) g.fields.push(code); }
|
||
persist(); renderAll();
|
||
}
|
||
function removeField(code) {
|
||
const d = cur(); snapshot();
|
||
d.groups.forEach(g=>g.fields=g.fields.filter(c=>c!==code));
|
||
d.ungrouped = d.ungrouped.filter(c=>c!==code);
|
||
state.selectedField=null; persist(); renderAll(); toast('已从画布移除');
|
||
}
|
||
function selectField(code){ state.selectedField=code; state.selectedGroup=null; }
|
||
function selectGroup(id){ state.selectedGroup=id; state.selectedField=null; }
|
||
|
||
/* ===================== 渲染:属性面板 ===================== */
|
||
function renderInspector() {
|
||
const body = document.getElementById('inspBody');
|
||
const ctx = document.getElementById('inspCtx');
|
||
if (state.selectedField && !fieldMap()[state.selectedField]) state.selectedField=null;
|
||
|
||
if (state.selectedField) {
|
||
const code = state.selectedField, f = fieldMap()[code], p = fp(code);
|
||
ctx.textContent = '字段 · '+f.name;
|
||
const isForm = state.mode==='form';
|
||
const groupOpts = cur().groups.map(g=>`<option value="${g.id}" ${currentGroupOf(code)===g.id?'selected':''}>${g.title}</option>`).join('')
|
||
+ `<option value="__ungrouped" ${currentGroupOf(code)==='__ungrouped'?'selected':''}>未分组</option>`;
|
||
const enumEditor = (f.ui==='select')
|
||
? `<div class="form-row col2"><label>枚举项</label><div class="control"><textarea class="inp" id="p_options" placeholder="每行一个,如: 待付款 已付款">${(p.options||f.options||[]).join('\n')}</textarea></div></div>`
|
||
: '';
|
||
body.innerHTML = `
|
||
<div class="insp-section"><div class="insp-section__title">字段属性</div>
|
||
<div class="form-row"><label>显示名</label><div class="control"><input class="inp" id="p_label" value="${p.label}"></div></div>
|
||
<div class="form-row"><label>显示</label><div class="control"><label class="switch"><input type="checkbox" id="p_visible" ${p.visible?'checked':''}><span></span></label></div></div>
|
||
<div class="form-row"><label>${isForm?'必填':'必填列'}</label><div class="control"><label class="switch"><input type="checkbox" id="p_required" ${p.required?'checked':''}><span></span></label></div></div>
|
||
${isForm?`<div class="form-row"><label>只读</label><div class="control"><label class="switch"><input type="checkbox" id="p_readonly" ${p.readonly?'checked':''}><span></span></label></div></div>
|
||
<div class="form-row"><label>占位符</label><div class="control"><input class="inp" id="p_placeholder" value="${p.placeholder||''}" placeholder="空占位提示"></div></div>
|
||
<div class="form-row"><label>栅格</label><div class="control"><select class="sel" id="p_span">${[1,2,3,4].map(n=>`<option value="${n}" ${p.span==n?'selected':''}>占 ${n} 列</option>`).join('')}</select></div></div>`
|
||
:`<div class="form-row"><label>列宽</label><div class="control"><input class="inp" id="p_width" type="number" value="${p.width}"></div></div>
|
||
<div class="form-row"><label>对齐</label><div class="control"><select class="sel" id="p_align">${[['left','左'],['center','中'],['right','右']].map(([v,t])=>`<option value="${v}" ${p.align==v?'selected':''}>${t}</option>`).join('')}</select></div></div>
|
||
<div class="form-row"><label>可排序</label><div class="control"><label class="switch"><input type="checkbox" id="p_sortable" ${p.sortable?'checked':''}><span></span></label></div></div>
|
||
<div class="form-row"><label>冻结</label><div class="control"><label class="switch"><input type="checkbox" id="p_frozen" ${p.frozen?'checked':''}><span></span></label></div></div>`}
|
||
<div class="form-row"><label>默认值</label><div class="control"><input class="inp" id="p_defVal" value="${p.defVal||''}" placeholder="新建记录默认值"></div></div>
|
||
<div class="form-row col2"><label>帮助文本</label><div class="control"><textarea class="inp" id="p_help" placeholder="字段下方说明">${p.help||''}</textarea></div></div>
|
||
<div class="form-row col2"><label>校验正则</label><div class="control"><input class="inp" id="p_pattern" value="${p.pattern||''}" placeholder="如 ^\\d{6}$"></div></div>
|
||
<div class="form-row"><label>英文标签</label><div class="control"><input class="inp" id="p_i18n_en" value="${p.i18n?.en||''}" placeholder="多语言(i18n)"></div></div>
|
||
${enumEditor}
|
||
<div class="form-row"><label>所属分组</label><div class="control"><select class="sel" id="p_group">${groupOpts}</select></div></div>
|
||
</div>
|
||
<div class="insp-section"><div class="insp-section__title">字段来源(不可编辑)</div>
|
||
<div class="field-meta ${f.stale?'warn':''}">
|
||
编码 <code>${f.code}</code> · 来源 <code>${f.src==='view'?'视图':'保存表'}</code><br/>
|
||
类型 <code>${TYPE_BADGE[f.ui]}</code>${f.key?' · <code>主键</code>':''}${f.length?' · 长度 <code>'+f.length+'</code>':''}<br/>
|
||
${f.stale?'⚠ 该字段在数据源中已不存在,请确认或移除。<br/>':'控件由字段类型自动决定,<strong>不提供控件选择</strong>。'}
|
||
</div>
|
||
</div>
|
||
<button class="btn" id="p_remove" style="width:100%;justify-content:center;color:var(--fms-danger)">从画布移除该字段</button>`;
|
||
// 绑定
|
||
body.querySelector('#p_label').addEventListener('input', e=>setFp(code,{label:e.target.value}));
|
||
body.querySelector('#p_visible').addEventListener('change', e=>setFp(code,{visible:e.target.checked?1:0}));
|
||
body.querySelector('#p_required').addEventListener('change', e=>setFp(code,{required:e.target.checked?1:0}));
|
||
if (isForm) {
|
||
body.querySelector('#p_readonly').addEventListener('change', e=>setFp(code,{readonly:e.target.checked?1:0}));
|
||
body.querySelector('#p_placeholder').addEventListener('input', e=>setFp(code,{placeholder:e.target.value}));
|
||
body.querySelector('#p_span').addEventListener('change', e=>setFp(code,{span:+e.target.value}));
|
||
} else {
|
||
body.querySelector('#p_width').addEventListener('input', e=>setFp(code,{width:+e.target.value}));
|
||
body.querySelector('#p_align').addEventListener('change', e=>setFp(code,{align:e.target.value}));
|
||
body.querySelector('#p_sortable').addEventListener('change', e=>setFp(code,{sortable:e.target.checked?1:0}));
|
||
body.querySelector('#p_frozen').addEventListener('change', e=>setFp(code,{frozen:e.target.checked?1:0}));
|
||
}
|
||
body.querySelector('#p_defVal').addEventListener('input', e=>setFp(code,{defVal:e.target.value}));
|
||
body.querySelector('#p_help').addEventListener('input', e=>setFp(code,{help:e.target.value}));
|
||
body.querySelector('#p_pattern').addEventListener('input', e=>setFp(code,{pattern:e.target.value}));
|
||
body.querySelector('#p_i18n_en').addEventListener('input', e=>setFp(code,{i18n:Object.assign({},fp(code).i18n,{en:e.target.value})}));
|
||
if (f.ui==='select') body.querySelector('#p_options').addEventListener('input', e=>setFp(code,{options:e.target.value.split('\n').map(s=>s.trim()).filter(Boolean)}));
|
||
body.querySelector('#p_group').addEventListener('change', e=>moveFieldToGroup(code, e.target.value));
|
||
body.querySelector('#p_remove').addEventListener('click', ()=>removeField(code));
|
||
return;
|
||
}
|
||
|
||
if (state.selectedGroup && state.selectedGroup!=='__ungrouped') {
|
||
const g = cur().groups.find(x=>x.id===state.selectedGroup);
|
||
if (g) {
|
||
ctx.textContent = '分组 · '+g.title;
|
||
body.innerHTML = `
|
||
<div class="insp-section"><div class="insp-section__title">分组属性</div>
|
||
<div class="form-row"><label>名称</label><div class="control"><input class="inp" id="g_title" value="${g.title}"></div></div>
|
||
<div class="form-row"><label>方向</label><div class="control"><select class="sel" id="g_dir">${[['vertical','上下'],['horizontal','左右']].map(([v,t])=>`<option value="${v}" ${g.direction==v?'selected':''}>${t}</option>`).join('')}</select></div></div>
|
||
<div class="form-row col2"><label>说明</label><div class="control"><div class="field-meta">分组把字段按业务归类(如「基础信息 / 金额信息」)。主表与子表均支持。双击标题可重命名。</div></div></div>
|
||
<div class="form-row"><label>启用</label><div class="control"><label class="switch"><input type="checkbox" id="g_canuse" ${g.canuse?'checked':''}><span></span></label></div></div>
|
||
<div class="form-row"><label>字段数</label><div class="control"><span class="field-meta" style="padding:4px 10px">${g.fields.length} 个</span></div></div>
|
||
</div>`;
|
||
body.querySelector('#g_title').addEventListener('input', e=>{ g.title=e.target.value; document.getElementById('inspCtx').textContent='分组 · '+g.title; });
|
||
body.querySelector('#g_dir').addEventListener('change', e=>{ g.direction=e.target.value; persist(); renderCanvas(); });
|
||
body.querySelector('#g_canuse').addEventListener('change', e=>{ g.canuse=e.target.checked?1:0; persist(); });
|
||
return;
|
||
}
|
||
}
|
||
ctx.textContent = '未选中';
|
||
body.innerHTML = `<div class="insp-empty">选择中间画布的一个<strong>字段</strong>或<strong>分组</strong>即可编辑。<br/><br/>左侧字段库即数据源表的列——<br/>你选<strong>字段</strong>,控件由字段类型自动生成。</div>`;
|
||
}
|
||
|
||
/* ===================== 总渲染 ===================== */
|
||
function renderAll() { renderLibrary(); renderCanvas(); renderInspector(); }
|
||
|
||
/* ===================== 顶部交互 ===================== */
|
||
document.getElementById('modeSwitch').addEventListener('click', e => {
|
||
const b = e.target.closest('button[data-mode]'); if(!b) return;
|
||
state.mode = b.dataset.mode;
|
||
document.querySelectorAll('#modeSwitch button').forEach(x=>x.classList.toggle('active', x===b));
|
||
state.selectedField=null; state.selectedGroup=null; renderAll();
|
||
});
|
||
document.getElementById('viewSwitch').addEventListener('click', e => {
|
||
const b = e.target.closest('button[data-view]'); if(!b) return;
|
||
state.view = b.dataset.view;
|
||
document.querySelectorAll('#viewSwitch button').forEach(x=>x.classList.toggle('active', x===b));
|
||
renderAll();
|
||
});
|
||
document.getElementById('colChips').addEventListener('click', e => {
|
||
const b = e.target.closest('button[data-cols]'); if(!b) return;
|
||
state.columns = +b.dataset.cols;
|
||
document.querySelectorAll('#colChips button').forEach(x=>x.classList.toggle('active', x===b));
|
||
if (state.view==='preview') renderCanvas(); else renderInspector();
|
||
});
|
||
document.getElementById('dirChips').addEventListener('click', e => {
|
||
const b = e.target.closest('button[data-dir]'); if(!b) return;
|
||
state.direction = b.dataset.dir;
|
||
document.querySelectorAll('#dirChips button').forEach(x=>x.classList.toggle('active', x===b));
|
||
// 应用到当前所有分组方向(批量)
|
||
cur().groups.forEach(g=>g.direction=state.direction); persist(); renderAll();
|
||
});
|
||
document.getElementById('addGroupBtn').addEventListener('click', () => {
|
||
snapshot(); cur().groups.push({ id:'g'+Date.now(), title:'新分组', direction:state.direction, canuse:1, fields:[], _collapsed:false });
|
||
persist(); renderAll();
|
||
});
|
||
document.getElementById('addAllBtn').addEventListener('click', () => {
|
||
const d = cur(), added = addedCodes();
|
||
if (!d.groups.length) { snapshot(); d.groups.push({ id:'g'+Date.now(), title:'全部字段', direction:state.direction, canuse:1, fields:[] }); }
|
||
const target = d.groups[0]; const before = addedCodes().size;
|
||
snapshot();
|
||
cur().fields.forEach(f=>{ if(!added.has(f.code)) target.fields.push(f.code); });
|
||
persist(); renderAll(); toast('已加入 '+(addedCodes().size-before)+' 个字段');
|
||
});
|
||
document.getElementById('libSearch').addEventListener('input', renderLibrary);
|
||
document.getElementById('filterTabs').addEventListener('click', e => {
|
||
const b = e.target.closest('button[data-f]'); if(!b) return;
|
||
state.libFilter = b.dataset.f;
|
||
document.querySelectorAll('#filterTabs button').forEach(x=>x.classList.toggle('active', x===b));
|
||
renderLibrary();
|
||
});
|
||
document.getElementById('undoBtn').addEventListener('click', undo);
|
||
document.getElementById('redoBtn').addEventListener('click', redo);
|
||
document.getElementById('saveBtn').addEventListener('click', () => {
|
||
const d = cur(); const total = d.groups.reduce((s,g)=>s+g.fields.length,0)+d.ungrouped.length;
|
||
persist();
|
||
toast('已保存「'+(state.mode==='form'?'主表表单':'子表表格')+'」配置('+total+' 字段 / '+d.groups.length+' 分组)');
|
||
});
|
||
document.getElementById('resetBtn').addEventListener('click', () => {
|
||
if (!confirm('重置为示例配置?当前修改将丢失。')) return;
|
||
DB = DEFAULT_DB(); history.past.length=0; history.future.length=0; updateHistBtns();
|
||
persist(); renderAll(); toast('已重置为示例');
|
||
});
|
||
|
||
/* ===================== 分组管理模态 ===================== */
|
||
const grpMask = document.getElementById('grpMask');
|
||
document.getElementById('groupBtn').addEventListener('click', ()=>{ renderGroupTree(); grpMask.classList.add('open'); });
|
||
document.getElementById('grpClose').addEventListener('click', ()=>grpMask.classList.remove('open'));
|
||
document.getElementById('grpCancel').addEventListener('click', ()=>grpMask.classList.remove('open'));
|
||
grpMask.addEventListener('click', e=>{ if(e.target===grpMask) grpMask.classList.remove('open'); });
|
||
function renderGroupTree() {
|
||
const d = cur(); const tree = document.getElementById('grpTree');
|
||
tree.innerHTML = d.groups.map(g=>`
|
||
<div class="grp-node ${g.canuse?'':'is-disabled'}" data-gid="${g.id}">
|
||
<span class="grp-node__name">${g.title} <span style="color:var(--fms-text-placeholder);font-size:11px">· ${g.fields.length}字段</span></span>
|
||
<span class="grp-node__actions">
|
||
<button class="icon-btn" data-ga="up" title="上移"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="m18 15-6-6-6 6"/></svg></button>
|
||
<button class="icon-btn" data-ga="down" title="下移"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="m6 9 6 6 6-6"/></svg></button>
|
||
<span class="grp-divider"></span>
|
||
<button class="icon-btn" data-ga="enable" title="${g.canuse?'停用':'启用'}">${g.canuse?'●':'○'}</button>
|
||
<button class="icon-btn" data-ga="rename" title="重命名"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></button>
|
||
<button class="icon-btn is-danger" data-ga="del" title="删除"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg></button>
|
||
</span>
|
||
</div>`).join('') || '<div class="insp-empty">暂无分组</div>';
|
||
tree.querySelectorAll('.grp-node').forEach(node => {
|
||
const id = node.dataset.gid, g = cur().groups.find(x=>x.id===id);
|
||
node.querySelector('[data-ga="up"]').addEventListener('click', ()=>{ const i=cur().groups.findIndex(x=>x.id===id); if(i>0){ snapshot(); [cur().groups[i-1],cur().groups[i]]=[cur().groups[i],cur().groups[i-1]]; persist(); renderGroupTree(); renderAll(); } });
|
||
node.querySelector('[data-ga="down"]').addEventListener('click', ()=>{ const i=cur().groups.findIndex(x=>x.id===id); if(i<cur().groups.length-1){ snapshot(); [cur().groups[i+1],cur().groups[i]]=[cur().groups[i],cur().groups[i+1]]; persist(); renderGroupTree(); renderAll(); } });
|
||
node.querySelector('[data-ga="enable"]').addEventListener('click', ()=>{ g.canuse=g.canuse?0:1; persist(); renderGroupTree(); renderAll(); });
|
||
node.querySelector('[data-ga="del"]').addEventListener('click', ()=>{ const i=cur().groups.findIndex(x=>x.id===id); snapshot(); cur().ungrouped.push(...cur().groups[i].fields); cur().groups.splice(i,1); persist(); renderGroupTree(); renderAll(); });
|
||
node.querySelector('[data-ga="rename"]').addEventListener('click', ()=>{
|
||
const nameEl = node.querySelector('.grp-node__name'); nameEl.innerHTML=`<input value="${g.title}">`;
|
||
const inp = nameEl.querySelector('input'); inp.focus(); inp.select();
|
||
const commit = ()=>{ g.title=inp.value.trim()||g.title; persist(); renderGroupTree(); renderAll(); };
|
||
inp.addEventListener('blur', commit); inp.addEventListener('keydown', e=>{ if(e.key==='Enter') commit(); });
|
||
});
|
||
});
|
||
}
|
||
document.getElementById('grpNewAdd').addEventListener('click', () => {
|
||
const v = document.getElementById('grpNewTitle').value.trim(); if(!v) return;
|
||
snapshot(); cur().groups.push({ id:'g'+Date.now(), title:v, direction:state.direction, canuse:1, fields:[] });
|
||
document.getElementById('grpNewTitle').value=''; persist(); renderGroupTree(); renderAll();
|
||
});
|
||
document.getElementById('grpNewTitle').addEventListener('keydown', e=>{ if(e.key==='Enter') document.getElementById('grpNewAdd').click(); });
|
||
|
||
/* ===================== 同步差异 ===================== */
|
||
const syncMask = document.getElementById('syncMask');
|
||
document.getElementById('syncBtn').addEventListener('click', ()=>{ renderSyncDiff(); syncMask.classList.add('open'); });
|
||
document.getElementById('syncClose').addEventListener('click', ()=>syncMask.classList.remove('open'));
|
||
document.getElementById('syncCancel').addEventListener('click', ()=>syncMask.classList.remove('open'));
|
||
function computeDiff() {
|
||
const curF = cur().fields, srv = SERVER[state.mode];
|
||
const curMap = Object.fromEntries(curF.map(f=>[f.code,f]));
|
||
const srvMap = Object.fromEntries(srv.map(f=>[f.code,f]));
|
||
const added=[], removed=[], changed=[];
|
||
srv.forEach(s=>{ if(!curMap[s.code]) added.push(s); });
|
||
curF.forEach(c=>{ if(!srvMap[c.code]) removed.push(c); });
|
||
curF.forEach(c=>{ const s=srvMap[c.code]; if(s && (s.ui!==c.ui || s.length!==c.length)) changed.push({c,s}); });
|
||
return { added, removed, changed };
|
||
}
|
||
function renderSyncDiff() {
|
||
const { added, removed, changed } = computeDiff();
|
||
const body = document.getElementById('syncBody');
|
||
if (!added.length && !removed.length && !changed.length) { body.innerHTML='<div class="insp-empty">数据源无变化,字段已是最新。</div>'; return; }
|
||
const row = (tag,label,desc)=>`<div class="diff-row"><span class="tag ${tag}">${tag==='add'?'新增':tag==='del'?'移除':'变更'}</span><span class="name">${label}</span><span class="desc">${desc}</span></div>`;
|
||
body.innerHTML =
|
||
(added.length?`<div class="insp-section__title">新增字段(将加入「未分组」)</div>`:'') + added.map(f=>row('add', f.name, f.code+' · '+TYPE_BADGE[f.ui])).join('') +
|
||
(changed.length?`<div class="insp-section__title" style="margin-top:10px">类型/长度变更</div>`:'') + changed.map(({c,s})=>row('chg', c.name, `${c.code}:${TYPE_BADGE[c.ui]}→${TYPE_BADGE[s.ui]}${s.length?(' ('+s.length+')'):''}`)).join('') +
|
||
(removed.length?`<div class="insp-section__title" style="margin-top:10px">数据源已移除(保留在设计,标记告警)</div>`:'') + removed.map(f=>row('del', f.name, f.code+' · '+TYPE_BADGE[f.ui])).join('');
|
||
}
|
||
document.getElementById('syncApply').addEventListener('click', ()=>{
|
||
const { added, removed, changed } = computeDiff(); const d = cur(); snapshot();
|
||
added.forEach(s=>{ if(!d.fields.find(f=>f.code===s.code)) d.fields.push(JSON.parse(JSON.stringify(s))); d.ungrouped.push(s.code); });
|
||
changed.forEach(({c,s})=>{ const t=d.fields.find(f=>f.code===c.code); if(t){ t.ui=s.ui; t.length=s.length; t.options=s.options; } });
|
||
removed.forEach(c=>{ const t=d.fields.find(f=>f.code===c.code); if(t) t.stale=1; });
|
||
persist(); syncMask.classList.remove('open'); renderAll(); toast('同步完成:+'+added.length+' 变更'+changed.length+' 移除'+removed.length);
|
||
});
|
||
|
||
/* ===================== JSON 导出 ===================== */
|
||
const jsonMask = document.getElementById('jsonMask');
|
||
document.getElementById('jsonBtn').addEventListener('click', ()=>{
|
||
const d = cur();
|
||
const out = { mode:state.mode, table:d.table, groups:d.groups.map(g=>({ title:g.title, direction:g.direction, canuse:g.canuse, fields:g.fields.map(c=>({ code:c, ...fpStrip(c) })) })), ungrouped:d.ungrouped.map(c=>({ code:c, ...fpStrip(c) })) };
|
||
document.getElementById('jsonOut').textContent = JSON.stringify(out, null, 2);
|
||
jsonMask.classList.add('open');
|
||
});
|
||
function fpStrip(code){ const p=fp(code); const f=fieldMap()[code]; return { label:p.label, ui:f.ui, required:p.required, readonly:p.readonly, visible:p.visible, placeholder:p.placeholder, defVal:p.defVal, help:p.help, pattern:p.pattern, i18n:p.i18n, options:p.options, width:p.width, align:p.align, sortable:p.sortable, frozen:p.frozen }; }
|
||
document.getElementById('jsonClose').addEventListener('click', ()=>jsonMask.classList.remove('open'));
|
||
document.getElementById('jsonDone').addEventListener('click', ()=>jsonMask.classList.remove('open'));
|
||
document.getElementById('jsonCopy').addEventListener('click', ()=>{ navigator.clipboard?.writeText(document.getElementById('jsonOut').textContent); toast('已复制 JSON'); });
|
||
|
||
/* ===================== toast ===================== */
|
||
let toastTimer=null;
|
||
function toast(msg){ const t=document.getElementById('toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(toastTimer); toastTimer=setTimeout(()=>t.classList.remove('show'),1800); }
|
||
|
||
/* ===================== 初始化 ===================== */
|
||
load(); updateHistBtns(); renderAll();
|
||
</script>
|
||
</body>
|
||
</html>
|