20260904173136
This commit is contained in:
1 parent
0928437c67
commit
4e167df0f0
40 files changed
+3471
-2314
No files matched your search
@@ -1,40 +0,0 @@
|
||||
- generic [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- img "FMS" [ref=e5]
|
||||
- generic [ref=e6]: FMS 企业管理系统
|
||||
- generic [ref=e7]:
|
||||
- button "中" [ref=e8] [cursor=pointer]
|
||||
- button [ref=e9] [cursor=pointer]
|
||||
- generic [ref=e12]:
|
||||
- paragraph [ref=e22]: 企业管理系统
|
||||
- generic [ref=e24]:
|
||||
- heading "登录" [level=1] [ref=e25]
|
||||
- paragraph [ref=e26]: 欢迎回来,请输入您的账号信息
|
||||
- generic [ref=e27]:
|
||||
- generic [ref=e28]:
|
||||
- generic [ref=e29]:
|
||||
- text: "*"
|
||||
- generic [ref=e30]: 机构码
|
||||
- textbox [active] [ref=e39]
|
||||
- generic [ref=e40]:
|
||||
- generic [ref=e41]:
|
||||
- text: "*"
|
||||
- generic [ref=e42]: 账号
|
||||
- textbox [ref=e50]
|
||||
- generic [ref=e51]:
|
||||
- generic [ref=e52]: 密码
|
||||
- generic [ref=e56]:
|
||||
- textbox [ref=e61]
|
||||
- button "显示密码" [ref=e62] [cursor=pointer]
|
||||
- generic [ref=e66]: 请按住滑块拖动
|
||||
- button "登录" [ref=e74] [cursor=pointer]
|
||||
- paragraph [ref=e75]: © 2026 FMS · 企业管理系统
|
||||
- complementary "FPS 性能监控" [ref=e76]:
|
||||
- generic [ref=e77]:
|
||||
- generic [ref=e78]: FPS
|
||||
- strong [ref=e79]: "60"
|
||||
- button "重置 FPS 统计" [ref=e80] [cursor=pointer]
|
||||
- generic [ref=e104]:
|
||||
- generic [ref=e105]: avg 59
|
||||
- generic [ref=e106]: min 49
|
||||
@@ -1,313 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>模块管理布局演示</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4f6f8;
|
||||
--surface: #ffffff;
|
||||
--surface-subtle: #f8fafb;
|
||||
--line: #e5e9ee;
|
||||
--line-strong: #d4dbe3;
|
||||
--text: #182230;
|
||||
--muted: #657384;
|
||||
--muted-2: #8b98a8;
|
||||
--brand: #1677ff;
|
||||
--brand-soft: #eaf3ff;
|
||||
--success: #18864b;
|
||||
--warning: #b86b00;
|
||||
--shadow: 0 10px 28px rgba(29, 44, 63, 0.08);
|
||||
--radius: 8px;
|
||||
font-family: Inter, "Noto Sans SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 1120px; background: var(--bg); }
|
||||
button, input, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
|
||||
.app { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.topbar {
|
||||
height: 60px; display: flex; align-items: center; gap: 26px; padding: 0 24px;
|
||||
background: var(--surface); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; letter-spacing: 0; }
|
||||
.brand-mark { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 7px; color: white; background: #1f6feb; font-size: 14px; }
|
||||
.brand-title { font-size: 16px; }
|
||||
.zone-nav { display: flex; align-self: stretch; gap: 4px; }
|
||||
.zone-tab { position: relative; border: 0; padding: 0 14px; color: var(--muted); background: transparent; font-size: 13px; }
|
||||
.zone-tab::after { content: ""; position: absolute; left: 12px; right: 12px; bottom: -1px; height: 2px; background: transparent; }
|
||||
.zone-tab.active { color: var(--brand); font-weight: 600; }
|
||||
.zone-tab.active::after { background: var(--brand); }
|
||||
.topbar-spacer { flex: 1; }
|
||||
.top-status { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--success); }
|
||||
.top-actions { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.icon-btn, .plain-btn, .primary-btn {
|
||||
height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
||||
border-radius: 6px; border: 1px solid var(--line-strong); padding: 0 11px; background: var(--surface); color: var(--text); font-size: 12px;
|
||||
}
|
||||
.icon-btn { width: 32px; padding: 0; color: var(--muted); }
|
||||
.plain-btn:hover, .icon-btn:hover { background: var(--surface-subtle); border-color: #b8c3cf; }
|
||||
.primary-btn { border-color: var(--brand); color: white; background: var(--brand); }
|
||||
.primary-btn:hover { background: #0f66df; }
|
||||
.toolbar-label { color: var(--muted); font-size: 12px; }
|
||||
|
||||
.workspace { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 14px 18px 18px; gap: 12px; }
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; min-height: 48px; }
|
||||
.breadcrumb { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
|
||||
.breadcrumb strong { color: var(--text); font-size: 15px; }
|
||||
.head-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.unsaved { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; border: 1px solid #f4d5a4; border-radius: 999px; color: var(--warning); background: #fff9ef; font-size: 11px; }
|
||||
|
||||
.workbench { flex: 1; min-height: 660px; display: grid; grid-template-columns: 238px 154px minmax(0, 1fr); overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||
.module-pane, .section-pane { min-width: 0; border-right: 1px solid var(--line); background: var(--surface); }
|
||||
.pane-head { height: 48px; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-bottom: 1px solid var(--line); }
|
||||
.pane-title { font-size: 12px; font-weight: 700; }
|
||||
.pane-tools { display: flex; gap: 3px; }
|
||||
.mini-btn { width: 26px; height: 26px; display: grid; place-items: center; border: 0; border-radius: 5px; color: var(--muted); background: transparent; }
|
||||
.mini-btn:hover { background: var(--brand-soft); color: var(--brand); }
|
||||
.search { margin: 10px 10px 8px; height: 30px; display: flex; align-items: center; gap: 7px; padding: 0 9px; border: 1px solid var(--line); border-radius: 6px; color: var(--muted-2); background: var(--surface-subtle); }
|
||||
.search input { width: 100%; border: 0; outline: 0; color: var(--text); background: transparent; font-size: 12px; }
|
||||
.tree { padding: 2px 8px 12px; }
|
||||
.tree-group { margin-top: 4px; }
|
||||
.tree-row { min-height: 32px; display: flex; align-items: center; gap: 6px; padding: 0 7px; border-radius: 6px; color: var(--muted); font-size: 12px; }
|
||||
.tree-row:hover { background: var(--surface-subtle); }
|
||||
.tree-row.active { color: var(--brand); background: var(--brand-soft); font-weight: 600; }
|
||||
.tree-indent { width: 14px; flex: none; }
|
||||
.tree-name { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tree-tag { padding: 2px 5px; border-radius: 4px; color: var(--muted); background: #eef1f4; font-size: 10px; font-weight: 500; }
|
||||
.tree-tag.blue { color: #1554a0; background: #e8f1ff; }
|
||||
.tree-tag.green { color: #146c41; background: #e7f6ed; }
|
||||
.tree-tag.amber { color: #8b5b00; background: #fff4d9; }
|
||||
|
||||
.section-pane { padding: 10px 8px; background: #fbfcfd; }
|
||||
.section-label { padding: 9px 9px 5px; color: var(--muted-2); font-size: 10px; font-weight: 700; letter-spacing: .06em; }
|
||||
.section-item { width: 100%; display: flex; align-items: center; gap: 8px; min-height: 34px; padding: 0 9px; border: 0; border-radius: 6px; text-align: left; color: var(--muted); background: transparent; font-size: 12px; }
|
||||
.section-item:hover { background: #f0f4f8; color: var(--text); }
|
||||
.section-item.active { color: var(--brand); background: var(--brand-soft); font-weight: 600; }
|
||||
.section-item .count { margin-left: auto; min-width: 18px; padding: 1px 5px; border-radius: 999px; color: var(--muted); background: #e9eef3; text-align: center; font-size: 10px; }
|
||||
.section-separator { height: 1px; margin: 10px 9px; background: var(--line); }
|
||||
.tip { margin: 22px 8px 0; padding: 10px; border: 1px solid #d9e6f5; border-radius: 6px; color: #42617f; background: #f4f9ff; font-size: 11px; line-height: 1.6; }
|
||||
|
||||
.designer { min-width: 0; display: flex; flex-direction: column; background: #f5f7f9; }
|
||||
.designer-head { min-height: 56px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; border-bottom: 1px solid var(--line); background: var(--surface); }
|
||||
.designer-title { display: flex; align-items: center; gap: 8px; }
|
||||
.designer-title strong { font-size: 14px; }
|
||||
.designer-title span { color: var(--muted); font-size: 11px; }
|
||||
.view-switch { display: inline-flex; padding: 3px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-subtle); }
|
||||
.view-switch button { height: 26px; padding: 0 10px; border: 0; border-radius: 4px; color: var(--muted); background: transparent; font-size: 11px; }
|
||||
.view-switch button.active { color: var(--text); background: var(--surface); box-shadow: 0 1px 3px rgba(30, 45, 64, .12); font-weight: 600; }
|
||||
.device-switch { display: inline-flex; gap: 4px; margin-left: 9px; }
|
||||
.device-switch button { width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 5px; color: var(--muted); background: var(--surface); }
|
||||
.device-switch button.active { border-color: #a7c8f7; color: var(--brand); background: var(--brand-soft); }
|
||||
|
||||
.designer-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 188px minmax(430px, 1fr) 218px; gap: 12px; padding: 14px; overflow: hidden; }
|
||||
.library, .inspector { min-width: 0; padding: 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface); }
|
||||
.subhead { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||
.subhead strong { font-size: 12px; }
|
||||
.subhead span { color: var(--muted-2); font-size: 10px; }
|
||||
.library-search { height: 28px; display: flex; align-items: center; gap: 6px; padding: 0 8px; border: 1px solid var(--line); border-radius: 5px; color: var(--muted-2); background: var(--surface-subtle); font-size: 11px; }
|
||||
.library-group { margin-top: 14px; }
|
||||
.library-group-title { margin-bottom: 6px; color: var(--muted-2); font-size: 10px; font-weight: 700; }
|
||||
.library-item { min-height: 34px; display: flex; align-items: center; gap: 8px; margin-bottom: 5px; padding: 0 8px; border: 1px solid var(--line); border-radius: 5px; color: var(--muted); background: #fcfdfe; font-size: 11px; }
|
||||
.library-item:hover { border-color: #a7c8f7; color: var(--brand); background: var(--brand-soft); }
|
||||
.drag-grip { color: #9eabb8; font-size: 12px; letter-spacing: -2px; }
|
||||
.field-mark { width: 20px; height: 20px; display: grid; place-items: center; border-radius: 4px; color: #2f6ca8; background: #e8f2ff; font-size: 10px; font-weight: 700; }
|
||||
.canvas-wrap { min-width: 0; overflow: auto; }
|
||||
.canvas { min-height: 100%; padding: 14px; border: 1px dashed #c8d2dc; border-radius: 7px; background: #e9edf1; }
|
||||
.canvas-note { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; color: var(--muted); font-size: 10px; }
|
||||
.form-sheet { max-width: 690px; margin: 0 auto; padding: 18px 20px 22px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface); box-shadow: 0 3px 14px rgba(39, 54, 70, .06); }
|
||||
.form-sheet-head { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 18px; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
.form-sheet-head strong { font-size: 15px; }
|
||||
.form-sheet-head span { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; }
|
||||
.section-block { margin-top: 13px; }
|
||||
.section-block-title { display: flex; align-items: center; gap: 7px; margin-bottom: 8px; color: var(--text); font-size: 11px; font-weight: 700; }
|
||||
.section-block-title::before { content: ""; width: 3px; height: 13px; border-radius: 2px; background: var(--brand); }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.field-card { position: relative; display: flex; align-items: center; gap: 9px; min-height: 52px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: #fff; }
|
||||
.field-card:hover { border-color: #a7c8f7; }
|
||||
.field-card.selected { border: 1px solid var(--brand); box-shadow: 0 0 0 2px rgba(22, 119, 255, .11); }
|
||||
.field-card.span-2 { grid-column: span 2; }
|
||||
.field-card .drag-handle { color: #9ba9b6; font-size: 14px; }
|
||||
.field-copy { min-width: 0; flex: 1; }
|
||||
.field-copy strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
|
||||
.field-copy span { display: block; margin-top: 3px; color: var(--muted-2); font-size: 10px; }
|
||||
.required { color: #d95757; }
|
||||
.drop-zone { display: flex; align-items: center; justify-content: center; min-height: 38px; margin-top: 10px; border: 1px dashed #9ebce0; border-radius: 5px; color: #547ba9; background: #f5f9ff; font-size: 11px; }
|
||||
.inspector .subhead { padding-bottom: 10px; border-bottom: 1px solid var(--line); }
|
||||
.inspector-empty { padding: 28px 12px; color: var(--muted-2); text-align: center; font-size: 11px; line-height: 1.7; }
|
||||
.property { margin-top: 13px; }
|
||||
.property label { display: block; margin-bottom: 5px; color: var(--muted); font-size: 10px; }
|
||||
.property input, .property select { width: 100%; height: 30px; padding: 0 8px; border: 1px solid var(--line); border-radius: 5px; color: var(--text); background: var(--surface-subtle); outline: 0; font-size: 11px; }
|
||||
.property input:focus, .property select:focus { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(22, 119, 255, .12); }
|
||||
.property-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.toggle-line { display: flex; align-items: center; justify-content: space-between; min-height: 30px; color: var(--muted); font-size: 11px; }
|
||||
.toggle { width: 30px; height: 18px; padding: 2px; border: 0; border-radius: 999px; background: #bdc7d2; }
|
||||
.toggle::after { content: ""; display: block; width: 14px; height: 14px; border-radius: 50%; background: white; transition: transform .16s ease; }
|
||||
.toggle.on { background: var(--brand); }
|
||||
.toggle.on::after { transform: translateX(12px); }
|
||||
.inspector-foot { margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted-2); font-size: 10px; line-height: 1.6; }
|
||||
.data-view { display: none; flex: 1; padding: 16px; overflow: auto; background: #f5f7f9; }
|
||||
.data-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.data-toolbar strong { font-size: 12px; }
|
||||
table { width: 100%; border-collapse: collapse; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; background: var(--surface); font-size: 11px; }
|
||||
th, td { height: 40px; padding: 0 10px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; }
|
||||
th { color: var(--muted); background: var(--surface-subtle); font-weight: 600; }
|
||||
td { color: var(--text); }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
.check { color: var(--success); font-weight: 700; }
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
body { min-width: 0; }
|
||||
.workbench { grid-template-columns: 214px 142px minmax(0, 1fr); }
|
||||
.designer-body { grid-template-columns: 164px minmax(380px, 1fr) 190px; }
|
||||
.topbar { padding: 0 16px; gap: 16px; }
|
||||
.workspace { padding-left: 12px; padding-right: 12px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="brand"><span class="brand-mark">F</span><span class="brand-title">FMS 管理台</span></div>
|
||||
<nav class="zone-nav" aria-label="配置区域">
|
||||
<button class="zone-tab active" type="button">模块配置</button>
|
||||
<button class="zone-tab" type="button">菜单配置</button>
|
||||
</nav>
|
||||
<div class="topbar-spacer"></div>
|
||||
<div class="top-status"><span class="dot"></span>演示模式</div>
|
||||
<div class="top-actions">
|
||||
<button class="plain-btn" type="button">预览</button>
|
||||
<button class="primary-btn" type="button">保存</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="workspace">
|
||||
<div class="page-head">
|
||||
<div class="breadcrumb"><span>系统</span><span>/</span><strong>订单模块</strong><span class="tree-tag blue">数据模块</span><span class="unsaved">● 未保存</span></div>
|
||||
<div class="head-actions"><button class="plain-btn" type="button">重载</button><span class="toolbar-label">Ctrl + S 保存</span></div>
|
||||
</div>
|
||||
|
||||
<section class="workbench" aria-label="模块配置工作台">
|
||||
<aside class="module-pane">
|
||||
<div class="pane-head"><span class="pane-title">模块</span><div class="pane-tools"><button class="mini-btn" title="新建">+</button><button class="mini-btn" title="刷新">↻</button></div></div>
|
||||
<label class="search"><span>⌕</span><input placeholder="搜索模块" /></label>
|
||||
<div class="tree">
|
||||
<div class="tree-group">
|
||||
<div class="tree-row"><span>⌄</span><span>▣</span><span class="tree-name">系统</span><span class="tree-tag">分类</span></div>
|
||||
<div class="tree-row active"><span class="tree-indent"></span><span>◈</span><span class="tree-name">订单模块</span><span class="tree-tag blue">数据</span></div>
|
||||
<div class="tree-row"><span class="tree-indent"></span><span>⌘</span><span class="tree-name">订单查询</span><span class="tree-tag amber">查询</span></div>
|
||||
</div>
|
||||
<div class="tree-group">
|
||||
<div class="tree-row"><span>›</span><span>▣</span><span class="tree-name">基础资料</span><span class="tree-tag">分类</span></div>
|
||||
<div class="tree-row"><span>›</span><span>▣</span><span class="tree-name">报表中心</span><span class="tree-tag">分类</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside class="section-pane">
|
||||
<div class="section-label">概览</div>
|
||||
<button class="section-item" type="button">▤ <span>基本信息</span></button>
|
||||
<div class="section-label">数据结构</div>
|
||||
<button class="section-item" type="button">⌘ <span>字段定义</span><span class="count">18</span></button>
|
||||
<button class="section-item" type="button">▤ <span>分组管理</span><span class="count">3</span></button>
|
||||
<div class="section-label">界面配置</div>
|
||||
<button class="section-item active" type="button">✣ <span>表单设计</span></button>
|
||||
<button class="section-item" type="button">▦ <span>列表配置</span></button>
|
||||
<button class="section-item" type="button">⌕ <span>查询配置</span></button>
|
||||
<div class="section-label">业务能力</div>
|
||||
<button class="section-item" type="button">⚙ <span>自动编码</span></button>
|
||||
<button class="section-item" type="button">♢ <span>权限</span></button>
|
||||
<div class="section-label">高级设置</div>
|
||||
<button class="section-item" type="button">文 <span>多语言</span></button>
|
||||
<div class="section-separator"></div>
|
||||
<div class="tip">设计视图用于调整布局,数据视图适合批量修改字段属性。两种视图共享同一份草稿。</div>
|
||||
</aside>
|
||||
|
||||
<section class="designer">
|
||||
<div class="designer-head">
|
||||
<div class="designer-title"><strong>表单设计</strong><span>订单模块 · 编辑表单</span></div>
|
||||
<div>
|
||||
<div class="view-switch" role="tablist"><button class="active" data-view="design" type="button">设计视图</button><button data-view="data" type="button">数据视图</button></div>
|
||||
<span class="device-switch"><button class="active" type="button" title="桌面预览">▣</button><button type="button" title="移动预览">▯</button></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="designer-body" id="design-view">
|
||||
<aside class="library">
|
||||
<div class="subhead"><strong>字段库</strong><span>拖到画布</span></div>
|
||||
<div class="library-search">⌕ 筛选字段</div>
|
||||
<div class="library-group"><div class="library-group-title">订单字段</div>
|
||||
<div class="library-item"><span class="drag-grip">⋮⋮</span><span class="field-mark">文</span><span>订单编号</span></div>
|
||||
<div class="library-item"><span class="drag-grip">⋮⋮</span><span class="field-mark">文</span><span>客户名称</span></div>
|
||||
<div class="library-item"><span class="drag-grip">⋮⋮</span><span class="field-mark">数</span><span>订单金额</span></div>
|
||||
<div class="library-item"><span class="drag-grip">⋮⋮</span><span class="field-mark">日</span><span>下单日期</span></div>
|
||||
<div class="library-item"><span class="drag-grip">⋮⋮</span><span class="field-mark">选</span><span>订单状态</span></div>
|
||||
</div>
|
||||
<div class="library-group"><div class="library-group-title">布局组件</div>
|
||||
<div class="library-item"><span class="drag-grip">⋮⋮</span><span class="field-mark">分</span><span>分组容器</span></div>
|
||||
<div class="library-item"><span class="drag-grip">⋮⋮</span><span class="field-mark">文</span><span>说明文字</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="canvas-wrap"><div class="canvas"><div class="canvas-note"><span>画布 · 桌面端 12 栅格</span><span>可拖拽调整顺序与宽度</span></div>
|
||||
<div class="form-sheet">
|
||||
<div class="form-sheet-head"><div><strong>订单信息</strong><span>维护订单基本资料和业务状态</span></div><span class="tree-tag green">编辑态</span></div>
|
||||
<div class="section-block"><div class="section-block-title">基础信息</div><div class="form-grid">
|
||||
<button class="field-card selected" type="button" data-field="订单编号"><span class="drag-handle">⋮⋮</span><span class="field-mark">文</span><span class="field-copy"><strong>订单编号 <i class="required">*</i></strong><span>文本框 · 6 栅格</span></span></button>
|
||||
<button class="field-card" type="button" data-field="客户名称"><span class="drag-handle">⋮⋮</span><span class="field-mark">文</span><span class="field-copy"><strong>客户名称 <i class="required">*</i></strong><span>文本框 · 6 栅格</span></span></button>
|
||||
<button class="field-card" type="button" data-field="订单金额"><span class="drag-handle">⋮⋮</span><span class="field-mark">数</span><span class="field-copy"><strong>订单金额</strong><span>数字框 · 6 栅格</span></span></button>
|
||||
<button class="field-card" type="button" data-field="下单日期"><span class="drag-handle">⋮⋮</span><span class="field-mark">日</span><span class="field-copy"><strong>下单日期</strong><span>日期框 · 6 栅格</span></span></button>
|
||||
</div></div>
|
||||
<div class="section-block"><div class="section-block-title">业务状态</div><div class="form-grid">
|
||||
<button class="field-card span-2" type="button" data-field="订单状态"><span class="drag-handle">⋮⋮</span><span class="field-mark">选</span><span class="field-copy"><strong>订单状态</strong><span>下拉选择 · 12 栅格</span></span></button>
|
||||
</div><div class="drop-zone">拖拽字段到这里,插入新的表单项</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<aside class="inspector"><div class="subhead"><strong>属性</strong><span id="selected-label">订单编号</span></div>
|
||||
<div id="inspector-content"><div class="property"><label>字段标题</label><input value="订单编号" /></div><div class="property"><label>控件类型</label><select><option>文本框</option><option>数字框</option><option>日期框</option></select></div><div class="property-row"><div class="property"><label>栅格宽度</label><select><option>6 / 12</option><option>12 / 12</option><option>4 / 12</option></select></div><div class="property"><label>排序号</label><input value="10" /></div></div><div class="property"><div class="toggle-line"><span>必填</span><button class="toggle on" type="button"></button></div><div class="toggle-line"><span>只读</span><button class="toggle" type="button"></button></div><div class="toggle-line"><span>禁用</span><button class="toggle" type="button"></button></div></div><div class="inspector-foot">拖动字段卡片可以调整顺序。后续可在这里扩展显示条件、默认值和校验规则。</div></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="data-view" id="data-view"><div class="data-toolbar"><strong>编辑字段属性</strong><button class="plain-btn" type="button">批量同步到列表</button></div><table><thead><tr><th>拖动</th><th>字段</th><th>显示</th><th>必填</th><th>只读</th><th>分组</th><th>排序号</th></tr></thead><tbody><tr><td>⋮⋮</td><td>订单编号</td><td class="check">✓</td><td class="check">✓</td><td>—</td><td>基础信息</td><td>10</td></tr><tr><td>⋮⋮</td><td>客户名称</td><td class="check">✓</td><td class="check">✓</td><td>—</td><td>基础信息</td><td>20</td></tr><tr><td>⋮⋮</td><td>订单金额</td><td class="check">✓</td><td>—</td><td>—</td><td>基础信息</td><td>30</td></tr><tr><td>⋮⋮</td><td>下单日期</td><td class="check">✓</td><td>—</td><td>—</td><td>基础信息</td><td>40</td></tr><tr><td>⋮⋮</td><td>订单状态</td><td class="check">✓</td><td>—</td><td>—</td><td>业务状态</td><td>50</td></tr></tbody></table></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const viewButtons = document.querySelectorAll('[data-view]')
|
||||
const designView = document.getElementById('design-view')
|
||||
const dataView = document.getElementById('data-view')
|
||||
viewButtons.forEach((button) => button.addEventListener('click', () => {
|
||||
viewButtons.forEach((item) => item.classList.toggle('active', item === button))
|
||||
const isDesign = button.dataset.view === 'design'
|
||||
designView.style.display = isDesign ? 'grid' : 'none'
|
||||
dataView.style.display = isDesign ? 'none' : 'block'
|
||||
}))
|
||||
|
||||
const fieldNames = ['订单编号', '客户名称', '订单金额', '下单日期', '订单状态']
|
||||
document.querySelectorAll('.field-card').forEach((card) => card.addEventListener('click', () => {
|
||||
document.querySelectorAll('.field-card').forEach((item) => item.classList.remove('selected'))
|
||||
card.classList.add('selected')
|
||||
const name = card.dataset.field
|
||||
document.getElementById('selected-label').textContent = name
|
||||
const input = document.querySelector('#inspector-content input')
|
||||
if (input && fieldNames.includes(name)) input.value = name
|
||||
}))
|
||||
|
||||
document.querySelectorAll('.toggle').forEach((toggle) => toggle.addEventListener('click', () => toggle.classList.toggle('on')))
|
||||
document.querySelectorAll('.device-switch button').forEach((button) => button.addEventListener('click', () => {
|
||||
document.querySelectorAll('.device-switch button').forEach((item) => item.classList.toggle('active', item === button))
|
||||
document.querySelector('.form-sheet').style.maxWidth = button.textContent.trim() === '▯' ? '420px' : '690px'
|
||||
}))
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
// 模块数据驱动列表/编辑的「字段展示」工具:解析下拉选项、格式化日期等。
|
||||
// 供 fms-module-list / fms-module-edit 等按模块元数据渲染的组件共用;
|
||||
// 查询条件构建(buildQueryCondition 等)见 queryUtils.js。
|
||||
import { isEmptyValue } from './queryUtils'
|
||||
|
||||
const padDatePart = (number) => String(number).padStart(2, '0')
|
||||
|
||||
export function parseOptions(field) {
|
||||
if (!field?.b_options) return { optionConfig: {}, options: [] }
|
||||
try {
|
||||
const parsed = JSON.parse(field.b_options)
|
||||
let optionConfig = {}
|
||||
let source = []
|
||||
if (Array.isArray(parsed)) {
|
||||
source = parsed
|
||||
} else if (parsed && typeof parsed === 'object') {
|
||||
optionConfig = parsed
|
||||
if (Array.isArray(parsed.options)) {
|
||||
source = parsed.options
|
||||
} else if (Array.isArray(parsed.items)) {
|
||||
source = parsed.items
|
||||
}
|
||||
}
|
||||
const options = source.map((option) => {
|
||||
if (option && typeof option === 'object') {
|
||||
const value = option.value ?? option.key ?? option.id
|
||||
return { ...option, value, label: String(option.label ?? option.name ?? value ?? '') }
|
||||
}
|
||||
return { value: option, label: String(option ?? '') }
|
||||
})
|
||||
return { optionConfig, options }
|
||||
} catch {
|
||||
return { optionConfig: {}, options: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDateValue(value, withTime = false) {
|
||||
if (isEmptyValue(value)) return ''
|
||||
if (value && typeof value.format === 'function') {
|
||||
return value.format(withTime ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD')
|
||||
}
|
||||
const text = String(value).replace('T', ' ')
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(text)) return withTime ? text.slice(0, 19) : text.slice(0, 10)
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return text
|
||||
const datePart = `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}`
|
||||
return withTime
|
||||
? `${datePart} ${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}:${padDatePart(date.getSeconds())}`
|
||||
: datePart
|
||||
}
|
||||
+3
-47
@@ -1,9 +1,9 @@
|
||||
// 数据驱动列表的共享工具函数,避免在各组件间重复实现查询/格式化逻辑。
|
||||
// 模块数据驱动列表的「查询条件」工具:构建查询 SQL 片段、解析查询输入/默认值。
|
||||
// 供 fms-module-list / fms-module-edit 等按模块元数据渲染的组件共用;
|
||||
// 展示格式化(parseOptions / formatDateValue)见 fieldUtils.js。
|
||||
|
||||
export const identifierRe = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
|
||||
const padDatePart = (number) => String(number).padStart(2, '0')
|
||||
|
||||
export function escapeSqlText(value) {
|
||||
return String(value).replaceAll("'", "''")
|
||||
}
|
||||
@@ -18,35 +18,6 @@ export function isEmptyValue(value) {
|
||||
return value === undefined || value === null || value === ''
|
||||
}
|
||||
|
||||
export function parseOptions(field) {
|
||||
if (!field?.b_options) return { optionConfig: {}, options: [] }
|
||||
try {
|
||||
const parsed = JSON.parse(field.b_options)
|
||||
let optionConfig = {}
|
||||
let source = []
|
||||
if (Array.isArray(parsed)) {
|
||||
source = parsed
|
||||
} else if (parsed && typeof parsed === 'object') {
|
||||
optionConfig = parsed
|
||||
if (Array.isArray(parsed.options)) {
|
||||
source = parsed.options
|
||||
} else if (Array.isArray(parsed.items)) {
|
||||
source = parsed.items
|
||||
}
|
||||
}
|
||||
const options = source.map((option) => {
|
||||
if (option && typeof option === 'object') {
|
||||
const value = option.value ?? option.key ?? option.id
|
||||
return { ...option, value, label: String(option.label ?? option.name ?? value ?? '') }
|
||||
}
|
||||
return { value: option, label: String(option ?? '') }
|
||||
})
|
||||
return { optionConfig, options }
|
||||
} catch {
|
||||
return { optionConfig: {}, options: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function isRangeQuery(item) {
|
||||
return (
|
||||
item.config.b_operator === 'between' ||
|
||||
@@ -160,18 +131,3 @@ export function buildQueryCondition(item, value) {
|
||||
}
|
||||
return `${field} = ${literal}`
|
||||
}
|
||||
|
||||
export function formatDateValue(value, withTime = false) {
|
||||
if (isEmptyValue(value)) return ''
|
||||
if (value && typeof value.format === 'function') {
|
||||
return value.format(withTime ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD')
|
||||
}
|
||||
const text = String(value).replace('T', ' ')
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(text)) return withTime ? text.slice(0, 19) : text.slice(0, 10)
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return text
|
||||
const datePart = `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}`
|
||||
return withTime
|
||||
? `${datePart} ${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}:${padDatePart(date.getSeconds())}`
|
||||
: datePart
|
||||
}
|
||||
@@ -35,11 +35,8 @@ import {
|
||||
} from '@/components/ui'
|
||||
import { loadDataApi, loadDataBySqlApi, nextIdApi, saveObjectApi } from '@/services/api'
|
||||
import { diffRow } from '@/utils/dataChanges'
|
||||
import {
|
||||
identifierRe,
|
||||
isEmptyValue,
|
||||
parseOptions,
|
||||
} from '@/components/fms-module-list/moduleListUtils'
|
||||
import { identifierRe, isEmptyValue } from '@/components/fms-module-common/queryUtils'
|
||||
import { parseOptions } from '@/components/fms-module-common/fieldUtils'
|
||||
|
||||
const props = defineProps({
|
||||
/** 受控打开 */
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
buildQueryCondition,
|
||||
defaultQueryValue,
|
||||
escapeSqlText,
|
||||
parseOptions,
|
||||
requireModuleId,
|
||||
} from './moduleListUtils'
|
||||
} from '@/components/fms-module-common/queryUtils'
|
||||
import { parseOptions } from '@/components/fms-module-common/fieldUtils'
|
||||
import FmsModuleTable from './FmsModuleTable.vue'
|
||||
import FmsQueryToolbar from './FmsQueryToolbar.vue'
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, h, ref, shallowRef, useSlots, watch } from 'vue'
|
||||
import { Checkbox } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { useI18nStore } from '@/stores/i18n'
|
||||
import { formatDateValue, parseOptions } from './moduleListUtils'
|
||||
import { formatDateValue, parseOptions } from '@/components/fms-module-common/fieldUtils'
|
||||
|
||||
const props = defineProps({
|
||||
fields: { type: Array, default: () => [] },
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { DatePicker, Input, RangePicker, Select } from '@/components/ui'
|
||||
import { isDateQuery, isEmptyValue, isNumericField, isRangeQuery } from './moduleListUtils'
|
||||
import {
|
||||
isDateQuery,
|
||||
isEmptyValue,
|
||||
isNumericField,
|
||||
isRangeQuery,
|
||||
} from '@/components/fms-module-common/queryUtils'
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, required: true },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ChevronDown, ChevronUp, Ellipsis, Search } from '@lucide/vue'
|
||||
import { Button, Dropdown, FormItem, Select } from '@/components/ui'
|
||||
import { useI18nStore } from '@/stores/i18n'
|
||||
import FmsQueryControl from './FmsQueryControl.vue'
|
||||
import { defaultQueryValue, emptyQueryValue } from './moduleListUtils'
|
||||
import { defaultQueryValue, emptyQueryValue } from '@/components/fms-module-common/queryUtils'
|
||||
|
||||
const props = defineProps({
|
||||
queryItems: { type: Array, default: () => [] },
|
||||
|
||||
@@ -4,4 +4,3 @@ import FmsQueryControl from './FmsQueryControl.vue'
|
||||
import FmsQueryToolbar from './FmsQueryToolbar.vue'
|
||||
|
||||
export { FmsModuleListPage, FmsModuleTable, FmsQueryControl, FmsQueryToolbar }
|
||||
export * from './moduleListUtils.js'
|
||||
@@ -17,13 +17,6 @@ const router = createRouter({
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: { title: '登录' },
|
||||
},
|
||||
// 【临时】DnD 端到端验证页,验证后删除
|
||||
{
|
||||
path: '/tmp-dnd-test',
|
||||
name: 'tmp-dnd-test',
|
||||
component: () => import('@/views/tmp-dnd-panel.vue'),
|
||||
meta: { title: '临时 DnD 测试' },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: DefaultLayout,
|
||||
|
||||
@@ -4,6 +4,35 @@ export function cloneData(value) {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* 剔除派生字段:下划线前缀(_*)与视图前缀(v_*),与 diffRow 的 isIgnorableKey 对齐。
|
||||
* 前者为前端派生/虚拟字段(字段面板的 __form/__list/__query 参与勾选、i18n 透视行的
|
||||
* _id/_localeIds/_managedId 等),后者为视图只读列;均不参与比较、不提交后端。
|
||||
* 数组逐元素递归处理,其余非对象原样返回。
|
||||
* @template T
|
||||
* @param {T} value
|
||||
* @returns {T}
|
||||
*/
|
||||
export function stripInternalKeys(value) {
|
||||
if (Array.isArray(value)) return value.map(stripInternalKeys)
|
||||
if (!value || typeof value !== 'object') return value
|
||||
const stripped = {}
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
if (key.startsWith('_') || key.startsWith('v_')) continue
|
||||
stripped[key] = val
|
||||
}
|
||||
return stripped
|
||||
}
|
||||
|
||||
/**
|
||||
* 行集合差异比对(子表;单条记录字段级比对请用 diffRow)。
|
||||
* 比较时忽略派生字段(_* / v_*),避免字段面板的参与勾选等虚拟列造成「打开即脏」。
|
||||
*
|
||||
* 注意:inserts / updates 返回的是 current 中的原对象引用(不拷贝)——
|
||||
* 保存编排的 allocateTemporaryIds 依赖该引用共享,把临时 id → 真实 id 原地回灌到草稿行,
|
||||
* 随后的 remapForeignKeys 才能据此修正跨表外键。派生字段的剥离统一延后到
|
||||
* 提交前由 sanitizeSaveReqs 处理,切勿在此处返回剥离副本。
|
||||
*/
|
||||
export function diffRows(current, previous) {
|
||||
const previousById = new Map(previous.map((item) => [String(item.b_id), item]))
|
||||
const currentById = new Map(current.map((item) => [String(item.b_id), item]))
|
||||
@@ -11,7 +40,8 @@ export function diffRows(current, previous) {
|
||||
inserts: current.filter((item) => !previousById.has(String(item.b_id))),
|
||||
updates: current.filter((item) => {
|
||||
const old = previousById.get(String(item.b_id))
|
||||
return old && JSON.stringify(item) !== JSON.stringify(old)
|
||||
if (!old) return false
|
||||
return JSON.stringify(stripInternalKeys(item)) !== JSON.stringify(stripInternalKeys(old))
|
||||
}),
|
||||
deletes: previous
|
||||
.filter((item) => !currentById.has(String(item.b_id)))
|
||||
@@ -19,6 +49,20 @@ export function diffRows(current, previous) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交前剥离保存请求中的派生字段(_* / v_*),返回请求副本,不改原对象。
|
||||
* 必须在分配真实主键与跨表外键重映射之后调用:剥离产生副本,提前调用会切断
|
||||
* 请求行与草稿行的引用共享,导致临时 id 无法回灌(见 diffRows 注释)。
|
||||
*/
|
||||
export function sanitizeSaveReqs(saveReqs) {
|
||||
return (saveReqs || []).map((req) => {
|
||||
const next = { ...req }
|
||||
if (next.inserts) next.inserts = next.inserts.map(stripInternalKeys)
|
||||
if (next.updates) next.updates = next.updates.map(stripInternalKeys)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export async function allocateTemporaryIds(rowGroups) {
|
||||
const temporaryIds = [
|
||||
...new Set(
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 菜单基础信息面板(s_menu 主表单行)。
|
||||
*
|
||||
* 数据契约:默认 v-model 与父级 maindata 双向绑定,就地修改字段即可;
|
||||
* 父级另持 maindata_org 原始基线用于保存 diff,就地改不会污染保存基线。
|
||||
* 职责:字段展示与字段级交互、菜单类型联动、多语言名称编辑入口。
|
||||
* 保存校验规则的汇总定位与最终请求数据构建仍由父级统一处理。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { Languages } from '@lucide/vue'
|
||||
import { Button, Form, FormItem, Input, Select } from '@/components/ui'
|
||||
import IconPicker from '@/components/icon-picker/IconPicker.vue'
|
||||
import { MENU_TYPE_OPTIONS } from './utils'
|
||||
|
||||
const props = defineProps({
|
||||
/** 已保存菜单不允许修改菜单类型(父级按未保存标记计算) */
|
||||
typeLocked: { type: Boolean, default: false },
|
||||
/** 可绑定的业务模块下拉(父级加载一次后传入,避免每次选菜单重复请求) */
|
||||
moduleOptions: { type: Array, default: () => [] },
|
||||
/** 页面加载 / 保存中:禁用表单 */
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['openI18n'])
|
||||
|
||||
/** 菜单主表行(s_menu),父级 v-model 绑定 maindata */
|
||||
const maindata = defineModel({ type: Object, default: null })
|
||||
|
||||
const isPage = computed(() => maindata.value?.b_menu_type === 'page')
|
||||
const isExternal = computed(() => maindata.value?.b_menu_type === 'external')
|
||||
|
||||
/* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */
|
||||
const formRules = computed(() => ({
|
||||
b_code: [{ required: true, message: '请填写菜单编码' }],
|
||||
b_name: [{ required: true, message: '请填写菜单名称' }],
|
||||
b_menu_type: [{ required: true, message: '请选择菜单类型' }],
|
||||
b_route: isPage.value || isExternal.value ? [{ required: true, message: '请填写路由地址' }] : [],
|
||||
// 绑定模块非必填:页面菜单可不绑定业务模块(仅用于路由 + 数据来源),
|
||||
// 留空时保存为空,后续需要再补选。
|
||||
b_module_id: [],
|
||||
}))
|
||||
|
||||
const formRef = ref(null)
|
||||
|
||||
/** 保存前强制校验:返回带错误的字段列表 [{ name, errors }] */
|
||||
function validate() {
|
||||
return formRef.value?.validateAll?.() ?? []
|
||||
}
|
||||
|
||||
defineExpose({ validate })
|
||||
|
||||
function setField(field, value) {
|
||||
if (!maindata.value) return
|
||||
maindata.value[field] = value === '' || value == null ? null : value
|
||||
}
|
||||
|
||||
function onMenuTypeChange(menuType) {
|
||||
if (!maindata.value) return
|
||||
if (props.typeLocked) return
|
||||
maindata.value.b_menu_type = menuType
|
||||
// 目录和外链不绑定业务模块;页面同时使用路由和模块。
|
||||
if (menuType === 'directory') {
|
||||
maindata.value.b_route = null
|
||||
maindata.value.b_module_id = null
|
||||
} else if (menuType === 'external') {
|
||||
maindata.value.b_module_id = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 根节点直接渲染 Form:菜单配置卡片(父级 .mm-form-col__card)内的 DOM 结构与重构前一致 -->
|
||||
<Form
|
||||
v-if="maindata"
|
||||
ref="formRef"
|
||||
:model="maindata"
|
||||
:rules="formRules"
|
||||
layout="vertical"
|
||||
:gap="20"
|
||||
>
|
||||
<FormItem label="菜单名称" name="b_name" required>
|
||||
<div class="mm-name-field">
|
||||
<Input
|
||||
class="mm-name-field__input"
|
||||
:model-value="maindata.b_name"
|
||||
@update:model-value="(v) => setField('b_name', v)"
|
||||
/>
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-name-field__i18n"
|
||||
title="多语言名称"
|
||||
@click="emit('openI18n')"
|
||||
>
|
||||
<Languages :size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem label="菜单编码" name="b_code" required>
|
||||
<Input :model-value="maindata.b_code" @update:model-value="(v) => setField('b_code', v)" />
|
||||
</FormItem>
|
||||
<FormItem label="菜单类型" name="b_menu_type" required>
|
||||
<Select
|
||||
:model-value="maindata.b_menu_type"
|
||||
:options="MENU_TYPE_OPTIONS"
|
||||
:disabled="typeLocked || loading"
|
||||
@update:model-value="onMenuTypeChange"
|
||||
/>
|
||||
</FormItem>
|
||||
<!-- page: 路由地址 -->
|
||||
<FormItem v-if="isPage" label="路由地址" name="b_route" required>
|
||||
<Input :model-value="maindata.b_route" @update:model-value="(v) => setField('b_route', v)" />
|
||||
</FormItem>
|
||||
<!-- external: 外链地址(与路由地址共用 b_route 字段) -->
|
||||
<FormItem v-if="isExternal" label="外链地址" name="b_route" required>
|
||||
<Input :model-value="maindata.b_route" @update:model-value="(v) => setField('b_route', v)" />
|
||||
</FormItem>
|
||||
<!-- page: 绑定模块(非必填,可不绑定业务模块) -->
|
||||
<FormItem v-if="isPage" label="绑定模块" name="b_module_id">
|
||||
<Select
|
||||
:model-value="maindata.b_module_id"
|
||||
:options="moduleOptions"
|
||||
clearable
|
||||
@update:model-value="(v) => setField('b_module_id', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="图标" name="b_icon">
|
||||
<IconPicker
|
||||
:model-value="maindata.b_icon"
|
||||
@update:model-value="(v) => setField('b_icon', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="排序号" name="b_xh">
|
||||
<Input
|
||||
:model-value="maindata.b_xh"
|
||||
type="number"
|
||||
@update:model-value="(v) => setField('b_xh', Number(v) || 0)"
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 菜单名称 + 多语言图标按钮 */
|
||||
.mm-name-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mm-name-field__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mm-name-field__i18n {
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 权限来源库面板(全库聚合的操作 / 按钮权限,快速导入当前菜单)。
|
||||
*
|
||||
* 与 MenuPowersPanel 一样是页面右侧的独立面板:右侧 Splitter 三栏布局
|
||||
* (菜单配置 / 当前菜单权限 / 权限库),Splitter 只认直接子节点 SplitterPanel,
|
||||
* 两张权限卡必须各占一个槽位,无法由同一个组件渲染成两个根节点,故独立成面板。
|
||||
*
|
||||
* 数据契约:默认 v-model 与父级 powers 双向绑定,导入即向其中追加行;
|
||||
* 父级另持 powers_org 基线用于保存 diff。
|
||||
*/
|
||||
import { computed, onMounted, ref, shallowRef } from 'vue'
|
||||
import { Plus, RefreshCw } from '@lucide/vue'
|
||||
import { Button, Message } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { loadDataBySqlApi } from '@/services/api'
|
||||
import { createTempId } from '@/utils/common'
|
||||
|
||||
/**
|
||||
* 来源库数据与「是否已加载过」标记放在模块作用域:
|
||||
* 面板随选中菜单的切换(maindata 置空)反复挂载,而来源库是全库聚合数据、与菜单无关,
|
||||
* 因此整页会话内只加载一次,只有用户点「刷新」才重新拉取(与模块下拉的缓存策略一致)。
|
||||
*/
|
||||
let libraryLoaded = false
|
||||
const cachedLibrary = ref([])
|
||||
|
||||
/** 当前菜单权限点行,父级 v-model 绑定 powers(导入时追加) */
|
||||
const powers = defineModel({ type: Array, default: () => [] })
|
||||
|
||||
const libraryLoading = ref(false)
|
||||
/** 来源库勾选的权限编码 */
|
||||
const selectedCodes = ref([])
|
||||
|
||||
const columns = shallowRef([
|
||||
// 权限库按容器自然分配列宽,不提供固定宽度,也不启用列宽拖拽
|
||||
{ dataIndex: 'b_code', title: '权限编码' },
|
||||
{ dataIndex: 'b_name', title: '权限名称' },
|
||||
])
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedCodes.value,
|
||||
onChange: (keys) => {
|
||||
selectedCodes.value = keys
|
||||
},
|
||||
}))
|
||||
|
||||
/** 聚合全库出现的操作权限(编码 / 名称,按编码去重) */
|
||||
async function loadPowerLibrary() {
|
||||
libraryLoading.value = true
|
||||
try {
|
||||
const res = await loadDataBySqlApi(
|
||||
`SELECT b_code, MAX(b_name) AS b_name FROM s_power
|
||||
WHERE b_power_type = 'action' AND b_code IS NOT NULL AND b_code <> ''
|
||||
GROUP BY b_code ORDER BY b_code`,
|
||||
)
|
||||
cachedLibrary.value = (res.data || []).map((row) => ({
|
||||
b_code: row.b_code,
|
||||
b_name: row.b_name,
|
||||
}))
|
||||
libraryLoaded = true
|
||||
selectedCodes.value = []
|
||||
} catch (e) {
|
||||
Message.error('加载权限库失败:' + (e?.message || e))
|
||||
cachedLibrary.value = []
|
||||
} finally {
|
||||
libraryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 把来源库勾选的权限导入当前菜单(跳过已存在编码) */
|
||||
function importSelectedPowers() {
|
||||
const codes = selectedCodes.value
|
||||
if (!codes.length) {
|
||||
Message.warning('请先在权限库中勾选要导入的权限')
|
||||
return
|
||||
}
|
||||
const existing = new Set(powers.value.map((r) => String(r.b_code || '').trim()))
|
||||
const added = []
|
||||
for (const code of codes) {
|
||||
if (existing.has(String(code).trim())) continue
|
||||
const lib = cachedLibrary.value.find((r) => String(r.b_code) === String(code))
|
||||
added.push({
|
||||
b_id: createTempId(),
|
||||
b_code: lib?.b_code || code,
|
||||
b_name: lib?.b_name || '',
|
||||
b_power_type: 'action',
|
||||
b_canuse: 1,
|
||||
})
|
||||
existing.add(String(code).trim())
|
||||
}
|
||||
if (!added.length) {
|
||||
Message.warning('勾选的权限均已存在,无需导入')
|
||||
return
|
||||
}
|
||||
powers.value = [...powers.value, ...added]
|
||||
selectedCodes.value = []
|
||||
Message.success(`已导入 ${added.length} 条权限`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!libraryLoaded) void loadPowerLibrary()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mm-power-card">
|
||||
<div class="mm-power-card__head">
|
||||
<div class="mm-pane__title">权限库</div>
|
||||
<div class="mm-power-toolbar">
|
||||
<Button type="outline" size="small" :loading="libraryLoading" @click="loadPowerLibrary">
|
||||
<RefreshCw :size="12" /> 刷新
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!selectedCodes.length"
|
||||
@click="importSelectedPowers"
|
||||
>
|
||||
<Plus :size="12" /> 导入选中{{ selectedCodes.length ? `(${selectedCodes.length})` : '' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="libraryLoading" class="mm-power-loading">加载中...</div>
|
||||
<div v-else class="mm-lib-table-wrap">
|
||||
<FmsTable
|
||||
v-model:columns="columns"
|
||||
:data-source="cachedLibrary"
|
||||
:seq="false"
|
||||
row-key="b_code"
|
||||
:row-selection="rowSelection"
|
||||
:auto-fit-width="true"
|
||||
@change="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ---- 权限卡片 ---- */
|
||||
.mm-power-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mm-power-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.mm-pane__title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 区块标题 */
|
||||
.mm-pane__title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
/* 权限点列表工具条 */
|
||||
.mm-power-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mm-power-loading {
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 来源库表格容器(撑满卡片剩余高度) */
|
||||
.mm-lib-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 当前菜单权限点面板(s_power.b_power_type='action',操作 / 按钮权限)。
|
||||
*
|
||||
* 数据契约:默认 v-model 与父级 powers 双向绑定;父级另持 powers_org 基线(JSON 字符串)
|
||||
* 用于未保存判断与保存 diff,就地改行不会污染基线。
|
||||
* 职责:权限点表格展示、新增 / 删除 / 编辑、表格选择状态。
|
||||
* 权限新增行的临时 id 分配与落库由父级保存编排统一处理。
|
||||
*/
|
||||
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
|
||||
import { Plus, Trash2 } from '@lucide/vue'
|
||||
import { Button, Message } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { createTempId } from '@/utils/common'
|
||||
|
||||
const props = defineProps({
|
||||
/** 权限点加载中 */
|
||||
loading: { type: Boolean, default: false },
|
||||
/** 当前菜单 id:切换菜单时清空行勾选,避免上个菜单的选中计数残留到新菜单 */
|
||||
menuId: { type: [String, Number], default: null },
|
||||
})
|
||||
|
||||
/** 当前菜单权限点行,父级 v-model 绑定 powers */
|
||||
const powers = defineModel({ type: Array, default: () => [] })
|
||||
|
||||
/** 权限点表格列配置 */
|
||||
const columns = shallowRef([
|
||||
{
|
||||
dataIndex: 'b_code',
|
||||
title: '权限编码',
|
||||
width: 150,
|
||||
editor: { type: 'input' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_name',
|
||||
title: '权限名称',
|
||||
width: 180,
|
||||
editor: { type: 'input' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_canuse',
|
||||
title: '启用',
|
||||
align: 'center',
|
||||
width: 70,
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '操作',
|
||||
align: 'center',
|
||||
width: 70,
|
||||
actions: [
|
||||
{
|
||||
type: 'delete',
|
||||
label: '删除',
|
||||
onClick: (row) => removePowerRow(row),
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const tableRef = ref(null)
|
||||
const selectedKeys = ref([])
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedKeys.value = keys
|
||||
},
|
||||
}))
|
||||
|
||||
// 切换菜单后清空勾选:旧菜单的行 id 在新菜单下无意义,会让「删除(N)」显示对不上的计数
|
||||
watch(
|
||||
() => props.menuId,
|
||||
() => {
|
||||
selectedKeys.value = []
|
||||
},
|
||||
)
|
||||
|
||||
/** 新增操作 / 按钮权限点(临时负数 id,落库时由父级统一换真实主键) */
|
||||
async function addPowerRow() {
|
||||
if (props.loading) return
|
||||
const newId = createTempId()
|
||||
powers.value = [
|
||||
...powers.value,
|
||||
{ b_id: newId, b_code: '', b_name: '', b_power_type: 'action', b_canuse: 1 },
|
||||
]
|
||||
selectedKeys.value = []
|
||||
await nextTick()
|
||||
tableRef.value?.editRow?.(String(newId), 'b_code')
|
||||
}
|
||||
|
||||
function removePowerRow(row) {
|
||||
powers.value = powers.value.filter((r) => String(r.b_id) !== String(row.b_id))
|
||||
selectedKeys.value = selectedKeys.value.filter((k) => String(k) !== String(row.b_id))
|
||||
}
|
||||
|
||||
/** 批量删除勾选的权限点 */
|
||||
function batchDeletePowers() {
|
||||
const keys = selectedKeys.value
|
||||
if (!keys.length) return
|
||||
const delSet = new Set(keys.map(String))
|
||||
powers.value = powers.value.filter((r) => !delSet.has(String(r.b_id)))
|
||||
selectedKeys.value = []
|
||||
Message.success(`已删除 ${keys.length} 条权限`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mm-power-card">
|
||||
<div class="mm-power-card__head">
|
||||
<div class="mm-pane__title">当前菜单权限</div>
|
||||
<div class="mm-power-toolbar">
|
||||
<Button type="primary" size="small" :disabled="loading" @click="addPowerRow">
|
||||
<Plus :size="12" /> 新增
|
||||
</Button>
|
||||
<Button
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="loading || !selectedKeys.length"
|
||||
@click="batchDeletePowers"
|
||||
>
|
||||
<Trash2 :size="12" /> 删除{{ selectedKeys.length ? `(${selectedKeys.length})` : '' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" class="mm-power-loading">加载中...</div>
|
||||
<div v-else class="mm-power-table-wrap">
|
||||
<FmsTable
|
||||
ref="tableRef"
|
||||
v-model:columns="columns"
|
||||
:data-source="powers"
|
||||
:editable="true"
|
||||
row-key="b_id"
|
||||
:row-selection="rowSelection"
|
||||
:col-resizable="true"
|
||||
@change="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ---- 权限卡片 ---- */
|
||||
.mm-power-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mm-power-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.mm-pane__title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 区块标题 */
|
||||
.mm-pane__title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
/* 权限点列表工具条 */
|
||||
.mm-power-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mm-power-loading {
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 权限点 FmsTable 容器(撑满卡片剩余高度) */
|
||||
.mm-power-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,262 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 菜单树面板(menu-management 左侧)。
|
||||
*
|
||||
* 职责:搜索框与树展示,展开 / 选中 / 拖拽 / 右键菜单的 UI 交互,节点启用停用的操作入口。
|
||||
* 数据契约:树数据、选中项、未保存标记均由父级唯一持有,本组件只读并通过事件上报意图:
|
||||
* select / add / delete / drop / refresh / toggleEnable。
|
||||
* 搜索关键字与展开键是纯 UI 状态,留在本组件;展开键需由父级在新建菜单后追加祖先路径,
|
||||
* 故以 v-model:expanded-keys 暴露。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ExternalLink, FileText, Folder, Plus, RefreshCw, Search, Trash2 } from '@lucide/vue'
|
||||
import { Button, Checkbox, Dropdown, Input, Tree } from '@/components/ui'
|
||||
import { collectAllKeys, filterTreeByText } from '@/utils/tree'
|
||||
|
||||
const props = defineProps({
|
||||
/** 菜单树(s_menu 全量),父级持有并负责加载 / 保存 */
|
||||
treeData: { type: Array, required: true },
|
||||
/** 树数据加载中 */
|
||||
loading: { type: Boolean, default: false },
|
||||
/** 当前选中菜单 id */
|
||||
selectedId: { type: [String, Number], default: null },
|
||||
/** 展开的树节点 key(v-model:新建菜单后由父级追加祖先路径) */
|
||||
expandedKeys: { type: Array, default: () => [] },
|
||||
/** 未落库(新建未保存)的菜单 id 集合;父级唯一状态源,本组件只读 */
|
||||
unsavedIds: { type: Set, default: () => new Set() },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:expandedKeys',
|
||||
'select',
|
||||
'add',
|
||||
'delete',
|
||||
'drop',
|
||||
'refresh',
|
||||
'toggleEnable',
|
||||
])
|
||||
|
||||
/** 搜索关键字(纯 UI 状态) */
|
||||
const keyword = ref('')
|
||||
|
||||
const filteredTree = computed(() => filterTreeByText(props.treeData, keyword.value))
|
||||
|
||||
// 搜索时展开全部(保证命中节点可见),清空时收起
|
||||
watch(keyword, (kw) => {
|
||||
emit('update:expandedKeys', kw.trim() ? collectAllKeys(props.treeData) : [])
|
||||
})
|
||||
|
||||
const addOptions = [
|
||||
{ key: 'directory', label: '新增目录', icon: Folder },
|
||||
{ key: 'page', label: '新增页面', icon: FileText },
|
||||
{ key: 'external', label: '新增外链', icon: ExternalLink },
|
||||
]
|
||||
|
||||
/** 选中:仅上报目标 id,是否需要未保存确认由父级决定 */
|
||||
function onSelect(keys) {
|
||||
if (!keys.length) return
|
||||
const nextId = String(keys[0])
|
||||
if (nextId === String(props.selectedId)) return
|
||||
emit('select', nextId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 右键菜单项数据源。
|
||||
* 未保存(草稿)节点不能作为父级(其 id 尚未落库,子菜单会凭空挂在临时 id 下)。
|
||||
*/
|
||||
function buildContextMenu(node) {
|
||||
const isUnsaved = node != null && props.unsavedIds.has(String(node.b_id))
|
||||
const items = addOptions.map((option) => ({ ...option, disabled: isUnsaved }))
|
||||
if (node) {
|
||||
items.push({ divider: true })
|
||||
items.push({ key: 'delete', label: '删除', icon: Trash2, danger: true })
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
function onContextMenuClick({ item, node }) {
|
||||
const parentId = node ? String(node.b_id) : null
|
||||
if (item.key === 'delete') emit('delete', parentId)
|
||||
else emit('add', { parentId, menuType: item.key })
|
||||
}
|
||||
|
||||
/** 工具栏新增:挂到当前选中节点下,无选中则顶级 */
|
||||
function onToolbarAdd({ key }) {
|
||||
emit('add', { parentId: props.selectedId ? String(props.selectedId) : null, menuType: key })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="mm-tree-panel">
|
||||
<div class="mm-tree-header">
|
||||
<Input v-model="keyword" allow-clear class="mm-tree-search">
|
||||
<template #prefix><Search :size="14" class="mm-tree-search__icon" /></template>
|
||||
</Input>
|
||||
<div class="mm-tree-header__actions">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-tree-header__icon-btn"
|
||||
title="刷新菜单树"
|
||||
aria-label="刷新菜单树"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<RefreshCw :size="14" />
|
||||
</Button>
|
||||
<Dropdown :items="addOptions" @click="onToolbarAdd">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-tree-header__icon-btn"
|
||||
title="新增菜单"
|
||||
aria-label="新增菜单"
|
||||
>
|
||||
<Plus :size="14" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mm-tree-panel__scroll">
|
||||
<div v-if="loading" class="mm-tree-empty">加载中...</div>
|
||||
<!-- 过滤(搜索)状态下禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
|
||||
<Tree
|
||||
v-else
|
||||
:tree-data="filteredTree"
|
||||
:field-names="{ key: 'b_id', title: 'b_name', children: 'children' }"
|
||||
:expanded-keys="expandedKeys"
|
||||
@update:expanded-keys="(keys) => emit('update:expandedKeys', keys)"
|
||||
:selected-keys="selectedId ? [selectedId] : []"
|
||||
:context-menu="buildContextMenu"
|
||||
:draggable="!keyword.trim()"
|
||||
@select="onSelect"
|
||||
@context-menu-click="onContextMenuClick"
|
||||
@drop="(payload) => emit('drop', payload)"
|
||||
>
|
||||
<template #title="{ node }">
|
||||
<span class="mm-tree-node">
|
||||
<span
|
||||
class="mm-tree-node__name"
|
||||
:class="{ 'is-disabled': Number(node.b_canuse) !== 1 }"
|
||||
>
|
||||
{{ node.b_name || node.b_code || '(未命名)' }}
|
||||
</span>
|
||||
<Checkbox
|
||||
class="mm-tree-node__enable"
|
||||
:model-value="Number(node.b_canuse) === 1"
|
||||
@click.stop
|
||||
@change="(checked) => emit('toggleEnable', { node, checked })"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</Tree>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ---- 左侧树面板 ---- */
|
||||
.mm-tree-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mm-tree-panel__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mm-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mm-tree-search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mm-tree-search__icon {
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.mm-tree-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mm-tree-header__icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
background: var(--fms-card);
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
background 150ms ease,
|
||||
color 150ms ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--fms-secondary);
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 树节点 ---- */
|
||||
.mm-tree-node {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 2px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.mm-tree-node__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.is-disabled {
|
||||
color: var(--fms-text-disabled, #9ca3af);
|
||||
}
|
||||
}
|
||||
|
||||
/* 树节点启用勾选框:靠右、不随文字缩放 */
|
||||
.mm-tree-node__enable {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mm-tree-empty {
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 菜单管理(menu-management)页面级纯函数。
|
||||
* 只放无 Vue 状态依赖的逻辑:菜单类型转换、树节点定位、排序号重排、目录节点标记。
|
||||
* 树本身的通用查找/过滤/插入/移除见 @/utils/tree,不在此重复。
|
||||
*/
|
||||
import { ExternalLink, FileText, Folder } from '@lucide/vue'
|
||||
|
||||
/** 菜单类型下拉选项(directory 目录 / page 页面 / external 外链) */
|
||||
export const MENU_TYPE_OPTIONS = [
|
||||
{ label: '目录', value: 'directory' },
|
||||
{ label: '页面', value: 'page' },
|
||||
{ label: '外链', value: 'external' },
|
||||
]
|
||||
|
||||
/** 菜单类型 → 树节点图标(预载到 node.icon,tree-node.vue 自动渲染) */
|
||||
export const MENU_TYPE_ICON_MAP = {
|
||||
directory: Folder,
|
||||
page: FileText,
|
||||
external: ExternalLink,
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录节点强制非叶子(即使暂无子菜单也显示展开箭头,便于新增子菜单),
|
||||
* 并预载类型图标到 node.icon。就地修改并返回原数组。
|
||||
* @param {Array} nodes 树节点数组
|
||||
* @returns {Array} 原数组
|
||||
*/
|
||||
export function markDirectoryNonLeaf(nodes) {
|
||||
for (const node of nodes) {
|
||||
if (node.b_menu_type === 'directory') node.isLeaf = false
|
||||
const icon = MENU_TYPE_ICON_MAP[node.b_menu_type]
|
||||
if (icon) node.icon = icon
|
||||
if (node.children) markDirectoryNonLeaf(node.children)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* 从树节点提取纯菜单行(剔除 children/isLeaf/icon 等树注入字段)。
|
||||
* 未落库节点切回时,用它从树节点重建主表草稿。
|
||||
* @param {Object} node 树节点
|
||||
* @returns {Object} s_menu 行
|
||||
*/
|
||||
export function menuRowOf(node) {
|
||||
return {
|
||||
b_id: node.b_id,
|
||||
b_code: node.b_code,
|
||||
b_parent_id: node.b_parent_id ? String(node.b_parent_id) : null,
|
||||
b_name: node.b_name,
|
||||
b_i18n: node.b_i18n ?? null,
|
||||
b_menu_type: node.b_menu_type,
|
||||
b_route: node.b_route ?? null,
|
||||
b_module_id: node.b_module_id ?? null,
|
||||
b_icon: node.b_icon ?? null,
|
||||
b_xh: node.b_xh,
|
||||
b_canuse: node.b_canuse,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在树中查找节点所在的父数组及索引。
|
||||
* @param {Array} nodes 树节点数组
|
||||
* @param {string|number} id 目标节点 ID
|
||||
* @returns {{ list: Array, index: number }|null}
|
||||
*/
|
||||
export function findNodePosition(nodes, id) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
|
||||
if (nodes[i].children) {
|
||||
const found = findNodePosition(nodes[i].children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 重排某组兄弟的 b_xh(10 起步、步长 10),并把变化的行收集到 updatedRows。
|
||||
* @param {Array} list 兄弟节点数组(就地修改 b_xh)
|
||||
* @param {Array} updatedRows 变化行收集器({ b_id, b_xh })
|
||||
*/
|
||||
export function renumberSiblings(list, updatedRows) {
|
||||
list.forEach((s, i) => {
|
||||
const nextXh = (i + 1) * 10
|
||||
if (s.b_xh !== nextXh) {
|
||||
s.b_xh = nextXh
|
||||
updatedRows.push({ b_id: s.b_id, b_xh: nextXh })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -9,18 +9,18 @@
|
||||
* ├ 预览区:分段着色的编码预览,居中展示
|
||||
* └ 底部:重置周期
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Input, Select, Switch, Form, FormItem } from '@/components/ui'
|
||||
import { createTempId } from '@/utils/common'
|
||||
|
||||
const props = defineProps({
|
||||
moduleId: { type: String, required: true },
|
||||
autoCode: { type: Array, default: () => [] },
|
||||
fields: { type: Array, default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
/** 自动编码规则(s_autocode,每模块最多一条),父级 v-model 绑定 autocode */
|
||||
const autoCode = defineModel({ type: Array, default: () => [] })
|
||||
|
||||
const DATE_FORMAT_OPTIONS = [
|
||||
{ label: '不使用日期', value: '' },
|
||||
@@ -42,14 +42,46 @@ const RESET_OPTIONS = [
|
||||
{ label: '每日', value: 'day' },
|
||||
]
|
||||
|
||||
const localRow = ref(null)
|
||||
function createDraft(moduleId, fields, enabled = false) {
|
||||
const textFields = (fields || []).filter(
|
||||
(item) =>
|
||||
Number(item.b_canuse) === 1 &&
|
||||
item.b_field !== 'b_id' &&
|
||||
(item.b_type === 'input' || item.b_type === 'textarea'),
|
||||
)
|
||||
return {
|
||||
b_id: createTempId(),
|
||||
b_module_id: String(moduleId),
|
||||
b_field_id: textFields[0]?.b_id != null ? String(textFields[0].b_id) : null,
|
||||
b_prefix: '',
|
||||
b_dateformat: 'yyyyMM',
|
||||
b_separator: '-',
|
||||
b_seqwidth: 5,
|
||||
b_resettype: 'month',
|
||||
b_startvalue: 1,
|
||||
b_currentperiod: null,
|
||||
b_currentvalue: 0,
|
||||
b_canuse: enabled ? 1 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** 已落草稿的规则行;模块从未配置过自动编码时为 null */
|
||||
const localRow = computed(() => (autoCode.value.length ? autoCode.value[0] : null))
|
||||
|
||||
/** 未配置时的渲染占位行:只用于展示与校验,不写回 model,
|
||||
* 避免打开面板即建行导致「无变更却 diff 出一条 insert」 */
|
||||
const emptyRow = computed(() => createDraft(props.moduleId, props.fields, false))
|
||||
|
||||
/** 表单绑定行:优先真实行,未配置时回退占位行,保证「启用」开关始终可见可拨。
|
||||
* 就地改字段即可——autocode 与 autocode_org 互为深拷贝,不会污染保存基线。 */
|
||||
const formRow = computed(() => localRow.value || emptyRow.value)
|
||||
|
||||
const autoCodeEnabled = computed(() => Number(localRow.value?.b_canuse) === 1)
|
||||
|
||||
/* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */
|
||||
const formRules = computed(() => {
|
||||
const field = (props.fields || []).find(
|
||||
(item) => String(item.b_id) === String(localRow.value?.b_field_id || ''),
|
||||
(item) => String(item.b_id) === String(formRow.value?.b_field_id || ''),
|
||||
)
|
||||
return {
|
||||
b_field_id: [
|
||||
@@ -117,48 +149,21 @@ const fieldOptions = computed(() => {
|
||||
return options
|
||||
})
|
||||
|
||||
function createDraft(moduleId, fields, enabled = false) {
|
||||
const textFields = (fields || []).filter(
|
||||
(item) =>
|
||||
Number(item.b_canuse) === 1 &&
|
||||
item.b_field !== 'b_id' &&
|
||||
(item.b_type === 'input' || item.b_type === 'textarea'),
|
||||
)
|
||||
return {
|
||||
b_id: createTempId(),
|
||||
b_module_id: String(moduleId),
|
||||
b_field_id: textFields[0]?.b_id != null ? String(textFields[0].b_id) : null,
|
||||
b_prefix: '',
|
||||
b_dateformat: 'yyyyMM',
|
||||
b_separator: '-',
|
||||
b_seqwidth: 5,
|
||||
b_resettype: 'month',
|
||||
b_startvalue: 1,
|
||||
b_currentperiod: null,
|
||||
b_currentvalue: 0,
|
||||
b_canuse: enabled ? 1 : 0,
|
||||
}
|
||||
/** 惰性建行:用户首次操作(拨「启用」或改任意参数)时才落一行到草稿。
|
||||
* 返回 model 中的行对象(响应式代理),就地改其属性即可同时驱动视图与父级草稿。 */
|
||||
function ensureRow() {
|
||||
if (autoCode.value.length) return autoCode.value[0]
|
||||
autoCode.value = [createDraft(props.moduleId, props.fields, false)]
|
||||
return autoCode.value[0]
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.autoCode,
|
||||
(rows) => {
|
||||
const row = rows && rows.length ? rows[0] : null
|
||||
localRow.value = row ? { ...row } : createDraft(props.moduleId, props.fields, false)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function toggleEnabled(checked) {
|
||||
if (!localRow.value) return
|
||||
localRow.value.b_canuse = checked ? 1 : 0
|
||||
if (localRow.value) emit('update', [{ ...localRow.value }])
|
||||
const row = ensureRow()
|
||||
row.b_canuse = checked ? 1 : 0
|
||||
}
|
||||
|
||||
function setRowValue(field, value) {
|
||||
if (!localRow.value) return
|
||||
localRow.value[field] = value
|
||||
if (localRow.value) emit('update', [{ ...localRow.value }])
|
||||
ensureRow()[field] = value
|
||||
}
|
||||
|
||||
function pad(value) {
|
||||
@@ -189,26 +194,26 @@ function currentPeriod(date, resetType) {
|
||||
|
||||
/* 编码预览:前缀 + 日期 + 流水号 拼接后的完整文本 */
|
||||
const preview = computed(() => {
|
||||
if (!localRow.value) return ''
|
||||
if (!formRow.value) return ''
|
||||
const today = new Date()
|
||||
const startValue = Number(localRow.value.b_startvalue) || 1
|
||||
const storedValue = Number(localRow.value.b_currentvalue) || 0
|
||||
const activePeriod = currentPeriod(today, localRow.value.b_resettype)
|
||||
const sequence = localRow.value.b_currentperiod === activePeriod ? storedValue + 1 : startValue
|
||||
const startValue = Number(formRow.value.b_startvalue) || 1
|
||||
const storedValue = Number(formRow.value.b_currentvalue) || 0
|
||||
const activePeriod = currentPeriod(today, formRow.value.b_resettype)
|
||||
const sequence = formRow.value.b_currentperiod === activePeriod ? storedValue + 1 : startValue
|
||||
const parts = []
|
||||
if (localRow.value.b_prefix) parts.push(localRow.value.b_prefix)
|
||||
const dateText = formatDate(today, localRow.value.b_dateformat)
|
||||
if (formRow.value.b_prefix) parts.push(formRow.value.b_prefix)
|
||||
const dateText = formatDate(today, formRow.value.b_dateformat)
|
||||
if (dateText) parts.push(dateText)
|
||||
parts.push(String(sequence).padStart(Number(localRow.value.b_seqwidth) || 1, '0'))
|
||||
return parts.join(localRow.value.b_separator || '')
|
||||
parts.push(String(sequence).padStart(Number(formRow.value.b_seqwidth) || 1, '0'))
|
||||
return parts.join(formRow.value.b_separator || '')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="localRow" class="mm-auto-code">
|
||||
<div v-if="formRow" class="mm-auto-code">
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="localRow"
|
||||
:model="formRow"
|
||||
:rules="formRules"
|
||||
layout="vertical"
|
||||
:columns="5"
|
||||
@@ -224,7 +229,7 @@ const preview = computed(() => {
|
||||
</FormItem>
|
||||
<FormItem label="业务编号字段" name="b_field_id" :span="2">
|
||||
<Select
|
||||
:model-value="String(localRow.b_field_id || '')"
|
||||
:model-value="String(formRow.b_field_id || '')"
|
||||
:options="fieldOptions"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
placeholder="选择文本字段"
|
||||
@@ -233,7 +238,7 @@ const preview = computed(() => {
|
||||
</FormItem>
|
||||
<FormItem label="重置周期" name="b_resettype" :span="2">
|
||||
<Select
|
||||
:model-value="localRow.b_resettype"
|
||||
:model-value="formRow.b_resettype"
|
||||
:options="RESET_OPTIONS"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
@update:model-value="(v) => setRowValue('b_resettype', v)"
|
||||
@@ -243,7 +248,7 @@ const preview = computed(() => {
|
||||
<!-- 第二行:前缀 / 日期 / 分隔符 / 位数 / 起始值 -->
|
||||
<FormItem label="前缀" name="b_prefix">
|
||||
<Input
|
||||
:model-value="localRow.b_prefix"
|
||||
:model-value="formRow.b_prefix"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
placeholder="如 FMS"
|
||||
@update:model-value="(v) => setRowValue('b_prefix', v)"
|
||||
@@ -251,7 +256,7 @@ const preview = computed(() => {
|
||||
</FormItem>
|
||||
<FormItem label="日期" name="b_dateformat">
|
||||
<Select
|
||||
:model-value="localRow.b_dateformat"
|
||||
:model-value="formRow.b_dateformat"
|
||||
:options="DATE_FORMAT_OPTIONS"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
allow-free-input
|
||||
@@ -260,7 +265,7 @@ const preview = computed(() => {
|
||||
</FormItem>
|
||||
<FormItem label="分隔符" name="b_separator">
|
||||
<Input
|
||||
:model-value="localRow.b_separator"
|
||||
:model-value="formRow.b_separator"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
placeholder="-"
|
||||
@update:model-value="(v) => setRowValue('b_separator', v)"
|
||||
@@ -268,7 +273,7 @@ const preview = computed(() => {
|
||||
</FormItem>
|
||||
<FormItem label="位数" name="b_seqwidth">
|
||||
<Input
|
||||
:model-value="localRow.b_seqwidth"
|
||||
:model-value="formRow.b_seqwidth"
|
||||
type="number"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
placeholder="5"
|
||||
@@ -277,7 +282,7 @@ const preview = computed(() => {
|
||||
</FormItem>
|
||||
<FormItem label="起始值" name="b_startvalue">
|
||||
<Input
|
||||
:model-value="localRow.b_startvalue"
|
||||
:model-value="formRow.b_startvalue"
|
||||
type="number"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
placeholder="1"
|
||||
|
||||
@@ -3,29 +3,21 @@
|
||||
* 模块基础信息面板(s_module)。
|
||||
* 注意:新系统将图标/路由移到 s_menu,模块本身只维护数据语义:
|
||||
* category 分类节点 / data 数据模块 / query 查询模块。
|
||||
*
|
||||
* 数据契约:默认 v-model 与父级 maindata(s_module 主表行)双向绑定,就地修改字段即可,
|
||||
* 父级另持 maindata_org 原始基线用于保存 diff,就地改不会污染保存基线。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Input, Select, Switch, Form, FormGroup, FormItem } from '@/components/ui'
|
||||
import { buildModuleI18nKey } from './utils'
|
||||
|
||||
const props = defineProps({
|
||||
data: { type: Object, default: null },
|
||||
parentName: { type: String, default: '' },
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
const formData = ref(null)
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(val) => {
|
||||
if (val === formData.value) return
|
||||
formData.value = val ? { ...val } : null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
/** 模块主表行(s_module),父级 v-model 绑定 maindata */
|
||||
const maindata = defineModel({ type: Object, default: null })
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '分类', value: 'category' },
|
||||
@@ -40,9 +32,9 @@ const deletePolicyOptions = [
|
||||
{ label: 'hard · 临时记录', value: 'hard' },
|
||||
]
|
||||
|
||||
const isCategory = computed(() => formData.value?.b_module_type === 'category')
|
||||
const isData = computed(() => formData.value?.b_module_type === 'data')
|
||||
const isQuery = computed(() => formData.value?.b_module_type === 'query')
|
||||
const isCategory = computed(() => maindata.value?.b_module_type === 'category')
|
||||
const isData = computed(() => maindata.value?.b_module_type === 'data')
|
||||
const isQuery = computed(() => maindata.value?.b_module_type === 'query')
|
||||
|
||||
/* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */
|
||||
// data/query 的必填项与数据库 CK_s_module_config 约束对齐:
|
||||
@@ -84,46 +76,43 @@ function validate() {
|
||||
defineExpose({ validate })
|
||||
|
||||
function setField(field, value) {
|
||||
if (!formData.value) return
|
||||
formData.value[field] = value === '' ? null : value
|
||||
emit('update', formData.value)
|
||||
if (!maindata.value) return
|
||||
maindata.value[field] = value === '' ? null : value
|
||||
}
|
||||
|
||||
// 编码变化时始终以完整编码重新派生多语言键
|
||||
function onCodeInput(v) {
|
||||
setField('b_code', v)
|
||||
if (formData.value) {
|
||||
formData.value.b_i18n = buildModuleI18nKey(formData.value.b_module_type, v)
|
||||
emit('update', formData.value)
|
||||
if (maindata.value) {
|
||||
maindata.value.b_i18n = buildModuleI18nKey(maindata.value.b_module_type, v)
|
||||
}
|
||||
}
|
||||
|
||||
function onModuleTypeChange(v) {
|
||||
if (!formData.value) return
|
||||
formData.value.b_module_type = v
|
||||
if (!maindata.value) return
|
||||
maindata.value.b_module_type = v
|
||||
// 切换类型时清掉不适用字段,与数据库 CK_s_module_config 约束对齐
|
||||
// (category 要求数据来源字段全空;query 要求保存表/删除策略为空),否则残留值会导致保存失败
|
||||
if (v === 'category') {
|
||||
formData.value.b_viewtable = null
|
||||
formData.value.b_keyfield = null
|
||||
formData.value.b_savetable = null
|
||||
formData.value.b_orderfield = null
|
||||
formData.value.b_delete_policy = null
|
||||
maindata.value.b_viewtable = null
|
||||
maindata.value.b_keyfield = null
|
||||
maindata.value.b_savetable = null
|
||||
maindata.value.b_orderfield = null
|
||||
maindata.value.b_delete_policy = null
|
||||
} else if (v === 'query') {
|
||||
formData.value.b_savetable = null
|
||||
formData.value.b_delete_policy = null
|
||||
maindata.value.b_savetable = null
|
||||
maindata.value.b_delete_policy = null
|
||||
}
|
||||
formData.value.b_i18n = buildModuleI18nKey(v, formData.value.b_code)
|
||||
emit('update', formData.value)
|
||||
maindata.value.b_i18n = buildModuleI18nKey(v, maindata.value.b_code)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="formData" class="mm-basic">
|
||||
<div v-if="maindata" class="mm-basic">
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:model="maindata"
|
||||
:rules="formRules"
|
||||
layout="vertical"
|
||||
:columns="3"
|
||||
@@ -132,21 +121,21 @@ function onModuleTypeChange(v) {
|
||||
<FormGroup title="标识与归属">
|
||||
<FormItem :label="isCategory ? '分类编码' : '模块编码'" name="b_code" required>
|
||||
<Input
|
||||
:model-value="formData.b_code"
|
||||
:model-value="maindata.b_code"
|
||||
:disabled="loading"
|
||||
@update:model-value="onCodeInput"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="模块名称" name="b_name" required>
|
||||
<Input
|
||||
:model-value="formData.b_name"
|
||||
:model-value="maindata.b_name"
|
||||
:disabled="loading"
|
||||
@update:model-value="(v) => setField('b_name', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="模块类型" name="b_module_type" required>
|
||||
<Select
|
||||
:model-value="formData.b_module_type"
|
||||
:model-value="maindata.b_module_type"
|
||||
:options="typeOptions"
|
||||
:disabled="loading"
|
||||
@update:model-value="onModuleTypeChange"
|
||||
@@ -156,14 +145,14 @@ function onModuleTypeChange(v) {
|
||||
<Input :model-value="parentName" readonly />
|
||||
</FormItem>
|
||||
<FormItem label="多语言键" name="b_i18n" :span="2">
|
||||
<Input :model-value="formData.b_i18n" readonly />
|
||||
<Input :model-value="maindata.b_i18n" readonly />
|
||||
</FormItem>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="isData || isQuery" title="数据来源">
|
||||
<FormItem label="查询视图" name="b_viewtable" required>
|
||||
<Input
|
||||
:model-value="formData.b_viewtable"
|
||||
:model-value="maindata.b_viewtable"
|
||||
:disabled="loading"
|
||||
placeholder="data/query 必填"
|
||||
@update:model-value="(v) => setField('b_viewtable', v)"
|
||||
@@ -171,7 +160,7 @@ function onModuleTypeChange(v) {
|
||||
</FormItem>
|
||||
<FormItem label="主键字段" name="b_keyfield" :required="isData">
|
||||
<Input
|
||||
:model-value="formData.b_keyfield"
|
||||
:model-value="maindata.b_keyfield"
|
||||
:disabled="loading"
|
||||
placeholder="data 必填,query 可选,如 b_id"
|
||||
@update:model-value="(v) => setField('b_keyfield', v)"
|
||||
@@ -179,7 +168,7 @@ function onModuleTypeChange(v) {
|
||||
</FormItem>
|
||||
<FormItem v-if="isData" label="保存表" name="b_savetable" required>
|
||||
<Input
|
||||
:model-value="formData.b_savetable"
|
||||
:model-value="maindata.b_savetable"
|
||||
:disabled="loading"
|
||||
placeholder="data 模块的保存目标,必填"
|
||||
@update:model-value="(v) => setField('b_savetable', v)"
|
||||
@@ -187,7 +176,7 @@ function onModuleTypeChange(v) {
|
||||
</FormItem>
|
||||
<FormItem v-if="isData" label="默认排序" name="b_orderfield">
|
||||
<Input
|
||||
:model-value="formData.b_orderfield"
|
||||
:model-value="maindata.b_orderfield"
|
||||
:disabled="loading"
|
||||
placeholder="如 b_xh ASC, b_id DESC"
|
||||
@update:model-value="(v) => setField('b_orderfield', v)"
|
||||
@@ -195,7 +184,7 @@ function onModuleTypeChange(v) {
|
||||
</FormItem>
|
||||
<FormItem v-if="isData" label="删除策略" name="b_delete_policy" required>
|
||||
<Select
|
||||
:model-value="formData.b_delete_policy"
|
||||
:model-value="maindata.b_delete_policy"
|
||||
:options="deletePolicyOptions"
|
||||
:disabled="loading"
|
||||
placeholder="data 模块必填"
|
||||
@@ -207,7 +196,7 @@ function onModuleTypeChange(v) {
|
||||
<FormGroup title="行为设置">
|
||||
<FormItem label="排序号" name="b_xh">
|
||||
<Input
|
||||
:model-value="formData.b_xh"
|
||||
:model-value="maindata.b_xh"
|
||||
type="number"
|
||||
:disabled="loading"
|
||||
@update:model-value="(v) => setField('b_xh', v)"
|
||||
@@ -215,7 +204,7 @@ function onModuleTypeChange(v) {
|
||||
</FormItem>
|
||||
<FormItem label="启用" name="b_canuse">
|
||||
<Switch
|
||||
:model-value="Number(formData.b_canuse) === 1"
|
||||
:model-value="Number(maindata.b_canuse) === 1"
|
||||
:disabled="loading"
|
||||
@update:model-value="(v) => setField('b_canuse', v ? 1 : 0)"
|
||||
/>
|
||||
@@ -225,7 +214,7 @@ function onModuleTypeChange(v) {
|
||||
<FormGroup title="描述">
|
||||
<FormItem label="描述" name="b_bz" :span="3">
|
||||
<Input
|
||||
:model-value="formData.b_bz"
|
||||
:model-value="maindata.b_bz"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
:disabled="loading"
|
||||
|
||||
@@ -13,26 +13,23 @@ import { createEditRowFactory } from './components/design-editor/common/useStruc
|
||||
|
||||
const props = defineProps({
|
||||
moduleId: { type: String, required: true },
|
||||
editConfig: { type: Array, default: () => [] },
|
||||
/** @deprecated 仅兼容旧绑定:分组删除的列表引用清理已上移 index.vue onGroupsUpdate */
|
||||
listConfig: { type: Array, default: () => [] },
|
||||
fields: { type: Array, default: () => [] },
|
||||
groups: { type: Array, default: () => [] },
|
||||
/** 模块编辑方式:form 主表(表单画布设计)| table 子表(表格列设计) */
|
||||
editMode: { type: String, default: 'form' },
|
||||
loading: { type: Boolean, default: false },
|
||||
/** 面板当前是否可见(父级以 v-show 切换页签):不可见时 Delete 等快捷键不生效 */
|
||||
active: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update',
|
||||
'updateGroups',
|
||||
'updateListConfig',
|
||||
'update:editMode',
|
||||
])
|
||||
/* 数据全部走 v-model 双向绑定,本壳只负责编辑器路由与 subtable 行工厂注入。 */
|
||||
/** 编辑配置行(s_field_edit),父级 v-model 绑定 */
|
||||
const editConfig = defineModel({ type: Array, default: () => [] })
|
||||
/** 分组行(s_field_group),父级 v-model:groups 绑定 */
|
||||
const groups = defineModel('groups', { type: Array, default: () => [] })
|
||||
/** 列表配置行(s_field_view):表单设计器删除分组时需清理其 b_group_id 引用 */
|
||||
const listConfig = defineModel('listConfig', { type: Array, default: () => [] })
|
||||
/** 模块编辑方式:form 主表(表单画布设计)| table 子表(表格列设计) */
|
||||
const editMode = defineModel('editMode', { type: String, default: 'form' })
|
||||
|
||||
const isTable = computed(() => props.editMode === 'table')
|
||||
const isTable = computed(() => editMode.value === 'table')
|
||||
|
||||
/* 行工厂(subtable 差异字段):新建行默认列宽按控件类型 */
|
||||
function defaultWidthOf(field) {
|
||||
@@ -56,32 +53,25 @@ const createRow = createEditRowFactory({
|
||||
<template>
|
||||
<FormDesignEditor
|
||||
v-if="!isTable"
|
||||
v-model="editConfig"
|
||||
v-model:groups="groups"
|
||||
v-model:list-config="listConfig"
|
||||
v-model:edit-mode="editMode"
|
||||
:module-id="moduleId"
|
||||
:rows="editConfig"
|
||||
:list-config="listConfig"
|
||||
:fields="fields"
|
||||
:groups="groups"
|
||||
:edit-mode="editMode"
|
||||
:active="active"
|
||||
:loading="loading"
|
||||
@update="(v) => emit('update', v)"
|
||||
@update-groups="(g, ids) => emit('updateGroups', g, ids)"
|
||||
@update-list-config="(v) => emit('updateListConfig', v)"
|
||||
@update:edit-mode="(v) => emit('update:editMode', v)"
|
||||
/>
|
||||
<TableDesignEditor
|
||||
v-else
|
||||
mode="subtable"
|
||||
v-model="editConfig"
|
||||
v-model:groups="groups"
|
||||
v-model:edit-mode="editMode"
|
||||
:module-id="moduleId"
|
||||
:rows="editConfig"
|
||||
:fields="fields"
|
||||
:groups="groups"
|
||||
:create-row="createRow"
|
||||
:edit-mode="editMode"
|
||||
:active="active"
|
||||
:loading="loading"
|
||||
@update="(v) => emit('update', v)"
|
||||
@update-groups="(g, ids) => emit('updateGroups', g, ids)"
|
||||
@update:edit-mode="(v) => emit('update:editMode', v)"
|
||||
/>
|
||||
</template>
|
||||
@@ -7,10 +7,17 @@
|
||||
* 参与是派生态(配置行存在 = 参与),物化为字段行的 __form/__list/__query
|
||||
* 虚拟字段交给 FmsTable 内置 checkbox 渲染(与「启用」列同款),change 按列路由
|
||||
* 回 toggle 写配置草稿。参与开关语义见本文件内「字段参与开关」一节。
|
||||
*
|
||||
* 数据契约:字段与三份配置行、i18n 透视行、模块主表行全部走 v-model 双向绑定,
|
||||
* 本面板直接读写父级持有的对应数据(成对 ref 的当前值),不再由父级中转。
|
||||
*/
|
||||
import { ref, shallowRef, watch } from 'vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { RefreshCw } from '@lucide/vue'
|
||||
import { Button, Message } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { syncModuleItemI18nKeys } from './utils'
|
||||
import { describeApi } from '@/services/api'
|
||||
import { createTempId } from '@/utils/common'
|
||||
import { buildModuleItemI18nKey, syncModuleItemI18nKeys } from './utils'
|
||||
import { createEditRowFactory } from './components/design-editor/common/useStructureEditing'
|
||||
import { createListConfigRow, createQueryConfigRow } from './components/fieldDefaults'
|
||||
|
||||
@@ -90,23 +97,26 @@ function toggleQuery(queryConfig, field, moduleId) {
|
||||
|
||||
const props = defineProps({
|
||||
moduleId: { type: String, required: true },
|
||||
moduleCode: { type: String, default: '' },
|
||||
fields: { type: Array, default: () => [] },
|
||||
/** 参与勾选所需的三个配置草稿(由 index.vue 传入,切换后事件回写) */
|
||||
editConfig: { type: Array, default: () => [] },
|
||||
listConfig: { type: Array, default: () => [] },
|
||||
queryConfig: { type: Array, default: () => [] },
|
||||
/** 模块类型:表单参与列仅 data 模块展示(查询模块无表单设计) */
|
||||
moduleType: { type: String, default: null },
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update',
|
||||
'updateEditConfig',
|
||||
'updateListConfig',
|
||||
'updateQueryConfig',
|
||||
])
|
||||
/* 行内字段就地 patch,提交时换新数组引用触发 stk-table 重渲染;
|
||||
* __ 前缀虚拟字段由 diffRows / hasUnsavedChanges 忽略,不进保存请求。 */
|
||||
/** 字段定义行(s_field) */
|
||||
const fields = defineModel({ type: Array, default: () => [] })
|
||||
/** 参与勾选联动的三份配置行 */
|
||||
const editConfig = defineModel('editConfig', { type: Array, default: () => [] })
|
||||
const listConfig = defineModel('listConfig', { type: Array, default: () => [] })
|
||||
const queryConfig = defineModel('queryConfig', { type: Array, default: () => [] })
|
||||
/** 多语言透视行(s_i18n):字段同步时移除失效字段的受管键 */
|
||||
const i18nRows = defineModel('i18n', { type: Array, default: () => [] })
|
||||
/** 模块主表行:字段同步需要 b_viewtable(同步源)与 b_code(i18n 键前缀) */
|
||||
const moduleRow = defineModel('module', { type: Object, default: null })
|
||||
|
||||
/** 模块编码:字段 i18n 键(field.{moduleCode}.{字段名})的前缀 */
|
||||
const moduleCode = computed(() => String(moduleRow.value?.b_code || '').trim())
|
||||
|
||||
const FIELD_TYPES = [
|
||||
{ label: '输入框', value: 'input' },
|
||||
@@ -131,33 +141,33 @@ const DB_TYPES = [
|
||||
{ label: 'text', value: 'text' },
|
||||
]
|
||||
|
||||
const localFields = ref([])
|
||||
const activeKey = ref(null)
|
||||
|
||||
/** 字段 i18n 键随模块编码/字段名变化同步:syncModuleItemI18nKeys 就地改行并返回是否变化,
|
||||
* 变化后换新数组引用触发表格重渲染(赋值会再次触发本 watch,此时已同步即停止,无循环) */
|
||||
watch(
|
||||
() => [props.fields, props.moduleCode],
|
||||
([val, moduleCode]) => {
|
||||
if (val === localFields.value) return
|
||||
const rows = val?.map((row) => ({ ...row })) || []
|
||||
const changed = syncModuleItemI18nKeys(rows, 'field', moduleCode, 'b_field')
|
||||
localFields.value = rows
|
||||
if (changed) emit('update', rows)
|
||||
() => [fields.value, moduleCode.value],
|
||||
() => {
|
||||
if (!fields.value?.length) return
|
||||
if (syncModuleItemI18nKeys(fields.value, 'field', moduleCode.value, 'b_field')) {
|
||||
fields.value = [...fields.value]
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 参与状态物化到字段行上的 __ 虚拟字段(就地 patch,不替换数组引用,避免整表重建),
|
||||
// 供 FmsTable 内置 checkbox 渲染;虚拟字段不进草稿/保存请求(emitUpdate 时剥离)。
|
||||
// 供 FmsTable 内置 checkbox 渲染;__ 前缀被 diffRows / hasUnsavedChanges 忽略,不进保存请求。
|
||||
// 勾选变化经 onCellChange 路由到 toggle 写配置草稿,随后本 watch 重算虚拟字段
|
||||
const PARTICIPATION_KEYS = ['__form', '__list', '__query']
|
||||
|
||||
watch(
|
||||
() => [localFields.value, props.editConfig, props.listConfig, props.queryConfig],
|
||||
() => [fields.value, editConfig.value, listConfig.value, queryConfig.value],
|
||||
() => {
|
||||
for (const row of localFields.value) {
|
||||
row.__form = isFormOn(props.editConfig, row) ? 1 : 0
|
||||
row.__list = isListOn(props.listConfig, row) ? 1 : 0
|
||||
row.__query = isQueryOn(props.queryConfig, row) ? 1 : 0
|
||||
for (const row of fields.value) {
|
||||
row.__form = isFormOn(editConfig.value, row) ? 1 : 0
|
||||
row.__list = isListOn(listConfig.value, row) ? 1 : 0
|
||||
row.__query = isQueryOn(queryConfig.value, row) ? 1 : 0
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -169,24 +179,18 @@ function onCellChange(row, col) {
|
||||
if (PARTICIPATION_KEYS.includes(col?.dataIndex)) {
|
||||
if (!props.loading) {
|
||||
if (col.dataIndex === '__form') {
|
||||
emit('updateEditConfig', toggleForm(props.editConfig, row, props.moduleId))
|
||||
editConfig.value = toggleForm(editConfig.value, row, props.moduleId)
|
||||
} else if (col.dataIndex === '__list') {
|
||||
emit('updateListConfig', toggleList(props.listConfig, row, props.moduleId))
|
||||
listConfig.value = toggleList(listConfig.value, row, props.moduleId)
|
||||
} else {
|
||||
emit('updateQueryConfig', toggleQuery(props.queryConfig, row, props.moduleId))
|
||||
queryConfig.value = toggleQuery(queryConfig.value, row, props.moduleId)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
syncModuleItemI18nKeys(localFields.value, 'field', props.moduleCode, 'b_field')
|
||||
// 剥离参与虚拟字段:__ 前缀仅供表格渲染,不进字段草稿与保存请求
|
||||
emit(
|
||||
'update',
|
||||
localFields.value.map((row) => {
|
||||
const { __form, __list, __query, ...field } = row
|
||||
return field
|
||||
}),
|
||||
)
|
||||
syncModuleItemI18nKeys(fields.value, 'field', moduleCode.value, 'b_field')
|
||||
// 行对象已就地 patch,换新数组引用触发 stk-table 重渲染
|
||||
fields.value = [...fields.value]
|
||||
}
|
||||
|
||||
const columns = shallowRef(buildColumns())
|
||||
@@ -244,13 +248,119 @@ function buildColumns() {
|
||||
]
|
||||
return [...baseColumns, ...participationColumns, ...tailColumns]
|
||||
}
|
||||
|
||||
/* ================= 字段同步(按数据库视图结构对齐字段定义) =================
|
||||
* 同步只合并字段定义与移除已消失字段的配置行/i18n,不自动生成任何配置行——
|
||||
* 列表/查询配置为手动管理(行存在即展示),表单设计由用户逐个添加。
|
||||
* 计算同步计划(纯计算,不改草稿):describe 结果与现有字段合并、找出视图已不存在的字段。
|
||||
*/
|
||||
function buildSyncPlan(module, columns) {
|
||||
const viewColumnSet = new Set(columns.map((col) => String(col.name || '').toLowerCase()))
|
||||
const existingMap = new Map()
|
||||
for (const field of fields.value || []) {
|
||||
if (field.b_field) existingMap.set(field.b_field.toLowerCase(), field)
|
||||
}
|
||||
const mergedFields = []
|
||||
let addedCount = 0
|
||||
for (const col of columns) {
|
||||
const existing = existingMap.get(String(col.name).toLowerCase())
|
||||
if (existing) {
|
||||
mergedFields.push({ ...existing, b_canuse: 1 })
|
||||
} else {
|
||||
addedCount++
|
||||
mergedFields.push({
|
||||
b_id: createTempId(),
|
||||
b_module_id: String(module.b_id),
|
||||
b_field: col.name,
|
||||
b_name: col.name,
|
||||
b_i18n: null,
|
||||
b_type: 'input',
|
||||
b_dbtype: col.type || null,
|
||||
b_length: col.size ?? null,
|
||||
b_nullable: col.nullable ?? 1,
|
||||
b_default_value: null,
|
||||
b_canuse: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
// 视图中已不存在的字段:应用时移除并级联清理配置行与受管 i18n
|
||||
const removedIds = new Set()
|
||||
// 受管多语言的 _managedId 是 `field:${i18nKey}`,不是字段 b_id
|
||||
const removedI18nIds = new Set()
|
||||
for (const existing of fields.value || []) {
|
||||
if (!viewColumnSet.has(String(existing.b_field).toLowerCase())) {
|
||||
removedIds.add(String(existing.b_id))
|
||||
const key =
|
||||
existing.b_i18n || buildModuleItemI18nKey('field', module.b_code, existing.b_field)
|
||||
if (key) removedI18nIds.add(`field:${key}`)
|
||||
}
|
||||
}
|
||||
for (const field of mergedFields) {
|
||||
field.b_i18n = buildModuleItemI18nKey('field', module.b_code, field.b_field)
|
||||
}
|
||||
return { mergedFields, removedIds, removedI18nIds, addedCount }
|
||||
}
|
||||
|
||||
function applySyncPlan(plan) {
|
||||
if (plan.removedIds.size) {
|
||||
// 失效字段在列表/编辑/查询配置中的行一并移除
|
||||
for (const config of [listConfig, editConfig, queryConfig]) {
|
||||
config.value = (config.value || []).filter(
|
||||
(row) => !plan.removedIds.has(String(row.b_field_id)),
|
||||
)
|
||||
}
|
||||
// 失效字段的受管 i18n 行(_managedId 为 `field:${key}`)一并移除
|
||||
const removedManaged = new Set(Array.from(plan.removedI18nIds).map(String))
|
||||
i18nRows.value = (i18nRows.value || []).filter(
|
||||
(row) => !removedManaged.has(String(row._managedId)),
|
||||
)
|
||||
}
|
||||
fields.value = plan.mergedFields
|
||||
Message.success(`已成功同步 ${plan.mergedFields.length} 个字段`)
|
||||
}
|
||||
|
||||
const syncing = ref(false)
|
||||
|
||||
async function syncFields() {
|
||||
const module = moduleRow.value
|
||||
if (!module) return
|
||||
const view = String(module.b_viewtable || '').trim()
|
||||
if (!view || props.loading) return
|
||||
syncing.value = true
|
||||
try {
|
||||
const res = await describeApi(view)
|
||||
const plan = buildSyncPlan(module, res.data || [])
|
||||
if (!plan.addedCount && !plan.removedIds.size) {
|
||||
Message.info('字段与视图结构一致,无需同步')
|
||||
return
|
||||
}
|
||||
// 同步只改草稿、不落库(保存前可整体撤销),直接执行;
|
||||
// 差异明细由 applySyncPlan 的成功提示汇总
|
||||
applySyncPlan(plan)
|
||||
} catch (e) {
|
||||
Message.error('同步字段失败:' + (e?.message || e))
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mm-fields">
|
||||
<div v-if="moduleRow?.b_viewtable" class="mm-fields__toolbar">
|
||||
<Button
|
||||
type="outline"
|
||||
:disabled="loading"
|
||||
:loading="syncing"
|
||||
title="按数据库视图结构对齐字段定义"
|
||||
@click="syncFields"
|
||||
>
|
||||
<RefreshCw :size="14" /> 同步字段
|
||||
</Button>
|
||||
</div>
|
||||
<FmsTable
|
||||
v-model:columns="columns"
|
||||
:data-source="localFields"
|
||||
:data-source="fields"
|
||||
:sort-config="{ emptyToBottom: true, stringLocaleCompare: true }"
|
||||
:editable="!loading"
|
||||
row-key="b_id"
|
||||
@@ -268,4 +378,11 @@ function buildColumns() {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mm-fields__toolbar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 8px;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* 多语言面板(s_i18n):受控展示组件。
|
||||
* 数据与保存基线统一由 index.vue 持有:父级以 v-model:rows 传入透视草稿行
|
||||
* (drafts.i18n),reconcile(受管键补齐/跟随名称/失效清理)与保存 diff 均在
|
||||
* (i18n),reconcile(受管键补齐/跟随名称/失效清理)与保存 diff 均在
|
||||
* index.vue 编排;本面板只负责透视行的展示与行内编辑、增删交互。
|
||||
*/
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
@@ -11,13 +11,14 @@ import { createTempId } from '@/utils/common'
|
||||
|
||||
const props = defineProps({
|
||||
moduleId: { type: String, required: true },
|
||||
listConfig: { type: Array, default: () => [] },
|
||||
fields: { type: Array, default: () => [] },
|
||||
groups: { type: Array, default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'updateGroups'])
|
||||
/** 列表配置行(s_field_view),父级 v-model 绑定 */
|
||||
const listConfig = defineModel({ type: Array, default: () => [] })
|
||||
/** 分组行(s_field_group),父级 v-model:groups 绑定 */
|
||||
const groups = defineModel('groups', { type: Array, default: () => [] })
|
||||
|
||||
/* 行工厂(list 差异字段):列表行默认属性来自业务层 */
|
||||
const createRow = (field, groupId) => ({
|
||||
@@ -34,13 +35,11 @@ const createRow = (field, groupId) => ({
|
||||
<template>
|
||||
<TableDesignEditor
|
||||
mode="list"
|
||||
v-model="listConfig"
|
||||
v-model:groups="groups"
|
||||
:module-id="moduleId"
|
||||
:rows="listConfig"
|
||||
:fields="fields"
|
||||
:groups="groups"
|
||||
:create-row="createRow"
|
||||
:loading="loading"
|
||||
@update="(v) => emit('update', v)"
|
||||
@update-groups="(g, ids) => emit('updateGroups', g, ids)"
|
||||
/>
|
||||
</template>
|
||||
@@ -13,7 +13,7 @@
|
||||
* - 右表格:新增查询条件(手动选字段)、编辑 查询组件/操作符/默认值、
|
||||
* 行拖拽排序、删除行;选字段时自动重置组件/操作符为字段类型默认值。
|
||||
*/
|
||||
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
|
||||
import { computed, nextTick, ref, shallowRef } from 'vue'
|
||||
import { Plus, Trash2 } from '@lucide/vue'
|
||||
import { Button, Message } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
@@ -23,12 +23,13 @@ import { createQueryConfigRow, queryRowDefaults } from './components/fieldDefaul
|
||||
|
||||
const props = defineProps({
|
||||
moduleId: { type: String, required: true },
|
||||
queryConfig: { type: Array, default: () => [] },
|
||||
fields: { type: Array, default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
/** 查询配置行(s_field_query),父级 v-model 绑定 query_config。
|
||||
* 行内字段就地 patch,提交时换新数组引用触发 stk-table 重渲染。 */
|
||||
const queryConfig = defineModel({ type: Array, default: () => [] })
|
||||
|
||||
const COMPONENT_OPTIONS = [
|
||||
{ label: '输入框', value: 'input' },
|
||||
@@ -50,24 +51,12 @@ const OPERATOR_OPTIONS = [
|
||||
{ label: '范围 (between)', value: 'between' },
|
||||
]
|
||||
|
||||
const localQuery = ref([])
|
||||
const activeKey = ref(null)
|
||||
const tableRef = ref(null)
|
||||
const selectedRowKeys = ref([])
|
||||
|
||||
watch(
|
||||
() => props.queryConfig,
|
||||
(val) => {
|
||||
if (val === localQuery.value) return
|
||||
localQuery.value = val?.map((row) => ({ ...row })) || []
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function onRowOrderChange(rows) {
|
||||
const sorted = rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
|
||||
localQuery.value = sorted
|
||||
emit('update', localQuery.value)
|
||||
queryConfig.value = rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
|
||||
}
|
||||
|
||||
/* ================= 字段引用 ================= */
|
||||
@@ -105,7 +94,8 @@ function onCellChange(row, col) {
|
||||
row.b_default_value = null
|
||||
}
|
||||
}
|
||||
emit('update', localQuery.value)
|
||||
// 行对象已就地 patch,换新数组引用触发 stk-table 重渲染
|
||||
queryConfig.value = [...queryConfig.value]
|
||||
}
|
||||
|
||||
/* ================= 行增删 ================= */
|
||||
@@ -113,8 +103,8 @@ async function addRow() {
|
||||
if (props.loading) return
|
||||
const newId = createTempId()
|
||||
// stk-table 对同一引用数组的 push 不敏感(内部 shallowRef + 引用 watch),必须换新引用才会渲染新行
|
||||
localQuery.value = [
|
||||
...localQuery.value,
|
||||
queryConfig.value = [
|
||||
...queryConfig.value,
|
||||
{
|
||||
b_id: newId,
|
||||
b_module_id: props.moduleId,
|
||||
@@ -124,10 +114,9 @@ async function addRow() {
|
||||
b_component: 'input',
|
||||
b_operator: 'like',
|
||||
b_default_value: null,
|
||||
b_xh: (localQuery.value.length + 1) * 10,
|
||||
b_xh: (queryConfig.value.length + 1) * 10,
|
||||
},
|
||||
]
|
||||
emit('update', localQuery.value)
|
||||
activeKey.value = String(newId)
|
||||
await nextTick()
|
||||
// 聚焦第一个可编辑列(字段),选定字段后再补组件/操作符
|
||||
@@ -135,10 +124,9 @@ async function addRow() {
|
||||
}
|
||||
|
||||
function removeRow(row) {
|
||||
localQuery.value = localQuery.value.filter((item) => item !== row)
|
||||
queryConfig.value = queryConfig.value.filter((item) => item !== row)
|
||||
if (String(row.b_id) === String(activeKey.value)) activeKey.value = null
|
||||
selectedRowKeys.value = selectedRowKeys.value.filter((x) => x !== String(row.b_id))
|
||||
emit('update', localQuery.value)
|
||||
}
|
||||
|
||||
/* ================= 批量删除 ================= */
|
||||
@@ -154,22 +142,21 @@ function batchDelete() {
|
||||
const keys = selectedRowKeys.value
|
||||
if (!keys.length) return
|
||||
const delSet = new Set(keys)
|
||||
localQuery.value = localQuery.value.filter((r) => !delSet.has(String(r.b_id)))
|
||||
queryConfig.value = queryConfig.value.filter((r) => !delSet.has(String(r.b_id)))
|
||||
selectedRowKeys.value = []
|
||||
if (activeKey.value && delSet.has(activeKey.value)) activeKey.value = null
|
||||
emit('update', localQuery.value)
|
||||
Message.success(`已删除 ${keys.length} 条查询条件`)
|
||||
}
|
||||
|
||||
/* ================= 字段库交互 ================= */
|
||||
// 已加入的字段(供 FieldLibrary ✓ 标记与点击定位)
|
||||
function findRowByFieldId(fieldId) {
|
||||
return localQuery.value.find((row) => String(row.b_field_id) === String(fieldId))
|
||||
return queryConfig.value.find((row) => String(row.b_field_id) === String(fieldId))
|
||||
}
|
||||
|
||||
// 当前选中行对应的字段 id(高亮字段库)
|
||||
const selectedFieldId = computed(() => {
|
||||
const row = localQuery.value.find((item) => String(item.b_id) === String(activeKey.value))
|
||||
const row = queryConfig.value.find((item) => String(item.b_id) === String(activeKey.value))
|
||||
return row?.b_field_id != null ? String(row.b_field_id) : null
|
||||
})
|
||||
|
||||
@@ -183,9 +170,8 @@ function onFieldSelect(field) {
|
||||
nextTick(() => tableRef.value?.scrollToRow?.(String(existing.b_id)))
|
||||
return
|
||||
}
|
||||
const row = createQueryConfigRow(props.moduleId, field, localQuery.value)
|
||||
localQuery.value = [...localQuery.value, row]
|
||||
emit('update', localQuery.value)
|
||||
const row = createQueryConfigRow(props.moduleId, field, queryConfig.value)
|
||||
queryConfig.value = [...queryConfig.value, row]
|
||||
activeKey.value = String(row.b_id)
|
||||
nextTick(() => tableRef.value?.scrollToRow?.(String(row.b_id)))
|
||||
}
|
||||
@@ -230,7 +216,7 @@ const columns = shallowRef([
|
||||
<div class="mm-qdesign">
|
||||
<FieldLibrary
|
||||
:fields="fields"
|
||||
:rows="localQuery"
|
||||
:rows="queryConfig"
|
||||
:selected-field-id="selectedFieldId"
|
||||
:loading="loading"
|
||||
drag-mode="native"
|
||||
@@ -250,7 +236,7 @@ const columns = shallowRef([
|
||||
<FmsTable
|
||||
ref="tableRef"
|
||||
v-model:columns="columns"
|
||||
:data-source="localQuery"
|
||||
:data-source="queryConfig"
|
||||
:editable="!loading"
|
||||
:row-draggable="!loading"
|
||||
:row-selection="rowSelection"
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 模块树面板(module-management 左侧)。
|
||||
*
|
||||
* 职责:搜索框与树展示,展开 / 选中 / 拖拽 / 右键菜单的 UI 交互。
|
||||
* 数据契约:树数据、选中项、未保存标记均由父级唯一持有,本组件只读并通过事件上报意图:
|
||||
* select / add / delete / drop。
|
||||
* 树数据加载、新增/删除落库、拖拽排序持久化及跨面板联动仍由父级(index.vue)决定。
|
||||
* 搜索关键字与展开键是纯 UI 状态,留在本组件;展开键需由父级在新建模块后追加祖先路径,
|
||||
* 故以 v-model:expanded-keys 暴露。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Plus, Search, Trash2 } from '@lucide/vue'
|
||||
import { Button, Dropdown, Input, Tree } from '@/components/ui'
|
||||
import { collectAllKeys, filterTreeByText } from '@/utils/tree'
|
||||
import { MODULE_ADD_OPTIONS } from './utils'
|
||||
|
||||
const props = defineProps({
|
||||
/** 模块树(s_module 全量),父级持有并负责加载 / 保存 */
|
||||
treeData: { type: Array, required: true },
|
||||
/** 树数据加载中 */
|
||||
loading: { type: Boolean, default: false },
|
||||
/** 当前选中模块 id */
|
||||
selectedId: { type: [String, Number], default: null },
|
||||
/** 展开的树节点 key(v-model:新建模块后由父级追加祖先路径) */
|
||||
expandedKeys: { type: Array, default: () => [] },
|
||||
/** 未落库(新建未保存)的模块 id 集合;父级唯一状态源,本组件只读 */
|
||||
unsavedIds: { type: Set, default: () => new Set() },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:expandedKeys', 'select', 'add', 'delete', 'drop'])
|
||||
|
||||
/** 搜索关键字(纯 UI 状态) */
|
||||
const keyword = ref('')
|
||||
|
||||
const filteredTree = computed(() => filterTreeByText(props.treeData, keyword.value))
|
||||
|
||||
// 搜索时展开全部(保证命中节点可见),清空时收起
|
||||
watch(keyword, (kw) => {
|
||||
emit('update:expandedKeys', kw.trim() ? collectAllKeys(props.treeData) : [])
|
||||
})
|
||||
|
||||
/** 选中:仅上报目标 id,是否需要未保存确认由父级决定 */
|
||||
function onSelect(keys) {
|
||||
if (!keys.length) return
|
||||
const nextId = String(keys[0])
|
||||
if (nextId === String(props.selectedId)) return
|
||||
emit('select', nextId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 右键菜单项数据源。
|
||||
* 未保存(草稿)节点不能作为父级(其 id 尚未落库,子模块会凭空挂在临时 id 下)。
|
||||
*/
|
||||
function buildContextMenu(node) {
|
||||
const isUnsaved = node != null && props.unsavedIds.has(String(node.b_id))
|
||||
const items = MODULE_ADD_OPTIONS.map((option) => ({ ...option, disabled: isUnsaved }))
|
||||
if (node) {
|
||||
items.push({ divider: true })
|
||||
items.push({ key: 'delete', label: '删除模块', icon: Trash2, danger: true })
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
function onContextMenuClick({ item, node }) {
|
||||
const parentId = node ? String(node.b_id) : null
|
||||
if (item.key === 'delete') emit('delete', parentId)
|
||||
else emit('add', { parentId, moduleType: item.key })
|
||||
}
|
||||
|
||||
/** 工具栏新建:挂到当前选中节点下,无选中则顶级 */
|
||||
function onToolbarAdd({ key }) {
|
||||
emit('add', { parentId: props.selectedId ? String(props.selectedId) : null, moduleType: key })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="mm-tree-panel">
|
||||
<div class="mm-tree-header">
|
||||
<Input v-model="keyword" placeholder="搜索模块" allow-clear class="mm-tree-search">
|
||||
<template #prefix><Search :size="14" class="mm-tree-search__icon" /></template>
|
||||
</Input>
|
||||
<div class="mm-tree-header__actions">
|
||||
<Dropdown :items="MODULE_ADD_OPTIONS" @click="onToolbarAdd">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-tree-header__icon-btn"
|
||||
title="新建模块"
|
||||
aria-label="新建模块"
|
||||
>
|
||||
<Plus :size="14" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mm-tree-panel__scroll">
|
||||
<div v-if="loading" class="mm-tree-empty">加载中...</div>
|
||||
<!-- 过滤(搜索)状态下禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
|
||||
<Tree
|
||||
v-else
|
||||
:tree-data="filteredTree"
|
||||
:field-names="{ key: 'b_id', title: 'b_name', children: 'children' }"
|
||||
:expanded-keys="expandedKeys"
|
||||
@update:expanded-keys="(keys) => emit('update:expandedKeys', keys)"
|
||||
:selected-keys="selectedId ? [selectedId] : []"
|
||||
:context-menu="buildContextMenu"
|
||||
:draggable="!keyword.trim()"
|
||||
@select="onSelect"
|
||||
@context-menu-click="onContextMenuClick"
|
||||
@drop="(payload) => emit('drop', payload)"
|
||||
>
|
||||
<template #title="{ node }">
|
||||
<span class="mm-tree-node">
|
||||
<span
|
||||
class="mm-tree-node__name"
|
||||
:class="{ 'is-disabled': Number(node.b_canuse) !== 1 }"
|
||||
>
|
||||
{{ node.b_name || node.b_code }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</Tree>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ---- 左侧树面板 ---- */
|
||||
.mm-tree-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
/* 灰底画布上的白卡:白底 + 圆角 + 阴影,与右侧内容卡用灰缝分隔 */
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mm-tree-panel__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mm-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mm-tree-search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mm-tree-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mm-tree-header__icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
background: var(--fms-card);
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
background 150ms ease,
|
||||
color 150ms ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--fms-secondary);
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.mm-tree-search__icon {
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
/* 树节点单行:名称(停用变灰)撑满剩余空间。
|
||||
.mm-tree-node 是 .tree-node-content(flex 容器)的子元素,
|
||||
需 flex: 1 占满整行宽度,内部名称的 flex: 1 才有空间可撑。 */
|
||||
.mm-tree-node {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 2px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.mm-tree-node__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.is-disabled {
|
||||
color: var(--fms-text-disabled, #9ca3af);
|
||||
}
|
||||
}
|
||||
|
||||
.mm-tree-empty {
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
+53
-58
@@ -62,31 +62,28 @@ import {
|
||||
|
||||
const props = defineProps({
|
||||
moduleId: { type: String, required: true },
|
||||
/** 编辑行数组(s_field_edit 草稿,主表表单) */
|
||||
rows: { type: Array, default: () => [] },
|
||||
listConfig: { type: Array, default: () => [] },
|
||||
fields: { type: Array, default: () => [] },
|
||||
groups: { type: Array, default: () => [] },
|
||||
/** 模块编辑方式:form 主表(表单画布设计)| table 子表(表格列设计) */
|
||||
editMode: { type: String, default: 'form' },
|
||||
loading: { type: Boolean, default: false },
|
||||
/** 面板当前是否可见(父级以 v-show 切换页签):不可见时 Delete 等快捷键不生效 */
|
||||
active: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update',
|
||||
'updateGroups',
|
||||
'updateListConfig',
|
||||
'update:editMode',
|
||||
])
|
||||
/* 数据全部走 v-model 双向绑定:本编辑器直接读写父级持有的对应数据。 */
|
||||
/** 编辑行数组(s_field_edit 草稿,主表表单),父级 v-model 绑定 */
|
||||
const rows = defineModel({ type: Array, default: () => [] })
|
||||
/** 分组行数组(s_field_group),父级 v-model:groups 绑定 */
|
||||
const groups = defineModel('groups', { type: Array, default: () => [] })
|
||||
/** 列表配置行(s_field_view),父级 v-model:list-config 绑定(删除分组时清理分组引用) */
|
||||
const listConfig = defineModel('listConfig', { type: Array, default: () => [] })
|
||||
/** 模块编辑方式:form 主表(表单画布设计)| table 子表(表格列设计),父级 v-model:edit-mode 绑定 */
|
||||
const editMode = defineModel('editMode', { type: String, default: 'form' })
|
||||
|
||||
const localEdit = ref([])
|
||||
const activeKey = ref(null)
|
||||
|
||||
function onEditModeChange(value) {
|
||||
if (props.loading) return
|
||||
emit('update:editMode', value || 'form')
|
||||
editMode.value = value || 'form'
|
||||
}
|
||||
|
||||
/* ================= 头部切换器选项(ui Segmented) ================= */
|
||||
@@ -125,7 +122,7 @@ const sectionCols = reactive({}) // key(g-xxx/ungrouped) → 占格数
|
||||
|
||||
// 分组数据变化(加载/新增/删除/其他编辑)时回填 sectionCols,保证缓存与库值一致
|
||||
watch(
|
||||
() => props.groups,
|
||||
() => groups.value,
|
||||
(groups) => {
|
||||
for (const group of groups || []) {
|
||||
const n = Number(group?.b_colspan)
|
||||
@@ -148,10 +145,10 @@ function setSectionCols(key, val) {
|
||||
// 同步写回分组对象(b_colspan),交由父级进入待保存状态;未分组无持久化对象
|
||||
if (key !== 'ungrouped') {
|
||||
const gid = key.slice(2)
|
||||
const updated = (props.groups || []).map((g) =>
|
||||
const updated = (groups.value || []).map((g) =>
|
||||
String(g.b_id) === gid ? { ...g, b_colspan: sectionCols[key] } : g,
|
||||
)
|
||||
emit('updateGroups', updated)
|
||||
groups.value = updated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +214,7 @@ function onDragKeydown(evt) {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.rows,
|
||||
() => rows.value,
|
||||
(val) => {
|
||||
if (val === localEdit.value) return
|
||||
localEdit.value = val?.map((row) => ({ ...row })) || []
|
||||
@@ -226,10 +223,10 @@ watch(
|
||||
)
|
||||
|
||||
/* ================= 数据视图:行排序 ================= */
|
||||
function onRowOrderChange(rows) {
|
||||
const sorted = rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
|
||||
function onRowOrderChange(nextRows) {
|
||||
const sorted = nextRows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
|
||||
localEdit.value = sorted
|
||||
emit('update', localEdit.value)
|
||||
rows.value = localEdit.value
|
||||
}
|
||||
|
||||
function normalizedRow(row) {
|
||||
@@ -240,9 +237,10 @@ function normalizedRow(row) {
|
||||
}
|
||||
}
|
||||
|
||||
function emitRows(rows) {
|
||||
localEdit.value = rows.map(normalizedRow)
|
||||
emit('update', localEdit.value)
|
||||
/** 行变更的统一出口:规范化后写回 model(原 emit('update')) */
|
||||
function emitRows(nextRows) {
|
||||
localEdit.value = nextRows.map(normalizedRow)
|
||||
rows.value = localEdit.value
|
||||
}
|
||||
|
||||
/* ================= 公共查询表 ================= */
|
||||
@@ -252,7 +250,7 @@ const { fieldNameMap, fieldMap, fieldOf, fieldName, controlTypeLabel } = useFiel
|
||||
|
||||
const groupMap = computed(() => {
|
||||
const map = new Map()
|
||||
for (const group of props.groups || []) {
|
||||
for (const group of groups.value || []) {
|
||||
map.set(String(group.b_id), group)
|
||||
}
|
||||
return map
|
||||
@@ -260,7 +258,7 @@ const groupMap = computed(() => {
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
const options = []
|
||||
for (const group of props.groups || []) {
|
||||
for (const group of groups.value || []) {
|
||||
if (Number(group.b_canuse) !== 1) continue
|
||||
options.push({ label: group.b_title || `分组-${group.b_id}`, value: String(group.b_id) })
|
||||
}
|
||||
@@ -368,7 +366,7 @@ function rebuildSectionCache() {
|
||||
map[gid].push(row)
|
||||
}
|
||||
// 空分组也保证有稳定的数组引用,否则 vuedraggable 绑定到临时空数组,拖入结果会丢失
|
||||
for (const group of props.groups || []) {
|
||||
for (const group of groups.value || []) {
|
||||
const gid = String(group.b_id)
|
||||
if (!map[gid]) map[gid] = []
|
||||
}
|
||||
@@ -379,7 +377,7 @@ function rebuildSectionCache() {
|
||||
}
|
||||
}
|
||||
|
||||
watch([localEdit, () => props.groups], rebuildSectionCache, { deep: true, immediate: true })
|
||||
watch([localEdit, () => groups.value], rebuildSectionCache, { deep: true, immediate: true })
|
||||
|
||||
/* 分区持久顺序(含 ungrouped):用户拖拽分组/未分组卡片后保持,canvasSections 按其排列,
|
||||
* 否则每次重建未分组都会弹回末尾 */
|
||||
@@ -390,7 +388,7 @@ const sectionOrderKeys = ref([])
|
||||
// 每个分区有 cols(外层 48 宫格占格数),默认 48 独占一行,调小后流式排布。
|
||||
const canvasSections = computed(() => {
|
||||
const built = []
|
||||
const sortedGroups = props.groups.toSorted((a, b) => (a.b_xh || 0) - (b.b_xh || 0))
|
||||
const sortedGroups = groups.value.toSorted((a, b) => (a.b_xh || 0) - (b.b_xh || 0))
|
||||
for (const group of sortedGroups) {
|
||||
const gid = String(group.b_id)
|
||||
built.push({
|
||||
@@ -457,18 +455,15 @@ watch(
|
||||
function onGroupDragEnd() {
|
||||
sectionDragging.value = false
|
||||
sectionOrderKeys.value = sectionOrder.value.map((s) => s.key)
|
||||
const groupsMap = new Map((props.groups || []).map((g) => [String(g.b_id), g]))
|
||||
const groupsMap = new Map((groups.value || []).map((g) => [String(g.b_id), g]))
|
||||
const ordered = []
|
||||
for (const section of sectionOrder.value) {
|
||||
if (!section.group) continue
|
||||
const group = groupsMap.get(String(section.group.b_id))
|
||||
if (group) ordered.push(group)
|
||||
}
|
||||
if (!ordered.length || ordered.length !== (props.groups || []).length) return
|
||||
emit(
|
||||
'updateGroups',
|
||||
ordered.map((g, i) => ({ ...g, b_xh: (i + 1) * 10 })),
|
||||
)
|
||||
if (!ordered.length || ordered.length !== (groups.value || []).length) return
|
||||
groups.value = ordered.map((g, i) => ({ ...g, b_xh: (i + 1) * 10 }))
|
||||
}
|
||||
|
||||
/* ================= 分组标题内联编辑(点击分组名变输入框 + 确认/取消) ================= */
|
||||
@@ -513,15 +508,15 @@ function commitEditGroupTitle(section) {
|
||||
// 允许清空标题(标题留空即表示不显示分组标题)
|
||||
if (!section.group || section.group.b_title === title) return
|
||||
// 不可变更新:生成新的 groups 数组(含修改后的分组对象),交由父级进入待保存状态
|
||||
const updated = (props.groups || []).map((g) =>
|
||||
const updated = (groups.value || []).map((g) =>
|
||||
String(g.b_id) === String(section.group.b_id) ? { ...g, b_title: title } : g,
|
||||
)
|
||||
emit('updateGroups', updated)
|
||||
groups.value = updated
|
||||
}
|
||||
|
||||
/* ================= 分组内联操作(添加) ================= */
|
||||
function nextGroupXh() {
|
||||
return (props.groups || []).reduce((m, g) => Math.max(m, Number(g.b_xh || 0)), 0) + 10
|
||||
return (groups.value || []).reduce((m, g) => Math.max(m, Number(g.b_xh || 0)), 0) + 10
|
||||
}
|
||||
|
||||
/** 添加分组:插入一个空分组并立即进入内联命名 */
|
||||
@@ -538,11 +533,11 @@ function addGroup() {
|
||||
b_layout_direction: 'vertical',
|
||||
b_colspan: GROUP_GRID,
|
||||
}
|
||||
emit('updateGroups', [...(props.groups || []), newGroup])
|
||||
groups.value = [...(groups.value || []), newGroup]
|
||||
const key = `g-${newGroup.b_id}`
|
||||
editingGroupKey.value = key
|
||||
groupTitleDraft.value = ''
|
||||
// 双重 nextTick:第一次等父组件更新 props.groups,第二次等新分区的 Input 组件渲染到 DOM
|
||||
// 双重 nextTick:第一次等父组件更新 groups.value,第二次等新分区的 Input 组件渲染到 DOM
|
||||
nextTick(() => {
|
||||
nextTick(() => {
|
||||
const el = sectionEditInput.value
|
||||
@@ -557,18 +552,18 @@ function deleteGroupDirect(section) {
|
||||
const group = section?.group
|
||||
if (!group || props.loading) return
|
||||
const gid = String(group.b_id)
|
||||
const rows = section.rows || []
|
||||
const sectionRows = section.rows || []
|
||||
const title = group.b_title || `分组-${gid}`
|
||||
// 竞态兜底:若正在编辑该分组标题,先关闭编辑并把未提交的标题并入删除前的分组数组,
|
||||
// 避免 blur 提交的 updateGroups 与本函数基于旧 props.groups 的结果互相覆盖
|
||||
// 避免 blur 提交的分组变更与本函数基于旧分组的结果互相覆盖
|
||||
// 子分组提升为顶级(扁平分组:删除父分组后其子分组独立展示)
|
||||
let groups = (props.groups || []).map((g) =>
|
||||
let nextGroups = (groups.value || []).map((g) =>
|
||||
String(g.b_parent_id) === gid ? { ...g, b_parent_id: null } : g,
|
||||
)
|
||||
if (editingGroupKey.value === section.key) {
|
||||
const pendingTitle = groupTitleDraft.value.trim()
|
||||
if (pendingTitle && pendingTitle !== group.b_title) {
|
||||
groups = groups.map((g) =>
|
||||
nextGroups = nextGroups.map((g) =>
|
||||
String(g.b_id) === gid ? { ...g, b_title: pendingTitle } : g,
|
||||
)
|
||||
}
|
||||
@@ -578,20 +573,20 @@ function deleteGroupDirect(section) {
|
||||
for (const row of localEdit.value) {
|
||||
if (String(row.b_group_id || '') === gid) row.b_group_id = null
|
||||
}
|
||||
emit('update', localEdit.value)
|
||||
rows.value = localEdit.value
|
||||
// 列表配置中引用该分组的行清理 b_group_id(引用完整性,非可见性抄写)
|
||||
const listUpdated = (props.listConfig || []).map((r) =>
|
||||
const listUpdated = (listConfig.value || []).map((r) =>
|
||||
String(r.b_group_id || '') === gid ? { ...r, b_group_id: null } : r,
|
||||
)
|
||||
if (
|
||||
listUpdated.some((r, i) => JSON.stringify(r) !== JSON.stringify((props.listConfig || [])[i]))
|
||||
listUpdated.some((r, i) => JSON.stringify(r) !== JSON.stringify((listConfig.value || [])[i]))
|
||||
) {
|
||||
emit('updateListConfig', listUpdated)
|
||||
listConfig.value = listUpdated
|
||||
}
|
||||
emit('updateGroups', groups.filter((g) => String(g.b_id) !== gid))
|
||||
groups.value = nextGroups.filter((g) => String(g.b_id) !== gid)
|
||||
Message.success(
|
||||
rows.length
|
||||
? `已删除分组「${title}」,其下 ${rows.length} 个字段移至「未分组」`
|
||||
sectionRows.length
|
||||
? `已删除分组「${title}」,其下 ${sectionRows.length} 个字段移至「未分组」`
|
||||
: `已删除分组「${title}」`,
|
||||
)
|
||||
}
|
||||
@@ -1045,7 +1040,7 @@ function moveSelectedRow(delta) {
|
||||
function toggleRowFlag(row, key) {
|
||||
if (!row || props.loading) return
|
||||
toggleFlag(row, key)
|
||||
emit('update', localEdit.value)
|
||||
rows.value = localEdit.value
|
||||
}
|
||||
|
||||
function onLabelChip(row, key) {
|
||||
@@ -1070,7 +1065,7 @@ function pastePropsToSelected() {
|
||||
const row = selectedRow.value
|
||||
if (!row || props.loading || !clipboardProps.value) return
|
||||
applyClipboardProps(row, clipboardProps.value.props)
|
||||
emit('update', localEdit.value)
|
||||
rows.value = localEdit.value
|
||||
}
|
||||
|
||||
/* 字段库拖拽(clone 到画布):布局冻结模式下画布侧不会收到 add 事件,
|
||||
@@ -1168,7 +1163,7 @@ function toggleInspector(key) {
|
||||
const row = selectedRow.value
|
||||
if (!row || props.loading) return
|
||||
toggleFlag(row, key)
|
||||
emit('update', localEdit.value)
|
||||
rows.value = localEdit.value
|
||||
}
|
||||
|
||||
function onColspanChange(value) {
|
||||
@@ -1190,7 +1185,7 @@ function onColspanChange(value) {
|
||||
sorted[i].b_colspan = Math.max(1, base + (k < rem ? 1 : 0))
|
||||
})
|
||||
}
|
||||
emit('update', localEdit.value)
|
||||
rows.value = localEdit.value
|
||||
}
|
||||
|
||||
// 数字输入框 change(失焦/回车)提交:与原值不同才真正写入
|
||||
@@ -1206,7 +1201,7 @@ function onHeightChange(value) {
|
||||
const row = selectedRow.value
|
||||
if (!row || !isTextarea(row) || props.loading) return
|
||||
row.b_height = Math.min(480, Math.max(64, Number(value) || 120))
|
||||
emit('update', localEdit.value)
|
||||
rows.value = localEdit.value
|
||||
}
|
||||
|
||||
/* 占位符:change(失焦/回车)提交,有变化才真正写入 */
|
||||
@@ -1216,7 +1211,7 @@ function onPlaceholderChange(value) {
|
||||
const next = String(value ?? '').trim()
|
||||
if ((row.b_placeholder || '') === next) return
|
||||
row.b_placeholder = next || null
|
||||
emit('update', localEdit.value)
|
||||
rows.value = localEdit.value
|
||||
}
|
||||
|
||||
function removeRow(row) {
|
||||
@@ -1725,7 +1720,7 @@ onBeforeUnmount(() => {
|
||||
:active-key="activeKey"
|
||||
@back="switchView('design')"
|
||||
@row-click="(row) => (activeKey = String(row.b_id))"
|
||||
@change="() => emit('update', localEdit.value)"
|
||||
@change="() => (rows = localEdit)"
|
||||
@row-order-change="onRowOrderChange"
|
||||
/>
|
||||
|
||||
@@ -1799,7 +1794,7 @@ onBeforeUnmount(() => {
|
||||
:footer="false"
|
||||
>
|
||||
<FormPreview
|
||||
:groups="props.groups"
|
||||
:groups="groups"
|
||||
:rows="localEdit"
|
||||
:fields="props.fields"
|
||||
:cols-map="sectionCols"
|
||||
|
||||
+29
-26
@@ -15,19 +15,16 @@
|
||||
* - 删除列 = 真删行(行存在即展示,与查询配置同语义);无分组启停;
|
||||
* 剪贴板仅 列宽;数据视图含 标题多语言 列
|
||||
*
|
||||
* 用法:
|
||||
* 用法(数据全部走 v-model 双向绑定,本编辑器直接读写父级持有的对应数据):
|
||||
* <TableDesignEditor
|
||||
* mode="subtable|list"
|
||||
* v-model="edit_config|list_config"
|
||||
* v-model:groups="groups"
|
||||
* v-model:edit-mode="maindata.b_edit_mode" // 仅 subtable 用,用于「编辑方式」切换器
|
||||
* :module-id="String(...)"
|
||||
* :rows="drafts.editConfig|listConfig"
|
||||
* :fields="drafts.fields"
|
||||
* :groups="drafts.groups"
|
||||
* :edit-mode="editMode" // 仅 subtable 用,用于「编辑方式」切换器
|
||||
* :fields="fields"
|
||||
* :active="activeSection === 'edit'" // 仅 subtable 用:非激活 tab 不响应键盘删除
|
||||
* :loading="busy"
|
||||
* @update="(v) => onModuleUpdate('editConfig'|'listConfig', v)"
|
||||
* @update-groups="onGroupsUpdate"
|
||||
* @update:edit-mode="(v) => onModuleUpdate('module', {...})" // 仅 subtable
|
||||
* />
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
@@ -50,20 +47,20 @@ const props = defineProps({
|
||||
/** 编辑场景:subtable 子表编辑 | list 列表配置 */
|
||||
mode: { type: String, default: 'subtable' },
|
||||
moduleId: { type: String, required: true },
|
||||
/** 当前编辑的行数组(subtable → editConfig,list → listConfig) */
|
||||
rows: { type: Array, default: () => [] },
|
||||
fields: { type: Array, default: () => [] },
|
||||
groups: { type: Array, default: () => [] },
|
||||
/** 行工厂:宿主注入(不同场景生成的差异字段不同,编辑器不感知业务) */
|
||||
createRow: { type: Function, required: true },
|
||||
/** 编辑方式(subtable 专用,用于「编辑方式」切换器) */
|
||||
editMode: { type: String, default: 'form' },
|
||||
/** 面板当前是否可见(subtable 专用:非激活时 Delete 等快捷键不生效) */
|
||||
active: { type: Boolean, default: true },
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'updateGroups', 'update:editMode'])
|
||||
/** 当前编辑的行数组(subtable → editConfig,list → listConfig),父级 v-model 绑定 */
|
||||
const rows = defineModel({ type: Array, default: () => [] })
|
||||
/** 分组行数组(s_field_group),父级 v-model:groups 绑定 */
|
||||
const groups = defineModel('groups', { type: Array, default: () => [] })
|
||||
/** 编辑方式(subtable 专用),父级 v-model:edit-mode 绑定 */
|
||||
const editMode = defineModel('editMode', { type: String, default: 'form' })
|
||||
|
||||
const isSubtable = computed(() => props.mode === 'subtable')
|
||||
|
||||
@@ -86,7 +83,7 @@ const structureTreeRef = ref(null)
|
||||
|
||||
function onEditModeChange(value) {
|
||||
if (props.loading) return
|
||||
emit('update:editMode', value || 'form')
|
||||
editMode.value = value || 'form'
|
||||
}
|
||||
|
||||
/* ================= 公共查询表 ================= */
|
||||
@@ -95,13 +92,19 @@ const { fieldMap, fieldOf, fieldName, controlTypeLabel } = useFieldLookup(() =>
|
||||
/* ================= 结构编辑内核(子表 / 列表共用) ================= */
|
||||
const editing = useStructureEditing({
|
||||
moduleId: () => props.moduleId,
|
||||
getGroups: () => props.groups,
|
||||
getRows: () => props.rows,
|
||||
getGroups: () => groups.value,
|
||||
getRows: () => rows.value,
|
||||
loading: () => props.loading,
|
||||
// 行工厂由宿主注入:编辑器不感知业务差异(subtable 带 b_width,list 带 b_sortable 等)
|
||||
createRow: props.createRow,
|
||||
applyGroups: (groups, removedGroupIds) => emit('updateGroups', groups, removedGroupIds),
|
||||
applyRows: (rows) => emit('update', rows),
|
||||
// 写回 model。分组删除的跨表引用清理(listConfig/editConfig 的 b_group_id)
|
||||
// 已上移到父级 watch 按差集幂等处理,故此处不再上抛 removedGroupIds
|
||||
applyGroups: (nextGroups) => {
|
||||
groups.value = nextGroups
|
||||
},
|
||||
applyRows: (nextRows) => {
|
||||
rows.value = nextRows
|
||||
},
|
||||
requestRename: (gid) => structureTreeRef.value?.startRename(gid),
|
||||
// 子表:全量键集;列表:仅 列宽
|
||||
clipboardKeys: isSubtable.value ? undefined : ['b_width'],
|
||||
@@ -141,12 +144,12 @@ const {
|
||||
|
||||
const { visualColumns, bandRows, headerColumns, ungroupedCount } = useBandLayout(editing)
|
||||
|
||||
const groupCount = computed(() => (props.groups || []).length)
|
||||
const groupCount = computed(() => (groups.value || []).length)
|
||||
|
||||
/* ================= 数据视图:行排序 ================= */
|
||||
function onRowOrderChange(rows) {
|
||||
localRows.value = renumberRows(rows)
|
||||
emit('update', localRows.value)
|
||||
function onRowOrderChange(nextRows) {
|
||||
localRows.value = renumberRows(nextRows)
|
||||
rows.value = localRows.value
|
||||
}
|
||||
|
||||
/* ================= 列删除 ================= */
|
||||
@@ -254,7 +257,7 @@ function toggleInspector(key) {
|
||||
const row = selectedRow.value
|
||||
if (!row || props.loading) return
|
||||
toggleFlag(row, key)
|
||||
emit('update', localRows.value)
|
||||
rows.value = localRows.value
|
||||
}
|
||||
|
||||
/* ================= 视图切换 ================= */
|
||||
@@ -400,7 +403,7 @@ const treeFeatures = computed(() => ({
|
||||
:field-of="fieldOf"
|
||||
:group-id-of-row="groupIdOfRow"
|
||||
:commit-col-drag="commitColDrag"
|
||||
:on-commit="() => emit('update', localRows.value)"
|
||||
:on-commit="() => (rows = localRows)"
|
||||
:empty-text="isSubtable ? undefined : '暂无列配置'"
|
||||
/>
|
||||
|
||||
@@ -469,7 +472,7 @@ const treeFeatures = computed(() => ({
|
||||
:active-key="activeKey"
|
||||
@back="switchView('design')"
|
||||
@row-click="onDataRowClick"
|
||||
@change="() => emit('update', localRows.value)"
|
||||
@change="() => (rows = localRows)"
|
||||
@row-order-change="onRowOrderChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -705,9 +705,9 @@ export function useStructureEditing({
|
||||
if (idx < 0 || target < 0 || target >= own.length) return
|
||||
const a = own[idx]
|
||||
const b = own[target]
|
||||
const tmp = list[a]
|
||||
const movedRow = list[a]
|
||||
list[a] = list[b]
|
||||
list[b] = tmp
|
||||
list[b] = movedRow
|
||||
rebuildFlat()
|
||||
selectedNodeKey.value = `c:${row.b_field_id}`
|
||||
}
|
||||
|
||||
+2
-2
@@ -38,8 +38,8 @@ const emit = defineEmits([
|
||||
'remove-selected',
|
||||
])
|
||||
|
||||
function toggle(flag) {
|
||||
emit('toggle', flag)
|
||||
function toggle(field) {
|
||||
emit('toggle', field)
|
||||
}
|
||||
function onColspanInput(value) {
|
||||
emit('colspan-change', value)
|
||||
|
||||
+2
-2
@@ -58,8 +58,8 @@ const DEFAULT_STATUS_CHIPS = [
|
||||
{ flag: 'b_visible', label: '显示', icon: Eye, iconOff: EyeOff },
|
||||
]
|
||||
const statusChips = computed(() => props.statusFlags || DEFAULT_STATUS_CHIPS)
|
||||
function isOn(flag) {
|
||||
return Number(props.selectedRow?.[flag]) === 1
|
||||
function isOn(field) {
|
||||
return Number(props.selectedRow?.[field]) === 1
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,95 @@
|
||||
import { Database, Folder, FolderPlus, Search } from '@lucide/vue'
|
||||
import { createTempId } from '@/utils/common'
|
||||
import { diffRows } from '@/utils/dataChanges'
|
||||
|
||||
/** 模块类型 → 树节点图标(预载到 node.icon,tree-node.vue 自动渲染) */
|
||||
export const MODULE_TYPE_ICON_MAP = {
|
||||
category: Folder,
|
||||
data: Database,
|
||||
query: Search,
|
||||
}
|
||||
|
||||
/** 工具栏 / 右键「新增」菜单项(key 即 b_module_type) */
|
||||
export const MODULE_ADD_OPTIONS = [
|
||||
{ key: 'category', label: '新增分类', icon: FolderPlus },
|
||||
{ key: 'data', label: '新增数据模块', icon: Database },
|
||||
{ key: 'query', label: '新增查询模块', icon: Search },
|
||||
]
|
||||
|
||||
/**
|
||||
* 分类节点标记为非叶子(即使暂无子模块也显示折叠箭头)+ 附加类型图标。
|
||||
* 就地修改并返回原数组。
|
||||
* @param {Array} nodes 树节点数组
|
||||
* @returns {Array} 原数组
|
||||
*/
|
||||
export function markCategoryNonLeaf(nodes) {
|
||||
for (const node of nodes) {
|
||||
if (node.b_module_type === 'category') node.isLeaf = false
|
||||
const icon = MODULE_TYPE_ICON_MAP[node.b_module_type]
|
||||
if (icon) node.icon = icon
|
||||
if (node.children) markCategoryNonLeaf(node.children)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* 从树节点提取纯模块行(剔除 children/isLeaf/icon 等树注入字段)。
|
||||
* 未落库模块切回时,用它从树节点重建主表草稿。
|
||||
* @param {Object} node 树节点
|
||||
* @returns {Object} s_module 行
|
||||
*/
|
||||
export function moduleRowOf(node) {
|
||||
return {
|
||||
b_id: node.b_id,
|
||||
b_code: node.b_code,
|
||||
b_parent_id: node.b_parent_id ? String(node.b_parent_id) : null,
|
||||
b_name: node.b_name,
|
||||
b_i18n: node.b_i18n ?? null,
|
||||
b_module_type: node.b_module_type,
|
||||
b_viewtable: node.b_viewtable ?? null,
|
||||
b_savetable: node.b_savetable ?? null,
|
||||
b_keyfield: node.b_keyfield ?? null,
|
||||
b_orderfield: node.b_orderfield ?? null,
|
||||
b_delete_policy: node.b_delete_policy ?? null,
|
||||
b_edit_mode: node.b_edit_mode ?? null,
|
||||
b_canuse: node.b_canuse,
|
||||
b_xh: node.b_xh,
|
||||
b_bz: node.b_bz ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在树中查找节点所在的父数组及索引。
|
||||
* @param {Array} nodes 树节点数组
|
||||
* @param {string|number} id 目标节点 ID
|
||||
* @returns {{ list: Array, index: number }|null}
|
||||
*/
|
||||
export function findNodePosition(nodes, id) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
|
||||
if (nodes[i].children) {
|
||||
const found = findNodePosition(nodes[i].children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 重排某组兄弟的 b_xh(10 起步、步长 10),并把变化的行收集到 updatedRows。
|
||||
* @param {Array} list 兄弟节点数组(就地修改 b_xh)
|
||||
* @param {Array} updatedRows 变化行收集器({ b_id, b_xh })
|
||||
*/
|
||||
export function renumberSiblings(list, updatedRows) {
|
||||
list.forEach((s, i) => {
|
||||
const nextXh = (i + 1) * 10
|
||||
if (s.b_xh !== nextXh) {
|
||||
s.b_xh = nextXh
|
||||
updatedRows.push({ b_id: s.b_id, b_xh: nextXh })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function buildModuleItemI18nKey(prefix, moduleCode, itemCode) {
|
||||
const normalizedModuleCode = String(moduleCode || '').trim()
|
||||
const normalizedItemCode = String(itemCode || '').trim()
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import ModuleTableEditPanel from '@/views/module/module-management/TableEditPanel.vue'
|
||||
|
||||
const fields = [
|
||||
{ b_id: '1', b_name: '姓名', b_field: 'name', b_type: 'input' },
|
||||
{ b_id: '2', b_name: '年龄', b_field: 'age', b_type: 'number' },
|
||||
{ b_id: '3', b_name: '状态', b_field: 'status', b_type: 'select' },
|
||||
{ b_id: '4', b_name: '备注', b_field: 'remark', b_type: 'textarea' },
|
||||
{ b_id: '5', b_name: '金额', b_field: 'amount', b_type: 'money' },
|
||||
]
|
||||
const groups = ref([
|
||||
{ b_id: 'g1', b_module_id: 'm1', b_parent_id: null, b_title: '基本信息', b_xh: 10, b_canuse: 1 },
|
||||
{ b_id: 'g2', b_module_id: 'm1', b_parent_id: null, b_title: '扩展信息', b_xh: 20, b_canuse: 1 },
|
||||
])
|
||||
const editConfig = ref([
|
||||
{ b_id: 'e1', b_module_id: 'm1', b_field_id: '1', b_group_id: null, b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 120, b_xh: 10 },
|
||||
{ b_id: 'e2', b_module_id: 'm1', b_field_id: '2', b_group_id: 'g1', b_visible: 1, b_required: 1, b_readonly: 0, b_disabled: 0, b_width: 100, b_xh: 20 },
|
||||
{ b_id: 'e3', b_module_id: 'm1', b_field_id: '3', b_group_id: 'g1', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 160, b_xh: 30 },
|
||||
{ b_id: 'e4', b_module_id: 'm1', b_field_id: '4', b_group_id: 'g2', b_visible: 1, b_required: 0, b_readonly: 0, b_disabled: 0, b_width: 220, b_xh: 40 },
|
||||
])
|
||||
const listConfig = ref([])
|
||||
const queryConfig = ref([])
|
||||
function onUpdate(v) {
|
||||
editConfig.value = v
|
||||
}
|
||||
function onUpdateGroups(v) {
|
||||
groups.value = v
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="height: 100vh; display: flex; padding: 12px; box-sizing: border-box">
|
||||
<ModuleTableEditPanel
|
||||
module-id="m1"
|
||||
:edit-config="editConfig"
|
||||
:list-config="listConfig"
|
||||
:query-config="queryConfig"
|
||||
:fields="fields"
|
||||
:groups="groups"
|
||||
edit-mode="table"
|
||||
:active="true"
|
||||
:loading="false"
|
||||
@update="onUpdate"
|
||||
@update-groups="onUpdateGroups"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 回归用例:菜单管理 - 新建节点(未保存)→ 切到旧节点 → 切回新节点。
|
||||
* 曾因 loadMenuConfig 用临时 id 查库返回空 → drafts = { menu: null }(真值)→
|
||||
* 曾因 loadMenuConfig 用临时 id 查库返回空 → maindata 为 null →
|
||||
* 模板以 null menu 渲染 → render 抛错 → 在布局 <Transition> 下崩溃
|
||||
* (表现为 "Component inside <Transition> renders non-element root node" +
|
||||
* "Cannot set properties of null (setting '__vnode')")。
|
||||
@@ -55,12 +55,12 @@ describe('菜单管理:未保存新节点来回切换', () => {
|
||||
await wrapper.vm.addMenu(null, 'page')
|
||||
await flushPromises()
|
||||
expect(String(wrapper.vm.menuSelectedId)).toBe('9001')
|
||||
expect(wrapper.vm.drafts?.menu?.b_menu_type).toBe('page')
|
||||
expect(wrapper.vm.maindata?.b_menu_type).toBe('page')
|
||||
|
||||
// 2. 切到旧节点(已保存)
|
||||
wrapper.vm.menuSelectedId = '1'
|
||||
await flushPromises()
|
||||
expect(wrapper.vm.drafts?.menu?.b_id).toBe('1')
|
||||
expect(wrapper.vm.maindata?.b_id).toBe('1')
|
||||
|
||||
// 3. 切回新节点:不应对临时 id 发起查库请求
|
||||
loadDataApi.mockClear()
|
||||
@@ -69,9 +69,9 @@ describe('菜单管理:未保存新节点来回切换', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(loadDataApi).not.toHaveBeenCalled()
|
||||
expect(wrapper.vm.drafts?.menu).not.toBeNull()
|
||||
expect(String(wrapper.vm.drafts?.menu?.b_id)).toBe('9001')
|
||||
expect(wrapper.vm.drafts?.menu?.b_menu_type).toBe('page')
|
||||
expect(wrapper.vm.maindata).not.toBeNull()
|
||||
expect(String(wrapper.vm.maindata?.b_id)).toBe('9001')
|
||||
expect(wrapper.vm.maindata?.b_menu_type).toBe('page')
|
||||
expect(String(wrapper.vm.loadedMenuId)).toBe('9001')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user