20260904173136

This commit is contained in:
oneao committed 2026-09-04 17:31:36 +08:00
1 parent 0928437c67
commit 4e167df0f0
40 files changed
+3471 -2314

No files matched your search

-40
View File
@@ -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">⌕&nbsp; 筛选字段</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
}
@@ -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'
-7
View File
@@ -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,
+45 -1
View File
@@ -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>
@@ -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"
@@ -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>
@@ -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}`
}
@@ -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)
@@ -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')
})
})