20260824172723

This commit is contained in:
oneao committed 2026-08-24 17:27:24 +08:00
1 parent 791d1e01d7
commit 97aea665f9
10 files changed
+443 -723

No files matched your search

@@ -38,8 +38,6 @@ import { createTempId } from '@/utils/tempId'
const props = defineProps({
moduleId: { type: String, required: true },
/** 模块名称(仅用于画布标题展示) */
moduleName: { type: String, default: '' },
editConfig: { type: Array, default: () => [] },
listConfig: { type: Array, default: () => [] },
queryConfig: { type: Array, default: () => [] },
@@ -62,11 +60,9 @@ const viewMode = ref('design') // 'design' | 'data'
const selectedRowKey = ref(null)
const dragPayload = ref(null)
const dragSourceId = ref(null)
// 拖拽对齐提示线:{ fieldId, pos: 'before' | 'after' }
// 拖拽对齐提示:{ fieldId, pos: 'before' | 'after' | 'line-before' | 'newline' }
const dragLine = ref(null)
// 悬停行的几何高亮框(相对 sheet 的绝对定位矩形,把整行字段圈起来,明确「行」的概念)
const lineRect = ref(null)
// 拖拽悬停中的分区 key(用于分组高亮)
// 拖拽悬停中的分区 key(用于分组放置逻辑)
const dragOverSection = ref(null)
const lastDeleted = ref(null)
let undoTimer = null
@@ -455,8 +451,6 @@ function toggleFormDirection() {
)
}
const moduleTitle = computed(() => props.moduleName || '编辑表单')
// 字段库:展示模块的全部字段(含未加入表单的字段),点击定位到画布卡片
const libraryFields = computed(() => props.fields || [])
@@ -666,33 +660,20 @@ function selectField(field) {
})
}
/* ================= 画布拖拽(vuedraggable / SortableJS) =================
注意:Sortable 的 end 事件只在拖拽「源列表」触发,跨列表拖拽的目标列表只收到 add/sort。
/* ================= 画布拖拽(vuedraggable / SortableJS fallback 模式) =================
注意:启用 force-fallback 后拖拽完全基于鼠标事件模拟,不触发原生 HTML5 拖拽——
滚动条在拖拽期间可正常滚动,边缘自动滚动(auto-scroll)也正常生效。
Sortable 的 end 事件只在拖拽「源列表」触发,跨列表拖拽的目标列表只收到 add/sort。
因此字段库拖入画布必须在 onLibDragEnd(源=字段库)提交,画布内拖拽在 onFieldDragEnd 提交。 */
/* 跟手标题条(阿里式拖拽):浏览器原生 HTML5 拖影是半透明的且透明度不受 CSS 控制,
这里把原生拖影换成 1×1 透明占位隐藏掉,再自绘一个只带字段名的小标签跟随鼠标移动——
不拖整张卡片,拖的是「标题条」;落点由目标卡片对应方向的边框高亮表达。
native 拖拽本身(dragover/drop 事件)不受影响,落区检测照常工作 */
/* 跟手标题条(阿里式拖拽):自绘一个只带字段名的小标签跟随鼠标移动——
不拖整张卡片,拖的是「标题条」;落点由目标卡片对应方向的边框高亮表达 */
let dragShadowEl = null
let dragShadowOffsetX = 0
let dragShadowOffsetY = 0
/* 原生 dragstart(捕获阶段,先于 SortableJS 处理) */
function onNativeDragStart(evt) {
if (!evt.dataTransfer || evt.defaultPrevented) return
const card =
evt.target instanceof Element
? evt.target.closest('.mm-fdesign__field, .mm-fdesign__lib-item')
: null
if (!card) return
// 1) 用 1×1 透明占位替换浏览器默认的半透明拖影
const blank = document.createElement('div')
blank.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0.01;pointer-events:none'
document.body.appendChild(blank)
evt.dataTransfer.setDragImage(blank, 0, 0)
requestAnimationFrame(() => blank.remove())
// 2) 自绘「标题条」跟手:字段类型标记(如有)+ 字段名
/* 从被拖卡片 DOM 提取字段名,创建跟手标题条 */
function createDragBadge(card) {
removeDragShadow()
const nameEl = card.querySelector('.mm-fdesign__field-name, .mm-fdesign__lib-name')
const markEl = card.querySelector('.mm-fdesign__mark')
@@ -711,14 +692,15 @@ function onNativeDragStart(evt) {
badge.appendChild(name)
document.body.appendChild(badge)
dragShadowEl = badge
// 让标签右上角跟手(斜下方 12px 悬挂),像「拎着」一个小标签
// 让标签右上角跟手(斜下方 14px 悬挂),像「拎着」一个小标签
dragShadowOffsetX = 14
dragShadowOffsetY = 14
positionDragShadow(evt.clientX, evt.clientY)
}
function onDragShadowMove(evt) {
positionDragShadow(evt.clientX, evt.clientY)
// fallback 模式下没有原生 dragover,放置意图检测改在 mousemove 中同步进行
updateDragIntent(evt)
}
function positionDragShadow(clientX, clientY) {
@@ -733,32 +715,50 @@ function removeDragShadow() {
}
}
/* 拖拽期间全局禁用文本选中(fallback 拖拽用鼠标模拟,mousemove 会触发文本框选),
结束后恢复——body 级 user-select 覆盖整个页面,防止拖到任意文字上都出现蓝色高亮 */
function disableTextSelection() {
document.body.style.userSelect = 'none'
document.body.style.webkitUserSelect = 'none'
}
function enableTextSelection() {
document.body.style.userSelect = ''
document.body.style.webkitUserSelect = ''
}
function onFieldDragStart(evt) {
dragPayload.value = { kind: 'card' }
dragSourceId.value = String(evt?.item?.dataset?.fieldId || '')
createDragBadge(evt.item)
window.addEventListener('keydown', onDragKeydown)
window.addEventListener('drag', onDragShadowMove)
window.addEventListener('mousemove', onDragShadowMove)
disableTextSelection()
}
// SortableJS onMove:仅用于布局冻结(禁止拖拽中实时重排 DOM,主流表单设计器的做法)。
// 放置意图的记录改由原生 dragover(onCanvasDragOver)承担:SortableJS 在目标插入位置
// 等于被拖元素当前位置时会跳过 move 回调(如往「紧邻的上一行」拖拽),会导致意图丢失。
// 放置意图的记录由 mousemove(onDragShadowMove → updateDragIntent)承担。
function onFieldMove() {
return false
}
/* 放置意图记录:原生 dragover 每次指针移动都触发,不受 SortableJS 内部决策影响。
/* 放置意图记录:mousemove 每次指针移动都触发(fallback 模式替代原生 dragover)。
语义:上/下/左/右四条边各占 50%(中线分界),程序按「比例最近边缘」判断鼠标
偏向哪条边——上 → 该行上方新行,下 → 该行下方新行,左 → 插到该字段前,
右 → 插到该字段后;完全居中默认并入该行末尾。
注意:必须用捕获阶段监听——SortableJS 在冒泡阶段对 dragover/drop 调用了
stopPropagation,冒泡监听挂在祖先容器上会永远收不到事件。 */
function onCanvasDragOver(evt) {
右 → 插到该字段后;完全居中默认并入该行末尾。 */
function updateDragIntent(evt) {
if (!dragPayload.value) return
const hoverEl = evt.target instanceof Element ? evt.target : null
if (!hoverEl || hoverEl.closest('.sortable-fallback')) return // 忽略拖拽跟随影子
/* 鼠标离开画布区域:清除放置意图,松手时按取消处理(避免把字段误移到最后悬停的分组) */
const canvasWrap = hoverEl.closest('.mm-fdesign__canvas-wrap')
if (!canvasWrap) {
dragLine.value = null
dragOverSection.value = null
return
}
const sectionEl = hoverEl.closest('[data-section-key]')
dragOverSection.value = sectionEl ? sectionEl.getAttribute('data-section-key') : null
@@ -821,15 +821,6 @@ function onCanvasDragOver(evt) {
dragLine.value = { fieldId, pos }
}
/* 拖拽离开画布区域:清除放置意图,松手时按取消处理(避免把字段误移到最后悬停的分组) */
function onCanvasDragLeave(evt) {
if (!dragPayload.value) return
const toEl = evt.relatedTarget
if (toEl instanceof Element && toEl.closest('.mm-fdesign__canvas-wrap')) return // 仍在画布内
dragLine.value = null
dragOverSection.value = null
}
/* 鼠标坐标命中的「行」(按卡片 top 聚类成视觉行),返回行矩形 + 行内字段顺序。
用于鼠标落在卡片间隙 / 行内空白时仍能按整行区域判定上/下新行 */
function rowRectAt(clientX, clientY, sectionEl) {
@@ -886,7 +877,7 @@ function dropTipText(pos) {
}
/* 同行意图(before/after)时:被悬停字段所在视觉行的全体成员 fieldId。
整行卡片一起高亮,而不是只亮被悬停的那一张,让用户明确「是并入这一行」 */
整行卡片一起保持显眼(拖拽变淡时不被压暗),让用户明确「是并入这一行」 */
const dragLineRowIds = computed(() => {
const line = dragLine.value
if (!line || (line.pos !== 'before' && line.pos !== 'after')) return null
@@ -896,45 +887,6 @@ const dragLineRowIds = computed(() => {
return new Set(collectLineIndexes(rows, idx).idxs.map((i) => String(rows[i].b_field_id)))
})
/* 悬停行高亮框:根据 dragLine 实时计算目标视觉行的几何矩形(相对 sheet),
用虚线框把整行字段圈起来,用户一眼看清「这一行」的边界,以及新行插入的横贯线位置 */
let lastLineKey = ''
watch(
() => dragLine.value,
(line) => {
const key = line ? `${line.fieldId}:${line.pos}` : ''
if (key === lastLineKey) return // 意图未变化,不重复计算
lastLineKey = key
if (!line) {
lineRect.value = null
return
}
const rows = localEdit.value
const idx = rows.findIndex((r) => String(r.b_field_id) === String(line.fieldId))
const sheetEl = document.querySelector('.mm-fdesign__sheet')
if (idx < 0 || !sheetEl) {
lineRect.value = null
return
}
const els = collectLineIndexes(rows, idx).idxs
.map((i) => document.querySelector(`.mm-fdesign__field[data-row-id="${rows[i].b_id}"]`))
.filter(Boolean)
if (!els.length) {
lineRect.value = null
return
}
const rects = els.map((el) => el.getBoundingClientRect())
const sheetRect = sheetEl.getBoundingClientRect()
lineRect.value = {
top: Math.min(...rects.map((r) => r.top)) - sheetRect.top - 4,
left: Math.min(...rects.map((r) => r.left)) - sheetRect.left - 4,
width: Math.max(...rects.map((r) => r.right)) - Math.min(...rects.map((r) => r.left)) + 8,
height: Math.max(...rects.map((r) => r.bottom)) - Math.min(...rects.map((r) => r.top)) + 8,
}
},
{ flush: 'post' },
)
// 目标分组段的末尾插入位置(rows 为移除被拖字段后的列表)
function sectionEndIndex(rows, gid) {
let last = -1
@@ -967,9 +919,9 @@ function distributeLine(rows, memberIdxs) {
完全根据拖拽悬停记录的意图(dragLine / dragOverSection)计算新顺序与宽度 */
function onFieldDragEnd(evt) {
window.removeEventListener('keydown', onDragKeydown)
window.removeEventListener('drag', onDragShadowMove)
window.removeEventListener('mousemove', onDragShadowMove)
removeDragShadow()
enableTextSelection()
const rowId = String(evt?.item?.dataset?.fieldId || '')
const linePos = dragLine.value
const overKey = dragOverSection.value
@@ -1235,16 +1187,17 @@ function togglePair(row) {
function onLibDragStart(evt) {
dragPayload.value = { kind: 'library', fieldId: String(evt?.item?.dataset?.fieldId || '') }
dragSourceId.value = 'lib'
createDragBadge(evt.item)
window.addEventListener('keydown', onDragKeydown)
window.addEventListener('drag', onDragShadowMove)
window.addEventListener('mousemove', onDragShadowMove)
disableTextSelection()
}
function onLibDragEnd() {
window.removeEventListener('keydown', onDragKeydown)
window.removeEventListener('drag', onDragShadowMove)
window.removeEventListener('mousemove', onDragShadowMove)
removeDragShadow()
enableTextSelection()
const payload = dragPayload.value
const linePos = dragLine.value
const overKey = dragOverSection.value
@@ -1456,6 +1409,10 @@ function onDesignerKeydown(event) {
onMounted(() => window.addEventListener('keydown', onDesignerKeydown))
onBeforeUnmount(() => {
window.removeEventListener('keydown', onDesignerKeydown)
window.removeEventListener('keydown', onDragKeydown)
window.removeEventListener('mousemove', onDragShadowMove)
removeDragShadow()
enableTextSelection()
clearTimeout(undoTimer)
})
</script>
@@ -1518,11 +1475,7 @@ onBeforeUnmount(() => {
</div>
<!-- ================= 设计视图 ================= -->
<div
v-show="viewMode === 'design'"
class="mm-fdesign"
@dragstart.capture="onNativeDragStart"
>
<div v-show="viewMode === 'design'" class="mm-fdesign">
<aside class="mm-fdesign__lib">
<div class="mm-fdesign__subhead">
<strong>字段库</strong>
@@ -1547,6 +1500,8 @@ onBeforeUnmount(() => {
:animation="150"
ghost-class="mm-fdesign__lib-ghost"
:fallback-on-body="true"
:force-fallback="true"
fallback-class="mm-fdesign__drag-fallback"
@start="onLibDragStart"
@end="onLibDragEnd"
>
@@ -1599,8 +1554,6 @@ onBeforeUnmount(() => {
<div
class="mm-fdesign__canvas-wrap"
:class="{ 'is-drag-active': dragPayload }"
@dragover.capture="onCanvasDragOver"
@dragleave.capture="onCanvasDragLeave"
>
<div class="mm-fdesign__canvas">
<div class="mm-fdesign__canvas-note">
@@ -1631,32 +1584,6 @@ onBeforeUnmount(() => {
:class="{ 'is-horizontal': formDirection === 'horizontal' }"
@click="selectRow(null)"
>
<div class="mm-fdesign__sheet-head">
<div>
<strong>{{ moduleTitle }}</strong>
<span>编辑表单 · 展示当前草稿布局</span>
</div>
<span class="mm-fdesign__badge">编辑态</span>
</div>
<!-- 拖拽悬停行高亮框:虚线框包住整行字段,明确「行」边界与插入横贯线位置 -->
<div
v-if="lineRect"
class="mm-fdesign__line-highlight"
:class="{
'is-line-before': dragLine && dragLine.pos === 'line-before',
'is-newline': dragLine && dragLine.pos === 'newline',
'is-before': dragLine && dragLine.pos === 'before',
'is-after': dragLine && dragLine.pos === 'after',
}"
:style="{
top: `${lineRect.top}px`,
left: `${lineRect.left}px`,
width: `${lineRect.width}px`,
height: `${lineRect.height}px`,
}"
/>
<template v-if="canvasSections.length">
<div
v-for="section in canvasSections"
@@ -1664,7 +1591,6 @@ onBeforeUnmount(() => {
class="mm-fdesign__section"
:data-section-key="section.key"
:class="{
'is-over': dragPayload && dragOverSection === section.key,
'is-horizontal': section.group?.b_layout_direction === 'horizontal',
}"
>
@@ -1697,6 +1623,8 @@ onBeforeUnmount(() => {
ghost-class="mm-fdesign__sortable-ghost"
:drag-class="'mm-fdesign__sortable-drag'"
:fallback-on-body="true"
:force-fallback="true"
fallback-class="mm-fdesign__drag-fallback"
:filter="'.mm-fdesign__field-toolbar, button'"
:prevent-on-filter="true"
:move="onFieldMove"
@@ -1788,21 +1716,15 @@ onBeforeUnmount(() => {
class="mm-fdesign__ctrl"
type="text"
disabled
:placeholder="placeholderOf(row)"
/>
<textarea
v-else-if="fieldType(row) === 'textarea'"
class="mm-fdesign__ctrl"
disabled
:style="{ height: `${heightOf(row)}px` }"
:placeholder="placeholderOf(row)"
/>
<select
v-else-if="fieldType(row) === 'select'"
class="mm-fdesign__ctrl"
disabled
>
<option value="">{{ placeholderOf(row) }}</option>
<select v-else-if="fieldType(row) === 'select'" class="mm-fdesign__ctrl" disabled>
<option value=""></option>
</select>
<div
v-else-if="fieldType(row) === 'checkbox'"
@@ -1828,15 +1750,8 @@ onBeforeUnmount(() => {
class="mm-fdesign__ctrl"
type="number"
disabled
placeholder="0"
/>
<input
v-else
class="mm-fdesign__ctrl"
type="text"
disabled
:placeholder="placeholderOf(row)"
/>
<input v-else class="mm-fdesign__ctrl" type="text" disabled />
</div>
<div
v-if="selectedRowKey === String(row.b_id)"
@@ -1893,6 +1808,8 @@ onBeforeUnmount(() => {
:animation="150"
ghost-class="mm-fdesign__sortable-ghost"
:fallback-on-body="true"
:force-fallback="true"
fallback-class="mm-fdesign__drag-fallback"
:move="onFieldMove"
@start="onFieldDragStart"
@end="onFieldDragEnd"
@@ -2441,9 +2358,9 @@ onBeforeUnmount(() => {
.mm-fdesign__sheet {
position: relative; /* 拖拽行高亮框的绝对定位参照 */
width: 80%;
min-width: 80%;
max-width: none;
/* 纸面宽度自适应:宽屏时 90% 上限 960px,窄屏时跟随容器尽量占满,
避免小屏幕上画布被固定百分比挤压(配置导航已改横向,画布空间更充足) */
width: min(90%, 960px);
margin: 0 auto;
padding: 16px 18px 20px;
border: 1px solid var(--fms-border);
@@ -2451,17 +2368,13 @@ onBeforeUnmount(() => {
background: var(--fms-card);
box-shadow: 0 3px 14px rgba(39, 54, 70, 0.06);
/* 表单级左右布局:分组作为单元横向排列,可换行;表单头部始终占满整行 */
/* 表单级左右布局:分组作为单元横向排列,可换行 */
&.is-horizontal {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-start;
.mm-fdesign__sheet-head {
flex: 1 1 100%;
}
.mm-fdesign__section {
flex: 1 1 340px;
min-width: 0;
@@ -2470,81 +2383,12 @@ onBeforeUnmount(() => {
}
}
/* 拖拽悬停行高亮框:单层细虚线框包住整行字段,明确「行」单元;
新行意图时插入侧加粗为实线横贯整行(卡片级横线已移除,不重复) */
.mm-fdesign__line-highlight {
position: absolute;
z-index: 6;
pointer-events: none;
border: 1px dashed var(--fms-primary);
border-radius: 8px;
background: color-mix(in srgb, var(--fms-primary) 4%, transparent);
/* 新行意图:插入侧边框加粗成实线横贯整行(50% 色带移至被悬停卡片,与左右半区遮罩同款) */
&.is-line-before {
border-top: 2px solid var(--fms-primary);
}
&.is-newline {
border-bottom: 2px solid var(--fms-primary);
}
/* 同行意图:左/右半侧加粗提示插入侧 */
&.is-before {
border-left: 3px solid var(--fms-primary);
}
&.is-after {
border-right: 3px solid var(--fms-primary);
}
}
.mm-fdesign__sheet-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding-bottom: 12px;
margin-bottom: 14px;
border-bottom: 1px solid var(--fms-border);
strong {
display: block;
font-size: 14px;
color: var(--fms-text);
}
span {
display: block;
margin-top: 3px;
color: var(--fms-text-secondary);
font-size: 11px;
}
}
.mm-fdesign__badge {
flex: none;
padding: 1px 8px;
border-radius: 999px;
background: #e7f6ed;
color: #146c41;
font-size: 11px;
line-height: 18px;
}
.mm-fdesign__section {
margin-top: 12px;
&:first-of-type {
margin-top: 0;
}
/* 拖拽悬停:目标分组栅格虚线高亮 */
&.is-over > .mm-fdesign__grid {
outline: 2px dashed var(--fms-primary);
outline-offset: 2px;
border-radius: 6px;
}
}
/* 分组停用标记 */
@@ -2622,6 +2466,41 @@ onBeforeUnmount(() => {
opacity: 0.55;
}
/* 拖拽中:禁用卡片边框/阴影过渡动画——鼠标在卡片边缘快速移动时
before/after 等意图频繁切换,0.15s 过渡会让高亮条「追赶」造成抖动 */
.mm-fdesign__canvas-wrap.is-drag-active .mm-fdesign__field {
transition: none;
}
/* 拖拽中:目标卡片只保留插入方向的单侧边框高亮(is-drop-* 的 inset 阴影)。
隐藏 hover 边框、选中虚线边框与外发光——避免整圈边框同时亮起干扰判断;
被拖卡片(.is-dragging / .mm-fdesign__sortable-drag)保持原样 */
.mm-fdesign__canvas-wrap.is-drag-active
.mm-fdesign__field:not(.is-dragging):not(.mm-fdesign__sortable-drag) {
border-color: transparent !important;
box-shadow: none !important;
}
.mm-fdesign__canvas-wrap.is-drag-active
.mm-fdesign__field.is-drop-before:not(.is-dragging):not(.mm-fdesign__sortable-drag) {
box-shadow: inset 2px 0 0 0 var(--fms-primary) !important;
}
.mm-fdesign__canvas-wrap.is-drag-active
.mm-fdesign__field.is-drop-after:not(.is-dragging):not(.mm-fdesign__sortable-drag) {
box-shadow: inset -2px 0 0 0 var(--fms-primary) !important;
}
.mm-fdesign__canvas-wrap.is-drag-active
.mm-fdesign__field.is-drop-line-before:not(.is-dragging):not(.mm-fdesign__sortable-drag) {
box-shadow: inset 0 2px 0 0 var(--fms-primary) !important;
}
.mm-fdesign__canvas-wrap.is-drag-active
.mm-fdesign__field.is-drop-newline:not(.is-dragging):not(.mm-fdesign__sortable-drag) {
box-shadow: inset 0 -2px 0 0 var(--fms-primary) !important;
}
/* 拖拽中:隐藏卡片上的操作按钮(上移/下移/删除),避免干扰拖拽焦点 */
.mm-fdesign__canvas-wrap.is-drag-active .mm-fdesign__field-toolbar {
display: none;
@@ -2635,6 +2514,9 @@ onBeforeUnmount(() => {
border-radius: 6px;
background: var(--fms-card);
cursor: grab;
/* 拖拽时禁用文本选中,避免 label 文字像鼠标框选一样出现蓝色高亮 */
user-select: none;
-webkit-user-select: none;
transition:
border-color 0.15s,
box-shadow 0.15s,
@@ -2661,87 +2543,13 @@ onBeforeUnmount(() => {
opacity: 0.55;
}
/* 拖拽悬停目标行成员:仅极淡底色(行边界由整行高亮框承担,不再逐卡描边) */
&.is-drop-hover {
background: color-mix(in srgb, var(--fms-primary) 5%, var(--fms-card));
}
/* 同行配对第一选择:虚线边框 + 外发光,提示等待第二个字段 */
&.is-pairing {
border: 1px dashed var(--fms-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-primary) 18%, transparent);
}
/* 同行合并意图的左右半区遮罩(::before/::after 基座,仅 before/after 意图显示) */
&::before,
&::after {
content: '';
position: absolute;
left: -4px;
right: -4px;
height: 0;
border-top: 2px solid var(--fms-primary);
pointer-events: none;
opacity: 0;
z-index: 5;
}
&::before {
top: -5px;
}
&::after {
bottom: -5px;
}
/* 同行合并意图:目标卡片左/右半区高亮遮罩(操作文字由 .mm-fdesign__drop-tip 气泡承载)。
上/下方新行意图不再画卡片级横线——整行高亮框的实线边已表达插入位置,避免重复 */
&.is-drop-before::before,
&.is-drop-after::after {
content: '';
top: 0;
bottom: 0;
left: 0;
right: auto;
width: 50%;
height: auto;
border: none;
border-radius: 5px 0 0 5px;
background: color-mix(in srgb, var(--fms-primary) 14%, transparent);
opacity: 1;
}
&.is-drop-after::after {
left: auto;
right: 0;
border-radius: 0 5px 5px 0;
}
/* 上方/下方新行:被悬停卡片内上半/下半部半区遮罩(与左右半区同款,50% 高度,内部填充) */
&.is-drop-line-before::before,
&.is-drop-newline::after {
top: auto;
bottom: auto;
left: 0;
right: 0;
width: auto;
height: 50%;
border: none;
border-radius: 5px 5px 0 0;
background: color-mix(in srgb, var(--fms-primary) 14%, transparent);
opacity: 1;
}
&.is-drop-line-before::before {
top: 0;
}
&.is-drop-newline::after {
bottom: 0;
border-radius: 0 0 5px 5px;
}
/* 阿里式:目标卡片对应方向的边框高亮(inset 阴影不撑布局,拖拽中优先于选中外圈) */
/* ---- 拖拽插入方向高亮:目标卡片对应侧边框高亮(阿里式 inset 阴影,不撑布局) ---- */
&.is-drop-before {
box-shadow: inset 2px 0 0 0 var(--fms-primary);
}
@@ -2937,7 +2745,7 @@ textarea.mm-fdesign__ctrl {
/* ---- vuedraggable / SortableJS 拖拽视觉 ---- */
/* 幽灵占位:SortableJS 把 ghostClass 加在原卡片上(布局冻结,原位不动),
显示为灰色占位块(内部控件隐藏),一眼看出「这里被拖走了」;
跟手的是拖拽标题条(onNativeDragStart 自绘,见 .mm-fdesign__drag-badge) */
跟手的是拖拽标题条(createDragBadge 自绘,见 .mm-fdesign__drag-badge) */
.mm-fdesign__sortable-ghost,
.mm-fdesign__lib-ghost {
opacity: 1;
@@ -2950,7 +2758,7 @@ textarea.mm-fdesign__ctrl {
}
/* 阿里式拖拽标题条:跟手只带字段名的小标签(不拖整卡),
位置/层级由 onNativeDragStart 内联控制,这里负责外观 */
位置/层级由 createDragBadge 内联控制,这里负责外观 */
.mm-fdesign__drag-badge {
display: inline-flex;
align-items: center;
@@ -2968,6 +2776,8 @@ textarea.mm-fdesign__ctrl {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
-webkit-user-select: none;
}
.mm-fdesign__drag-badge-mark {
@@ -3454,3 +3264,11 @@ textarea.mm-fdesign__ctrl {
}
</style>
<!-- fallback 克隆(SortableJS 在 body 上生成的整卡克隆)不在 scoped 作用域内,需全局隐藏:
拖动时只保留自绘的跟手标题条(.mm-fdesign__drag-badge),不显示整卡克隆 -->
<style lang="scss">
.mm-fdesign__drag-fallback {
display: none !important;
}
</style>
@@ -59,7 +59,6 @@ import ModuleAutoCodePanel from './components/ModuleAutoCodePanel.vue'
import ModulePowersPanel from './components/ModulePowersPanel.vue'
import ModuleI18nPanel from './components/ModuleI18nPanel.vue'
import MenuDetailPanel from './components/MenuDetailPanel.vue'
import FieldGroupManager from './components/FieldGroupManager.vue'
/* ================= 顶部切换(Tabs 默认样式) ================= */
const activeZone = ref('module')
@@ -324,7 +323,9 @@ watch(moduleSelectedId, (id) => {
drafts.value = null
return
}
// 清空旧草稿,让右侧进入加载态(否则旧数据残留,v-if="drafts" 仍为真,看不到 loading)
loadedModuleId.value = null
drafts.value = null
void loadModuleConfig(id)
})
@@ -705,10 +706,7 @@ const configGroups = computed(() => {
groups.push({
key: 'structure',
label: '数据结构',
items: [
{ key: 'fields', label: '字段定义', icon: Table2 },
{ key: 'groups', label: '分组管理', icon: FolderTree },
],
items: [{ key: 'fields', label: '字段定义', icon: Table2 }],
})
// 界面配置:表单设计(数据模块)→ 列表配置 → 查询配置
@@ -764,18 +762,10 @@ const configGroups = computed(() => {
return groups
})
// 分组管理弹窗(导航「分组管理」项触发,保存后与列表/编辑面板共享同一分组草稿)
const groupManagerVisible = ref(false)
function onNavItemClick(item) {
activeSection.value = item.key
groupManagerVisible.value = item.key === 'groups'
}
// 离开「分组管理」分区时自动关闭弹窗,避免悬挂
watch(activeSection, (key) => {
if (key !== 'groups') groupManagerVisible.value = false
})
/* 横向胶囊 Tabs 的数据源:展平 configGroups 的分组为平铺 items */
const configNavItems = computed(() =>
configGroups.value.flatMap((group) => group.items.map((item) => ({ ...item }))),
)
const referencedGroupIds = computed(() => {
const ids = new Set()
@@ -1337,6 +1327,11 @@ const menuConfigGroups = computed(() => {
return groups
})
/* 菜单横向胶囊 Tabs 的数据源 */
const menuConfigNavItems = computed(() =>
menuConfigGroups.value.flatMap((group) => group.items.map((item) => ({ ...item }))),
)
// 重载当前菜单:若有未保存修改先确认放弃
function reloadMenu() {
if (!selectedMenu.value || menuSaving.value) return
@@ -1432,55 +1427,40 @@ loadMenuTree()
</aside>
</SplitterPanel>
<!-- 配置导航:独立中栏 -->
<SplitterPanel :default-size="158" :min="140" :max="220">
<nav v-if="drafts" class="mm-config-nav" aria-label="模块配置导航">
<template v-for="group in configGroups" :key="group.key">
<div class="mm-config-nav__group">
<div class="mm-config-nav__group-title">{{ group.label }}</div>
<button
v-for="item in group.items"
:key="item.key"
type="button"
class="mm-config-nav__item"
:class="{ 'is-active': activeSection === item.key }"
@click="onNavItemClick(item)"
>
<component :is="item.icon" :size="14" class="mm-config-nav__icon" />
<span class="mm-config-nav__label">{{ item.label }}</span>
<span v-if="item.badge" class="mm-config-nav__badge">{{ item.badge }}</span>
</button>
</div>
</template>
</nav>
<div v-else class="mm-config-nav-empty">请选择左侧模块节点</div>
</SplitterPanel>
<SplitterPanel>
<main class="mm-main">
<template v-if="selectedModule">
<div class="mm-header">
<div class="mm-header__info">
<component :is="moduleTypeIcon" :size="16" class="mm-header__icon" />
<span class="mm-header__name">{{ selectedModule.b_name || selectedModule.b_code }}</span>
<span class="mm-header__type">{{ moduleTypeLabel }}</span>
<span v-if="moduleBreadcrumb.length > 1" class="mm-header__crumb">
{{ moduleBreadcrumb.slice(0, -1).join(' / ') }}
</span>
<span v-if="unsavedModuleIds.has(String(selectedModule.b_id))" class="mm-header__unsaved">
未保存
</span>
</div>
<div class="mm-header__actions">
<span class="mm-header__hint">Ctrl + S 保存</span>
<Button type="outline" :disabled="busy || !selectedModule" @click="reloadModule">
<RefreshCw :size="14" /> 重载
</Button>
<Button type="primary" :loading="configSaving" :disabled="configLoading" @click="saveModule">
<Save :size="14" /> 保存
</Button>
</div>
</div>
<Tabs
v-if="drafts"
v-model="activeSection"
type="pill"
nav-only
class="mm-config-nav"
:items="configNavItems"
aria-label="模块配置导航"
>
<template #label="{ item }">
<component :is="item.icon" :size="14" class="mm-config-nav__icon" />
<span class="mm-config-nav__label">{{ item.label }}</span>
<span v-if="item.badge" class="mm-config-nav__badge">{{ item.badge }}</span>
</template>
<template #rightExtra>
<div class="mm-header__actions">
<span class="mm-header__hint">Ctrl + S 保存</span>
<Button type="outline" :disabled="busy || !selectedModule" @click="reloadModule">
<RefreshCw :size="14" /> 重载
</Button>
<Button
type="primary"
:loading="configSaving"
:disabled="configLoading"
@click="saveModule"
>
<Save :size="14" /> 保存
</Button>
</div>
</template>
</Tabs>
<div class="mm-body__inner">
<div v-if="drafts" class="mm-config-content">
@@ -1514,23 +1494,6 @@ loadMenuTree()
/>
</div>
<div
v-if="isDataModule || isQueryModule"
v-show="activeSection === 'groups'"
class="mm-config-pane"
>
<div class="mm-groups-pane">
<FolderTree :size="22" class="mm-groups-pane__icon" />
<p class="mm-groups-pane__text">
字段分组用于组织编辑表单与列表的展示结构,当前共
{{ drafts?.groups?.length ?? 0 }} 个分组。分组的新增、排序与启停通过分组管理弹窗维护,保存后即时生效。
</p>
<Button type="outline" :disabled="busy" @click="groupManagerVisible = true">
打开分组管理
</Button>
</div>
</div>
<div
v-if="isDataModule || isQueryModule"
v-show="activeSection === 'list'"
@@ -1555,7 +1518,6 @@ loadMenuTree()
<div v-if="isDataModule" v-show="activeSection === 'edit'" class="mm-config-pane">
<ModuleEditConfigPanel
:module-id="String(selectedModule.b_id)"
:module-name="selectedModule.b_name || selectedModule.b_code"
:edit-config="drafts?.editConfig"
:list-config="drafts?.listConfig"
:query-config="drafts?.queryConfig"
@@ -1640,14 +1602,6 @@ loadMenuTree()
</main>
</SplitterPanel>
</Splitter>
<!-- 分组管理弹窗:导航「分组管理」项触发;与列表/编辑面板共用同一份分组草稿 -->
<FieldGroupManager
v-model:open="groupManagerVisible"
:module-id="String(selectedModule?.b_id || '')"
:groups="drafts?.groups"
:referenced-group-ids="referencedGroupIds"
@saved="onGroupsSaved"
/>
</div>
<!-- ================ 菜单配置 ================ -->
@@ -1704,59 +1658,35 @@ loadMenuTree()
</aside>
</SplitterPanel>
<!-- 配置导航:独立中栏(与模块配置统一三栏结构) -->
<SplitterPanel :default-size="158" :min="140" :max="220">
<nav
v-if="activeZone === 'menu' && selectedMenu"
class="mm-config-nav"
aria-label="菜单配置导航"
>
<template v-for="group in menuConfigGroups" :key="group.key">
<div class="mm-config-nav__group">
<div class="mm-config-nav__group-title">{{ group.label }}</div>
<button
v-for="item in group.items"
:key="item.key"
type="button"
class="mm-config-nav__item"
:class="{ 'is-active': menuActiveSection === item.key }"
@click="menuActiveSection = item.key"
>
<component :is="item.icon" :size="14" class="mm-config-nav__icon" />
<span class="mm-config-nav__label">{{ item.label }}</span>
<span v-if="item.badge" class="mm-config-nav__badge">{{ item.badge }}</span>
</button>
</div>
</template>
</nav>
<div v-else-if="activeZone === 'menu'" class="mm-config-nav-empty">请选择左侧菜单节点</div>
</SplitterPanel>
<SplitterPanel>
<main class="mm-main">
<template v-if="selectedMenu">
<div class="mm-header">
<div class="mm-header__info">
<component :is="menuTypeIcon" :size="16" class="mm-header__icon" />
<span class="mm-header__name">{{ selectedMenu.b_name || selectedMenu.b_code }}</span>
<span class="mm-header__type">{{ menuTypeLabel(selectedMenu.b_menu_type) }}</span>
<span v-if="menuBreadcrumb.length > 1" class="mm-header__crumb">
{{ menuBreadcrumb.slice(0, -1).join(' / ') }}
</span>
<span v-if="unsavedMenuIds.has(String(selectedMenu.b_id))" class="mm-header__unsaved">
未保存
</span>
</div>
<div class="mm-header__actions">
<span class="mm-header__hint">Ctrl + S 保存</span>
<Button type="outline" :disabled="menuSaving" @click="reloadMenu">
<RefreshCw :size="14" /> 重载
</Button>
<Button type="primary" :loading="menuSaving" @click="saveMenu">
<Save :size="14" /> 保存
</Button>
</div>
</div>
<Tabs
v-if="menuDrafts"
v-model="menuActiveSection"
type="pill"
nav-only
class="mm-config-nav"
:items="menuConfigNavItems"
aria-label="菜单配置导航"
>
<template #label="{ item }">
<component :is="item.icon" :size="14" class="mm-config-nav__icon" />
<span class="mm-config-nav__label">{{ item.label }}</span>
<span v-if="item.badge" class="mm-config-nav__badge">{{ item.badge }}</span>
</template>
<template #rightExtra>
<div class="mm-header__actions">
<span class="mm-header__hint">Ctrl + S 保存</span>
<Button type="outline" :disabled="menuSaving" @click="reloadMenu">
<RefreshCw :size="14" /> 重载
</Button>
<Button type="primary" :loading="menuSaving" @click="saveMenu">
<Save :size="14" /> 保存
</Button>
</div>
</template>
</Tabs>
<div class="mm-body__inner">
<MenuDetailPanel
@@ -1856,8 +1786,8 @@ loadMenuTree()
}
/* ---- 左树顶部 header:标题在左,新增/刷新图标在右。
与右侧 .mm-header 同规格:padding 10px + 内容 28px(图标按钮与 Button 等高)
= 48px,两侧 header 的底边框在同一水平线对齐。
与右侧配置导航同规格:padding 6px + 内容 28px(图标按钮与 Button 等高)
= 40px,两侧 header 的底边框在同一水平线对齐。
负 margin 抵消面板 8px padding,使 border-bottom 横跨面板全宽。 */
.mm-tree-header {
display: flex;
@@ -1865,7 +1795,7 @@ loadMenuTree()
justify-content: space-between;
gap: 6px;
margin: -8px -8px 0;
padding: 10px;
padding: 6px;
border-bottom: 1px solid var(--fms-border);
}
@@ -1998,72 +1928,7 @@ loadMenuTree()
font-size: 13px;
}
.mm-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex: none;
padding: 10px;
border-bottom: 1px solid var(--fms-border);
}
.mm-header__info {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.mm-header__icon {
flex: none;
color: var(--fms-text-secondary);
}
.mm-header__crumb {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--fms-text-secondary);
&::before {
content: '‹ ';
opacity: 0.6;
}
}
.mm-header__name {
font-size: 15px;
font-weight: 600;
color: var(--fms-text);
line-height: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mm-header__type {
flex: none;
padding: 1px 8px;
border-radius: 999px;
font-size: 12px;
line-height: 18px;
background: var(--fms-secondary);
color: var(--fms-text-secondary);
}
.mm-header__unsaved {
flex: none;
padding: 1px 8px;
border-radius: 999px;
font-size: 12px;
line-height: 18px;
background: #fef3c7;
color: #b45309;
}
/* 操作按钮组(Tabs 的 rightExtra 插槽内) */
.mm-header__actions {
display: flex;
align-items: center;
@@ -2086,94 +1951,47 @@ loadMenuTree()
min-height: 0;
}
/* 中栏配置导航:独立 Splitter 面板内撑满,右侧竖线分隔工作区 */
.mm-config-nav {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
height: 100%;
min-height: 0;
padding: 10px 8px;
overflow-y: auto;
border-right: 1px solid var(--fms-border);
background: var(--fms-card);
/* ---- 配置导航:横向胶囊式 Tabs,位于 header 内左侧,操作按钮贴右 ---- */
/* ---- 配置导航:横向胶囊式 Tabs,位于工作区顶部,操作按钮在 rightExtra 插槽 ---- */
.mm-config-nav.tabs {
flex: none;
/* 与左侧树 header 的分割线对齐:左右两栏 header 同高同线,视觉统一 */
border-bottom: 1px solid var(--fms-border);
}
.mm-config-nav-empty {
display: flex;
.mm-config-nav :deep(.tabs-nav) {
padding: 6px;
}
/* 标签列表由 Scrollbar 组件接管滚动,这里只保持垂直居中 */
.mm-config-nav :deep(.tabs-nav-list) {
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 16px;
border-right: 1px solid var(--fms-border);
color: var(--fms-text-secondary);
font-size: 12px;
text-align: center;
}
.mm-config-nav__group {
margin-bottom: 12px;
}
.mm-config-nav__group-title {
padding: 2px 8px 4px;
font-size: 11px;
font-weight: 600;
color: var(--fms-text-secondary);
letter-spacing: 0.4px;
}
.mm-config-nav__item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 5px 8px;
border: none;
border-radius: var(--fms-control-radius);
background: transparent;
color: var(--fms-text);
font-size: 13px;
text-align: left;
cursor: pointer;
&:hover {
background: var(--fms-secondary);
}
&.is-active {
background: color-mix(in srgb, var(--fms-primary) 10%, transparent);
color: var(--fms-primary);
font-weight: 500;
}
/* rightExtra 操作区与标签在同一行垂直居中 */
.mm-config-nav :deep(.tabs-nav-extra) {
margin-top: 0;
margin-bottom: 0;
padding-right: 10px;
}
/* 标签内布局:图标 + 文字 + badge */
.mm-config-nav__icon {
flex: none;
color: var(--fms-text-secondary);
}
.mm-config-nav__item.is-active .mm-config-nav__icon {
.mm-config-nav .tabs-tab-active .mm-config-nav__icon {
color: var(--fms-primary);
}
.mm-config-nav__label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mm-config-nav__badge {
flex: none;
padding: 0 5px;
border-radius: 999px;
background: var(--fms-secondary);
color: var(--fms-text-secondary);
font-size: 11px;
font-size: 10px;
line-height: 16px;
}
@@ -2185,7 +2003,6 @@ loadMenuTree()
display: flex;
flex-direction: column;
overflow: hidden;
padding: 12px 16px 16px;
}
.mm-config-pane {
@@ -2195,34 +2012,8 @@ loadMenuTree()
display: flex;
flex-direction: column;
overflow: auto;
}
/* 分组管理占位页(弹窗由中栏导航触发) */
.mm-groups-pane {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
flex: 1;
min-height: 180px;
padding: 24px;
border: 1px dashed var(--fms-border);
border-radius: var(--fms-control-radius);
background: var(--fms-secondary);
text-align: center;
}
.mm-groups-pane__icon {
color: var(--fms-text-secondary);
}
.mm-groups-pane__text {
max-width: 440px;
margin: 0;
color: var(--fms-text-secondary);
font-size: 13px;
line-height: 1.7;
/* 滚动容器内边距:内容与滚动边缘保持间距,聚焦光环(3px)不被 overflow: auto 裁剪 */
padding: 12px 16px 16px;
}
/* 配置区加载中 / 空态占位 */