chore: 重构前基线快照(工作区既有改动 + 重构计划文档)
This commit is contained in:
1 parent
35ca496b5f
commit
61ac01075b
9 files changed
+1474
-238
No files matched your search
+56
-29
@@ -2,6 +2,12 @@
|
||||
/**
|
||||
* 自动编码面板(s_autocode):单条规则表单 + 实时预览。
|
||||
* 保存时 b_current_period / b_current_value 由服务端维护,前端不提交。
|
||||
*
|
||||
* 布局:
|
||||
* ┌ 顶部行:启用开关 + 业务编号字段
|
||||
* ├ 参数行:前缀 / 日期 / 分隔符 / 位数 / 起始值 单行排布
|
||||
* ├ 预览区:分段着色的编码预览,居中展示
|
||||
* └ 底部:重置周期
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Input, Select, Switch, Form, FormItem } from '@/components/ui'
|
||||
@@ -185,14 +191,14 @@ function currentPeriod(date, resetType) {
|
||||
return yyyy + MM
|
||||
}
|
||||
|
||||
/* 编码预览:前缀 + 日期 + 流水号 拼接后的完整文本 */
|
||||
const preview = computed(() => {
|
||||
if (!localRow.value) return ''
|
||||
const today = new Date()
|
||||
const startValue = Number(localRow.value.b_startvalue) || 1
|
||||
const storedValue = Number(localRow.value.b_currentvalue) || 0
|
||||
const activePeriod = currentPeriod(today, localRow.value.b_resettype)
|
||||
const sequence =
|
||||
localRow.value.b_currentperiod === activePeriod ? storedValue + 1 : startValue
|
||||
const sequence = localRow.value.b_currentperiod === activePeriod ? storedValue + 1 : startValue
|
||||
const parts = []
|
||||
if (localRow.value.b_prefix) parts.push(localRow.value.b_prefix)
|
||||
const dateText = formatDate(today, localRow.value.b_dateformat)
|
||||
@@ -204,15 +210,23 @@ const preview = computed(() => {
|
||||
|
||||
<template>
|
||||
<div v-if="localRow" class="mm-auto-code">
|
||||
<Form ref="formRef" :model="localRow" :rules="formRules" layout="vertical" :columns="2" max-width="720px">
|
||||
<FormItem label="启用自动编码" name="b_canuse">
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="localRow"
|
||||
:rules="formRules"
|
||||
layout="vertical"
|
||||
:columns="5"
|
||||
max-width="720px"
|
||||
>
|
||||
<!-- 第一行:启用 + 业务编号字段 + 重置周期 -->
|
||||
<FormItem label="启用" name="b_canuse">
|
||||
<Switch
|
||||
:model-value="autoCodeEnabled"
|
||||
:disabled="loading"
|
||||
@update:model-value="toggleEnabled"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="业务编号字段" name="b_field_id">
|
||||
<FormItem label="业务编号字段" name="b_field_id" :span="2">
|
||||
<Select
|
||||
:model-value="String(localRow.b_field_id || '')"
|
||||
:options="fieldOptions"
|
||||
@@ -221,14 +235,25 @@ const preview = computed(() => {
|
||||
@update:model-value="(v) => setRowValue('b_field_id', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="固定前缀" name="b_prefix">
|
||||
<FormItem label="重置周期" name="b_resettype" :span="2">
|
||||
<Select
|
||||
:model-value="localRow.b_resettype"
|
||||
:options="RESET_OPTIONS"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
@update:model-value="(v) => setRowValue('b_resettype', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<!-- 第二行:前缀 / 日期 / 分隔符 / 位数 / 起始值 -->
|
||||
<FormItem label="前缀" name="b_prefix">
|
||||
<Input
|
||||
:model-value="localRow.b_prefix"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
placeholder="如 FMS"
|
||||
@update:model-value="(v) => setRowValue('b_prefix', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="日期格式" name="b_dateformat">
|
||||
<FormItem label="日期" name="b_dateformat">
|
||||
<Select
|
||||
:model-value="localRow.b_dateformat"
|
||||
:options="DATE_FORMAT_OPTIONS"
|
||||
@@ -241,14 +266,16 @@ const preview = computed(() => {
|
||||
<Input
|
||||
:model-value="localRow.b_separator"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
placeholder="-"
|
||||
@update:model-value="(v) => setRowValue('b_separator', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="流水号位数" name="b_seqwidth">
|
||||
<FormItem label="位数" name="b_seqwidth">
|
||||
<Input
|
||||
:model-value="localRow.b_seqwidth"
|
||||
type="number"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
placeholder="5"
|
||||
@update:model-value="(v) => setRowValue('b_seqwidth', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
@@ -257,38 +284,38 @@ const preview = computed(() => {
|
||||
:model-value="localRow.b_startvalue"
|
||||
type="number"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
placeholder="1"
|
||||
@update:model-value="(v) => setRowValue('b_startvalue', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="重置周期" name="b_resettype">
|
||||
<Select
|
||||
:model-value="localRow.b_resettype"
|
||||
:options="RESET_OPTIONS"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
@update:model-value="(v) => setRowValue('b_resettype', v)"
|
||||
|
||||
<!-- 第三行:编号预览(跨整行) -->
|
||||
<FormItem
|
||||
label="编号预览"
|
||||
name="b_preview"
|
||||
:span="5"
|
||||
class="mm-auto-code__preview-item"
|
||||
:class="{ 'is-disabled': !autoCodeEnabled }"
|
||||
>
|
||||
<Input
|
||||
:model-value="preview"
|
||||
readonly
|
||||
:disabled="loading"
|
||||
placeholder="—"
|
||||
@update:model-value="() => {}"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="编号预览" name="b_preview" :span="2">
|
||||
<div class="mm-auto-code__preview">{{ preview }}</div>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mm-auto-code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 未启用自动编码:预览整体淡化 */
|
||||
.mm-auto-code__preview-item {
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.mm-auto-code__preview {
|
||||
min-height: 32px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
background: var(--fms-secondary-hover);
|
||||
color: var(--fms-text);
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
line-height: 22px;
|
||||
.mm-auto-code__preview-item.is-disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
+340
-38
@@ -50,7 +50,7 @@ const emit = defineEmits([
|
||||
])
|
||||
|
||||
const localEdit = ref([])
|
||||
// 分组本地副本:拖拽实时换位时立即更新,组件 :groups 绑定 localGroups 而非 props.groups,
|
||||
// 分组本地副本:结构变更提交后立即更新,组件 :groups 绑定 localGroups 而非 props.groups,
|
||||
// 避免分组顺序视觉滞后(props.groups 需父级回流后更新)。
|
||||
const localGroups = ref([])
|
||||
watch(
|
||||
@@ -448,11 +448,14 @@ const visualColumns = computed(() => {
|
||||
|
||||
// 表头 band 行(按层级渲染):每层一行,叶子带 rowspan = maxLevel - level + 1(补足到列头行),
|
||||
// 非叶子带 rowspan = 1。colspan = 子树列数。任意参差深度都能正确对齐。
|
||||
// 未分组列:在 band 行第 0 层渲染成列头(rowspan 贯穿全部表头行,含列头行),
|
||||
// 让未分组列头占据整段表头高度,而不是留下一块空白。
|
||||
const bandRows = computed(() => {
|
||||
const { roots, maxLevel } = bandMeta.value
|
||||
// 无分组时不渲染 band 行(避免空分组树时出现多余的空表头行)
|
||||
if (!roots.length) return []
|
||||
const ungroupedCount = groupColumnsMap.value.ungrouped.length
|
||||
const ungrouped = groupColumnsMap.value.ungrouped
|
||||
const totalHeaderRows = maxLevel + 2 // band 行 + 列头行
|
||||
const byLevel = new Map()
|
||||
const collect = (nodes) => {
|
||||
for (const node of nodes) {
|
||||
@@ -466,13 +469,16 @@ const bandRows = computed(() => {
|
||||
for (let level = 0; level <= maxLevel; level++) {
|
||||
const nodes = byLevel.get(level) || []
|
||||
const cells = []
|
||||
// 存在未分组列时,band 行开头预留与列头对齐的空位(否则分组带会从第 1 列起铺,
|
||||
// 把未分组列的列头盖在分组带下方,导致「预览」与「分组树」的列-分组归属不一致)
|
||||
if (level === 0 && ungroupedCount > 0) {
|
||||
cells.push({ spacer: true, span: ungroupedCount, rowspan: maxLevel + 1 })
|
||||
// 未分组列头在 band 行第 0 层渲染,rowspan 覆盖所有 band 行 + 列头行;
|
||||
// 这样列头行只需渲染分组列,未分组列头占满整段高度
|
||||
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,
|
||||
@@ -485,6 +491,16 @@ const bandRows = computed(() => {
|
||||
return rows
|
||||
})
|
||||
|
||||
// 列头行渲染哪些列:未分组列头已在 band 行(rowspan 贯穿)时,列头行只渲染分组列;
|
||||
// 无分组或全未分组时,列头行渲染全部列
|
||||
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)
|
||||
})
|
||||
|
||||
/* ================= 关键处理区:分组树数据 =================
|
||||
* 结构编辑面板已迁移到专用组件 ModuleStructureTree(structure-tree/),
|
||||
* 本面板仅保留数据语义(bandMeta / commitColDrag / commitBandDrag 等)。
|
||||
@@ -911,6 +927,74 @@ function removeSelected() {
|
||||
removeRow(selectedRow.value)
|
||||
}
|
||||
|
||||
/* ================= 结构树节点常驻操作:上移 / 下移 / 删除 =================
|
||||
* 上移/下移:列=同分组直属列内换位;分组=同级兄弟间换位(复用 band-edge 语义)。
|
||||
* 删除:列=removeRow;分组=deleteBand(其下字段归未分组、子分组提升)。 */
|
||||
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]
|
||||
const b = own[target]
|
||||
const tmp = list[a]
|
||||
list[a] = list[b]
|
||||
list[b] = tmp
|
||||
rebuildFlat()
|
||||
selectedNodeKey.value = `c:${row.b_field_id}`
|
||||
}
|
||||
|
||||
// 分组在同级兄弟间上/下移动:复用 commitBandDrag 的 band-edge 语义
|
||||
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
|
||||
@@ -1052,6 +1136,103 @@ function commitColDrag(fieldId, line) {
|
||||
selectedNodeKey.value = `c:${moved.b_field_id}`
|
||||
}
|
||||
|
||||
/* ================= 预览表列头拖拽排序 =================
|
||||
* 直接拖列头换位:列落列 → col-edge(前后插位并沿用目标列分组);
|
||||
* 列落分组带 / 空分组占位 → into-band(移入该分组直属列末尾)。
|
||||
* 复用 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 || props.loading) 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 || props.loading) return
|
||||
commitColDrag(fieldId, { kind: 'ungrouped' })
|
||||
}
|
||||
|
||||
function previewColPos(e) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
return e.clientX < rect.left + rect.width / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
function onPreviewDragStart(e, target) {
|
||||
if (props.loading || 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 || props.loading) {
|
||||
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 || props.loading) 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()
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
function commitBandDrag(sourceGid, line) {
|
||||
const source = bandMeta.value.metaMap.get(String(sourceGid))
|
||||
if (!source) return
|
||||
@@ -1065,7 +1246,7 @@ function commitBandDrag(sourceGid, line) {
|
||||
orderedSibGids = [...children.map((n) => n.gid), sourceGid]
|
||||
} else if (line.kind === 'band-edge') {
|
||||
const target = bandMeta.value.metaMap.get(String(line.gid))
|
||||
if (!target) return
|
||||
if (!target || isDescendantOf(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)
|
||||
@@ -1089,7 +1270,7 @@ function commitBandDrag(sourceGid, line) {
|
||||
return c
|
||||
})
|
||||
emit('updateGroups', groups)
|
||||
// 同步本地副本:结构树组件绑定 localGroups,拖拽实时换位立即生效
|
||||
// 同步本地副本:结构树组件绑定 localGroups,drop 提交后立即生效,无需等父级回流
|
||||
localGroups.value = groups.map((g) => ({ ...g }))
|
||||
// 用本次新分组重排列序:若用 bandMeta(旧 props)重排,b_xh 会按旧树前序编号,与新结构不一致
|
||||
rebuildFlat(bandRootsFromGroups(groups))
|
||||
@@ -1352,41 +1533,95 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<!-- 多级表头:每层一个 band 行(纯展示) -->
|
||||
<!-- 多级表头:每层一个 band 行(纯展示);未分组列头在 band 行第 0 层,贯穿整段高度 -->
|
||||
<tr v-for="(row, li) in bandRows" :key="`band-row-${li}`">
|
||||
<th
|
||||
v-for="band in row"
|
||||
:key="band.spacer ? `spacer-${li}` : band.gid"
|
||||
class="mm-tdesign__band"
|
||||
:key="band.kind === 'band' ? `band-${band.gid}` : `uc-${band.row.b_field_id}`"
|
||||
:class="
|
||||
band.spacer
|
||||
? 'mm-tdesign__band--spacer'
|
||||
: {
|
||||
'is-selected': selectedNodeKey === `g:${band.gid}`,
|
||||
'is-disabled': Number(band.group.b_canuse) !== 1,
|
||||
}
|
||||
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"
|
||||
:data-band-id="band.spacer ? undefined : band.gid"
|
||||
: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.spacer">
|
||||
<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 visualColumns"
|
||||
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>
|
||||
@@ -1416,7 +1651,20 @@ onBeforeUnmount(() => {
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="mm-fdesign__empty">暂无列:从左侧字段库点击添加字段</div>
|
||||
<!-- 无未分组列时拖列:悬浮「未分组」落点,便于把列拖出分组 -->
|
||||
<div
|
||||
v-if="
|
||||
previewDragField && groupColumnsMap.ungrouped.length === 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>
|
||||
|
||||
@@ -1442,6 +1690,9 @@ onBeforeUnmount(() => {
|
||||
@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>
|
||||
@@ -1694,6 +1945,7 @@ onBeforeUnmount(() => {
|
||||
:data-source="localEdit"
|
||||
:editable="!loading"
|
||||
:row-draggable="!loading"
|
||||
:row-drag-handle="false"
|
||||
row-key="b_id"
|
||||
:active-row-key="activeKey"
|
||||
@row-click="(_, row) => (activeKey = String(row.b_id))"
|
||||
@@ -2020,7 +2272,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.mm-fdesign__inspector {
|
||||
border-left: 1px solid var(--fms-border);
|
||||
/* 与左侧树面板的 border-right 重叠成双线,这里去掉:单一分割线由 tree-panel 提供 */
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
background: var(--fms-card);
|
||||
@@ -2250,12 +2502,43 @@ onBeforeUnmount(() => {
|
||||
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: 1px solid var(--fms-border);
|
||||
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);
|
||||
@@ -2265,7 +2548,8 @@ onBeforeUnmount(() => {
|
||||
/* ---- 表头分组带(band,纯展示) ---- */
|
||||
.mm-tdesign__band {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-right: 1px solid var(--fms-border);
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
background: var(--fms-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
@@ -2293,16 +2577,6 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 未分组列占位带:不可见,仅撑起列头对齐,避免分组带覆盖未分组列 */
|
||||
.mm-tdesign__band--spacer {
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.mm-tdesign__band-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2315,7 +2589,8 @@ onBeforeUnmount(() => {
|
||||
.mm-tdesign__cell {
|
||||
position: relative;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-right: 1px solid var(--fms-border);
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
background: #fafbfc;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -2343,6 +2618,33 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 预览列头拖拽落点指示: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;
|
||||
@@ -2352,7 +2654,6 @@ onBeforeUnmount(() => {
|
||||
|
||||
.mm-tdesign__cell--slot {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
color: var(--fms-text-disabled, #9ca3af);
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
@@ -2404,7 +2705,8 @@ onBeforeUnmount(() => {
|
||||
/* ---- 预览 mock 数据行 ---- */
|
||||
.mm-tdesign__mock {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--fms-border);
|
||||
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);
|
||||
|
||||
+255
-90
@@ -7,6 +7,8 @@
|
||||
* - 拖拽:列↔列换位、列入组、列出组、组嵌套、组排序、外部字段库拖入;
|
||||
* 落点由组件按领域语义直接计算(列只有 before/after,分组才分 before/inside/after),
|
||||
* 通过语义事件(col-drop / band-drop / field-drop)交给面板 commit。
|
||||
* 采用「悬停亮指示、松手提交」:dragover 仅更新落点指示,drop 时才提交一次变更,
|
||||
* 避免拖拽中反复重排 DOM 形成反馈循环(两个分组互换/嵌套时的抖动与冲突)。
|
||||
* - 右键菜单:分组(新增子分组/重命名/启用停用/删除)、列(复制/粘贴/删除)。
|
||||
* - 内联重命名:分组标题原地编辑,startRename(gid) 供外部(面板新增分组后)触发。
|
||||
* - 选中:受控 selectedKey,双向同步面板属性面板 / 预览高亮。
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
Power,
|
||||
Trash2,
|
||||
} from '@lucide/vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import StructureTreeNode from './StructureTreeNode.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -42,6 +45,10 @@ const emit = defineEmits([
|
||||
'field-drop',
|
||||
'context-menu-click',
|
||||
'rename-commit',
|
||||
// 节点常驻操作:上移/下移/删除/新增子分组({ key, direction? }),由面板按领域语义提交
|
||||
'node-move',
|
||||
'node-delete',
|
||||
'node-add-sub',
|
||||
])
|
||||
|
||||
/* ================= 字段/名称推导 ================= */
|
||||
@@ -176,39 +183,49 @@ function makeColNode(row) {
|
||||
|
||||
function buildTreeNodes(metaNodes) {
|
||||
const columns = groupColumnsMap.value
|
||||
return metaNodes.map((meta) => ({
|
||||
key: `g:${meta.gid}`,
|
||||
title: meta.group.b_title || '空分组',
|
||||
kind: 'group',
|
||||
gid: meta.gid,
|
||||
group: meta.group,
|
||||
span: meta.span,
|
||||
disabled: Number(meta.group.b_canuse) !== 1,
|
||||
isLeaf: false,
|
||||
children: [
|
||||
...(columns.map.get(meta.gid) || []).map((row) => makeColNode(row)),
|
||||
...buildTreeNodes(meta.children),
|
||||
],
|
||||
}))
|
||||
return metaNodes.map((meta, gi) => {
|
||||
const cols = columns.map.get(meta.gid) || []
|
||||
return {
|
||||
key: `g:${meta.gid}`,
|
||||
title: meta.group.b_title || '空分组',
|
||||
kind: 'group',
|
||||
gid: meta.gid,
|
||||
group: meta.group,
|
||||
span: meta.span,
|
||||
disabled: Number(meta.group.b_canuse) !== 1,
|
||||
canUp: gi > 0,
|
||||
canDown: gi < metaNodes.length - 1,
|
||||
isLeaf: false,
|
||||
children: [
|
||||
...cols.map((row, ci) => ({
|
||||
...makeColNode(row),
|
||||
canUp: ci > 0,
|
||||
canDown: ci < cols.length - 1,
|
||||
})),
|
||||
...buildTreeNodes(meta.children),
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const treeData = computed(() => {
|
||||
const nodes = []
|
||||
const ungrouped = groupColumnsMap.value.ungrouped
|
||||
// 未分组在前,分组结构在后
|
||||
if (ungrouped.length) {
|
||||
nodes.push({
|
||||
key: 'g:ungrouped',
|
||||
title: '未分组',
|
||||
kind: 'group',
|
||||
gid: null,
|
||||
span: ungrouped.length,
|
||||
icon: 'inbox',
|
||||
disabled: true, // 虚拟节点:仅作收纳落点
|
||||
isLeaf: false,
|
||||
children: ungrouped.map((row) => makeColNode(row)),
|
||||
})
|
||||
}
|
||||
// 未分组在前,分组结构在后。
|
||||
// 始终保留「未分组」容器(哪怕 0 列),否则全部分组时无法把列拖出分组
|
||||
nodes.push({
|
||||
key: 'g:ungrouped',
|
||||
title: '未分组',
|
||||
kind: 'group',
|
||||
gid: null,
|
||||
span: ungrouped.length,
|
||||
icon: 'inbox',
|
||||
disabled: true, // 虚拟节点:仅作收纳落点
|
||||
canUp: false,
|
||||
canDown: false,
|
||||
isLeaf: false,
|
||||
children: ungrouped.map((row) => makeColNode(row)),
|
||||
})
|
||||
nodes.push(...buildTreeNodes(bandMeta.value.roots))
|
||||
return nodes
|
||||
})
|
||||
@@ -326,8 +343,9 @@ const dragType = ref(null)
|
||||
const dragKey = ref(null)
|
||||
const dropKey = ref(null)
|
||||
const dropPosition = ref(null) // 'before' | 'inside' | 'after'
|
||||
// 实时换位去重:上次已提交的落点 `${targetKey}:${pos}`,位置不变时不重复提交
|
||||
const lastCommittedKey = ref(null)
|
||||
// 交互模型:内部拖拽用 vuedraggable(SortableJS force-fallback,同主表设计器)。
|
||||
// 布局冻结(onSortMove 返回 false):拖拽中不重排 DOM,悬停仅亮落点指示,
|
||||
// 松手(onSortEnd)才提交一次 —— 避免「拖过即换」的重排反馈循环(抖动/冲突)。
|
||||
|
||||
function isDescendantGroup(ancestorGid, gid) {
|
||||
const meta = bandMeta.value.metaMap.get(String(gid))
|
||||
@@ -342,18 +360,106 @@ function isDescendantGroup(ancestorGid, gid) {
|
||||
return false
|
||||
}
|
||||
|
||||
function handleDragStart(node, e) {
|
||||
if (props.loading || node.disabled) {
|
||||
e.preventDefault()
|
||||
return
|
||||
/* ========== 内部拖拽:vuedraggable(SortableJS)==========
|
||||
* 布局冻结 + 自绘全不透明标题卡片跟随(同主表设计器);
|
||||
* 自动滚动由 SortableJS 内置提供,无需手写。 */
|
||||
let dragBadgeEl = null
|
||||
function createDragBadge(title) {
|
||||
removeDragBadge()
|
||||
const badge = document.createElement('div')
|
||||
badge.className = 'mst-drag-badge'
|
||||
const name = document.createElement('span')
|
||||
name.className = 'mst-drag-badge__name'
|
||||
name.textContent = title
|
||||
badge.appendChild(name)
|
||||
// 用 transform 跟随鼠标;pointer-events:none 不干扰落点检测
|
||||
badge.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:2147483000'
|
||||
document.body.appendChild(badge)
|
||||
dragBadgeEl = badge
|
||||
}
|
||||
function removeDragBadge() {
|
||||
if (dragBadgeEl) {
|
||||
dragBadgeEl.remove()
|
||||
dragBadgeEl = null
|
||||
}
|
||||
}
|
||||
|
||||
let dragCancelled = false
|
||||
|
||||
// SortableJS move 钩子:布局冻结,禁止其实时重排 DOM
|
||||
function onSortMove() {
|
||||
return false
|
||||
}
|
||||
|
||||
// SortableJS start:识别被拖节点,创建跟手卡片,挂 mousemove/keydown 监听
|
||||
function onSortStart(evt) {
|
||||
if (props.loading) return
|
||||
const nodeKey = evt?.item?.dataset?.key
|
||||
if (!nodeKey) return
|
||||
const node = findNodeByKey(nodeKey)
|
||||
if (!node || node.disabled) return
|
||||
dragType.value = node.kind
|
||||
dragKey.value = node.key
|
||||
lastCommittedKey.value = null
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', node.key)
|
||||
dragCancelled = false
|
||||
createDragBadge(node.title)
|
||||
window.addEventListener('mousemove', onSortWindowMove)
|
||||
window.addEventListener('keydown', onSortKeydown)
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.webkitUserSelect = 'none'
|
||||
}
|
||||
|
||||
// mousemove:定位跟手卡片 + 用 elementFromPoint 计算落点
|
||||
function onSortWindowMove(e) {
|
||||
if (dragCancelled) return
|
||||
if (dragBadgeEl) {
|
||||
dragBadgeEl.style.transform = `translate(${e.clientX - 8}px, ${e.clientY - 8}px)`
|
||||
}
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY)
|
||||
if (!el) { clearDropTarget(); return }
|
||||
const nodeEl = el.closest('.mst-node')
|
||||
if (!nodeEl) { clearDropTarget(); return }
|
||||
const nodeKey = nodeEl.getAttribute('data-key')
|
||||
if (!nodeKey) { clearDropTarget(); return }
|
||||
const node = findNodeByKey(nodeKey)
|
||||
if (!node) { clearDropTarget(); return }
|
||||
const fakeEvent = { currentTarget: nodeEl, clientX: e.clientX, clientY: e.clientY }
|
||||
const pos = calcDropPosition(node, fakeEvent)
|
||||
if (!pos) { clearDropTarget(); return }
|
||||
if (dragType.value !== 'field' && !resolveDrop(nodeKey, pos)) { clearDropTarget(); return }
|
||||
dropKey.value = nodeKey
|
||||
dropPosition.value = pos
|
||||
}
|
||||
|
||||
function onSortKeydown(e) {
|
||||
if (e.key === 'Escape') dragCancelled = true
|
||||
}
|
||||
|
||||
// SortableJS end:提交一次(Esc 取消则不提交)
|
||||
function onSortEnd() {
|
||||
const targetKey = dropKey.value
|
||||
const pos = dropPosition.value
|
||||
const type = dragType.value
|
||||
const srcKey = dragKey.value
|
||||
cleanupDrag()
|
||||
if (dragCancelled) {
|
||||
dragCancelled = false
|
||||
return
|
||||
}
|
||||
if (!srcKey || !targetKey || srcKey === targetKey) return
|
||||
if (type === 'col') {
|
||||
emit('col-drop', resolveColDrop(srcKey, targetKey, pos))
|
||||
} else if (type === 'group') {
|
||||
const resolved = resolveBandDrop(srcKey, targetKey, pos)
|
||||
if (resolved) emit('band-drop', resolved)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupDrag() {
|
||||
window.removeEventListener('mousemove', onSortWindowMove)
|
||||
window.removeEventListener('keydown', onSortKeydown)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.webkitUserSelect = ''
|
||||
resetDragVisual()
|
||||
}
|
||||
|
||||
// 外部字段库拖拽识别:Chrome dragover 阶段 getData() 为空(隐私保护),
|
||||
@@ -365,6 +471,19 @@ function isExternalFieldDrag(e) {
|
||||
)
|
||||
}
|
||||
|
||||
function clearDropTarget() {
|
||||
dropKey.value = null
|
||||
dropPosition.value = null
|
||||
}
|
||||
|
||||
// 当前拖拽源落在 (targetKey, pos) 是否可提交(内部拖拽校验;外部字段恒可落点)
|
||||
function resolveDrop(targetKey, pos) {
|
||||
const srcKey = dragKey.value
|
||||
if (dragType.value === 'col') return resolveColDrop(srcKey, targetKey, pos)
|
||||
if (dragType.value === 'group') return resolveBandDrop(srcKey, targetKey, pos)
|
||||
return true
|
||||
}
|
||||
|
||||
function handleDragOver(node, e) {
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) {
|
||||
@@ -377,35 +496,21 @@ function handleDragOver(node, e) {
|
||||
}
|
||||
// 无有效拖拽源(如拖入非节点区域)不设落点
|
||||
if (!dragKey.value) {
|
||||
dropKey.value = null
|
||||
dropPosition.value = null
|
||||
clearDropTarget()
|
||||
return
|
||||
}
|
||||
const pos = calcDropPosition(node, e)
|
||||
if (!pos || dragKey.value === node.key) {
|
||||
dropKey.value = null
|
||||
dropPosition.value = null
|
||||
clearDropTarget()
|
||||
return
|
||||
}
|
||||
// 落点不可解析(如分组拖入自身子树 / 未分组区 / 其下的列)时不亮指示,drop 也不会提交
|
||||
if (dragType.value !== 'field' && !resolveDrop(node.key, pos)) {
|
||||
clearDropTarget()
|
||||
return
|
||||
}
|
||||
dropKey.value = node.key
|
||||
dropPosition.value = pos
|
||||
|
||||
// 外部字段:仅显示落点,等 drop 提交(dragover 阶段 getData 为空)
|
||||
if (dragType.value === 'field') return
|
||||
|
||||
// 内部拖拽:实时换位——拖过即换,不等待 drop。
|
||||
// 用 lastCommittedKey 去重,避免同一位持续 dragover 反复提交。
|
||||
const commitKey = `${node.key}:${pos}`
|
||||
if (lastCommittedKey.value === commitKey) return
|
||||
lastCommittedKey.value = commitKey
|
||||
const srcKey = dragKey.value
|
||||
if (dragType.value === 'col') {
|
||||
const resolved = resolveColDrop(srcKey, node.key, pos)
|
||||
if (resolved) emit('col-drop', resolved)
|
||||
} else if (dragType.value === 'group') {
|
||||
const resolved = resolveBandDrop(srcKey, node.key, pos)
|
||||
if (resolved) emit('band-drop', resolved)
|
||||
}
|
||||
}
|
||||
|
||||
// 领域化落点:列(叶子)只有 before/after(上半/下半);
|
||||
@@ -450,8 +555,6 @@ function handleDrop(node, e) {
|
||||
const pos = dropPosition.value
|
||||
const type = dragType.value
|
||||
const srcKey = dragKey.value
|
||||
// 实时换位是否已发生(resetDragVisual 会清空 lastCommittedKey,需先记录)
|
||||
const alreadyLiveSwapped = lastCommittedKey.value != null
|
||||
resetDragVisual()
|
||||
if (!srcKey || !targetKey || srcKey === targetKey) return
|
||||
|
||||
@@ -463,20 +566,28 @@ function handleDrop(node, e) {
|
||||
return
|
||||
}
|
||||
|
||||
// 内部拖拽已在 dragover 阶段实时换位,drop 仅兜底提交一次(若从未实时换位过)
|
||||
if (!alreadyLiveSwapped) {
|
||||
if (type === 'col') {
|
||||
emit('col-drop', resolveColDrop(srcKey, targetKey, pos))
|
||||
} else if (type === 'group') {
|
||||
const resolved = resolveBandDrop(srcKey, targetKey, pos)
|
||||
if (resolved) emit('band-drop', resolved)
|
||||
}
|
||||
// 内部拖拽:dragover 阶段仅亮指示,drop 时统一提交一次
|
||||
if (type === 'col') {
|
||||
emit('col-drop', resolveColDrop(srcKey, targetKey, pos))
|
||||
} else if (type === 'group') {
|
||||
const resolved = resolveBandDrop(srcKey, targetKey, pos)
|
||||
if (resolved) emit('band-drop', resolved)
|
||||
}
|
||||
}
|
||||
|
||||
// 拖出树外松手(未触发 drop):清理实时换位状态
|
||||
function handleDragEnd() {
|
||||
resetDragVisual()
|
||||
/* ================= 节点常驻操作:上移 / 下移 / 删除 =================
|
||||
* 由节点行上的图标按钮触发,交给面板按领域语义提交:
|
||||
* - node-move:列在组内换位 / 分组在同级兄弟间换位
|
||||
* - node-delete:列删除 / 分组删除(其下字段归未分组)
|
||||
*/
|
||||
function onNodeMove(node, direction) {
|
||||
emit('node-move', { key: node.key, direction })
|
||||
}
|
||||
function onNodeDelete(node) {
|
||||
emit('node-delete', { key: node.key })
|
||||
}
|
||||
function onNodeAddSub(node) {
|
||||
emit('node-add-sub', { key: node.key })
|
||||
}
|
||||
|
||||
function resolveColDrop(srcKey, targetKey, pos) {
|
||||
@@ -508,10 +619,15 @@ function resolveBandDrop(srcKey, targetKey, pos) {
|
||||
const row = props.rows.find((r) => String(r.b_field_id) === targetKey.slice(2))
|
||||
const targetGid = row ? groupIdOfRow(row) : null
|
||||
if (targetGid == null) return null
|
||||
// 目标带是源分组自身或其子孙:禁止,否则会把分组放进自己的子树造成环
|
||||
if (targetGid === gid || isDescendantGroup(gid, targetGid)) return null
|
||||
return { gid, line: { kind: 'band-edge', gid: targetGid, pos } }
|
||||
}
|
||||
if (targetKey === 'g:ungrouped') return null // 分组不入未分组
|
||||
const targetGid = targetKey.slice(2)
|
||||
if (targetGid === gid) return null
|
||||
// 拖入自身子孙:禁止(calcDropPosition 已挡,这里兜底)
|
||||
if (isDescendantGroup(gid, targetGid)) return null
|
||||
if (pos === 'inside') return { gid, line: { kind: 'band-nest', gid: targetGid } }
|
||||
return { gid, line: { kind: 'band-edge', gid: targetGid, pos } }
|
||||
}
|
||||
@@ -521,7 +637,7 @@ function resetDragVisual() {
|
||||
dragKey.value = null
|
||||
dropKey.value = null
|
||||
dropPosition.value = null
|
||||
lastCommittedKey.value = null
|
||||
removeDragBadge()
|
||||
}
|
||||
|
||||
/* ================= 右键菜单 ================= */
|
||||
@@ -596,6 +712,8 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousedown', onDocMouseDown, true)
|
||||
document.removeEventListener('keydown', onDocKeydown, true)
|
||||
// 拖拽进行中卸载:清理鼠标/键盘监听与跟手卡片,避免泄漏
|
||||
if (dragKey.value) cleanupDrag()
|
||||
})
|
||||
|
||||
/* ================= 内联重命名 ================= */
|
||||
@@ -656,11 +774,17 @@ provide('moduleStructureTree', {
|
||||
toggleExpand,
|
||||
registerNode,
|
||||
unregisterNode,
|
||||
handleDragStart,
|
||||
// 内部拖拽(vuedraggable,布局冻结 + 松手提交)
|
||||
onSortStart,
|
||||
onSortEnd,
|
||||
onSortMove,
|
||||
// 外部字段库拖入(原生 DnD)
|
||||
handleDragOver,
|
||||
onNodeDragLeave,
|
||||
handleDrop,
|
||||
handleDragEnd,
|
||||
onNodeMove,
|
||||
onNodeDelete,
|
||||
onNodeAddSub,
|
||||
openContextMenu,
|
||||
commitRename,
|
||||
cancelRename,
|
||||
@@ -668,21 +792,28 @@ provide('moduleStructureTree', {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="mst-tree"
|
||||
role="tree"
|
||||
@keydown="onTreeKeydown"
|
||||
@dragleave="onRootDragLeave"
|
||||
>
|
||||
<StructureTreeNode
|
||||
v-for="n in treeData"
|
||||
:key="n.key"
|
||||
:node="n"
|
||||
:level="0"
|
||||
/>
|
||||
<div v-if="!treeData.length" class="mst-tree__empty">
|
||||
<slot name="empty">暂无分组</slot>
|
||||
</div>
|
||||
<div class="mst-tree" role="tree" @keydown="onTreeKeydown" @dragleave="onRootDragLeave">
|
||||
<draggable
|
||||
:list="treeData"
|
||||
item-key="key"
|
||||
handle=".mst-node__grip"
|
||||
:force-fallback="true"
|
||||
:fallback-on-body="true"
|
||||
fallback-class="mst-drag-fallback"
|
||||
:animation="0"
|
||||
:move="onSortMove"
|
||||
@start="onSortStart"
|
||||
@end="onSortEnd"
|
||||
>
|
||||
<template #item="{ element: n }">
|
||||
<StructureTreeNode :node="n" :level="0" />
|
||||
</template>
|
||||
<template #footer>
|
||||
<div v-if="!treeData.length" class="mst-tree__empty">
|
||||
<slot name="empty">暂无分组</slot>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
|
||||
<!-- 右键菜单(挂载到 body,fixed 定位) -->
|
||||
@@ -723,6 +854,8 @@ provide('moduleStructureTree', {
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
outline: none;
|
||||
/* 右侧留白:节点/按钮/悬停高亮不紧贴滚动区右缘 */
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.mst-tree__empty {
|
||||
@@ -796,3 +929,35 @@ provide('moduleStructureTree', {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 拖拽标题卡片(全局样式):createDragBadge 用 JS 动态创建元素并挂到 body,
|
||||
不在 Vue 模板内,scoped 样式匹配不到,必须定义为全局样式。
|
||||
外观:白底 + 圆角 + 主色描边 + 阴影的迷你卡片,只显示节点标题 -->
|
||||
<style lang="scss">
|
||||
/* SortableJS fallback 克隆(body 上的整行克隆)不在 scoped 作用域内,需全局隐藏:
|
||||
拖动时只保留自绘的跟手卡片(.mst-drag-badge),不显示整行克隆 */
|
||||
.mst-drag-fallback {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mst-drag-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 100px;
|
||||
max-width: 200px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
background: #fff; /* 纯白不透明,避免主题卡片色带透明度导致卡片透底 */
|
||||
border: 1px solid color-mix(in srgb, var(--fms-primary) 35%, transparent);
|
||||
box-shadow: 0 6px 18px rgba(39, 54, 70, 0.18);
|
||||
color: var(--fms-text-primary, #1f2d3d);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
</style>
|
||||
+147
-27
@@ -4,7 +4,8 @@
|
||||
* 只负责单行渲染与事件透传,全部状态与交互逻辑由父级 ModuleStructureTree 统一持有。
|
||||
*/
|
||||
import { computed, inject, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Check, ChevronRight, Folder, GripVertical, Inbox, Table2, X } from '@lucide/vue'
|
||||
import { ArrowDown, ArrowUp, Check, ChevronRight, Folder, FolderPlus, GripVertical, Inbox, Table2, Trash2, X } from '@lucide/vue'
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, required: true },
|
||||
@@ -22,13 +23,37 @@ const isSelected = computed(() => ctx.selectedKey.value === key.value)
|
||||
const isEditing = computed(() => isGroup.value && props.node.gid != null && ctx.editingGid.value === props.node.gid)
|
||||
const isDragging = computed(() => ctx.dragKey.value === key.value)
|
||||
const isDropTarget = computed(() => ctx.dragKey.value != null && ctx.dropKey.value === key.value)
|
||||
// 指示线仅外部字段拖入时显示:内部拖拽已实时换位(拖过即换),不需要落点线
|
||||
// 落点指示:内部拖拽(列/分组)与外部字段拖入统一显示(before/after 划线,inside 高亮),
|
||||
// 结构变更一律在 drop 时提交
|
||||
const dropClass = computed(() => {
|
||||
if (!isDropTarget.value || ctx.dragType.value !== 'field') return ''
|
||||
if (!isDropTarget.value) return ''
|
||||
const p = ctx.dropPosition.value
|
||||
return p === 'before' ? 'drop-before' : p === 'after' ? 'drop-after' : 'drop-inside'
|
||||
})
|
||||
const canDrag = computed(() => !isUngrouped.value && !ctx.loading.value && !isEditing.value)
|
||||
// 常驻操作按钮:上移/下移(列=同组内换位,分组=同级兄弟间换位)、删除(列删除 / 分组删除)。
|
||||
// 边界位(首/尾)由数据侧 canUp/canDown 标记禁用;未分组容器不显示。
|
||||
const canMoveUp = computed(
|
||||
() => !isUngrouped.value && !ctx.loading.value && !isEditing.value && props.node.canUp !== false,
|
||||
)
|
||||
const canMoveDown = computed(
|
||||
() => !isUngrouped.value && !ctx.loading.value && !isEditing.value && props.node.canDown !== false,
|
||||
)
|
||||
const canDelete = computed(() => !isUngrouped.value && !ctx.loading.value && !isEditing.value)
|
||||
// 分组:新增子分组按钮(未分组容器 / 列无此操作)
|
||||
const canAddSub = computed(() => isGroup.value && !ctx.loading.value && !isEditing.value)
|
||||
function onMove(direction) {
|
||||
if (direction < 0 ? !canMoveUp.value : !canMoveDown.value) return
|
||||
ctx.onNodeMove(props.node, direction)
|
||||
}
|
||||
function onDelete() {
|
||||
if (!canDelete.value) return
|
||||
ctx.onNodeDelete(props.node)
|
||||
}
|
||||
function onAddSub() {
|
||||
if (!canAddSub.value) return
|
||||
ctx.onNodeAddSub(props.node)
|
||||
}
|
||||
const tabindex = computed(() => (ctx.activeKey.value === key.value ? 0 : -1))
|
||||
const nodeIcon = computed(() => {
|
||||
if (isGroup.value) return isUngrouped.value ? Inbox : Folder
|
||||
@@ -53,18 +78,11 @@ function onRowClick() {
|
||||
rowRef.value?.focus()
|
||||
}
|
||||
function onSwitcherClick() {
|
||||
if (isUngrouped.value) return
|
||||
ctx.toggleExpand(key.value)
|
||||
ctx.activeKey.value = key.value
|
||||
rowRef.value?.focus()
|
||||
}
|
||||
function onDragStart(e) {
|
||||
if (!canDrag.value) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
ctx.handleDragStart(props.node, e)
|
||||
}
|
||||
// 原生 dragover/drop:仅外部字段库拖入使用(内部拖拽由 vuedraggable handle 启动)
|
||||
function onDragOver(e) {
|
||||
ctx.handleDragOver(props.node, e)
|
||||
}
|
||||
@@ -74,9 +92,6 @@ function onDragLeave(e) {
|
||||
function onDrop(e) {
|
||||
ctx.handleDrop(props.node, e)
|
||||
}
|
||||
function onDragEnd() {
|
||||
ctx.handleDragEnd()
|
||||
}
|
||||
function onContextMenu(e) {
|
||||
if (isUngrouped.value || ctx.loading.value) return
|
||||
ctx.openContextMenu(props.node, e)
|
||||
@@ -93,7 +108,7 @@ function onRenameCancel() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div :data-key="node.key">
|
||||
<div
|
||||
ref="rowRef"
|
||||
class="mst-node"
|
||||
@@ -108,25 +123,28 @@ function onRenameCancel() {
|
||||
[dropClass]: !!dropClass,
|
||||
}"
|
||||
:style="{ '--level': level }"
|
||||
:data-key="node.key"
|
||||
role="treeitem"
|
||||
:aria-selected="isSelected"
|
||||
:aria-expanded="isGroup && !isUngrouped ? isExpanded : undefined"
|
||||
:aria-disabled="isUngrouped ? true : undefined"
|
||||
:tabindex="tabindex"
|
||||
:draggable="canDrag ? 'true' : undefined"
|
||||
@click="onRowClick"
|
||||
@dragstart="onDragStart"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop"
|
||||
@dragend="onDragEnd"
|
||||
@contextmenu.stop="onContextMenu"
|
||||
>
|
||||
<span v-if="canDrag" class="mst-node__grip" aria-hidden="true"><GripVertical /></span>
|
||||
<span
|
||||
v-if="canDrag"
|
||||
class="mst-node__grip"
|
||||
title="按住拖动:换位 / 拖入分组嵌套"
|
||||
aria-label="拖动排序或移动"
|
||||
><GripVertical /></span>
|
||||
<span v-else class="mst-node__grip is-placeholder" aria-hidden="true"></span>
|
||||
|
||||
<span
|
||||
v-if="isGroup && !isUngrouped"
|
||||
v-if="isGroup"
|
||||
class="mst-node__switcher"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
@@ -184,21 +202,80 @@ function onRenameCancel() {
|
||||
<i v-if="node.kind === 'col' && node.required" class="mst-node__required">*</i>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 常驻操作:上移 / 下移 / 删除 -->
|
||||
<span v-if="!isUngrouped" class="mst-node__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="mst-node__act"
|
||||
title="上移"
|
||||
aria-label="上移"
|
||||
draggable="false"
|
||||
:disabled="!canMoveUp"
|
||||
@click.stop="onMove(-1)"
|
||||
>
|
||||
<ArrowUp :size="12" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mst-node__act"
|
||||
title="下移"
|
||||
aria-label="下移"
|
||||
draggable="false"
|
||||
:disabled="!canMoveDown"
|
||||
@click.stop="onMove(1)"
|
||||
>
|
||||
<ArrowDown :size="12" />
|
||||
</button>
|
||||
<button
|
||||
v-if="isGroup && !isUngrouped"
|
||||
type="button"
|
||||
class="mst-node__act"
|
||||
title="新增子分组"
|
||||
aria-label="新增子分组"
|
||||
draggable="false"
|
||||
:disabled="!canAddSub"
|
||||
@click.stop="onAddSub"
|
||||
>
|
||||
<FolderPlus :size="12" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mst-node__act is-danger"
|
||||
title="删除"
|
||||
aria-label="删除"
|
||||
draggable="false"
|
||||
:disabled="!canDelete"
|
||||
@click.stop="onDelete"
|
||||
>
|
||||
<Trash2 :size="12" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 展开/收起子级:未分组容器始终展开其下列 -->
|
||||
<!-- 展开/收起子级:未分组容器始终展开其下列(子级列表也由 vuedraggable 驱动拖拽) -->
|
||||
<Transition name="mst-collapse">
|
||||
<div
|
||||
v-if="isGroup && isExpanded && node.children && node.children.length"
|
||||
class="mst-node__children"
|
||||
>
|
||||
<div class="mst-node__children-inner">
|
||||
<StructureTreeNode
|
||||
v-for="c in node.children"
|
||||
:key="c.key"
|
||||
:node="c"
|
||||
:level="level + 1"
|
||||
/>
|
||||
<draggable
|
||||
:list="node.children"
|
||||
item-key="key"
|
||||
handle=".mst-node__grip"
|
||||
:force-fallback="true"
|
||||
:fallback-on-body="true"
|
||||
fallback-class="mst-drag-fallback"
|
||||
:animation="0"
|
||||
:move="ctx.onSortMove"
|
||||
@start="ctx.onSortStart"
|
||||
@end="ctx.onSortEnd"
|
||||
>
|
||||
<template #item="{ element: c }">
|
||||
<StructureTreeNode :node="c" :level="level + 1" />
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -351,6 +428,49 @@ function onRenameCancel() {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* 常驻操作按钮:上移 / 下移 / 删除 */
|
||||
.mst-node__actions {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: 4px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.mst-node__act {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--fms-primary) 10%, transparent);
|
||||
color: var(--fms-primary);
|
||||
}
|
||||
|
||||
&.is-danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, #d95757 12%, transparent);
|
||||
color: #d95757;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.32;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.mst-node__edit-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 80px;
|
||||
|
||||
Reference in new issue
Block a user