20260828173144

This commit is contained in:
oneao committed 2026-08-28 17:31:45 +08:00
1 parent 76b12cc5e1
commit a85f9b0120
73 files changed
+7491 -2935

No files matched your search

+5 -34
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed, defineComponent, h, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { computed, defineComponent, h, provide, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { Splitter, SplitterPanel } from '@/components/ui'
@@ -106,36 +106,6 @@ function toggleSidebar() {
appStore.setSidebar(true)
}
provide('toggleSidebar', toggleSidebar)
/* ---------- 内容可视高度(页面撑满用) ----------
* 实测 .fms-content 可视高度(clientHeight - 纵向 padding),写入 CSS 变量
* --fms-content-height(随 DOM 继承给页面所有后代)。
* 需要「固定高度 + 内部滚动」的页面(如工具栏固定、表格内部滚动)直接写:
* height: var(--fms-content-height)
* 替代 calc(100vh - 116px) 这类魔数——顶栏/页签/padding 等布局内部变化时自动跟随,
* 页面无需改动。 */
const contentRef = ref(null)
let contentResizeObserver = null
function syncContentHeight() {
const el = contentRef.value
if (!el) return
const style = getComputedStyle(el)
const height =
el.clientHeight - parseFloat(style.paddingTop || '0') - parseFloat(style.paddingBottom || '0')
el.style.setProperty('--fms-content-height', `${height}px`)
}
onMounted(() => {
// ResizeObserver 首次 observe 会立即回调一次,初始值无需额外同步
contentResizeObserver = new ResizeObserver(syncContentHeight)
if (contentRef.value) contentResizeObserver.observe(contentRef.value)
})
onBeforeUnmount(() => {
contentResizeObserver?.disconnect()
contentResizeObserver = null
})
</script>
<template>
@@ -166,7 +136,7 @@ onBeforeUnmount(() => {
<AppTopbar />
<div class="fms-main">
<div ref="contentRef" class="fms-content">
<div class="fms-content">
<router-view v-slot="{ Component }">
<transition name="page" mode="out-in">
<div :key="route.fullPath" class="fms-page-wrap">
@@ -306,8 +276,9 @@ onBeforeUnmount(() => {
* overflow: auto hidden —— 只允许纵向滚动,禁止横向滚动条:
* 侧栏折叠/展开过渡(flex-basis 180ms)瞬间内容宽度会短暂超出容器,
* 若允许 x 滚动会出现滚动条闪现;页面内容的横向滚动应由内部容器(表格/splitter)处理。
* 页面需要「固定高度 + 内部滚动」时用 height: var(--fms-content-height)
* (由布局实测写入,随顶栏/页签/padding 变化自动跟随),不要手写 calc(100vh - xx) 魔数。
* 页面需要「固定高度 + 内部滚动」时走纯 flex 链:.fms-content / .fms-page-wrap 均已
* flex:1 + min-height:0,页面根再写 flex:1 + min-height:0 即可获得确定高度(overflow
* 交给页面内部容器),不要手写 calc(100vh - xx) 魔数。
* 页面内部的分层白卡:background: var(--fms-card) + border-radius + box-shadow,
* 卡间用 margin/gap 留出灰缝,不要用 border 画分隔线 */
.fms-content {
@@ -209,9 +209,10 @@ function onAdvancedReset() {
display: flex;
flex-direction: column;
gap: 12px;
/* 高度 = 内容区可视高度(由布局实测写入 --fms-content-height,自动跟随顶栏/页签/padding),
/* 纯 flex 链撑满内容区可视高度(.fms-content / .fms-page-wrap 均已 flex:1 + min-height:0),
让滚动条落在表格内部而不是整个页面滚动 */
height: var(--fms-content-height, calc(100vh - 116px));
flex: 1;
min-height: 0;
color: var(--fms-text);
}
@@ -0,0 +1,81 @@
<script setup>
/**
* 两个编辑器共享的数据视图:工具栏(返回设计视图 / 同步到列表 / 查询配置)+ FmsTable。
* 行数据与变更提交由壳组件持有,本组件只负责渲染与转发事件。
*/
import { Button } from '@/components/ui'
import FmsTable from '@/components/fms-table/FmsTable.vue'
defineProps({
rows: { type: Array, required: true },
loading: { type: Boolean, default: false },
listConfig: { type: Array, default: () => [] },
queryConfig: { type: Array, default: () => [] },
dataColumns: { type: Array, required: true },
/** 高亮行 id(行点击由壳组件更新) */
activeKey: { type: [String, Number], default: null },
/** 是否显示行拖拽把手(表格编辑器关闭,避免与列宽拖拽视觉混淆) */
rowDragHandle: { type: Boolean, default: true },
})
const emit = defineEmits([
'back',
'sync-list',
'sync-query',
'row-click',
'change',
'row-order-change',
])
</script>
<template>
<div class="mm-edit__data">
<div class="mm-panel__toolbar">
<Button type="outline" :disabled="loading" @click="emit('back')">返回设计视图</Button>
<Button type="outline" :disabled="loading || !listConfig.length" @click="emit('sync-list')">
同步到列表配置
</Button>
<Button type="outline" :disabled="loading || !queryConfig.length" @click="emit('sync-query')">
同步到查询配置
</Button>
</div>
<div class="mm-panel__body">
<FmsTable
:columns="dataColumns"
:data-source="rows"
:editable="!loading"
:row-draggable="!loading"
:row-drag-handle="rowDragHandle"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(_, row) => emit('row-click', row)"
@change="emit('change')"
@row-order-change="(rows) => emit('row-order-change', rows)"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.mm-edit__data {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.mm-panel__toolbar {
display: flex;
flex: none;
flex-wrap: wrap;
gap: 8px;
padding: 0 0 8px;
}
.mm-panel__body {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
</style>
@@ -0,0 +1,233 @@
<script setup>
/**
* 字段库:展示模块全部字段(含未加入编辑器的字段),按名称 a-z 排序;
* 搜索时按名称/编码过滤,命中才显示。点击加入/定位,拖出加入分组。
*
* 表格 / 表单两个编辑器共用(文案与选中态经 props 参数化):
* - dragMode="native" —— 原生 HTML5 拖拽(表格面板:落点是下方分组结构树)
* - dragMode="sortable" —— vuedraggable 拖出源(表单面板:与画布共用同一套
* 拖拽状态机,group/clone/filter/start/end 经 sortable
* 配置注入,避免在共享组件里复制表单拖拽逻辑)
*/
import { computed, ref } from 'vue'
import { Input } from '@/components/ui'
import draggable from 'vuedraggable'
import { filterLibraryFields } from '../../editPanelShared'
const props = defineProps({
/** 模块全部字段 */
fields: { type: Array, default: () => [] },
/** 已加入编辑器的行(用于 ✓ 勾选标记) */
rows: { type: Array, default: () => [] },
/** 当前选中行对应的字段 id(高亮),null 表示未选中 */
selectedFieldId: { type: [String, Number], default: null },
loading: { type: Boolean, default: false },
/** native | sortable */
dragMode: { type: String, default: 'native' },
/**
* sortable 模式下的 vuedraggable 配置:
* { group, clone, filter, onStart, onEnd } —— 由表单面板(拥有拖拽状态机)注入
*/
sortable: { type: Object, default: null },
placeholder: { type: String, default: '搜索字段名 / 编码' },
itemTitle: { type: String, default: '点击加入;已加入的点击定位' },
addedTitle: { type: String, default: '已加入' },
emptyText: { type: String, default: '无匹配字段' },
})
const emit = defineEmits(['select'])
const libSearch = ref('')
// 字段库:展示模块的全部字段(含未加入编辑器的字段),按名称 a-z 排序;
// 搜索时按名称/编码过滤,命中才显示
const libraryFields = computed(() => filterLibraryFields(props.fields, libSearch.value))
function isFieldAdded(field) {
return props.rows.some((row) => String(row.b_field_id) === String(field.b_id))
}
function isFieldSelected(field) {
return props.selectedFieldId != null && String(props.selectedFieldId) === String(field.b_id)
}
/* native 模式拖拽源:写入拖拽数据(application/fms-field 供落点端识别) */
function onFieldDragStart(event, field) {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData('application/fms-field', String(field.b_id))
event.dataTransfer.setData('text/plain', String(field.b_name || field.b_field || field.b_id))
}
}
</script>
<template>
<aside class="mm-fdesign__lib" aria-label="字段列表">
<Input v-model="libSearch" class="mm-fdesign__lib-search" :placeholder="placeholder" />
<div class="mm-fdesign__lib-group">
<draggable
v-if="dragMode === 'sortable'"
class="mm-fdesign__lib-list"
:list="libraryFields"
:group="sortable?.group"
item-key="b_id"
:clone="sortable?.clone"
:animation="150"
ghost-class="mm-fdesign__lib-ghost"
:fallback-on-body="true"
:force-fallback="true"
fallback-class="mm-fdesign__drag-fallback"
:filter="sortable?.filter"
:prevent-on-filter="true"
@start="sortable?.onStart"
@end="sortable?.onEnd"
>
<template #item="{ element: field }">
<button
type="button"
class="mm-fdesign__lib-item"
:class="{ 'is-selected': isFieldSelected(field), 'is-added': isFieldAdded(field) }"
:title="itemTitle"
:data-field-id="String(field.b_id)"
:data-added="isFieldAdded(field) ? 'true' : 'false'"
@click="emit('select', field)"
>
<span class="mm-fdesign__lib-name">{{ field.b_name || field.b_field }}</span>
<span v-if="isFieldAdded(field)" class="mm-fdesign__lib-added" :title="addedTitle"
>✓</span
>
</button>
</template>
</draggable>
<div v-else class="mm-fdesign__lib-list">
<button
v-for="field in libraryFields"
:key="field.b_id"
type="button"
class="mm-fdesign__lib-item"
:class="{ 'is-selected': isFieldSelected(field), 'is-added': isFieldAdded(field) }"
:title="itemTitle"
draggable="true"
:data-field-id="String(field.b_id)"
:data-added="isFieldAdded(field) ? 'true' : 'false'"
@click="emit('select', field)"
@dragstart="onFieldDragStart($event, field)"
>
<span class="mm-fdesign__lib-name">{{ field.b_name || field.b_field }}</span>
<span v-if="isFieldAdded(field)" class="mm-fdesign__lib-added" :title="addedTitle"
>✓</span
>
</button>
</div>
<div v-if="!libraryFields.length" class="mm-fdesign__lib-empty">{{ emptyText }}</div>
</div>
</aside>
</template>
<style lang="scss" scoped>
/* 左栏:白底 + 右分割线(与中间列分隔) */
.mm-fdesign__lib {
border-right: 1px solid var(--fms-border);
overflow: auto;
padding: 10px;
background: var(--fms-card);
}
/* 折叠态(与属性面板折叠共用类名,兼容既有布局) */
.mm-fdesign__lib.is-collapsed {
padding: 0;
border: none;
border-radius: 0;
background: transparent;
overflow: hidden;
}
/* 搜索框:压缩 ui Input 高度(覆盖默认 --fms-control-height) */
.mm-fdesign__lib-search.input-wrapper {
height: 26px;
min-height: 0;
padding: 0 8px;
font-size: 11px;
margin-bottom: 10px;
}
.mm-fdesign__lib-group {
margin-bottom: 12px;
}
.mm-fdesign__lib-item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-height: 28px;
margin-bottom: 2px;
padding: 0 6px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--fms-text);
font-size: 12px;
font-weight: 400;
text-align: left;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
transition:
background-color 0.15s,
color 0.15s;
&:hover {
background: color-mix(in srgb, var(--fms-primary) 8%, var(--fms-card));
color: var(--fms-primary);
}
&.is-selected {
background: color-mix(in srgb, var(--fms-primary) 14%, var(--fms-card));
color: var(--fms-primary);
}
/* 已加入编辑器的字段:不弱化视觉,与未加入字段一致(仅靠右侧 ✓ 标记区分) */
}
.mm-fdesign__lib-name {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mm-fdesign__lib-added {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: #e7f6ed;
color: #146c41;
font-size: 10px;
font-weight: 700;
}
.mm-fdesign__lib-empty {
padding: 12px 0;
color: var(--fms-text-disabled, #9ca3af);
font-size: 11px;
text-align: center;
}
/* 幽灵占位:SortableJS 把 ghostClass 加在原卡片上(布局冻结,原位不动),
显示为灰色占位块(内部控件隐藏),一眼看出「这里被拖走了」 */
.mm-fdesign__lib-ghost {
opacity: 1;
background: color-mix(in srgb, var(--fms-border) 55%, transparent) !important;
border-radius: 6px;
> * {
visibility: hidden;
}
}
</style>
@@ -0,0 +1,381 @@
<script setup>
/**
* 表单(主表)属性面板:选中字段的占位符 / 栅格宽度 / textarea 高度 / 状态开关。
* 全部变更经 emits 交给壳组件(FormEditPanel)写入草稿并提交。
*/
import { Button, Input, Select, Switch } from '@/components/ui'
import { PanelRightClose, PanelRightOpen, Trash2 } from '@lucide/vue'
const props = defineProps({
inspectorCollapsed: { type: Boolean, default: false },
/** 选中字段行(null = 未选中) */
selectedRow: { type: Object, default: null },
loading: { type: Boolean, default: false },
fieldName: { type: Function, required: true },
controlTypeLabel: { type: Function, required: true },
spanOf: { type: Function, required: true },
isTextarea: { type: Function, required: true },
heightOf: { type: Function, required: true },
})
const emit = defineEmits([
'update:collapsed',
'placeholder-change',
'colspan-change',
'height-change',
'toggle',
'remove-selected',
])
const TEXTAREA_HEIGHT_OPTIONS = [
{ label: '小(80px)', value: '80' },
{ label: '中(120px)', value: '120' },
{ label: '大(180px)', value: '180' },
]
</script>
<template>
<aside
class="mm-fdesign__inspector"
:class="{ 'is-collapsed': inspectorCollapsed }"
aria-label="属性面板"
>
<Button
v-if="inspectorCollapsed"
type="ghost"
class="mm-fdesign__panel-expand"
title="展开属性面板"
aria-label="展开属性面板"
@click="emit('update:collapsed', false)"
>
<PanelRightOpen :size="14" />
</Button>
<template v-else>
<div class="mm-fdesign__subhead">
<strong>属性</strong>
<span class="mm-fdesign__subhead-meta">
<span>{{ selectedRow ? fieldName(selectedRow) : '未选中字段' }}</span>
<Button
type="ghost"
class="mm-fdesign__collapse"
title="折叠属性面板"
aria-label="折叠属性面板"
@click="emit('update:collapsed', true)"
>
<PanelRightClose :size="13" />
</Button>
</span>
</div>
<template v-if="selectedRow">
<div class="mm-fdesign__prop-group">基础信息</div>
<div class="mm-fdesign__prop">
<label>字段标题</label>
<span class="mm-fdesign__prop-static">{{ fieldName(selectedRow) }}</span>
</div>
<div class="mm-fdesign__prop">
<label>控件类型</label>
<span class="mm-fdesign__prop-static">{{ controlTypeLabel(selectedRow) }}</span>
</div>
<div class="mm-fdesign__prop mm-fdesign__prop--full">
<label for="mm-fdesign-placeholder">占位符</label>
<Input
id="mm-fdesign-placeholder"
class="mm-fdesign__prop-input"
maxlength="100"
:model-value="selectedRow.b_placeholder || ''"
:disabled="loading"
placeholder="输入框内的浅色提示文字"
@change="(v) => emit('placeholder-change', v)"
/>
</div>
<div class="mm-fdesign__prop-group">布局</div>
<div class="mm-fdesign__prop-row">
<div
class="mm-fdesign__prop"
:class="{ 'mm-fdesign__prop--full': !isTextarea(selectedRow) }"
>
<label for="mm-fdesign-colspan">栅格宽度</label>
<div class="mm-fdesign__span-control">
<Input
id="mm-fdesign-colspan"
type="number"
class="mm-fdesign__span-input"
:model-value="spanOf(selectedRow)"
:disabled="loading"
aria-label="栅格宽度"
@change="(v) => emit('colspan-change', v)"
/>
</div>
</div>
<div v-if="isTextarea(selectedRow)" class="mm-fdesign__prop">
<label for="mm-fdesign-height">文本域高度</label>
<div class="mm-fdesign__height-control">
<Select
id="mm-fdesign-height"
:model-value="String(heightOf(selectedRow))"
:disabled="loading"
:options="TEXTAREA_HEIGHT_OPTIONS"
@update:model-value="(v) => emit('height-change', v)"
/>
<Input
type="number"
class="mm-fdesign__span-input"
:model-value="heightOf(selectedRow)"
:disabled="loading"
aria-label="自定义文本域高度"
@change="(v) => emit('height-change', v)"
/>
</div>
</div>
</div>
<div class="mm-fdesign__prop-group">状态</div>
<div class="mm-fdesign__toggles">
<div class="mm-fdesign__toggle-line">
<span>必填</span>
<Switch
:model-value="Number(selectedRow.b_required) === 1"
:disabled="loading"
@update:model-value="() => emit('toggle', 'b_required')"
/>
</div>
<div class="mm-fdesign__toggle-line">
<span>只读</span>
<Switch
:model-value="Number(selectedRow.b_readonly) === 1"
:disabled="loading"
@update:model-value="() => emit('toggle', 'b_readonly')"
/>
</div>
<div class="mm-fdesign__toggle-line">
<span>禁用</span>
<Switch
:model-value="Number(selectedRow.b_disabled) === 1"
:disabled="loading"
@update:model-value="() => emit('toggle', 'b_disabled')"
/>
</div>
<div class="mm-fdesign__toggle-line">
<span>显示</span>
<Switch
:model-value="Number(selectedRow.b_visible) === 1"
:disabled="loading"
@update:model-value="() => emit('toggle', 'b_visible')"
/>
</div>
</div>
<div class="mm-fdesign__prop-group">操作</div>
<Button
type="danger"
:disabled="loading"
class="mm-fdesign__delete"
@click="emit('remove-selected')"
>
<Trash2 :size="14" /> 删除字段
</Button>
</template>
</template>
</aside>
</template>
<style lang="scss" scoped>
.mm-fdesign__inspector {
border-left: 1px solid var(--fms-border);
overflow: auto;
padding: 10px;
background: var(--fms-card);
}
/* 折叠态:面板内容隐藏,仅保留展开按钮 */
.mm-fdesign__inspector.is-collapsed {
padding: 0;
border: none;
border-radius: 0;
background: transparent;
overflow: hidden;
}
/* 展开属性面板(覆盖 ui Button 默认尺寸) */
.mm-fdesign__panel-expand.button {
display: flex;
align-items: flex-start;
justify-content: center;
width: 100%;
height: 100%;
min-height: 0;
padding-top: 10px;
background: transparent;
color: color-mix(in srgb, var(--fms-text-secondary) 70%, var(--fms-text));
&:hover:not(:disabled) {
background: transparent;
color: var(--fms-primary);
}
}
.mm-fdesign__subhead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
strong {
font-size: 14px;
color: var(--fms-text);
font-weight: 700;
}
}
.mm-fdesign__subhead-meta {
display: inline-flex;
align-items: center;
gap: 6px;
color: color-mix(in srgb, var(--fms-text-secondary) 70%, var(--fms-text));
font-size: 10px;
}
/* 「源已移除」告警徽标:供后续若字段来源缺失时高亮使用(仅样式层) */
.mm-fdesign__stale-tag {
display: inline-flex;
align-items: center;
padding: 0 5px;
border: 1px solid color-mix(in srgb, var(--fms-danger, #f5222d) 45%, transparent);
border-radius: var(--fms-control-radius, 4px);
background: color-mix(in srgb, var(--fms-danger, #f5222d) 10%, var(--fms-card));
color: var(--fms-danger, #f5222d);
font-size: 10px;
line-height: 16px;
}
/* 折叠属性面板(覆盖 ui Button 默认尺寸) */
.mm-fdesign__collapse.button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
min-height: 0;
padding: 0;
border-radius: 4px;
background: transparent;
color: color-mix(in srgb, var(--fms-text-secondary) 70%, var(--fms-text));
transform: none;
&:hover:not(:disabled) {
background: var(--fms-secondary);
color: var(--fms-text);
}
}
.mm-fdesign__prop-group {
margin-top: 14px;
color: var(--fms-text);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
border-left: 3px solid var(--fms-primary);
padding-left: 8px;
&:first-child {
margin-top: 0;
}
}
.mm-fdesign__prop {
margin-top: 10px;
label {
display: block;
margin-bottom: 4px;
color: color-mix(in srgb, var(--fms-text-secondary) 70%, var(--fms-text));
font-size: 10px;
}
}
/* 布局组只有栅格宽度一项时(非文本域字段)占满整行 */
.mm-fdesign__prop--full {
grid-column: span 2;
}
.mm-fdesign__prop-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
/* 栅格宽度 / 文本域高度:Select + 数字输入框横排 */
.mm-fdesign__span-control,
.mm-fdesign__height-control {
display: flex;
gap: 4px;
:deep(.select-wrapper) {
flex: 1;
min-width: 0;
}
}
/* 数字输入:压缩 ui Input 高度(覆盖默认 --fms-control-height) */
.mm-fdesign__span-input.input-wrapper {
flex: none;
width: 48px;
height: 26px;
min-height: 0;
padding: 0 4px;
font-size: 11px;
:deep(.input) {
text-align: center;
}
}
/* 栅格宽度:无下拉时数字输入框即主控件,填充剩余宽度(文本域高度仍用下拉,不受影响) */
.mm-fdesign__span-control > .mm-fdesign__span-input.input-wrapper {
flex: 1;
width: auto;
min-width: 0;
padding: 0 8px;
:deep(.input) {
text-align: left;
}
}
.mm-fdesign__prop-static {
display: flex;
align-items: center;
min-height: 26px;
padding: 0 8px;
border: 1px solid var(--fms-border);
border-radius: 4px;
background: var(--fms-secondary-hover);
color: var(--fms-text);
font-size: 11px;
}
/* 属性面板常规输入:压缩 ui Input 高度 */
.mm-fdesign__prop-input.input-wrapper {
height: 26px;
min-height: 0;
padding: 0 8px;
font-size: 11px;
}
.mm-fdesign__toggles {
margin-top: 10px;
}
.mm-fdesign__toggle-line {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 28px;
color: var(--fms-text);
font-size: 11px;
}
.mm-fdesign__delete {
margin-top: 10px;
width: 100%;
}
</style>
@@ -0,0 +1,102 @@
import { groupIdOfRow as groupIdOf } from '../../composables/useGroupTree'
/**
* 表单 24 栅格分配纯逻辑(无 Vue 依赖,可单测)。
* 与 CSS grid 自动换行一致:从分组段首部顺序向前打包,累计宽度超过 24 即换行。
*/
/** 栅格宽度:b_colspan 钳制到 [1, 24],非法值回退整行 */
export function spanOf(row) {
const v = Number(row?.b_colspan)
return v >= 1 && v <= 24 ? v : 24
}
/** 逻辑行推导:返回目标字段所在视觉行的索引,及行内其它字段宽度和 */
export function collectLineIndexes(rows, insertedIndex) {
const groupId = groupIdOf(rows[insertedIndex])
let start = insertedIndex
while (start > 0 && groupIdOf(rows[start - 1]) === groupId) start--
const idxs = []
let sum = 0
let found = false
for (let i = start; i < rows.length && groupIdOf(rows[i]) === groupId; i++) {
const span = spanOf(rows[i])
if (sum + span > 24 && idxs.length) {
if (found) break
idxs.length = 0
sum = 0
}
idxs.push(i)
sum += span
if (i === insertedIndex) found = true
}
const prevSum = idxs.reduce((s, i) => (i === insertedIndex ? s : s + spanOf(rows[i])), 0)
return { idxs, prevSum }
}
/**
* 插入后动态分配宽度:
* - 原行已满(prevSum >= 24):整行(含新字段)均分,余数补给靠前的已有字段
* - 原行未满:fill=true(从字段库拖入)时新字段填满剩余;fill=false(组内移动)时保持所有字段原宽度
*/
export function rebalanceInsert(rows, insertedIndex, { fill = true } = {}) {
const next = rows.map((row) => ({ ...row }))
const inserted = next[insertedIndex]
if (!inserted) return next
const { idxs, prevSum } = collectLineIndexes(next, insertedIndex)
if (prevSum >= 24) {
const count = idxs.length
const base = Math.floor(24 / count)
const remainder = 24 - base * count
// 已有字段优先补给余数,新插入字段排最后(取较小值)
const existing = [...idxs].filter((i) => i !== insertedIndex).toSorted((a, b) => a - b)
const order = [...existing, insertedIndex]
order.forEach((idx, k) => {
next[idx].b_colspan = base + (k < remainder ? 1 : 0)
})
} else if (fill) {
// 从字段库拖入:新字段填满剩余空间
const othersSum = idxs.reduce((s, i) => (i === insertedIndex ? s : s + spanOf(next[i])), 0)
next[insertedIndex].b_colspan = 24 - othersSum
}
return next
}
/** 一行成员(含新插入字段)均分 24 栅格 */
export function distributeLine(rows, memberIdxs) {
const base = Math.floor(24 / memberIdxs.length)
const rem = 24 - base * memberIdxs.length
memberIdxs.forEach((ix, k) => {
rows[ix].b_colspan = base + (k < rem ? 1 : 0)
})
}
/** 按 b_id 将一行成员重新均分 24 栅格(用于字段离开/加入后回收原行空间) */
export function rebalanceRowByIds(rows, ids) {
const members = rows.filter((r) => ids.includes(String(r.b_id)))
if (!members.length) return
const base = Math.floor(24 / members.length)
const rem = 24 - base * members.length
members.forEach((m, k) => {
m.b_colspan = base + (k < rem ? 1 : 0)
})
}
/** 目标分组段的末尾插入位置(rows 为移除被拖字段后的列表) */
export function sectionEndIndex(rows, gid, groups) {
let last = -1
rows.forEach((r, i) => {
if (groupIdOf(r) === gid) last = i
})
if (last >= 0) return last + 1
// 空分组:按分组顺序插到前一个非空分组段之后;前面都为空则插到最前
const order = [...(groups || []).map((g) => String(g.b_id)), null]
for (let p = order.indexOf(gid) - 1; p >= 0; p--) {
let prevLast = -1
rows.forEach((r, i) => {
if (groupIdOf(r) === order[p]) prevLast = i
})
if (prevLast >= 0) return prevLast + 1
}
return 0
}
@@ -0,0 +1,929 @@
<script setup>
/**
* 子表表格设计面板壳组件。
* 保留对外 props/emits 契约完全不变,负责:
* - 本地草稿 / 分组 / 选中态
* - 分组树推导(useGroupTree)+ 结构规范(rebuildFlat / commitColDrag / commitBandDrag)
* - 结构树专用组件 ModuleStructureTree 的编排与事件转发
* - 复制/粘贴列属性
* - 键盘快捷键(Ctrl+C/V/X / Delete)
* UI 子组件(FieldLibrary / TablePreview / TableInspector / EditDataView)通过
* props 接收数据、emits 提交变更。
*/
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Trash2, Plus } from '@lucide/vue'
import { Message, Button, Popover, Radio, RadioGroup, Tabs } from '@/components/ui'
import ModuleStructureTree from '../structure-tree/ModuleStructureTree.vue'
import { createTempId } from '@/utils/tempId'
import { useGroupTree, buildGroupTree } from '../../composables/useGroupTree'
import { useFieldLookup } from '../../composables/useFieldLookup'
import { applyClipboardProps, buildClipboardProps, buildVisibilityMap, createEditRowFactory, renumberRows, toggleFlag } from '../../editPanelShared'
import FieldLibrary from '../edit-panels/FieldLibrary.vue'
import TablePreview from './TablePreview.vue'
import TableInspector from './TableInspector.vue'
import EditDataView from '../edit-panels/EditDataView.vue'
const props = defineProps({
moduleId: { type: String, required: true },
editConfig: { type: Array, default: () => [] },
listConfig: { type: Array, default: () => [] },
queryConfig: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] },
editMode: { type: String, default: 'form' },
loading: { type: Boolean, default: false },
active: { type: Boolean, default: true },
})
const emit = defineEmits(['update', 'updateGroups', 'syncList', 'syncQuery', 'update:editMode'])
/* ================= 本地草稿 ================= */
const localEdit = ref([])
const localGroups = ref([])
watch(() => props.groups, (g) => { localGroups.value = g ? g.map((row) => ({ ...row })) : [] }, { immediate: true })
const activeKey = ref(null)
const structureTreeRef = ref(null)
function onEditModeChange(value) {
if (props.loading) return
emit('update:editMode', value || 'form')
}
/* ================= 视图形态 ================= */
const viewMode = ref('design')
const selectedNodeKey = ref(null)
const inspectorCollapsed = ref(false)
function emitUpdate() { emit('update', localEdit.value) }
/* ================= 数据视图:行排序 ================= */
function onRowOrderChange(rows) {
localEdit.value = renumberRows(rows)
emitUpdate()
}
/* ================= 公共查询表 ================= */
const { fieldNameMap, fieldMap, fieldOf, fieldName, controlTypeLabel } = useFieldLookup(() => props.fields)
/* ================= 数据视图:同步到列表 / 查询 ================= */
function syncToList() {
if (props.loading) return
const visibleMap = buildVisibilityMap(localEdit.value)
if (visibleMap.size === 0) { Message.info('编辑配置中没有字段'); return }
let count = 0
const updated = (props.listConfig || []).map((row) => {
const target = visibleMap.get(String(row.b_field_id))
if (target !== undefined) { count++; return { ...row, b_visible: target } }
return row
})
emit('syncList', updated)
Message.success(`已同步 ${count} 条到列表配置`)
}
function syncToQuery() {
if (props.loading) return
const visibleMap = buildVisibilityMap(localEdit.value)
if (visibleMap.size === 0) { Message.info('编辑配置中没有字段'); return }
let count = 0
const updated = (props.queryConfig || []).map((row) => {
const target = visibleMap.get(String(row.b_field_id))
if (target !== undefined) { count++; return { ...row, b_canuse: target } }
return row
})
emit('syncQuery', updated)
Message.success(`已同步 ${count} 条到查询配置`)
}
/* ================= 数据视图:表格列 ================= */
const dataColumns = computed(() => [
{ dataIndex: 'b_field_id', title: '字段', width: 180, customCell: ({ row }) => fieldNameMap.value.get(String(row.b_field_id)) || row.b_field_id || '' },
{ dataIndex: 'b_visible', title: '显示', width: 70, align: 'center', editor: { type: 'checkbox' } },
{ dataIndex: 'b_required', title: '必填', width: 70, align: 'center', editor: { type: 'checkbox' } },
{ dataIndex: 'b_readonly', title: '只读', width: 70, align: 'center', editor: { type: 'checkbox' } },
{ dataIndex: 'b_disabled', title: '禁用', width: 70, align: 'center', editor: { type: 'checkbox' } },
{ dataIndex: 'b_width', title: '列宽(px)', width: 110, editor: {} },
{ dataIndex: 'b_xh', title: '排序号', width: 90 },
])
/* ================= 设计视图:字段/分组推导 ================= */
const COLUMN_WIDTH_OPTIONS = [
{ label: '窄 90', value: '90' }, { label: '默认 120', value: '120' },
{ label: '中 160', value: '160' }, { label: '宽 200', value: '200' }, { label: '大 240', value: '240' },
]
function widthOf(row) {
const v = Number(row?.b_width)
return v >= 40 && v <= 600 ? v : 120
}
function defaultWidthOf(field) {
const map = { checkbox: 90, number: 140, money: 140, date: 140, datetime: 170, select: 160, textarea: 220 }
return map[field?.b_type] || 140
}
function onWidthChange(value) {
const row = selectedRow.value
if (!row || props.loading) return
row.b_width = Math.min(600, Math.max(40, Number(value) || 120))
emitUpdate()
}
function onWidthCommit(value) {
const row = selectedRow.value
if (!row || props.loading) return
const width = Math.min(600, Math.max(40, Number(value) || 120))
if (width === widthOf(row)) return
row.b_width = width
emitUpdate()
}
function onWidthStep(delta) {
const row = selectedRow.value
if (!row || props.loading) return
onWidthChange(widthOf(row) + delta)
}
const columnWidthOptions = computed(() => {
if (!selectedRow.value) return COLUMN_WIDTH_OPTIONS
const current = String(widthOf(selectedRow.value))
if (COLUMN_WIDTH_OPTIONS.some((o) => o.value === current)) return COLUMN_WIDTH_OPTIONS
return [...COLUMN_WIDTH_OPTIONS, { label: `当前 ${current}px`, value: current }]
})
/* ================= 分组树(多级表头) ================= */
const { groupNodeMap, groupColumnsMap, bandMeta, isDescendantGroup, groupIdOfRow } = useGroupTree(localGroups, localEdit)
const visualColumns = computed(() => {
const columns = groupColumnsMap.value
const list = []
for (const row of columns.ungrouped) list.push({ kind: 'col', key: `col-${row.b_field_id}`, row, gid: null })
const walk = (nodes) => {
for (const node of nodes) {
const own = columns.map.get(node.gid) || []
for (const row of own) list.push({ kind: 'col', key: `col-${row.b_field_id}`, row, gid: node.gid })
if (!own.length && !node.children.length) list.push({ kind: 'slot', key: `slot-${node.gid}`, gid: node.gid, group: node.group })
walk(node.children)
}
}
walk(bandMeta.value.roots)
return list
})
const bandRows = computed(() => {
const { roots, maxLevel } = bandMeta.value
if (!roots.length) return []
const ungrouped = groupColumnsMap.value.ungrouped
const totalHeaderRows = maxLevel + 2
const byLevel = new Map()
const collect = (nodes) => {
for (const node of nodes) { if (!byLevel.has(node.level)) byLevel.set(node.level, []); byLevel.get(node.level).push(node); collect(node.children) }
}
collect(roots)
const rows = []
for (let level = 0; level <= maxLevel; level++) {
const nodes = byLevel.get(level) || []
const cells = []
if (level === 0 && ungrouped.length) {
for (const row of ungrouped) cells.push({ kind: 'ungrouped-col', row, span: 1, rowspan: totalHeaderRows })
}
cells.push(...nodes.map((node) => ({ kind: 'band', gid: node.gid, group: node.group, level: node.level, span: node.span, rowspan: node.children.length ? 1 : maxLevel - node.level + 1 })))
rows.push(cells)
}
return rows
})
const ungroupedInBand = computed(() => bandRows.value.length > 0 && groupColumnsMap.value.ungrouped.length > 0)
const headerColumns = computed(() => {
if (!ungroupedInBand.value) return visualColumns.value
return visualColumns.value.filter((c) => c.gid != null)
})
/* ================= 关键处理区:分组树数据 ================= */
function findRowByFieldId(fid) { return localEdit.value.find((r) => String(r.b_field_id) === String(fid)) || null }
watch(() => props.editConfig, (val) => {
if (val === localEdit.value) return
localEdit.value = val?.map((row) => ({ ...row })) || []
reorderSilently()
}, { immediate: true })
const groupCount = computed(() => (props.groups || []).length)
/* ================= 结构编辑:事件转发 ================= */
function onColDrop({ fieldId, line }) { if (props.loading || !fieldId || !line) return; commitColDrag(fieldId, line) }
function onBandDrop({ gid, line }) { if (props.loading || !gid || !line) return; commitBandDrag(gid, line) }
function onFieldDrop({ fieldId, dropKey, dropPosition }) { onExternalFieldDrop(fieldId, dropKey, dropPosition) }
function onExternalFieldDrop(fieldId, dropKey, dropPosition) {
if (props.loading || !fieldId) return
const field = fieldMap.value.get(String(fieldId))
if (!field) return
const existing = localEdit.value.find((r) => String(r.b_field_id) === String(fieldId))
if (existing) {
if (dropKey === 'g:ungrouped') { commitColDrag(fieldId, { kind: 'ungrouped' }) }
else if (dropKey.startsWith('g:')) { commitColDrag(fieldId, { kind: 'into-band', gid: dropKey.slice(2) }) }
else {
const row = findRowByFieldId(dropKey.slice(2))
commitColDrag(fieldId, { kind: 'col-edge', gid: row ? groupIdOfRow(row) : null, targetId: dropKey.slice(2), pos: dropPosition === 'before' ? 'before' : 'after' })
}
return
}
let groupId = null, targetId = null
if (dropKey === 'g:ungrouped') { groupId = null }
else if (dropKey.startsWith('g:')) { groupId = dropKey.slice(2) }
else {
const row = findRowByFieldId(dropKey.slice(2))
groupId = row ? groupIdOfRow(row) : null; targetId = row ? dropKey.slice(2) : null
}
const created = insertField(field, { groupId, targetId, position: dropPosition === 'before' ? 'before' : 'after' })
if (!created) return
selectRow(created)
nextTick(() => scrollToRowCell(String(created.b_field_id)))
}
function onStructureContextMenuClick({ item, node }) {
if (node?.kind === 'group') {
const band = bandMeta.value.metaMap.get(String(node.gid))
if (!band) return
if (item.key === 'add-sub') addSubGroup(band)
else if (item.key === 'rename') structureTreeRef.value?.startRename(String(node.gid))
else if (item.key === 'enable') { selectBand(band); toggleBandEnable() }
else if (item.key === 'delete') deleteBand(band)
} else if (node?.kind === 'col') {
if (item.key === 'copy') { selectRow(node.row); copyRowProps() }
else if (item.key === 'paste') { selectRow(node.row); pastePropsToSelected() }
else if (item.key === 'delete') removeRow(node.row)
}
}
function onRenameCommit({ gid, title }) {
const updated = (props.groups || []).map((g) => String(g.b_id) === String(gid) ? { ...g, b_title: title } : g)
emit('updateGroups', updated)
}
/* ================= 结构规范化 ================= */
function reorderSilently() {
const columns = groupColumnsMap.value
const list = [...columns.ungrouped]
const walk = (nodes) => { for (const node of nodes) { for (const row of columns.map.get(node.gid) || []) list.push(row); walk(node.children) } }
walk(bandMeta.value.roots)
if (list.length === localEdit.value.length && list.every((r, i) => r === localEdit.value[i])) return
localEdit.value = list
}
function rebuildFlat(roots = bandMeta.value.roots) {
const columns = groupColumnsMap.value
const list = [...columns.ungrouped]
const walk = (nodes) => { for (const node of nodes) { for (const row of columns.map.get(node.gid) || []) list.push(row); walk(node.children) } }
walk(roots)
localEdit.value = list.map((row, i) => ({ ...row, b_xh: (i + 1) * 10 }))
emitUpdate()
}
// 由给定分组数组构建可用于重排的 band 根节点(gid + children),与 bandMeta 解耦
function toRoots(nodes) {
return nodes.map((node) => ({ gid: String(node.group.b_id), children: toRoots(node.children) }))
}
function bandRootsFromGroups(groups) {
return toRoots(buildGroupTree(groups))
}
function renumberAllSiblings(groups) {
const roots = buildGroupTree(groups)
const result = []
const walk = (nodes) => { nodes.forEach((node, i) => { result.push({ ...node.group, b_xh: (i + 1) * 10 }); walk(node.children) }) }
walk(roots)
return result
}
function emitGroupsRenumbered(nextGroups) { emit('updateGroups', renumberAllSiblings(nextGroups)) }
/* ================= 选择 ================= */
const selectedRow = computed(() => {
const key = selectedNodeKey.value
if (!key || !key.startsWith('c:')) return null
return findRowByFieldId(key.slice(2)) || null
})
const selectedBand = computed(() => {
const key = selectedNodeKey.value
if (!key || !key.startsWith('g:') || key === 'g:ungrouped') return null
return bandMeta.value.metaMap.get(key.slice(2)) || null
})
function selectRow(row) { selectedNodeKey.value = row ? `c:${row.b_field_id}` : null }
function selectBand(band) { selectedNodeKey.value = band ? `g:${band.gid}` : null }
function clearSelection() { selectedNodeKey.value = null }
/* ================= 分组带:增删改 ================= */
function nextGroupXh() { return (props.groups || []).reduce((m, g) => Math.max(m, Number(g.b_xh || 0)), 0) + 10 }
function addRootGroup() {
if (props.loading) return
const newGroup = { b_id: createTempId(), b_module_id: props.moduleId, b_parent_id: null, b_title: '', b_xh: nextGroupXh(), b_canuse: 1 }
emitGroupsRenumbered([...(props.groups || []), newGroup])
focusNewBandTitle(newGroup.b_id)
}
function addSubGroup(band) {
if (!band || props.loading) return
const newGroup = { b_id: createTempId(), b_module_id: props.moduleId, b_parent_id: String(band.gid), b_title: '', b_xh: nextGroupXh(), b_canuse: 1 }
emitGroupsRenumbered([...(props.groups || []), newGroup])
focusNewBandTitle(newGroup.b_id)
}
function focusNewBandTitle(gid) {
nextTick(() => {
if (!bandMeta.value.metaMap.has(String(gid))) return
selectedNodeKey.value = `g:${gid}`
structureTreeRef.value?.startRename(String(gid))
})
}
function deleteBand(band) {
if (!band || props.loading) return
const gid = String(band.gid)
const full = bandMeta.value.metaMap.get(gid)
if (!full) return
const title = full.group.b_title || `分组-${gid}`
const affected = new Set()
const collect = (nodes) => { for (const node of nodes) { affected.add(String(node.gid)); collect(node.children) } }
collect([full])
const childGids = new Set(full.children.map((n) => String(n.gid)))
const parentId = full.group.b_parent_id == null ? null : String(full.group.b_parent_id)
let groups = (props.groups || []).filter((g) => String(g.b_id) !== gid)
groups = groups.map((g) => (childGids.has(String(g.b_id)) ? { ...g, b_parent_id: parentId } : g))
for (const row of localEdit.value) { if (groupIdOfRow(row) === gid) row.b_group_id = null }
rebuildFlat(bandRootsFromGroups(groups))
emit('updateGroups', groups)
const listUpdated = (props.listConfig || []).map((r) => {
const rgid = r.b_group_id == null ? null : String(r.b_group_id)
return rgid === gid ? { ...r, b_group_id: null } : r
})
if (listUpdated.some((r, i) => JSON.stringify(r) !== JSON.stringify((props.listConfig || [])[i]))) { emit('syncList', listUpdated) }
if (selectedNodeKey.value === `g:${gid}`) selectedNodeKey.value = null
Message.success(affected.size > 1 ? `已删除分组「${title}」,其下字段移至「未分组」` : `已删除分组「${title}」`)
}
function onBandTitleChange(value) {
const band = selectedBand.value
if (!band || props.loading) return
const title = String(value ?? '').trim()
if (band.group.b_title === title) return
emit('updateGroups', (props.groups || []).map((g) => String(g.b_id) === String(band.gid) ? { ...g, b_title: title } : g))
}
function toggleBandEnable() {
const band = selectedBand.value
if (!band || props.loading) return
emit('updateGroups', (props.groups || []).map((g) => String(g.b_id) === String(band.gid) ? { ...g, b_canuse: Number(g.b_canuse) === 1 ? 0 : 1 } : g))
}
/* ================= 列:增删改 ================= */
const createEditRow = createEditRowFactory({ moduleId: props.moduleId, getExtra: (field) => ({ b_width: defaultWidthOf(field) }) })
function appendIntoGroup(row, groupId, rows) {
if (groupId != null) {
const last = bandOwnRangeEnd(String(groupId), rows)
if (last >= 0) rows.splice(last + 1, 0, row)
else rows.splice(countColumnsBefore(String(groupId), rows), 0, row)
} else { rows.push(row) }
}
function insertField(field, { groupId = null, targetId = null, position = 'after' } = {}) {
if (!field || props.loading) return null
const existing = localEdit.value.find((r) => String(r.b_field_id) === String(field.b_id))
if (existing) return existing
const row = createEditRow(field, groupId)
if (targetId != null) {
const tIdx = localEdit.value.findIndex((r) => String(r.b_field_id) === String(targetId))
if (tIdx >= 0) localEdit.value.splice(tIdx + (position === 'after' ? 1 : 0), 0, row)
else appendIntoGroup(row, groupId, localEdit.value)
} else { appendIntoGroup(row, groupId, localEdit.value) }
rebuildFlat()
return row
}
function removeRow(row) {
if (!row || props.loading) return
localEdit.value = renumberRows(localEdit.value.filter((item) => String(item.b_id) !== String(row.b_id)))
emitUpdate()
if (selectedNodeKey.value === `c:${row.b_field_id}`) selectedNodeKey.value = null
}
function removeSelected() {
const band = selectedBand.value
if (band) { deleteBand(band); return }
removeRow(selectedRow.value)
}
/* ================= 结构树节点常驻操作 ================= */
function onNodeMove({ key, direction }) {
if (props.loading || !key || !direction) return
if (key.startsWith('c:')) { const row = findRowByFieldId(key.slice(2)); if (row) moveColumn(row, direction) }
else if (key.startsWith('g:') && key !== 'g:ungrouped') { const band = bandMeta.value.metaMap.get(key.slice(2)); if (band) moveBand(band, direction) }
}
function onNodeDelete({ key }) {
if (props.loading || !key) return
if (key.startsWith('c:')) { const row = findRowByFieldId(key.slice(2)); if (row) removeRow(row) }
else if (key.startsWith('g:') && key !== 'g:ungrouped') { const band = bandMeta.value.metaMap.get(key.slice(2)); if (band) deleteBand(band) }
}
function onNodeAddSub({ key }) {
if (props.loading || !key || !key.startsWith('g:') || key === 'g:ungrouped') return
const band = bandMeta.value.metaMap.get(key.slice(2))
if (band) addSubGroup(band)
}
function moveColumn(row, delta) {
const list = localEdit.value
const gid = groupIdOfRow(row)
const own = []
for (let i = 0; i < list.length; i++) { if (groupIdOfRow(list[i]) === gid) own.push(i) }
const idx = own.findIndex((i) => String(list[i].b_id) === String(row.b_id))
const target = idx + (delta > 0 ? 1 : -1)
if (idx < 0 || target < 0 || target >= own.length) return
const a = own[idx], b = own[target]
const tmp = list[a]; list[a] = list[b]; list[b] = tmp
rebuildFlat()
selectedNodeKey.value = `c:${row.b_field_id}`
}
function moveBand(band, delta) {
const gid = String(band.gid)
const parentId = band.group.b_parent_id == null ? null : String(band.group.b_parent_id)
const parentNode = parentId != null ? bandMeta.value.metaMap.get(parentId) : null
const sibs = parentNode ? parentNode.children : bandMeta.value.roots
const idx = sibs.findIndex((n) => String(n.gid) === gid)
if (idx < 0) return
const target = idx + (delta > 0 ? 1 : -1)
if (target < 0 || target >= sibs.length) return
commitBandDrag(gid, { kind: 'band-edge', gid: sibs[target].gid, pos: delta > 0 ? 'after' : 'before' })
}
function selectField(field) {
if (!field || props.loading) return
const existing = localEdit.value.find((r) => String(r.b_field_id) === String(field.b_id))
if (existing) { selectRow(existing); nextTick(() => scrollToRowCell(String(existing.b_field_id))); return }
const band = selectedBand.value
const gid = band ? String(band.gid) : null
const row = insertField(field, { groupId: gid })
if (!row) return
selectRow(row)
nextTick(() => scrollToRowCell(String(row.b_field_id)))
}
function scrollToRowCell(fieldId) {
document.querySelector(`.mm-tdesign__cell[data-field-id="${fieldId}"]`)?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' })
}
/* ================= 复制/粘贴(列属性) ================= */
const clipboardProps = ref(null)
const CLIP_KEYS = ['b_required', 'b_readonly', 'b_disabled', 'b_visible', 'b_placeholder', 'b_height', 'b_width']
function copyRowProps() {
const row = selectedRow.value
if (!row || props.loading) return
clipboardProps.value = buildClipboardProps(row, fieldName, CLIP_KEYS)
}
function pastePropsToSelected() {
const row = selectedRow.value
if (!row || props.loading || !clipboardProps.value) return
applyClipboardProps(row, clipboardProps.value.props)
emitUpdate()
}
/* ================= 结构拖拽 commit ================= */
function bandOwnRangeEnd(gid, rows) {
let last = -1
rows.forEach((r, i) => { if (groupIdOfRow(r) === gid) last = i })
return last
}
function countColumnsBefore(gid, rows) {
const columns = groupColumnsMap.value
let count = 0, found = false
const walk = (nodes) => {
if (found) return
for (const node of nodes) {
if (node.gid === gid) { found = true; return }
count += (columns.map.get(node.gid) || []).length
walk(node.children)
if (found) return
}
}
walk(bandMeta.value.roots)
return found ? count : rows.length
}
function commitColDrag(fieldId, line) {
const rows = localEdit.value
const idx = rows.findIndex((r) => String(r.b_field_id) === String(fieldId))
if (idx < 0) return
const [moved] = rows.splice(idx, 1)
let insertAt = -1
if (line.kind === 'col-edge') {
const tIdx = rows.findIndex((r) => String(r.b_field_id) === String(line.targetId))
if (tIdx < 0) { rows.splice(idx, 0, moved); return }
moved.b_group_id = line.gid == null ? null : String(line.gid)
insertAt = tIdx + (line.pos === 'after' ? 1 : 0)
} else if (line.kind === 'into-band') {
moved.b_group_id = String(line.gid)
const last = bandOwnRangeEnd(String(line.gid), rows)
insertAt = last >= 0 ? last + 1 : countColumnsBefore(String(line.gid), rows)
} else if (line.kind === 'col-group-edge') {
const target = bandMeta.value.metaMap.get(String(line.gid))
if (!target) { rows.splice(idx, 0, moved); return }
moved.b_group_id = target.group.b_parent_id == null ? null : String(target.group.b_parent_id)
insertAt = countColumnsBefore(String(line.gid), rows)
if (line.pos === 'after') insertAt += target.span
} else if (line.kind === 'ungrouped') { moved.b_group_id = null; insertAt = rows.length }
else { rows.splice(idx, 0, moved); return }
if (insertAt < 0) insertAt = rows.length
rows.splice(insertAt, 0, moved)
rebuildFlat()
selectedNodeKey.value = `c:${moved.b_field_id}`
}
function commitBandDrag(sourceGid, line) {
const source = bandMeta.value.metaMap.get(String(sourceGid))
if (!source) return
let newParentId = null, orderedSibGids = []
if (line.kind === 'band-nest') {
const target = bandMeta.value.metaMap.get(String(line.gid))
if (!target || isDescendantGroup(String(sourceGid), line.gid)) return
newParentId = String(target.gid)
orderedSibGids = [...target.children.filter((n) => n.gid !== sourceGid).map((n) => n.gid), sourceGid]
} else if (line.kind === 'band-edge') {
const target = bandMeta.value.metaMap.get(String(line.gid))
if (!target || isDescendantGroup(String(sourceGid), line.gid)) return
newParentId = target.group.b_parent_id == null ? null : String(target.group.b_parent_id)
const parentNode = newParentId != null ? bandMeta.value.metaMap.get(newParentId) : null
const sibGids = (parentNode ? parentNode.children : bandMeta.value.roots).filter((n) => n.gid !== sourceGid).map((n) => n.gid)
const tIdx = sibGids.indexOf(String(line.gid))
if (tIdx < 0) return
orderedSibGids = [...sibGids]; orderedSibGids.splice(line.pos === 'after' ? tIdx + 1 : tIdx, 0, sourceGid)
} else { return }
const sibSet = new Set(orderedSibGids.map(String))
const orderById = new Map(orderedSibGids.map((gid, i) => [String(gid), i]))
const groups = (props.groups || []).map((g) => {
const c = { ...g }; const gid = String(g.b_id)
if (gid === String(sourceGid)) c.b_parent_id = newParentId
if (sibSet.has(gid)) c.b_xh = (orderById.get(gid) + 1) * 10
return c
})
emit('updateGroups', groups)
localGroups.value = groups.map((g) => ({ ...g }))
rebuildFlat(bandRootsFromGroups(groups))
selectedNodeKey.value = `g:${sourceGid}`
}
/* ================= 键盘快捷键 ================= */
function onDesignerKeydown(event) {
if (!props.active) return
if (viewMode.value === 'data' && activeKey.value != null) {
const row = localEdit.value.find((r) => String(r.b_id) === String(activeKey.value))
if (row) selectedNodeKey.value = `c:${row.b_field_id}`
}
if (event.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return
const mod = event.ctrlKey || event.metaKey
if (mod && (event.key === 'c' || event.key === 'C')) { event.preventDefault(); copyRowProps(); return }
if (mod && (event.key === 'v' || event.key === 'V')) { event.preventDefault(); pastePropsToSelected(); return }
if (mod && (event.key === 'x' || event.key === 'X')) { event.preventDefault(); copyRowProps(); removeSelected(); return }
if (['Delete', 'Backspace'].includes(event.key)) {
const band = selectedBand.value
if (band) { event.preventDefault(); deleteBand(band); return }
if (!selectedRow.value) return
event.preventDefault(); removeSelected()
}
}
onMounted(() => window.addEventListener('keydown', onDesignerKeydown))
onBeforeUnmount(() => { window.removeEventListener('keydown', onDesignerKeydown) })
/* ================= 属性编辑(列,写入草稿,随页面「保存」提交) ================= */
function toggleInspector(key) {
const row = selectedRow.value
if (!row || props.loading) return
toggleFlag(row, key)
emitUpdate()
}
/* ================= 视图切换 ================= */
function switchView(mode) {
viewMode.value = mode
if (mode === 'data') { activeKey.value = selectedRow.value ? String(selectedRow.value.b_id) : null }
else if (activeKey.value != null) {
const row = localEdit.value.find((r) => String(r.b_id) === String(activeKey.value))
if (row) selectedNodeKey.value = `c:${row.b_field_id}`
}
}
</script>
<template>
<div class="mm-edit">
<!-- ================= 设计视图 ================= -->
<div v-show="viewMode === 'design'" class="mm-fdesign">
<FieldLibrary
:fields="props.fields"
:rows="localEdit"
:selected-field-id="selectedRow?.b_field_id"
:loading="props.loading"
drag-mode="native"
item-title="点击加入表格;已加入的点击定位;拖拽到下方分组树可加入对应分组"
added-title="已加入表格"
@select="selectField"
/>
<!-- 中间列:header + 预览 + 分组树+属性 -->
<div class="mm-fdesign__main">
<div class="mm-edit__head">
<div class="mm-edit__head-left">
<RadioGroup class="mm-edit__mode" role="group" aria-label="编辑方式"
:model-value="editMode || 'form'" :disabled="loading" @update:model-value="onEditModeChange">
<Radio value="form" title="主表:以表单形式编辑">主表表单</Radio>
<Radio value="table" title="子表:以表格形式编辑">子表表格</Radio>
</RadioGroup>
</div>
<div class="mm-edit__head-center">
<Tabs class="mm-edit__view-switch" nav-only
:items="[{ key: 'design', label: '设计' }, { key: 'data', label: '数据' }]"
:model-value="viewMode" aria-label="视图切换" @update:model-value="switchView" />
</div>
<div class="mm-edit__head-right">
<Button type="outline" :disabled="loading" @click="addRootGroup">
<Plus :size="14" /> 添加分组
</Button>
<Button type="outline" :disabled="loading || !(selectedRow || selectedBand)"
title="删除选中的列或分组带" @click="removeSelected">
<Trash2 :size="14" /> 删除
</Button>
<Popover trigger="hover" placement="bottom-end" :arrow="false" :offset="8">
<template #trigger><span class="mm-edit__help-icon" aria-hidden="true">?</span></template>
<template #content>
<div class="mm-edit__help-title">操作说明</div>
<ul class="mm-edit__help-list">
<li>上方表格仅作预览,拖列头右缘可调列宽</li>
<li>下方分组树:拖拽排序 / 嵌套 / 移动列</li>
<li>右键节点可新增子分组 / 重命名 / 删除</li>
<li><kbd>Ctrl+C</kbd> 复制选中列属性</li>
<li><kbd>Ctrl+X</kbd> 剪切(复制并删除当前列)</li>
<li><kbd>Ctrl+V</kbd> 粘贴属性到选中列</li>
<li><kbd>Delete</kbd> 删除选中列 / 分组带</li>
</ul>
</template>
</Popover>
</div>
</div>
<TablePreview
:visual-columns="visualColumns"
:band-rows="bandRows"
:header-columns="headerColumns"
:ungrouped-count="groupColumnsMap.ungrouped.length"
:selected-node-key="selectedNodeKey"
:loading="loading"
:width-of="widthOf"
:field-name="fieldName"
:field-of="fieldOf"
:group-id-of-row="groupIdOfRow"
:commit-col-drag="commitColDrag"
:on-commit="emitUpdate"
/>
<div class="mm-fdesign__config" :class="{ 'is-inspector-collapsed': inspectorCollapsed }">
<div class="mm-fdesign__tree-panel">
<div class="mm-fdesign__tree-head">
<span>分组结构</span>
<span class="mm-fdesign__tree-head-meta">{{ groupCount }} 个分组</span>
</div>
<div class="mm-fdesign__tree-scroll">
<ModuleStructureTree
ref="structureTreeRef"
:groups="localGroups"
:rows="localEdit"
:fields="props.fields"
:selected-key="selectedNodeKey"
:loading="props.loading"
:paste-enabled="!!clipboardProps"
@update:selected-key="(k) => (selectedNodeKey = k)"
@col-drop="onColDrop"
@band-drop="onBandDrop"
@field-drop="onFieldDrop"
@context-menu-click="onStructureContextMenuClick"
@rename-commit="onRenameCommit"
@node-move="onNodeMove"
@node-delete="onNodeDelete"
@node-add-sub="onNodeAddSub"
>
<template #empty>
<span class="mm-fdesign__tree-empty">暂无分组,点击工具栏「添加分组」</span>
</template>
</ModuleStructureTree>
</div>
</div>
<TableInspector
:inspector-collapsed="inspectorCollapsed"
:selected-row="selectedRow"
:selected-band="selectedBand"
:loading="loading"
:field-name="fieldName"
:control-type-label="controlTypeLabel"
:width-of="widthOf"
:column-width-options="columnWidthOptions"
@update:collapsed="(v) => (inspectorCollapsed = v)"
@width-change="onWidthChange"
@width-commit="onWidthCommit"
@width-step="onWidthStep"
@toggle="toggleInspector"
@band-title-change="onBandTitleChange"
@toggle-band-enable="toggleBandEnable"
@remove-selected="removeSelected"
@add-sub-group="addSubGroup(selectedBand)"
@rename="structureTreeRef?.startRename(selectedBand?.gid)"
@delete-band="deleteBand(selectedBand)"
/>
</div>
</div>
</div>
<!-- ================= 数据视图 ================= -->
<EditDataView
v-show="viewMode === 'data'"
:rows="localEdit"
:loading="loading"
:list-config="props.listConfig"
:query-config="props.queryConfig"
:data-columns="dataColumns"
:active-key="activeKey"
:row-drag-handle="false"
@back="switchView('design')"
@sync-list="syncToList"
@sync-query="syncToQuery"
@row-click="(row) => (activeKey = String(row.b_id))"
@change="emitUpdate"
@row-order-change="onRowOrderChange"
/>
</div>
</template>
<style lang="scss" scoped>
.mm-edit {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
overflow: hidden;
}
/* ---- 中间列顶部 header ---- */
.mm-edit__head {
display: flex;
align-items: center;
gap: 12px;
flex: none;
padding: 8px 12px;
border-bottom: 1px solid var(--fms-border);
}
.mm-edit__head-center {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.mm-edit__head-left {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1 1 auto;
justify-content: flex-start;
}
.mm-edit__head-right {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
justify-content: flex-end;
}
/* 编辑方式:ui RadioGroup(紧凑字号) */
.mm-edit__mode { font-size: 12px; }
/* 视图切换:ui Tabs nav-only */
.mm-edit__view-switch { font-size: 12px; }
/* ---- 快捷键帮助(右上角 ? 图标,ui Popover hover 展示) ---- */
.mm-edit__help-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: 1px solid var(--fms-border);
border-radius: 50%;
background: var(--fms-card);
color: var(--fms-text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 1;
padding-bottom: 1px;
cursor: help;
transition: border-color 0.15s, color 0.15s, background 0.15s;
&:hover {
border-color: color-mix(in srgb, var(--fms-primary) 45%, transparent);
background: color-mix(in srgb, var(--fms-primary) 5%, var(--fms-card));
color: var(--fms-primary);
}
}
.mm-edit__help-title {
margin-bottom: 6px;
color: var(--fms-text);
font-size: 12px;
font-weight: 600;
}
.mm-edit__help-list {
margin: 0;
padding: 0;
list-style: none;
li {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
color: var(--fms-text-secondary);
font-size: 11px;
}
kbd {
flex: none;
min-width: 76px;
padding: 1px 6px;
border: 1px solid var(--fms-border);
border-bottom-width: 2px;
border-radius: 4px;
background: var(--fms-secondary);
color: var(--fms-text);
font-size: 10px;
font-family: inherit;
text-align: center;
}
}
/* ---- 设计视图:左字段库 | 主区(上预览表 / 下分组树+属性) ---- */
.mm-fdesign {
display: grid;
grid-template-columns: 184px minmax(0, 1fr);
flex: 1;
min-height: 0;
min-width: 0;
background: var(--fms-card);
}
.mm-fdesign__main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
/* 关键处理区:分组树(多级)| 属性面板 */
.mm-fdesign__config {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
}
.mm-fdesign__config.is-inspector-collapsed {
grid-template-columns: minmax(0, 1fr) 32px;
}
/* 分组树面板 */
.mm-fdesign__tree-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
padding: 8px;
border-right: 1px solid var(--fms-border);
overflow: hidden;
}
.mm-fdesign__tree-head {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 4px 8px;
color: var(--fms-text);
font-size: 12px;
font-weight: 700;
}
.mm-fdesign__tree-head-meta {
color: var(--fms-text-secondary);
font-size: 10px;
font-weight: 400;
}
.mm-fdesign__tree-scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
.mm-fdesign__tree-empty {
padding: 12px 8px;
color: var(--fms-text-secondary);
font-size: 11px;
text-align: center;
}
</style>
@@ -0,0 +1,450 @@
<script setup>
/**
* 子表属性面板:列属性 / 分组带属性 / 空态三态。
* 全部变更经 emits 交给壳组件(TableEditPanel)写入草稿并提交,
* 本组件不持有选中行/分组数据。
*/
import { Button, Input, Select, Switch } from '@/components/ui'
import { PanelRightClose, PanelRightOpen, Trash2, FolderPlus, Pencil } from '@lucide/vue'
const props = defineProps({
inspectorCollapsed: { type: Boolean, default: false },
/** 选中列(null = 未选中列) */
selectedRow: { type: Object, default: null },
/** 选中分组带(null = 未选中分组) */
selectedBand: { type: Object, default: null },
loading: { type: Boolean, default: false },
fieldName: { type: Function, required: true },
controlTypeLabel: { type: Function, required: true },
widthOf: { type: Function, required: true },
columnWidthOptions: { type: Array, required: true },
})
const emit = defineEmits([
'update:collapsed',
'width-change',
'width-commit',
'width-step',
'toggle',
'band-title-change',
'toggle-band-enable',
'remove-selected',
'add-sub-group',
'rename',
'delete-band',
])
</script>
<template>
<aside
class="mm-fdesign__inspector"
:class="{ 'is-collapsed': inspectorCollapsed }"
aria-label="属性面板"
>
<Button
v-if="inspectorCollapsed"
type="ghost"
class="mm-fdesign__panel-expand"
title="展开属性面板"
aria-label="展开属性面板"
@click="emit('update:collapsed', false)"
>
<PanelRightOpen :size="14" />
</Button>
<template v-else>
<div class="mm-fdesign__subhead">
<strong>属性</strong>
<span class="mm-fdesign__subhead-meta">
<span>{{
selectedRow
? fieldName(selectedRow)
: selectedBand
? selectedBand.group.b_title || '分组'
: '未选中'
}}</span>
<Button
type="ghost"
class="mm-fdesign__collapse"
title="折叠属性面板"
aria-label="折叠属性面板"
@click="emit('update:collapsed', true)"
>
<PanelRightClose :size="13" />
</Button>
</span>
</div>
<!-- 列属性 -->
<template v-if="selectedRow">
<div class="mm-fdesign__prop-group">基础信息</div>
<div class="mm-fdesign__prop">
<label>列标题</label>
<span class="mm-fdesign__prop-static">{{ fieldName(selectedRow) }}</span>
</div>
<div class="mm-fdesign__prop">
<label>控件类型</label>
<span class="mm-fdesign__prop-static">{{ controlTypeLabel(selectedRow) }}</span>
</div>
<div class="mm-fdesign__prop">
<label for="mm-tdesign-width">列宽 (px)</label>
<div class="mm-fdesign__span-control">
<Select
id="mm-tdesign-width"
:model-value="String(widthOf(selectedRow))"
:disabled="loading"
:options="columnWidthOptions"
@update:model-value="(v) => emit('width-change', v)"
/>
<Button
type="ghost"
class="mm-fdesign__step"
:disabled="loading || widthOf(selectedRow) <= 40"
aria-label="列宽减 10"
@click="emit('width-step', -10)"
>
−
</Button>
<Input
type="number"
class="mm-fdesign__span-input"
:model-value="widthOf(selectedRow)"
:disabled="loading"
aria-label="自定义列宽"
@change="(v) => emit('width-commit', v)"
/>
<Button
type="ghost"
class="mm-fdesign__step"
:disabled="loading || widthOf(selectedRow) >= 600"
aria-label="列宽加 10"
@click="emit('width-step', 10)"
>
+
</Button>
</div>
</div>
<div class="mm-fdesign__prop-group">状态</div>
<div class="mm-fdesign__toggles">
<div class="mm-fdesign__toggle-line">
<span>必填</span>
<Switch
:model-value="Number(selectedRow.b_required) === 1"
:disabled="loading"
@update:model-value="() => emit('toggle', 'b_required')"
/>
</div>
<div class="mm-fdesign__toggle-line">
<span>只读</span>
<Switch
:model-value="Number(selectedRow.b_readonly) === 1"
:disabled="loading"
@update:model-value="() => emit('toggle', 'b_readonly')"
/>
</div>
<div class="mm-fdesign__toggle-line">
<span>禁用</span>
<Switch
:model-value="Number(selectedRow.b_disabled) === 1"
:disabled="loading"
@update:model-value="() => emit('toggle', 'b_disabled')"
/>
</div>
<div class="mm-fdesign__toggle-line">
<span>显示</span>
<Switch
:model-value="Number(selectedRow.b_visible) === 1"
:disabled="loading"
@update:model-value="() => emit('toggle', 'b_visible')"
/>
</div>
</div>
<div class="mm-fdesign__prop-group">操作</div>
<Button
type="danger"
:disabled="loading"
class="mm-fdesign__delete"
@click="emit('remove-selected')"
>
<Trash2 :size="14" /> 删除列
</Button>
</template>
<!-- 分组带属性 -->
<template v-else-if="selectedBand">
<div class="mm-fdesign__prop-group">分组带</div>
<div class="mm-fdesign__prop">
<label for="mm-tdesign-band-title">带标题</label>
<Input
id="mm-tdesign-band-title"
class="mm-fdesign__prop-input"
maxlength="100"
:model-value="selectedBand.group.b_title || ''"
:disabled="loading"
placeholder="分组名称"
@change="(v) => emit('band-title-change', v)"
/>
</div>
<div class="mm-fdesign__prop-row">
<div class="mm-fdesign__prop">
<label>层级</label>
<span class="mm-fdesign__prop-static">第 {{ selectedBand.level + 1 }} 级</span>
</div>
<div class="mm-fdesign__prop">
<label>列数</label>
<span class="mm-fdesign__prop-static">{{ selectedBand.span }}</span>
</div>
</div>
<div class="mm-fdesign__toggles">
<div class="mm-fdesign__toggle-line">
<span>启用</span>
<Switch
:model-value="Number(selectedBand.group.b_canuse) === 1"
:disabled="loading"
@update:model-value="emit('toggle-band-enable')"
/>
</div>
</div>
<div class="mm-fdesign__prop-group">操作</div>
<Button
type="outline"
:disabled="loading"
class="mm-fdesign__band-action"
@click="emit('add-sub-group')"
>
<FolderPlus :size="14" /> 新增子分组
</Button>
<Button
type="outline"
:disabled="loading"
class="mm-fdesign__band-action"
@click="emit('rename')"
>
<Pencil :size="14" /> 重命名
</Button>
<Button
type="danger"
:disabled="loading"
class="mm-fdesign__delete"
@click="emit('delete-band')"
>
<Trash2 :size="14" /> 删除分组
</Button>
</template>
<!-- 空态 -->
<template v-else>
<div class="mm-fdesign__insp-empty">
选择一列或一个表头分组带进行配置
</div>
</template>
</template>
</aside>
</template>
<style lang="scss" scoped>
.mm-fdesign__inspector {
/* 与左侧树面板的 border-right 重叠成双线,这里去掉:单一分割线由 tree-panel 提供 */
overflow: auto;
padding: 10px;
background: var(--fms-card);
}
.mm-fdesign__inspector.is-collapsed {
padding: 0;
border: none;
border-radius: 0;
background: transparent;
overflow: hidden;
}
/* 展开属性面板(覆盖 ui Button 默认尺寸) */
.mm-fdesign__panel-expand.button {
display: flex;
align-items: flex-start;
justify-content: center;
width: 100%;
height: 100%;
min-height: 0;
padding-top: 10px;
background: transparent;
color: color-mix(in srgb, var(--fms-text-secondary) 70%, var(--fms-text));
&:hover:not(:disabled) {
background: transparent;
color: var(--fms-primary);
}
}
.mm-fdesign__subhead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
strong {
font-size: 14px;
color: var(--fms-text);
font-weight: 700;
}
}
.mm-fdesign__subhead-meta {
display: inline-flex;
align-items: center;
gap: 6px;
color: color-mix(in srgb, var(--fms-text-secondary) 70%, var(--fms-text));
font-size: 10px;
}
/* 折叠属性面板(覆盖 ui Button 默认尺寸) */
.mm-fdesign__collapse.button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
min-height: 0;
padding: 0;
border-radius: 4px;
background: transparent;
color: color-mix(in srgb, var(--fms-text-secondary) 70%, var(--fms-text));
transform: none;
&:hover:not(:disabled) {
background: var(--fms-secondary);
color: var(--fms-text);
}
}
.mm-fdesign__insp-empty {
padding: 24px 8px;
color: var(--fms-text-secondary);
font-size: 11px;
text-align: center;
line-height: 1.7;
}
.mm-fdesign__prop-group {
margin-top: 14px;
color: var(--fms-text);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
border-left: 3px solid var(--fms-primary);
padding-left: 8px;
&:first-child {
margin-top: 0;
}
}
.mm-fdesign__prop {
margin-top: 10px;
label {
display: block;
margin-bottom: 4px;
color: color-mix(in srgb, var(--fms-text-secondary) 70%, var(--fms-text));
font-size: 10px;
}
}
.mm-fdesign__prop-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.mm-fdesign__prop-static {
display: flex;
align-items: center;
min-height: 26px;
padding: 0 8px;
border: 1px solid var(--fms-border);
border-radius: 4px;
background: var(--fms-secondary-hover);
color: var(--fms-text);
font-size: 11px;
}
/* 带标题:压缩 ui Input 高度 */
.mm-fdesign__prop-input.input-wrapper {
height: 26px;
min-height: 0;
padding: 0 8px;
font-size: 11px;
}
.mm-fdesign__span-control {
display: flex;
gap: 4px;
:deep(.select-wrapper) {
flex: 1;
min-width: 0;
}
}
/* 自定义列宽数字输入:压缩 ui Input 高度 */
.mm-fdesign__span-input.input-wrapper {
flex: none;
width: 48px;
height: 26px;
min-height: 0;
padding: 0 4px;
font-size: 11px;
:deep(.input) {
text-align: center;
}
}
/* 列宽步进钮(覆盖 ui Button 默认尺寸) */
.mm-fdesign__step.button {
flex: none;
width: 22px;
height: 26px;
min-height: 0;
padding: 0;
border: 1px solid var(--fms-border);
border-radius: 4px;
background: var(--fms-card);
color: var(--fms-text-secondary);
font-size: 12px;
line-height: 1;
&:hover:not(:disabled) {
border-color: var(--fms-primary);
color: var(--fms-primary);
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.mm-fdesign__toggles {
margin-top: 10px;
}
.mm-fdesign__toggle-line {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 28px;
color: var(--fms-text);
font-size: 11px;
}
.mm-fdesign__delete {
margin-top: 10px;
width: 100%;
}
.mm-fdesign__band-action {
margin-top: 8px;
width: 100%;
}
</style>
@@ -0,0 +1,520 @@
<script setup>
/**
* 子表多级表头预览表:band 行渲染、mock 数据行、预览拖拽落点指示。
* 严格被动预览,唯一交互是列头右缘拖拽列宽与列头拖拽排序(均经 composable 处理)。
* 列/分组结构数据由壳组件(TableEditPanel)经 props 传入,变更经 commitColDrag 提交。
*/
import { computed, onBeforeUnmount } from 'vue'
import { useColumnResize } from './useColumnResize'
import { useTablePreviewDrag } from './useTablePreviewDrag'
const props = defineProps({
/** 视觉列序(壳组件按分组树前序推导) */
visualColumns: { type: Array, required: true },
/** 表头 band 行(按层级渲染) */
bandRows: { type: Array, required: true },
/** 列头行渲染的列 */
headerColumns: { type: Array, required: true },
/** 未分组列数(无未分组列时显示「未分组」悬浮落点) */
ungroupedCount: { type: Number, default: 0 },
/** 统一选中态:'g:<gid>' 分组带 | 'c:<fieldId>' 列 | null */
selectedNodeKey: { type: String, default: null },
loading: { type: Boolean, default: false },
/** 取当前列宽(40–600 边界) */
widthOf: { type: Function, required: true },
fieldName: { type: Function, required: true },
fieldOf: { type: Function, required: true },
/** 行 → 分组 id(拖拽落点用) */
groupIdOfRow: { type: Function, required: true },
/** 落点提交(壳组件 commitColDrag) */
commitColDrag: { type: Function, required: true },
/** 拖拽结束时提交(emit update) */
onCommit: { type: Function, required: true },
})
/* 列宽拖拽:pointer capture 三件套 + 40–600 边界 */
const { resizingCol, startColResize, cleanup } = useColumnResize({
loading: computed(() => props.loading),
widthOf: (row) => props.widthOf(row),
onCommit: () => props.onCommit(),
})
onBeforeUnmount(cleanup)
/* 预览表拖拽:列头换位 / 入组 / 未分组悬浮落点 */
const {
previewDragField,
previewDropTarget,
previewUngroupedHover,
onPreviewUngroupedDragOver,
onPreviewUngroupedDragLeave,
onPreviewUngroupedDrop,
onPreviewDragStart,
onPreviewDragOver,
onPreviewDrop,
onPreviewDragEnd,
bandTarget,
colTarget,
} = useTablePreviewDrag({
loading: computed(() => props.loading),
groupIdOfRow: (row) => props.groupIdOfRow(row),
commitColDrag: (fieldId, line) => props.commitColDrag(fieldId, line),
})
/* 预览 mock 数据行:仅 1 行示例数据。按字段类型 + 字段名联想生成贴近真实业务
数据的占位值,用于实时预览视觉,不参与保存。 */
const mockRows = computed(() => [0])
function mockValue(col) {
if (col.kind !== 'col') return ''
const field = props.fieldOf(col.row)
const name = String(field?.b_name || field?.b_field || '')
switch (field?.b_type) {
case 'checkbox':
return '☑'
case 'select':
return /状态|类型|性质|级别/.test(name) ? '启用' : '选项 A'
case 'number':
return /数量|个数|件数|合计/.test(name) ? '10' : '1,200'
case 'money':
return '¥1,200.00'
case 'date':
return '2026-03-18'
case 'datetime':
return '2026-03-18 09:30'
case 'textarea':
return '示例说明:这是一段较长的多行文本内容,用于预览换行效果'
default: {
// 输入框:按字段名联想示例值,让预览更贴近真实录入
if (/姓名|名字|负责人|联系人|客户|供应商/.test(name)) return '张三'
if (/电话|手机|号码/.test(name)) return '138****1234'
if (/邮箱|邮件/.test(name)) return 'zhangsan@example.com'
if (/地址|位置|地点/.test(name)) return '北京市朝阳区示例路 88 号'
if (/编号|编码|单号|批次|流水号/.test(name)) return 'FMS-2026-0001'
if (/数量|个数|件数/.test(name)) return '10'
if (/单位/.test(name)) return '件'
if (/日期|时间/.test(name)) return '2026-03-18'
if (/备注|说明|描述|摘要/.test(name)) return '示例备注内容'
return '示例内容'
}
}
}
</script>
<template>
<div class="mm-fdesign__preview">
<div class="mm-tdesign__canvas">
<table v-if="visualColumns.length" class="mm-tdesign__table">
<colgroup>
<col
v-for="col in visualColumns"
:key="`cg-${col.key}`"
:style="col.kind === 'col' ? `width:${widthOf(col.row)}px` : 'width:56px'"
/>
</colgroup>
<thead>
<!-- 多级表头:每层一个 band 行(纯展示);未分组列头在 band 行第 0 层,贯穿整段高度 -->
<tr v-for="(row, li) in bandRows" :key="`band-row-${li}`">
<th
v-for="band in row"
:key="band.kind === 'band' ? `band-${band.gid}` : `uc-${band.row.b_field_id}`"
:class="
band.kind === 'ungrouped-col'
? [
'mm-tdesign__cell',
'mm-tdesign__cell--ungrouped',
{ 'is-selected': selectedNodeKey === `c:${band.row.b_field_id}` },
{ 'is-invisible': Number(band.row.b_visible) !== 1 },
{
'is-preview-drop-before':
previewDropTarget?.key === `c:${band.row.b_field_id}` &&
previewDropTarget.pos === 'before',
'is-preview-drop-after':
previewDropTarget?.key === `c:${band.row.b_field_id}` &&
previewDropTarget.pos === 'after',
},
]
: [
'mm-tdesign__band',
{ 'is-selected': selectedNodeKey === `g:${band.gid}` },
{ 'is-disabled': Number(band.group.b_canuse) !== 1 },
{
'is-preview-drop-inside': previewDropTarget?.key === `g:${band.gid}`,
},
]
"
:colspan="band.span"
:rowspan="band.rowspan"
:draggable="band.kind === 'ungrouped-col' ? 'true' : undefined"
:data-band-id="band.kind === 'band' ? band.gid : undefined"
:data-field-id="
band.kind === 'ungrouped-col' ? String(band.row.b_field_id) : undefined
"
@dragstart="
band.kind === 'ungrouped-col'
? onPreviewDragStart($event, bandTarget(band))
: undefined
"
@dragover="onPreviewDragOver($event, bandTarget(band))"
@drop="onPreviewDrop($event, bandTarget(band))"
@dragend="onPreviewDragEnd"
>
<template v-if="band.kind === 'ungrouped-col'">
<div class="mm-tdesign__cell-head">
<span class="mm-fdesign__field-name">{{ fieldName(band.row) }}</span>
<i v-if="Number(band.row.b_required) === 1" class="mm-fdesign__required">*</i>
</div>
<i v-if="Number(band.row.b_visible) !== 1" class="mm-fdesign__invisible-mark">隐藏</i>
<span v-if="resizingCol === band.row" class="mm-tdesign__col-width">{{ widthOf(band.row) }}px</span>
<span class="mm-tdesign__resizer" title="拖拽调整列宽" @pointerdown.stop.prevent="startColResize($event, band.row)"></span>
</template>
<template v-else>
<span class="mm-tdesign__band-title">{{ band.group.b_title || '空' }}</span>
</template>
</th>
</tr>
<!-- 列头行:未分组列头已在 band 行时,这里只渲染分组列 -->
<tr>
<th
v-for="col in headerColumns"
:key="col.key"
class="mm-tdesign__cell"
:class="{
'mm-tdesign__cell--slot': col.kind === 'slot',
'is-selected': col.kind === 'col' && selectedNodeKey === `c:${col.row.b_field_id}`,
'is-invisible': col.kind === 'col' && Number(col.row.b_visible) !== 1,
'is-preview-drop-before':
col.kind === 'col' &&
previewDropTarget?.key === `c:${col.row.b_field_id}` &&
previewDropTarget.pos === 'before',
'is-preview-drop-after':
col.kind === 'col' &&
previewDropTarget?.key === `c:${col.row.b_field_id}` &&
previewDropTarget.pos === 'after',
'is-preview-drop-inside':
col.kind === 'slot' && previewDropTarget?.key === `g:${col.gid}`,
}"
:draggable="col.kind === 'col' ? 'true' : undefined"
:data-field-id="col.kind === 'col' ? String(col.row.b_field_id) : undefined"
@dragstart="
col.kind === 'col' ? onPreviewDragStart($event, colTarget(col)) : undefined
"
@dragover="onPreviewDragOver($event, colTarget(col))"
@drop="onPreviewDrop($event, colTarget(col))"
@dragend="onPreviewDragEnd"
>
<template v-if="col.kind === 'slot'">
<span class="mm-tdesign__slot-text">+</span>
</template>
<template v-else>
<div class="mm-tdesign__cell-head">
<span class="mm-fdesign__field-name">{{ fieldName(col.row) }}</span>
<i v-if="Number(col.row.b_required) === 1" class="mm-fdesign__required">*</i>
</div>
<i v-if="Number(col.row.b_visible) !== 1" class="mm-fdesign__invisible-mark">隐藏</i>
<span v-if="resizingCol === col.row" class="mm-tdesign__col-width">{{ widthOf(col.row) }}px</span>
<span class="mm-tdesign__resizer" title="拖拽调整列宽" @pointerdown.stop.prevent="startColResize($event, col.row)"></span>
</template>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(_, ri) in mockRows" :key="`mock-${ri}`">
<td
v-for="col in visualColumns"
:key="col.key"
class="mm-tdesign__mock"
:class="{ 'is-muted': col.kind === 'slot' }"
>
{{ mockValue(col) }}
</td>
</tr>
</tbody>
</table>
<!-- 无未分组列时拖列:悬浮「未分组」落点,便于把列拖出分组 -->
<div
v-if="previewDragField && ungroupedCount === 0"
class="mm-tdesign__ungrouped-drop"
:class="{ 'is-hover': previewUngroupedHover }"
@dragover="onPreviewUngroupedDragOver"
@dragleave="onPreviewUngroupedDragLeave"
@drop="onPreviewUngroupedDrop"
>
未分组
</div>
<div v-if="!visualColumns.length" class="mm-fdesign__empty">暂无列:从左侧字段库点击添加字段</div>
</div>
</div>
</template>
<style lang="scss" scoped>
/* 预览区:最上面的实时表格,只拖列宽 */
.mm-fdesign__preview {
flex: 0 0 auto;
max-height: 46%;
min-height: 0;
overflow: auto;
border-bottom: 1px solid var(--fms-border);
background: var(--fms-background);
}
.mm-tdesign__canvas {
position: relative;
min-width: 100%;
min-height: 100%;
padding: 10px;
box-sizing: border-box;
}
/* 无未分组列时拖列:悬浮「未分组」落点 */
.mm-tdesign__ungrouped-drop {
position: absolute;
left: 10px;
top: 10px;
z-index: 5;
display: inline-flex;
align-items: center;
padding: 6px 12px;
border: 1px dashed var(--fms-border);
border-radius: 6px;
background: var(--fms-card);
color: var(--fms-text-secondary);
font-size: 11px;
box-shadow: 0 4px 12px rgba(39, 54, 70, 0.12);
cursor: copy;
transition:
border-color 0.15s,
color 0.15s,
background 0.15s;
&.is-hover {
border-color: var(--fms-primary);
color: var(--fms-primary);
background: color-mix(in srgb, var(--fms-primary) 8%, var(--fms-card));
}
}
.mm-tdesign__table {
/* 网格线不重叠:单元格只画右边 + 下边,表格只画上边 + 左边,
相邻边框并排会叠成 2px 粗线,改为单边绘制后每条分割线只有 1px */
border-collapse: separate;
border-spacing: 0;
width: max-content;
table-layout: fixed;
border-top: 1px solid var(--fms-border);
border-left: 1px solid var(--fms-border);
border-radius: 8px;
background: var(--fms-card);
box-shadow: 0 1px 2px rgba(39, 54, 70, 0.05);
/* 不用 overflow:hidden:会裁剪选中列悬浮在表头上方的工具栏 */
}
/* ---- 表头分组带(band,纯展示) ---- */
.mm-tdesign__band {
padding: 6px 10px;
border-right: 1px solid var(--fms-border);
border-bottom: 1px solid var(--fms-border);
background: var(--fms-secondary);
font-size: 12px;
font-weight: 700;
color: var(--fms-text);
text-align: center;
vertical-align: middle;
user-select: none;
-webkit-user-select: none;
transition:
border-color 0.15s,
box-shadow 0.15s,
background 0.15s;
&:hover {
background: color-mix(in srgb, var(--fms-primary) 6%, var(--fms-secondary));
}
&.is-selected {
background: color-mix(in srgb, var(--fms-primary) 12%, var(--fms-secondary));
box-shadow: inset 0 0 0 1.5px var(--fms-primary);
}
&.is-disabled {
opacity: 0.5;
}
}
.mm-tdesign__band-title {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 0;
}
/* ---- 列头 cell ---- */
.mm-tdesign__cell {
position: relative;
padding: 5px 8px;
border-right: 1px solid var(--fms-border);
border-bottom: 1px solid var(--fms-border);
background: #fafbfc;
font-size: 11px;
font-weight: 600;
color: var(--fms-text);
text-align: left;
vertical-align: middle;
user-select: none;
-webkit-user-select: none;
transition:
border-color 0.15s,
box-shadow 0.15s,
background 0.15s;
&:hover {
background: color-mix(in srgb, var(--fms-primary) 6%, #fafbfc);
}
&.is-selected {
background: color-mix(in srgb, var(--fms-primary) 10%, #fff);
box-shadow: inset 0 0 0 1.5px var(--fms-primary);
}
&.is-invisible {
opacity: 0.55;
}
}
/* 预览列头拖拽落点指示:before/after 画主色竖线,inside(拖入分组带/空分组)整格高亮 */
.mm-tdesign__cell.is-preview-drop-before::before,
.mm-tdesign__cell.is-preview-drop-after::before {
content: '';
position: absolute;
top: 4px;
bottom: 4px;
width: 2px;
border-radius: 2px;
background: var(--fms-primary);
z-index: 4;
}
.mm-tdesign__cell.is-preview-drop-before::before {
left: -1px;
}
.mm-tdesign__cell.is-preview-drop-after::before {
right: -1px;
}
.mm-tdesign__band.is-preview-drop-inside,
.mm-tdesign__cell--slot.is-preview-drop-inside {
outline: 2px solid color-mix(in srgb, var(--fms-primary) 55%, transparent);
outline-offset: -2px;
}
.mm-tdesign__cell-head {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.mm-tdesign__cell--slot {
background: transparent;
color: var(--fms-text-disabled, #9ca3af);
font-weight: 400;
text-align: center;
}
.mm-tdesign__slot-text {
font-size: 14px;
line-height: 1;
}
.mm-tdesign__col-width {
flex: none;
color: color-mix(in srgb, var(--fms-text-secondary) 70%, var(--fms-text));
font-size: 10px;
font-weight: 400;
font-variant-numeric: tabular-nums;
}
/* 列宽拖拽手柄:hover 常显一条竖线,拖拽命中区稍宽 */
.mm-tdesign__resizer {
position: absolute;
top: 0;
right: -1px;
bottom: 0;
width: 7px;
cursor: col-resize;
z-index: 3;
&::after {
content: '';
position: absolute;
top: 4px;
bottom: 4px;
right: 3px;
width: 1px;
background: transparent;
transition: background 0.15s;
}
&:hover::after {
background: color-mix(in srgb, var(--fms-primary) 45%, transparent);
}
&:hover {
background: color-mix(in srgb, var(--fms-primary) 10%, transparent);
}
}
/* ---- 预览 mock 数据行 ---- */
.mm-tdesign__mock {
padding: 4px 8px;
border-right: 1px solid var(--fms-border);
border-bottom: 1px solid var(--fms-border);
background: var(--fms-card);
font-size: 11px;
color: var(--fms-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.is-muted {
color: var(--fms-text-placeholder, #9ca3af);
font-style: italic;
}
}
/* ---- 通用小件:必填 / 隐藏标记 ---- */
.mm-fdesign__required {
color: #d95757;
font-style: normal;
margin-left: 2px;
}
.mm-fdesign__invisible-mark {
margin-left: auto;
padding: 0 5px;
border-radius: 4px;
background: var(--fms-secondary);
color: color-mix(in srgb, var(--fms-text-secondary) 65%, var(--fms-text));
font-size: 10px;
font-style: normal;
line-height: 16px;
opacity: 0;
transition: opacity 0.15s;
}
.mm-tdesign__cell:hover .mm-fdesign__invisible-mark,
.mm-tdesign__cell.is-selected .mm-fdesign__invisible-mark,
.mm-tdesign__cell.is-invisible .mm-fdesign__invisible-mark {
opacity: 1;
}
.mm-fdesign__empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 80px;
color: var(--fms-text-secondary);
font-size: 11px;
}
</style>
@@ -0,0 +1,72 @@
import { ref } from 'vue'
/**
* 列宽拖拽(列头右缘手柄)。
* pointer 事件 + setPointerCapture:
* - pointerdown 的 preventDefault 会抑制兼容 mouse 事件,从而阻断 SortableJS 的 mousedown 拖拽;
* - setPointerCapture 保证指针移出单元格时仍能持续收到 pointermove/pointerup。
* 入参:
* loading —— 禁用态(ref)
* widthOf(row) —— 取当前列宽(40–600 边界)
* onCommit() —— 拖拽结束时提交(emit update)
*/
export function useColumnResize({ loading, widthOf, onCommit }) {
const resizingCol = ref(null)
let resizeStartX = 0
let resizeStartWidth = 0
let resizePointerId = null
let resizeEl = null
function startColResize(evt, row) {
if (loading.value || !row) return
resizingCol.value = row
resizeStartX = evt.clientX
resizeStartWidth = widthOf(row)
const el = evt.currentTarget
resizeEl = el
try {
el.setPointerCapture(evt.pointerId)
resizePointerId = evt.pointerId
} catch {
/* ignore:capture 失败时事件仍可正常接收 */
}
el.addEventListener('pointermove', onColResizePointerMove)
el.addEventListener('pointerup', onColResizePointerEnd)
el.addEventListener('pointercancel', onColResizePointerEnd)
}
function onColResizePointerMove(evt) {
const row = resizingCol.value
if (!row) return
const w = Math.min(600, Math.max(40, resizeStartWidth + (evt.clientX - resizeStartX)))
row.b_width = Math.round(w)
}
function onColResizePointerEnd() {
const el = resizeEl
if (el) {
if (resizePointerId != null) {
try {
el.releasePointerCapture(resizePointerId)
} catch {
/* ignore */
}
}
el.removeEventListener('pointermove', onColResizePointerMove)
el.removeEventListener('pointerup', onColResizePointerEnd)
el.removeEventListener('pointercancel', onColResizePointerEnd)
}
const row = resizingCol.value
resizingCol.value = null
resizeEl = null
resizePointerId = null
if (row) onCommit()
}
function cleanup() {
// 组件卸载时清理未结束的列宽拖拽
if (resizeEl) onColResizePointerEnd()
}
return { resizingCol, startColResize, cleanup }
}
@@ -0,0 +1,124 @@
import { ref } from 'vue'
/* 纯函数(模块级,不依赖 composable 状态):落点坐标判定 / 目标归一 */
function previewColPos(e) {
const rect = e.currentTarget.getBoundingClientRect()
return e.clientX < rect.left + rect.width / 2 ? 'before' : 'after'
}
function bandTarget(band) {
return band.kind === 'band' ? { kind: 'band', gid: band.gid } : { kind: 'col', row: band.row }
}
function colTarget(col) {
return col.kind === 'slot' ? { kind: 'band', gid: col.gid } : { kind: 'col', row: col.row }
}
/**
* 预览表列头拖拽排序:
* - 直接拖列头换位:列落列 → col-edge(前后插位并沿用目标列分组);
* - 列落分组带 / 空分组占位 → into-band(移入该分组直属列末尾)。
* 复用壳组件的 commitColDrag 领域语义,落点指示就地高亮。
* 入参:
* loading —— 禁用态(ref)
* groupIdOfRow(row)—— 行 → 分组 id
* commitColDrag(fieldId, line) —— 落点提交
*/
export function useTablePreviewDrag({ loading, groupIdOfRow, commitColDrag }) {
const previewDragField = ref(null)
const previewDropTarget = ref(null) // { key, kind: 'col'|'band', pos }
const previewUngroupedHover = ref(false)
function clearPreviewDrop() {
previewDragField.value = null
previewDropTarget.value = null
previewUngroupedHover.value = false
}
/* 无未分组列时的「未分组」悬浮落点:把列拖出分组 */
function onPreviewUngroupedDragOver(e) {
if (!previewDragField.value || loading.value) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
previewUngroupedHover.value = true
previewDropTarget.value = { key: 'g:ungrouped', kind: 'band', pos: 'inside' }
}
function onPreviewUngroupedDragLeave() {
previewUngroupedHover.value = false
}
function onPreviewUngroupedDrop(e) {
e.preventDefault()
const fieldId = previewDragField.value
clearPreviewDrop()
if (!fieldId || loading.value) return
commitColDrag(fieldId, { kind: 'ungrouped' })
}
function onPreviewDragStart(e, target) {
if (loading.value || target.kind !== 'col') {
e.preventDefault()
return
}
previewDragField.value = String(target.row.b_field_id)
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(target.row.b_field_id))
}
}
function onPreviewDragOver(e, target) {
if (!previewDragField.value || loading.value) {
clearPreviewDrop()
return
}
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
if (target.kind === 'band') {
previewDropTarget.value = { key: `g:${target.gid}`, kind: 'band', pos: 'inside' }
} else {
previewDropTarget.value = {
key: `c:${target.row.b_field_id}`,
kind: 'col',
pos: previewColPos(e),
}
}
}
function onPreviewDrop(e, target) {
e.preventDefault()
const fieldId = previewDragField.value
clearPreviewDrop()
if (!fieldId || loading.value) return
if (target.kind === 'band') {
commitColDrag(fieldId, { kind: 'into-band', gid: String(target.gid) })
return
}
if (String(fieldId) === String(target.row.b_field_id)) return // 拖到自身:无操作
commitColDrag(fieldId, {
kind: 'col-edge',
gid: groupIdOfRow(target.row),
targetId: String(target.row.b_field_id),
pos: previewColPos(e),
})
}
function onPreviewDragEnd() {
clearPreviewDrop()
}
return {
previewDragField,
previewDropTarget,
previewUngroupedHover,
onPreviewUngroupedDragOver,
onPreviewUngroupedDragLeave,
onPreviewUngroupedDrop,
onPreviewDragStart,
onPreviewDragOver,
onPreviewDrop,
onPreviewDragEnd,
previewColPos,
bandTarget,
colTarget,
}
}
@@ -44,7 +44,7 @@ import ModuleBasicPanel from './components/ModuleBasicPanel.vue'
import ModuleFieldsPanel from './components/ModuleFieldsPanel.vue'
import ModuleListConfigPanel from './components/ModuleListConfigPanel.vue'
import ModuleFormEditPanel from './components/ModuleFormEditPanel.vue'
import ModuleTableEditPanel from './components/ModuleTableEditPanel.vue'
import ModuleTableEditPanel from './components/table-edit/TableEditPanel.vue'
import ModuleQueryConfigPanel from './components/ModuleQueryConfigPanel.vue'
import ModuleAutoCodePanel from './components/ModuleAutoCodePanel.vue'
import ModulePowersPanel from './components/ModulePowersPanel.vue'
@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import ModuleTableEditPanel from '@/views/system/module-management/components/ModuleTableEditPanel.vue'
import ModuleTableEditPanel from '@/views/system/module-management/components/table-edit/TableEditPanel.vue'
/**
* 专用结构树组件 ModuleStructureTree 的状态驱动拖拽,在结构树内完成全部落点