20260828173144
This commit is contained in:
1 parent
76b12cc5e1
commit
a85f9b0120
73 files changed
+7491
-2935
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
-2614
File diff suppressed because it is too large.
Load diff
+81
@@ -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>
|
||||
+233
@@ -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>
|
||||
+3177
File diff suppressed because it is too large.
Load diff
+381
@@ -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>
|
||||
+102
@@ -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
|
||||
}
|
||||
+929
@@ -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>
|
||||
+450
@@ -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>
|
||||
+520
@@ -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>
|
||||
+72
@@ -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 }
|
||||
}
|
||||
+124
@@ -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 的状态驱动拖拽,在结构树内完成全部落点
|
||||
|
||||
Reference in new issue
Block a user