Files
workspace/code/fms/demo.html
T
2026-08-26 17:31:31 +08:00

1008 lines
68 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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="每行一个,如:&#10;待付款&#10;已付款">${(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>