chore: 重构前基线快照(工作区既有改动 + 重构计划文档)

This commit is contained in:
oneao committed 2026-08-28 14:14:03 +08:00
1 parent 35ca496b5f
commit 61ac01075b
9 files changed
+1474 -238

No files matched your search

@@ -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>
@@ -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);
@@ -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>
@@ -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;