20260824172723
This commit is contained in:
1 parent
791d1e01d7
commit
97aea665f9
10 files changed
+443
-723
No files matched your search
@@ -24,6 +24,7 @@
|
||||
"axios": "^1.19.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"nprogress": "^0.2.0",
|
||||
"overlayscrollbars": "^2.16.0",
|
||||
"pinia": "^4.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"sortablejs": "^1.15.7",
|
||||
|
||||
Generated
+8
@@ -23,6 +23,9 @@ importers:
|
||||
nprogress:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
overlayscrollbars:
|
||||
specifier: ^2.16.0
|
||||
version: 2.16.0
|
||||
pinia:
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3(@vue/devtools-api@8.2.1)(vue@3.5.41)
|
||||
@@ -1256,6 +1259,9 @@ packages:
|
||||
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
overlayscrollbars@2.16.0:
|
||||
resolution: {integrity: sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==}
|
||||
|
||||
oxfmt@0.63.0:
|
||||
resolution: {integrity: sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -2656,6 +2662,8 @@ snapshots:
|
||||
|
||||
obug@2.1.4: {}
|
||||
|
||||
overlayscrollbars@2.16.0: {}
|
||||
|
||||
oxfmt@0.63.0:
|
||||
dependencies:
|
||||
tinypool: 2.1.0
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
.input-wrapper.input-focused {
|
||||
border-color: var(--fms-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-primary) 20%, transparent);
|
||||
/* 聚焦光环向外扩散 3px,提升层级避免被相邻表单项/后续兄弟元素遮挡 */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 多行模式:紧凑的垂直布局,footer 只保留计数和手柄需要的空间 */
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
/**
|
||||
* Scrollbar 通用滚动容器(基于 OverlayScrollbars v2):
|
||||
* - 隐藏原生滚动条,自绘悬浮滚动条(不占布局高度)
|
||||
* - 内容溢出时自动出现,支持横向 / 纵向 / 双向滚动
|
||||
* - 用法:<Scrollbar class="xxx"><内容/></Scrollbar>
|
||||
* 参考文档:https://kingsora.github.io/OverlayScrollbars/
|
||||
*/
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { OverlayScrollbars } from 'overlayscrollbars'
|
||||
import 'overlayscrollbars/styles/overlayscrollbars.css'
|
||||
|
||||
const props = defineProps({
|
||||
/** 滚动条主题(自定义 class 会加到滚动条元素上,配合全局样式定制外观) */
|
||||
theme: { type: String, default: '' },
|
||||
/** 滚动条显示策略:visible 常显 / auto 溢出时显示 / hidden 隐藏 */
|
||||
visibility: {
|
||||
type: String,
|
||||
default: 'auto',
|
||||
validator: (value) => ['visible', 'auto', 'hidden'].includes(value),
|
||||
},
|
||||
/** 自动隐藏:never 永不 / scroll 滚动时显示 / move 鼠标移动或滚动显示 / leave 移出后隐藏 */
|
||||
autoHide: {
|
||||
type: String,
|
||||
default: 'leave',
|
||||
validator: (value) => ['never', 'scroll', 'move', 'leave'].includes(value),
|
||||
},
|
||||
/** 是否允许点击轨道跳转(true / 'instant' 瞬间跳转 / false) */
|
||||
clickScroll: { type: [Boolean, String], default: true },
|
||||
/** 无障碍标签 */
|
||||
ariaLabel: { type: String, default: '滚动区域' },
|
||||
})
|
||||
|
||||
const el = ref(null)
|
||||
let instance = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!el.value) return
|
||||
instance = OverlayScrollbars(el.value, {
|
||||
scrollbars: {
|
||||
theme: props.theme || undefined,
|
||||
visibility: props.visibility,
|
||||
autoHide: props.autoHide,
|
||||
clickScroll: props.clickScroll,
|
||||
},
|
||||
aria: { label: props.ariaLabel },
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
instance?.destroy()
|
||||
instance = null
|
||||
})
|
||||
|
||||
/** 供外部调用 */
|
||||
defineExpose({
|
||||
/** 更新(内容/尺寸变化后重新计算滚动条) */
|
||||
update: () => instance?.update(),
|
||||
/** 获取实例内部元素 */
|
||||
getElements: () => instance?.getElements?.(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el" class="fms-scrollbar">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -28,6 +28,8 @@
|
||||
font-size: var(--fms-font-size);
|
||||
line-height: var(--fms-line-height);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
@@ -46,6 +48,8 @@
|
||||
.select-trigger-focused {
|
||||
border-color: var(--fms-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-primary) 20%, transparent);
|
||||
/* 聚焦光环向外扩散 3px,提升层级避免被相邻表单项/后续兄弟元素遮挡 */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 触发器输入框:透明内嵌,撑满框体;筛选直接在输入框内键入 */
|
||||
|
||||
@@ -30,8 +30,9 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* tab 列表:承担方向排列、间距与滚动,flex: 1 占满剩余空间,
|
||||
* 将 leftExtra / rightExtra 推到标签栏两端而非紧贴标签 */
|
||||
/* tab 列表:承担方向排列、间距,flex: 1 占满剩余空间,
|
||||
* 将 leftExtra / rightExtra 推到标签栏两端而非紧贴标签;
|
||||
* 滚动由外层 Scrollbar 组件(.tabs-nav-scroll)接管,列表本身不设 overflow */
|
||||
.tabs-nav-list {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
@@ -39,10 +40,20 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar 容器:占满 nav 剩余空间,由组件自绘悬浮滚动条 */
|
||||
.tabs-nav-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tabs-top .tabs-nav {
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* line 类型有底部分割线;pill 类型不需要——胶囊背景已足够区分 */
|
||||
.tabs-line.tabs-top .tabs-nav {
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
@@ -54,7 +65,6 @@
|
||||
|
||||
.tabs-left .tabs-nav {
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
@@ -65,7 +75,6 @@
|
||||
|
||||
.tabs-right .tabs-nav {
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
@@ -154,45 +163,69 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 激活:指示条 + 主色文字 + 字重,不只依赖颜色表达选中 */
|
||||
/* 激活:主色文字 + 字重,不只依赖颜色表达选中 */
|
||||
.tabs-tab-active {
|
||||
color: var(--fms-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ==================== line 类型(默认):下划线指示条 ==================== */
|
||||
/* line 指示条:top 为下边框,left/right 为侧边框,无需 JS 测量 */
|
||||
.tabs-top .tabs-tab {
|
||||
.tabs-line.tabs-top .tabs-tab {
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tabs-top .tabs-tab-active {
|
||||
.tabs-line.tabs-top .tabs-tab-active {
|
||||
border-bottom-color: var(--fms-primary);
|
||||
}
|
||||
|
||||
.tabs-left .tabs-tab,
|
||||
.tabs-right .tabs-tab {
|
||||
.tabs-line.tabs-left .tabs-tab,
|
||||
.tabs-line.tabs-right .tabs-tab {
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tabs-left .tabs-tab {
|
||||
.tabs-line.tabs-left .tabs-tab {
|
||||
border-right: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tabs-left .tabs-tab-active {
|
||||
.tabs-line.tabs-left .tabs-tab-active {
|
||||
border-right-color: var(--fms-primary);
|
||||
background: var(--fms-secondary-hover);
|
||||
}
|
||||
|
||||
.tabs-right .tabs-tab {
|
||||
.tabs-line.tabs-right .tabs-tab {
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tabs-right .tabs-tab-active {
|
||||
.tabs-line.tabs-right .tabs-tab-active {
|
||||
border-left-color: var(--fms-primary);
|
||||
background: var(--fms-secondary-hover);
|
||||
}
|
||||
|
||||
/* ==================== pill 类型(胶囊):圆角背景式激活 ==================== */
|
||||
.tabs-pill .tabs-nav-list {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tabs-pill .tabs-tab {
|
||||
border-radius: 999px;
|
||||
padding: 0 12px;
|
||||
transition:
|
||||
color 150ms ease,
|
||||
background 150ms ease;
|
||||
}
|
||||
|
||||
.tabs-pill .tabs-tab:hover:not(:disabled) {
|
||||
background: var(--fms-secondary);
|
||||
}
|
||||
|
||||
.tabs-pill .tabs-tab-active {
|
||||
background: color-mix(in srgb, var(--fms-primary) 10%, transparent);
|
||||
border-bottom: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 禁用:降透明 + not-allowed(原生 disabled 同时保证不可交互) */
|
||||
.tabs-tab:disabled {
|
||||
color: var(--fms-disabled-text);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, Fragment, isVNode, nextTick, reactive, ref, useId, useSlots, watch } from 'vue'
|
||||
import Scrollbar from '@/components/ui/scrollbar/scrollbar.vue'
|
||||
|
||||
/* ---------- props / emits / slots ---------- */
|
||||
const props = defineProps({
|
||||
@@ -11,6 +12,14 @@ const props = defineProps({
|
||||
default: 'top',
|
||||
validator: (value) => ['top', 'left', 'right'].includes(value),
|
||||
},
|
||||
/** 样式类型:line 下划线指示条,pill 胶囊式标签 */
|
||||
type: {
|
||||
type: String,
|
||||
default: 'line',
|
||||
validator: (value) => ['line', 'pill'].includes(value),
|
||||
},
|
||||
/** 仅渲染标签栏(不渲染内容区):用于纯导航场景,内容由外部 v-show 控制 */
|
||||
navOnly: { type: Boolean, default: false },
|
||||
/** 标签列表在标签栏内居中显示(top 水平居中,left/right 垂直居中) */
|
||||
centered: { type: Boolean, default: false },
|
||||
/** 整组禁用 */
|
||||
@@ -230,46 +239,52 @@ function onTablistKeydown(event) {
|
||||
<template>
|
||||
<div
|
||||
class="tabs"
|
||||
:class="[`tabs-${placement}`, { 'tabs-disabled': disabled, 'tabs-centered': centered }]"
|
||||
:class="[
|
||||
`tabs-${placement}`,
|
||||
`tabs-${type}`,
|
||||
{ 'tabs-disabled': disabled, 'tabs-centered': centered, 'tabs-nav-only': navOnly },
|
||||
]"
|
||||
>
|
||||
<div class="tabs-nav">
|
||||
<div v-if="slots.leftExtra" class="tabs-nav-extra">
|
||||
<slot name="leftExtra" />
|
||||
</div>
|
||||
<div
|
||||
class="tabs-nav-list"
|
||||
role="tablist"
|
||||
:aria-orientation="placement === 'top' ? 'horizontal' : 'vertical'"
|
||||
@keydown="onTablistKeydown"
|
||||
>
|
||||
<button
|
||||
v-for="(pane, index) in mergedPanes"
|
||||
:key="pane.key"
|
||||
:ref="(el) => setTabRef(pane.key, el)"
|
||||
type="button"
|
||||
class="tabs-tab"
|
||||
:class="{
|
||||
'tabs-tab-active': currentKey === pane.key,
|
||||
'tabs-tab-disabled': pane.disabled,
|
||||
}"
|
||||
role="tab"
|
||||
:id="`${baseId}-tab-${index}`"
|
||||
:aria-selected="currentKey === pane.key"
|
||||
:aria-controls="`${baseId}-panel-${index}`"
|
||||
:tabindex="currentKey === pane.key ? 0 : -1"
|
||||
:disabled="disabled || pane.disabled"
|
||||
@click="(event) => onTabClick(pane, event)"
|
||||
<Scrollbar class="tabs-nav-scroll">
|
||||
<div
|
||||
class="tabs-nav-list"
|
||||
role="tablist"
|
||||
:aria-orientation="placement === 'top' ? 'horizontal' : 'vertical'"
|
||||
@keydown="onTablistKeydown"
|
||||
>
|
||||
<component :is="pane.labelRender" v-if="pane.labelRender" />
|
||||
<span v-else-if="pane.labelText !== undefined">{{ pane.labelText }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-for="(pane, index) in mergedPanes"
|
||||
:key="pane.key"
|
||||
:ref="(el) => setTabRef(pane.key, el)"
|
||||
type="button"
|
||||
class="tabs-tab"
|
||||
:class="{
|
||||
'tabs-tab-active': currentKey === pane.key,
|
||||
'tabs-tab-disabled': pane.disabled,
|
||||
}"
|
||||
role="tab"
|
||||
:id="`${baseId}-tab-${index}`"
|
||||
:aria-selected="currentKey === pane.key"
|
||||
:aria-controls="`${baseId}-panel-${index}`"
|
||||
:tabindex="currentKey === pane.key ? 0 : -1"
|
||||
:disabled="disabled || pane.disabled"
|
||||
@click="(event) => onTabClick(pane, event)"
|
||||
>
|
||||
<component :is="pane.labelRender" v-if="pane.labelRender" />
|
||||
<span v-else-if="pane.labelText !== undefined">{{ pane.labelText }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Scrollbar>
|
||||
<div v-if="slots.rightExtra" class="tabs-nav-extra">
|
||||
<slot name="rightExtra" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs-content">
|
||||
<div v-if="!navOnly" class="tabs-content">
|
||||
<template v-for="(pane, index) in mergedPanes" :key="pane.key">
|
||||
<div
|
||||
v-if="renderedKeys.has(pane.key)"
|
||||
|
||||
+113
-295
@@ -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;
|
||||
}
|
||||
|
||||
/* 配置区加载中 / 空态占位 */
|
||||
|
||||
@@ -220,12 +220,19 @@ describe('模块管理页面(重构后)', () => {
|
||||
const wrapper = await mountPage()
|
||||
await selectModule(wrapper, '订单')
|
||||
|
||||
// 分组导航渲染:概览 / 数据结构 / 界面配置 / 业务能力 / 高级设置
|
||||
const groupTitles = wrapper.findAll('.mm-config-nav__group-title').map((t) => t.text())
|
||||
expect(groupTitles).toEqual(['概览', '数据结构', '界面配置', '业务能力', '高级设置'])
|
||||
// 横向导航渲染全部配置项(无分组小标题)
|
||||
const labels = wrapper.findAll('.mm-config-nav__label').map((t) => t.text())
|
||||
expect(labels).toContain('基本信息')
|
||||
expect(labels).toContain('字段定义')
|
||||
expect(labels).toContain('表单设计')
|
||||
expect(labels).toContain('列表配置')
|
||||
expect(labels).toContain('查询配置')
|
||||
expect(labels).toContain('自动编码')
|
||||
expect(labels).toContain('权限')
|
||||
expect(labels).toContain('多语言')
|
||||
|
||||
// 仅一个激活项(基本信息)
|
||||
const active = wrapper.findAll('.mm-config-nav__item.is-active')
|
||||
const active = wrapper.findAll('.mm-config-nav .tabs-tab-active')
|
||||
expect(active).toHaveLength(1)
|
||||
expect(active[0].text()).toContain('基本信息')
|
||||
})
|
||||
@@ -235,13 +242,13 @@ describe('模块管理页面(重构后)', () => {
|
||||
await selectModule(wrapper, '订单')
|
||||
|
||||
const fieldsItem = wrapper
|
||||
.findAll('.mm-config-nav__item')
|
||||
.findAll('.mm-config-nav .tabs-tab')
|
||||
.find((el) => el.text().includes('字段定义'))
|
||||
expect(fieldsItem).toBeTruthy()
|
||||
await fieldsItem.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const active = wrapper.findAll('.mm-config-nav__item.is-active')
|
||||
const active = wrapper.findAll('.mm-config-nav .tabs-tab-active')
|
||||
expect(active).toHaveLength(1)
|
||||
expect(active[0].text()).toContain('字段定义')
|
||||
// 对应内容面板显示
|
||||
@@ -300,8 +307,8 @@ describe('模块管理页面(重构后)', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(nextIdApi).toHaveBeenCalled()
|
||||
// 新建草稿标记「未保存」
|
||||
expect(wrapper.text()).toContain('未保存')
|
||||
// 新建草稿:选中新节点(is-selected 生效)
|
||||
expect(moduleTreeOf(wrapper).find('.is-selected').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('右键模块节点:删除确认弹窗为 danger 按钮', async () => {
|
||||
@@ -428,11 +435,11 @@ describe('模块管理页面(重构后)', () => {
|
||||
expect(labels).toContain('新增查询模块')
|
||||
})
|
||||
|
||||
it('分类节点:分组导航仅显示概览(无数据结构/展示配置/业务能力/多语言)', async () => {
|
||||
it('分类节点:分组导航仅显示基本信息(无数据结构/展示配置/业务能力/多语言)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await selectModule(wrapper, '系统')
|
||||
const titles = wrapper.findAll('.mm-config-nav__group-title').map((t) => t.text())
|
||||
expect(titles).toEqual(['概览'])
|
||||
const labels = wrapper.findAll('.mm-config-nav__label').map((t) => t.text())
|
||||
expect(labels).toEqual(['基本信息'])
|
||||
})
|
||||
|
||||
it('查询模块:分组导航无「表单设计」「自动编码」', async () => {
|
||||
@@ -440,7 +447,6 @@ describe('模块管理页面(重构后)', () => {
|
||||
await selectModule(wrapper, '查询A')
|
||||
const labels = wrapper.findAll('.mm-config-nav__label').map((t) => t.text())
|
||||
expect(labels).toContain('字段定义')
|
||||
expect(labels).toContain('分组管理')
|
||||
expect(labels).toContain('查询配置')
|
||||
expect(labels).not.toContain('表单设计')
|
||||
expect(labels).not.toContain('自动编码')
|
||||
@@ -490,8 +496,8 @@ describe('模块管理页面(重构后)', () => {
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// 新建草稿:显示「未保存」
|
||||
expect(wrapper.text()).toContain('未保存')
|
||||
// 新建草稿:选中新节点
|
||||
expect(moduleTreeOf(wrapper).find('.is-selected').exists()).toBe(true)
|
||||
|
||||
// 通过基本信息 stub 补齐必填字段
|
||||
const basicStub = wrapper.findComponent({ name: 'ModuleBasicPanel' })
|
||||
@@ -520,8 +526,8 @@ describe('模块管理页面(重构后)', () => {
|
||||
const moduleReq = reqs.find((r) => r.table === 's_module')
|
||||
expect(moduleReq).toBeTruthy()
|
||||
expect(moduleReq.inserts).toBeTruthy()
|
||||
// 保存成功后未保存标记清除
|
||||
expect(wrapper.text()).not.toContain('未保存')
|
||||
// 保存成功后仍选中新节点
|
||||
expect(moduleTreeOf(wrapper).find('.is-selected').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('重载:有未保存修改时弹出确认', async () => {
|
||||
@@ -563,8 +569,8 @@ describe('模块管理页面(重构后)', () => {
|
||||
await cancelBtn.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// 未切换:header 仍是「订单」
|
||||
expect(wrapper.find('.mm-header__name').text()).toContain('订单')
|
||||
// 未切换:树节点仍是「订单」
|
||||
expect(moduleTreeOf(wrapper).find('.is-selected').text()).toContain('订单')
|
||||
})
|
||||
|
||||
it('切换保护:放弃修改 → 切换到目标节点', async () => {
|
||||
@@ -587,7 +593,7 @@ describe('模块管理页面(重构后)', () => {
|
||||
await nextTick()
|
||||
|
||||
// 已切换到「系统」
|
||||
expect(wrapper.find('.mm-header__name').text()).toContain('系统')
|
||||
expect(moduleTreeOf(wrapper).find('.is-selected').text()).toContain('系统')
|
||||
})
|
||||
|
||||
it('切换保护:保存并切换 → 先保存再切换', async () => {
|
||||
@@ -612,7 +618,7 @@ describe('模块管理页面(重构后)', () => {
|
||||
|
||||
expect(saveObjectApi).toHaveBeenCalled()
|
||||
// 已切换到「系统」
|
||||
expect(wrapper.find('.mm-header__name').text()).toContain('系统')
|
||||
expect(moduleTreeOf(wrapper).find('.is-selected').text()).toContain('系统')
|
||||
})
|
||||
|
||||
it('删除确认后执行:调用 saveObjectApi(含子表删除)', async () => {
|
||||
@@ -770,21 +776,21 @@ describe('模块管理页面(重构后)', () => {
|
||||
await nextTick()
|
||||
|
||||
const nav = wrapper.findAll('.mm-config-nav').at(-1)
|
||||
const titles = nav.findAll('.mm-config-nav__group-title').map((t) => t.text())
|
||||
expect(titles).toEqual(['概览', '业务能力', '高级设置'])
|
||||
const labels = nav.findAll('.mm-config-nav__label').map((t) => t.text())
|
||||
expect(labels).toEqual(['基本信息', '权限配置', '多语言'])
|
||||
// 仅一个激活项(基本信息)
|
||||
expect(nav.findAll('.mm-config-nav__item.is-active')).toHaveLength(1)
|
||||
expect(nav.findAll('.mm-config-nav__item.is-active')[0].text()).toContain('基本信息')
|
||||
expect(nav.findAll('.tabs-tab-active')).toHaveLength(1)
|
||||
expect(nav.findAll('.tabs-tab-active')[0].text()).toContain('基本信息')
|
||||
|
||||
// 点击「权限配置」→ 激活态切换,activeSection 同步到面板
|
||||
const powersItem = nav
|
||||
.findAll('.mm-config-nav__item')
|
||||
.findAll('.tabs-tab')
|
||||
.find((el) => el.text().includes('权限配置'))
|
||||
expect(powersItem).toBeTruthy()
|
||||
await powersItem.trigger('click')
|
||||
await nextTick()
|
||||
expect(nav.findAll('.mm-config-nav__item.is-active')).toHaveLength(1)
|
||||
expect(nav.findAll('.mm-config-nav__item.is-active')[0].text()).toContain('权限配置')
|
||||
expect(nav.findAll('.tabs-tab-active')).toHaveLength(1)
|
||||
expect(nav.findAll('.tabs-tab-active')[0].text()).toContain('权限配置')
|
||||
const menuStub = wrapper.findComponent({ name: 'MenuDetailPanel' })
|
||||
expect(menuStub.attributes('active-section')).toBe('powers')
|
||||
})
|
||||
@@ -802,53 +808,12 @@ describe('模块管理页面(重构后)', () => {
|
||||
await nextTick()
|
||||
|
||||
const nav = wrapper.findAll('.mm-config-nav').at(-1)
|
||||
expect(nav.findAll('.mm-config-nav__group-title').map((t) => t.text())).toEqual([
|
||||
'概览',
|
||||
'高级设置',
|
||||
expect(nav.findAll('.mm-config-nav__label').map((t) => t.text())).toEqual([
|
||||
'基本信息',
|
||||
'多语言',
|
||||
])
|
||||
expect(nav.text()).not.toContain('权限配置')
|
||||
})
|
||||
|
||||
it('点击导航「分组管理」:激活分区并打开分组管理弹窗', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await selectModule(wrapper, '订单')
|
||||
|
||||
const groupsItem = wrapper
|
||||
.findAll('.mm-config-nav__item')
|
||||
.find((el) => el.text().includes('分组管理'))
|
||||
expect(groupsItem).toBeTruthy()
|
||||
await groupsItem.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// 分组管理是唯一激活项,弹窗打开(FieldGroupManager 内容)
|
||||
const active = wrapper.findAll('.mm-config-nav__item.is-active')
|
||||
expect(active).toHaveLength(1)
|
||||
expect(active[0].text()).toContain('分组管理')
|
||||
const modal = wrapper.find('.stub-modal')
|
||||
expect(modal.exists()).toBe(true)
|
||||
// 简化后的分组管理:顶部输入框即新增(不再有「新增根分组」按钮)
|
||||
expect(modal.find('.mm-groups__input').exists()).toBe(true)
|
||||
expect(modal.text()).toContain('将添加为顶级分组')
|
||||
})
|
||||
|
||||
it('点击其他导航项:分组管理弹窗自动关闭', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await selectModule(wrapper, '订单')
|
||||
|
||||
const groupsItem = wrapper
|
||||
.findAll('.mm-config-nav__item')
|
||||
.find((el) => el.text().includes('分组管理'))
|
||||
await groupsItem.trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.find('.stub-modal').exists()).toBe(true)
|
||||
|
||||
const fieldsItem = wrapper
|
||||
.findAll('.mm-config-nav__item')
|
||||
.find((el) => el.text().includes('字段定义'))
|
||||
await fieldsItem.trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.find('.stub-modal').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ModuleBasicPanel(基本信息面板)', () => {
|
||||
@@ -1026,7 +991,7 @@ describe('MenuDetailPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// 放置意图:在目标卡片上 stub rect 并触发原生 dragover,由画布容器监听器记录意图
|
||||
// 放置意图:在目标卡片上 stub rect 并触发 mousemove(fallback 模式下意图检测挂在 window mousemove)
|
||||
async function hoverCard(wrapper, fieldId, rect, clientX, clientY) {
|
||||
const card = wrapper.find(`.mm-fdesign__field[data-field-id="${fieldId}"]`)
|
||||
card.element.getBoundingClientRect = () => ({
|
||||
@@ -1037,7 +1002,9 @@ async function hoverCard(wrapper, fieldId, rect, clientX, clientY) {
|
||||
right: rect.left + rect.width,
|
||||
bottom: rect.top + rect.height,
|
||||
})
|
||||
await card.trigger('dragover', { clientX, clientY })
|
||||
card.element.dispatchEvent(
|
||||
new MouseEvent('mousemove', { clientX, clientY, bubbles: true, cancelable: true }),
|
||||
)
|
||||
}
|
||||
|
||||
// 构造拖拽 start/end 事件的 item 元素(带 data-field-id)
|
||||
@@ -1110,6 +1077,7 @@ describe('ModuleEditConfigPanel(表单设计视图)', () => {
|
||||
Draggable: stubDraggable,
|
||||
},
|
||||
},
|
||||
attachTo: document.body,
|
||||
})
|
||||
|
||||
// 快速构造画布行(拖拽用例通用)
|
||||
@@ -1748,7 +1716,10 @@ describe('ModuleEditConfigPanel(表单设计视图)', () => {
|
||||
.find('.mm-fdesign__grid-empty')
|
||||
.element.closest('.mm-fdesign__section')
|
||||
await simulateLibDrop(wrapper, extraFields[3], async () => {
|
||||
emptySectionEl.dispatchEvent(new Event('dragover', { bubbles: true }))
|
||||
// fallback 模式:意图检测挂在 window mousemove 上
|
||||
emptySectionEl.dispatchEvent(
|
||||
new MouseEvent('mousemove', { bubbles: true, clientX: 100, clientY: 100 }),
|
||||
)
|
||||
})
|
||||
|
||||
const updates = wrapper.emitted('update')
|
||||
@@ -2031,7 +2002,10 @@ describe('ModuleEditConfigPanel(表单设计视图)', () => {
|
||||
?.closest('.mm-fdesign__section')
|
||||
expect(emptySectionEl).toBeTruthy()
|
||||
await simulateCardDrop(wrapper, target, 'f3', async () => {
|
||||
emptySectionEl.dispatchEvent(new Event('dragover', { bubbles: true }))
|
||||
// fallback 模式:意图检测挂在 window mousemove 上
|
||||
emptySectionEl.dispatchEvent(
|
||||
new MouseEvent('mousemove', { bubbles: true, clientX: 100, clientY: 100 }),
|
||||
)
|
||||
})
|
||||
const last = wrapper.emitted('update').at(-1)[0]
|
||||
const moved = last.find((r) => r.b_field_id === 'f3')
|
||||
@@ -2061,7 +2035,10 @@ describe('ModuleEditConfigPanel(表单设计视图)', () => {
|
||||
?.closest('.mm-fdesign__section')
|
||||
expect(emptySectionEl).toBeTruthy()
|
||||
await simulateCardDrop(wrapper, target, 'f2', async () => {
|
||||
emptySectionEl.dispatchEvent(new Event('dragover', { bubbles: true }))
|
||||
// fallback 模式:意图检测挂在 window mousemove 上
|
||||
emptySectionEl.dispatchEvent(
|
||||
new MouseEvent('mousemove', { bubbles: true, clientX: 100, clientY: 100 }),
|
||||
)
|
||||
})
|
||||
const last = wrapper.emitted('update').at(-1)[0]
|
||||
expect(last.find((r) => r.b_field_id === 'f2').b_group_id).toBe('g2')
|
||||
@@ -2151,10 +2128,10 @@ describe('ModuleEditConfigPanel(表单设计视图)', () => {
|
||||
target.vm.$emit('start', { item: itemEl('f2') })
|
||||
// 先悬停 f1 记录意图
|
||||
await hoverCard(wrapper, 'f1', { top: 10, height: 60, left: 0, width: 200 }, 180, 40)
|
||||
// 拖出画布区域(dragleave,relatedTarget 不在画布内)
|
||||
await wrapper
|
||||
.find('.mm-fdesign__canvas-wrap')
|
||||
.trigger('dragleave', { relatedTarget: null })
|
||||
// 拖出画布区域(fallback 模式:mousemove 到画布外的 body,意图被清除)
|
||||
await document.body.dispatchEvent(
|
||||
new MouseEvent('mousemove', { bubbles: true, clientX: 5, clientY: 5 }),
|
||||
)
|
||||
// 松手
|
||||
target.vm.$emit('end', { item: itemEl('f2') })
|
||||
await nextTick()
|
||||
@@ -2191,7 +2168,10 @@ describe('ModuleEditConfigPanel(表单设计视图)', () => {
|
||||
const ungroupedSection = wrapper.find('[data-section-key="ungrouped"]')
|
||||
expect(ungroupedSection.exists()).toBe(true)
|
||||
await simulateLibDrop(wrapper, extraFields[3], async () => {
|
||||
ungroupedSection.element.dispatchEvent(new Event('dragover', { bubbles: true }))
|
||||
// fallback 模式:意图检测挂在 window mousemove 上
|
||||
ungroupedSection.element.dispatchEvent(
|
||||
new MouseEvent('mousemove', { bubbles: true, clientX: 100, clientY: 100 }),
|
||||
)
|
||||
})
|
||||
const last = wrapper.emitted('update').at(-1)[0]
|
||||
const row = last.find((r) => r.b_field_id === 'f4')
|
||||
|
||||
Reference in new issue
Block a user