20260902173133
This commit is contained in:
1 parent
6b9cda5424
commit
db7813a2d2
16 files changed
+1350
-675
No files matched your search
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useSlots, watch } from 'vue'
|
||||
import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useSlots, watch } from 'vue'
|
||||
import { StkTable } from 'stk-table-vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { createSelectionColumn, isSelectionColumn } from './utils/createSelectionCellRenderers'
|
||||
@@ -10,7 +10,9 @@ defineOptions({ inheritAttrs: false })
|
||||
const props = defineProps({
|
||||
virtual: { type: Boolean, default: true },
|
||||
virtualX: { type: Boolean, default: true },
|
||||
colResizable: { type: Boolean, default: true },
|
||||
// 列宽拖拽默认开启;false=关闭;{ disabled: (col) => boolean }=按列禁用
|
||||
// (铺满容器的弹性列应禁用拖拽,见库文档「列宽铺满容器hack方式」)
|
||||
colResizable: { type: [Boolean, Object], default: true },
|
||||
// 边框自绘:true=外框圆角描边+行分隔线(默认);false=无外框仅行分隔线(嵌入卡片场景)。
|
||||
// stk-table 库内边框恒为 false,不用其渐变边框。
|
||||
bordered: { type: [Boolean, String], default: true },
|
||||
@@ -28,9 +30,6 @@ const props = defineProps({
|
||||
activeRowKey: { type: [String, Number], default: null },
|
||||
// 行拖拽排序:开启后显示拖拽手柄,并通过 row-order-change 回传排序后的完整数据。
|
||||
rowDraggable: { type: Boolean, default: false },
|
||||
// 行拖拽触发方式:true=仅拖拽手柄列(默认,显示手柄图标);
|
||||
// false=整行可拖拽(不显示手柄列,拖拽任意单元格即可排序;输入/复选框控件除外,避免破坏编辑)。
|
||||
rowDragHandle: { type: Boolean, default: true },
|
||||
// 存在 editor 配置的列默认可编辑;设为 false 可整体禁用编辑。
|
||||
editable: { type: Boolean, default: true },
|
||||
// 默认跟随应用深浅色模式,也允许单个表格显式覆盖。
|
||||
@@ -54,12 +53,28 @@ const effectiveVirtual = computed(
|
||||
() => props.virtual && props.dataSource.length > MIN_VIRTUAL_ROW_COUNT,
|
||||
)
|
||||
|
||||
// 横向虚拟门控:列数不超过阈值时关闭。库在 virtualX prop 为 true 时会把每列
|
||||
// 宽度钉死为 minWidth ?? width ?? 100(cellStyleMap 的 --cw + width/min-width/
|
||||
// max-width 三重锁),未设 width 的列失去「按比例铺满容器」的原生自适应。
|
||||
// 列数少、总宽不超容器的表用不上横向虚拟,关掉以恢复原生列宽分配。
|
||||
const MIN_VIRTUAL_X_COLUMN_COUNT = 20
|
||||
const effectiveVirtualX = computed(
|
||||
() => props.virtualX && displayColumns.value.length > MIN_VIRTUAL_X_COLUMN_COUNT,
|
||||
)
|
||||
|
||||
const rootRef = ref(null)
|
||||
const tableRef = ref(null)
|
||||
const slots = useSlots()
|
||||
const appStore = useAppStore()
|
||||
const tableTheme = computed(() => props.theme ?? (appStore.darkMode ? 'dark' : 'light'))
|
||||
|
||||
// 父组件是否绑定 v-model:columns。未绑定时拖列宽/列序无处回写(拖了松手回弹),
|
||||
// 直接关闭这两个功能。声明了 update:columns emit 后监听器不会进 attrs,
|
||||
// 需从 vnode.props 探测;绑定关系在模板编写时确定,运行期不会变化,无需响应式。
|
||||
const columnsBound =
|
||||
'onUpdate:columns' in (getCurrentInstance()?.vnode.props ?? {})
|
||||
const effectiveColResizable = columnsBound ? props.colResizable : false
|
||||
|
||||
// Tab 面板从隐藏恢复时,等到容器拥有有效尺寸再刷新虚拟滚动布局。
|
||||
// rAF 将同一帧内的多次 ResizeObserver 通知合并,避免重复重排。
|
||||
let refreshRafId = 0
|
||||
@@ -102,20 +117,8 @@ onMounted(() => {
|
||||
})
|
||||
resizeObserver.observe(rootRef.value)
|
||||
}
|
||||
// 整行拖拽:挂载后等表格渲染完再给数据行加 draggable
|
||||
setupWholeRowDrag()
|
||||
})
|
||||
|
||||
// 数据/开关变化后重跑整行拖拽初始化(行增删、虚拟滚动重建时兜底)
|
||||
watch(
|
||||
() => [props.rowDraggable, props.rowDragHandle, props.dataSource?.length],
|
||||
async () => {
|
||||
await nextTick()
|
||||
setupWholeRowDrag()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
const NAVIGATION_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab'])
|
||||
const rowEditingStates = new WeakMap()
|
||||
let activeEditingRow = null
|
||||
@@ -274,7 +277,7 @@ const seqConfig = computed(() => {
|
||||
}
|
||||
})
|
||||
const columnDragConfig = computed(() => {
|
||||
if (!props.columnDraggable) return false
|
||||
if (!columnsBound || !props.columnDraggable) return false
|
||||
const configured = props.columnDraggable === true ? {} : props.columnDraggable
|
||||
return {
|
||||
...configured,
|
||||
@@ -325,7 +328,7 @@ const displayColumns = computed(() => {
|
||||
resizable: false,
|
||||
})
|
||||
}
|
||||
if (props.rowDraggable && props.rowDragHandle) cols.unshift(dragRowColumn)
|
||||
if (props.rowDraggable) cols.unshift(dragRowColumn)
|
||||
if (hasRowSelection.value) cols.unshift(selectionColumn)
|
||||
return cols.map((column) => ({
|
||||
...column,
|
||||
@@ -356,106 +359,6 @@ function handleRowOrderChange(sourceIndex, targetIndex) {
|
||||
emit('row-order-change', [...rows], sourceIndex, targetIndex)
|
||||
}
|
||||
|
||||
/* ================= 整行拖拽(无需手柄列) =================
|
||||
* stk-table 原生行拖拽必须通过 dragRow 手柄列触发;当 rowDraggable && !rowDragHandle
|
||||
* 时改用「整行可拖拽」:MutationObserver 给 tbody 数据行加 draggable 属性(兜底虚拟
|
||||
* 滚动新建的行),根节点上 dragstart/dragover/drop 事件委托落点与手柄模式一致。 */
|
||||
const wholeRowDragSource = ref(-1)
|
||||
let wholeRowDragTr = null
|
||||
let wholeRowOverTr = null
|
||||
let rowDragObserver = null
|
||||
|
||||
function applyDraggableToRows() {
|
||||
const root = rootRef.value
|
||||
if (!root) return
|
||||
root.querySelectorAll('tbody tr[data-row-i]').forEach((tr) => {
|
||||
tr.setAttribute('draggable', 'true')
|
||||
})
|
||||
}
|
||||
|
||||
function setupWholeRowDrag() {
|
||||
teardownWholeRowDrag()
|
||||
if (!props.rowDraggable || props.rowDragHandle || !rootRef.value) return
|
||||
applyDraggableToRows()
|
||||
const tbody = rootRef.value.querySelector('.stk-tbody-main')
|
||||
if (tbody) {
|
||||
rowDragObserver = new MutationObserver(applyDraggableToRows)
|
||||
rowDragObserver.observe(tbody, { childList: true, subtree: true })
|
||||
}
|
||||
}
|
||||
|
||||
function teardownWholeRowDrag() {
|
||||
rowDragObserver?.disconnect()
|
||||
rowDragObserver = null
|
||||
clearWholeRowDragVisuals()
|
||||
wholeRowDragSource.value = -1
|
||||
}
|
||||
|
||||
function clearWholeRowDragVisuals() {
|
||||
wholeRowDragTr?.classList.remove('tr-dragging')
|
||||
wholeRowDragTr = null
|
||||
wholeRowOverTr?.classList.remove('tr-dragging-over')
|
||||
wholeRowOverTr = null
|
||||
}
|
||||
|
||||
function isInteractiveDragTarget(target) {
|
||||
return !!target?.closest?.(
|
||||
'input, textarea, select, button, a, [contenteditable], .fms-checkbox, .fms-cell-editor-root',
|
||||
)
|
||||
}
|
||||
|
||||
function onWholeRowDragStart(e) {
|
||||
if (!props.rowDraggable || props.rowDragHandle) return
|
||||
const tr = e.target?.closest?.('tr[data-row-i]')
|
||||
if (!tr) return
|
||||
if (isInteractiveDragTarget(e.target)) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
wholeRowDragSource.value = Number(tr.dataset.rowI)
|
||||
wholeRowDragTr = tr
|
||||
tr.classList.add('tr-dragging')
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', String(wholeRowDragSource.value))
|
||||
e.dataTransfer.setDragImage?.(tr, 0, tr.offsetHeight / 2)
|
||||
}
|
||||
}
|
||||
|
||||
function onWholeRowDragOver(e) {
|
||||
if (wholeRowDragSource.value < 0) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
||||
const tr = e.target?.closest?.('tr[data-row-i]')
|
||||
if (tr && tr !== wholeRowOverTr) {
|
||||
wholeRowOverTr?.classList.remove('tr-dragging-over')
|
||||
wholeRowOverTr = tr
|
||||
tr.classList.add('tr-dragging-over')
|
||||
}
|
||||
}
|
||||
|
||||
function onWholeRowDrop(e) {
|
||||
const source = wholeRowDragSource.value
|
||||
if (source < 0) return
|
||||
e.preventDefault()
|
||||
const tr = e.target?.closest?.('tr[data-row-i]')
|
||||
clearWholeRowDragVisuals()
|
||||
wholeRowDragSource.value = -1
|
||||
if (!tr) return
|
||||
const target = Number(tr.dataset.rowI)
|
||||
if (Number.isNaN(target) || target === source) return
|
||||
const rows = [...props.dataSource]
|
||||
const [moved] = rows.splice(source, 1)
|
||||
rows.splice(target, 0, moved)
|
||||
tableRef.value?.clearSelectedArea?.()
|
||||
emit('row-order-change', rows, source, target)
|
||||
}
|
||||
|
||||
function onWholeRowDragEnd() {
|
||||
clearWholeRowDragVisuals()
|
||||
wholeRowDragSource.value = -1
|
||||
}
|
||||
|
||||
// 行高/表头行高通过 prop 传给 StkTable:库内部会将其注入为滚动容器上的
|
||||
// --row-height / --header-row-height / --footer-row-height,并参与虚拟滚动计算,
|
||||
// 此处只补充库不管理的表头/表尾背景色。
|
||||
@@ -669,7 +572,6 @@ onBeforeUnmount(() => {
|
||||
clearTimeout(resizeDebounceTimer)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
teardownWholeRowDrag()
|
||||
removeNavigationListeners()
|
||||
clearEditActivateTimer()
|
||||
pressedNavigationKeys.clear()
|
||||
@@ -690,15 +592,8 @@ defineExpose({
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="fms-table"
|
||||
:class="[
|
||||
{ 'is-whole-row-drag': rowDraggable && !rowDragHandle },
|
||||
bordered ? 'fms-table--bordered' : '',
|
||||
]"
|
||||
:class="[bordered ? 'fms-table--bordered' : '']"
|
||||
@keydown.capture="handleKeydownCapture"
|
||||
@dragstart="onWholeRowDragStart"
|
||||
@dragover="onWholeRowDragOver"
|
||||
@drop="onWholeRowDrop"
|
||||
@dragend="onWholeRowDragEnd"
|
||||
>
|
||||
<StkTable
|
||||
ref="tableRef"
|
||||
@@ -709,15 +604,15 @@ defineExpose({
|
||||
:row-height="props.rowHeight"
|
||||
:header-row-height="props.headerRowHeight"
|
||||
:virtual="effectiveVirtual"
|
||||
:virtual-x="virtualX"
|
||||
:virtual-x="effectiveVirtualX"
|
||||
:smooth-scroll="true"
|
||||
:auto-resize="false"
|
||||
:col-resizable="colResizable"
|
||||
:col-resizable="effectiveColResizable"
|
||||
:header-drag="columnDragConfig"
|
||||
:columns="displayColumns"
|
||||
@update:columns="onColumnsUpdate"
|
||||
:data-source="dataSource"
|
||||
:drag-row-config="{ mode: rowDraggable && rowDragHandle ? 'insert' : 'none' }"
|
||||
:drag-row-config="{ mode: rowDraggable ? 'insert' : 'none' }"
|
||||
:row-key="rowKey"
|
||||
:no-data-full="noDataFull"
|
||||
:show-overflow="showOverflow"
|
||||
@@ -756,20 +651,6 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
/* 整行拖拽:数据行鼠标提示可拖拽;拖拽中的行半透明、落点高亮 */
|
||||
.fms-table.is-whole-row-drag .stk-tbody-main tr[data-row-i] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.fms-table.is-whole-row-drag .stk-tbody-main tr[data-row-i].tr-dragging {
|
||||
cursor: grabbing;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.fms-table.is-whole-row-drag .stk-tbody-main tr[data-row-i].tr-dragging-over {
|
||||
box-shadow: inset 0 2px 0 0 var(--fms-primary);
|
||||
}
|
||||
|
||||
.fms-table__inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -798,10 +679,20 @@ defineExpose({
|
||||
.fms-table__inner.stk-table {
|
||||
th,
|
||||
td {
|
||||
border-right: 1px solid var(--fms-border);
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
}
|
||||
}
|
||||
|
||||
/* 列宽铺满容器(库文档「列宽铺满容器hack方式」):
|
||||
开启 col-resizable 后库默认把 .stk-table-main 设为 flex:none + fit-content,
|
||||
表格收缩到内容宽。非 virtual-x(列数 ≤ 阈值、未设宽列走原生自适应)时覆盖回
|
||||
flex:1 使表格铺满容器;配合「固定列设 width、弹性列设 minWidth 且禁用其拖拽」
|
||||
的列配置约定使用。宽表(virtual-x,横向滚动)保持库的收缩行为。 */
|
||||
.fms-table__inner.stk-table:not(.virtual-x) .stk-table-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fms-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 图标输入:text 输入 + 预览。
|
||||
* - 输入 "lucide:chevron-down" 形式 → 渲染 lucide 图标(名称自动归一化)
|
||||
* - 输入其他字符串 → 视为 SVG 标记直接渲染
|
||||
* 预览在失焦后渲染,避免输入过程中逐字符重建 SVG。
|
||||
* 渲染统一走 getIcon(),与侧边栏/菜单树一致。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getIcon } from '@/utils/lucideIcons'
|
||||
import Input from '@/components/ui/input/input.vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** 双向绑定的图标值("lucide:Name" 或 SVG 标记字符串) */
|
||||
modelValue: { type: String, default: '' },
|
||||
/** 禁用态 */
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change', 'clear'])
|
||||
|
||||
// 预览基于已提交(失焦)的值:输入过程中不实时渲染,避免 SVG 逐字符重建闪烁
|
||||
const committed = ref(props.modelValue)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
if (v === committed.value) return
|
||||
// 外部赋值(重载/切换菜单)时立即同步预览;仅用户输入时不更新
|
||||
committed.value = v
|
||||
},
|
||||
)
|
||||
|
||||
function onBlur() {
|
||||
committed.value = props.modelValue
|
||||
}
|
||||
|
||||
const previewIcon = computed(() => getIcon(committed.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="icon-input" :class="{ 'icon-input-disabled': disabled }">
|
||||
<div class="icon-input__field">
|
||||
<Input
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
allow-clear
|
||||
@update:model-value="(v) => emit('update:modelValue', v)"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="previewIcon" class="icon-input__preview" :title="committed">
|
||||
<component :is="previewIcon" class="icon-input__preview-icon" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.icon-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.icon-input__field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.icon-input__preview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--fms-text);
|
||||
|
||||
:deep(svg) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -168,7 +168,13 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootEl" :class="rootClasses" :style="itemStyle" @focusout="onRootFocusOut">
|
||||
<div
|
||||
ref="rootEl"
|
||||
:class="rootClasses"
|
||||
:style="itemStyle"
|
||||
:data-field="String(props.name)"
|
||||
@focusout="onRootFocusOut"
|
||||
>
|
||||
<label
|
||||
v-if="label !== '' || $slots.label"
|
||||
class="fms-form-item-label"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, useSlots } from 'vue'
|
||||
import { cloneVNode, computed, onBeforeUnmount, onMounted, ref, useSlots } from 'vue'
|
||||
import SplitterPanel from './panel.vue'
|
||||
|
||||
/* ---------- props / emits / slots ---------- */
|
||||
@@ -43,16 +43,23 @@ const isVertical = computed(() => props.direction === 'vertical')
|
||||
function camelize(str) {
|
||||
return str.replace(/-(\w)/g, (_, c) => c.toUpperCase())
|
||||
}
|
||||
const panelItems = computed(() =>
|
||||
(slots.default?.() ?? [])
|
||||
.filter((node) => node.type === SplitterPanel)
|
||||
.map((node) => {
|
||||
const raw = node.props ?? {}
|
||||
const props = {}
|
||||
for (const key of Object.keys(raw)) props[camelize(key)] = raw[key]
|
||||
return { node, props }
|
||||
}),
|
||||
)
|
||||
|
||||
// 收集默认插槽中的 SplitterPanel 节点,统一为 { node, props } 结构。
|
||||
// 注意:必须是普通函数并在渲染时调用 —— slot 内容每次渲染都可能是新的(父组件更新
|
||||
// 面板内容时会生成新 vnode),不能用 computed 缓存,否则父组件更新后仍渲染旧 vnode,
|
||||
// 导致 <component :is> 对已失效的 vnode 执行 patch,报 null vnode 错误。
|
||||
function getPanelItems() {
|
||||
const items = (slots.default?.() ?? []).filter((node) => node.type === SplitterPanel)
|
||||
return items.map((node, index) => {
|
||||
const raw = node.props ?? {}
|
||||
const normalized = {}
|
||||
for (const key of Object.keys(raw)) normalized[camelize(key)] = raw[key]
|
||||
// 计算当前面板的显示尺寸:受控 size > 拖拽固定 > default-size
|
||||
const size = normalized.size ?? innerSizes.value[index] ?? normalized.defaultSize
|
||||
const vnode = cloneVNode(node, { size })
|
||||
return { node: vnode, props: normalized, isLast: index === items.length - 1 }
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 尺寸工具 ---------- */
|
||||
function clamp(value, min, max) {
|
||||
@@ -80,26 +87,18 @@ function measurePanelSizes() {
|
||||
return Array.from(nodes).map((el) => (isVertical.value ? el.offsetHeight : el.offsetWidth))
|
||||
}
|
||||
|
||||
// 传给面板的显示尺寸:受控 size > 内部固定 > default-size
|
||||
function displaySize(index) {
|
||||
const panel = panelItems.value[index]
|
||||
if (!panel) return undefined
|
||||
if (panel.props.size !== undefined) return panel.props.size
|
||||
if (innerSizes.value[index] !== undefined) return innerSizes.value[index]
|
||||
return panel.props.defaultSize
|
||||
}
|
||||
|
||||
// 分隔条两侧面板都可调整时才允许拖拽/聚焦
|
||||
function canResize(index) {
|
||||
const prev = panelItems.value[index]
|
||||
const next = panelItems.value[index + 1]
|
||||
const items = getPanelItems()
|
||||
const prev = items[index]
|
||||
const next = items[index + 1]
|
||||
return prev && next && prev.props.resizable !== false && next.props.resizable !== false
|
||||
}
|
||||
|
||||
/* ---------- ARIA 值(预计算,避免模板内计算) ---------- */
|
||||
const ariaValues = computed(() => {
|
||||
const container = containerSize.value
|
||||
const items = panelItems.value
|
||||
const items = getPanelItems()
|
||||
if (!container || items.length < 2) return []
|
||||
// 各面板估算 px:内部固定 > size/default-size 换算 > 自动均分剩余
|
||||
const pxList = items.map((panel, i) => {
|
||||
@@ -133,10 +132,11 @@ function applyOffset(index, startSizes, container, delta) {
|
||||
let offset = delta
|
||||
const prev = startSizes[index]
|
||||
const next = startSizes[index + 1]
|
||||
const prevMin = toPx(panelItems.value[index].props.min, container) ?? 0
|
||||
const prevMax = toPx(panelItems.value[index].props.max, container) ?? container
|
||||
const nextMin = toPx(panelItems.value[index + 1].props.min, container) ?? 0
|
||||
const nextMax = toPx(panelItems.value[index + 1].props.max, container) ?? container
|
||||
const items = getPanelItems()
|
||||
const prevMin = toPx(items[index].props.min, container) ?? 0
|
||||
const prevMax = toPx(items[index].props.max, container) ?? container
|
||||
const nextMin = toPx(items[index + 1].props.min, container) ?? 0
|
||||
const nextMax = toPx(items[index + 1].props.max, container) ?? container
|
||||
offset = clamp(offset, prevMin - prev, prevMax - prev)
|
||||
offset = clamp(offset, next - nextMax, next - nextMin)
|
||||
const result = [...startSizes]
|
||||
@@ -158,7 +158,7 @@ function applySizes(result, index) {
|
||||
// (如侧栏折叠、窗口缩放)时剩余空间无法被自动填满
|
||||
function releaseAutoPanels() {
|
||||
const next = [...innerSizes.value]
|
||||
panelItems.value.forEach((panel, i) => {
|
||||
getPanelItems().forEach((panel, i) => {
|
||||
if (next[i] !== undefined && panel.props.size == null && panel.props.defaultSize == null) {
|
||||
delete next[i]
|
||||
}
|
||||
@@ -268,10 +268,10 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="fms-splitter" :class="`fms-splitter-${direction}`">
|
||||
<template v-for="(item, index) in panelItems" :key="index">
|
||||
<component :is="item.node" :size="displaySize(index)" />
|
||||
<template v-for="(item, index) in getPanelItems()" :key="index">
|
||||
<component :is="item.node" />
|
||||
<div
|
||||
v-if="index < panelItems.length - 1"
|
||||
v-if="!item.isLast"
|
||||
class="fms-splitter-bar"
|
||||
:class="{
|
||||
'fms-splitter-bar-active': activeBar === index,
|
||||
|
||||
@@ -18,20 +18,28 @@ const appStore = useAppStore()
|
||||
* 使页面组件自身重新挂载、状态归零,而外层缓存槽位不变。
|
||||
* - 每个页签一个命名包装组件(include 名:普通页 = componentName,详情页 =
|
||||
* cacheKeyOf(fullPath)),使 keep-alive 的 include 能按页签精确销毁实例;
|
||||
* 不同参数(/detail/1、/detail/2)因外层 key = fullPath 不同而天然多个独立缓存。 */
|
||||
* 不同参数(/detail/1、/detail/2)因外层 key = fullPath 不同而天然多个独立缓存。
|
||||
* - 包装组件渲染必须只依赖「自身页签」的数据,不能读当前路由:
|
||||
* keep-alive 缓存的组件实例仍是响应式活的(渲染 effect / watch 不会随 deactivate 暂停),
|
||||
* 若渲染函数读当前 route,每次路由切换都会让所有已缓存包装重渲染 —— 内层 key 变化
|
||||
* 会把缓存里的页面组件销毁重挂(状态丢失,表现为「缓存不生效」)。
|
||||
* 故自身 fullPath 经 props 传入(父级只给当前激活实例传 props,缓存实例冻结在自己的
|
||||
* fullPath 上);cacheByKey / 详情 id 在创建时按当时的路由快照定死。 */
|
||||
const pageWrapperCache = {}
|
||||
function getPageWrapper(name, fullPath, innerComponent) {
|
||||
function getPageWrapper(name, fullPath, innerComponent, cacheByKey) {
|
||||
if (!pageWrapperCache[name]) {
|
||||
pageWrapperCache[name] = {
|
||||
fullPath,
|
||||
comp: defineComponent({
|
||||
name,
|
||||
setup() {
|
||||
const r = useRoute()
|
||||
props: { fullPath: String },
|
||||
setup(props) {
|
||||
const app = useAppStore()
|
||||
// 详情页 id:从自身 fullPath 末段取(/detail/123?x=1 → 123),不读当前路由 params
|
||||
const detailId = () => (props.fullPath.split('?')[0].match(/[^/]+$/) || [])[0]
|
||||
return () => {
|
||||
const inner = r.meta?.cacheByKey ? h(DetailPage, { id: r.params.id }) : innerComponent
|
||||
const innerKey = `${r.fullPath}:${app.getRouteRefreshKey(r.fullPath)}`
|
||||
const inner = cacheByKey ? h(DetailPage, { id: detailId() }) : innerComponent
|
||||
const innerKey = `${props.fullPath}:${app.getRouteRefreshKey(props.fullPath)}`
|
||||
return h(inner, { key: innerKey })
|
||||
}
|
||||
},
|
||||
@@ -139,7 +147,9 @@ provide('toggleSidebar', toggleSidebar)
|
||||
<div class="fms-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="page" mode="out-in">
|
||||
<div :key="route.fullPath" class="fms-page-wrap">
|
||||
<!-- 稳定的包裹层:key 固定,保证 keep-alive 实例长期存活(缓存不随路由切换销毁)。
|
||||
页面缓存键由内层 <component :key="route.fullPath"> 驱动 -->
|
||||
<div key="page-wrap" class="fms-page-wrap">
|
||||
<keep-alive :include="appStore.cachedViews" :max="8">
|
||||
<component
|
||||
:is="
|
||||
@@ -149,9 +159,11 @@ provide('toggleSidebar', toggleSidebar)
|
||||
: route.meta?.componentName,
|
||||
route.fullPath,
|
||||
Component,
|
||||
!!route.meta?.cacheByKey,
|
||||
)
|
||||
"
|
||||
:key="route.fullPath"
|
||||
:full-path="route.fullPath"
|
||||
/>
|
||||
</keep-alive>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import * as LucideIcons from '@lucide/vue'
|
||||
|
||||
let iconMap = null
|
||||
@@ -19,11 +20,96 @@ function loadIcons() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步获取图标组件,传入 null/空字符串时返回 null。
|
||||
* @param {string | null | undefined} name lucide 图标名(首字母大写),如 "LayoutGrid"
|
||||
* 解析图标值,返回可渲染的 Vue 组件:
|
||||
* - 以 "lucide:" 开头(如 "lucide:chevron-down")→ 去掉前缀,名称归一化后匹配 lucide 图标
|
||||
* - 其他非空字符串 → 先尝试按 lucide 图标名匹配;匹配不到时仅当看起来是有效 SVG 标记才渲染
|
||||
* - 无效内容(既非 lucide 名也非 SVG 标记)→ 返回 null,不渲染
|
||||
* 传入 null/空字符串时返回 null。
|
||||
* @param {string | null | undefined} value
|
||||
*/
|
||||
export function getIcon(name) {
|
||||
export function getIcon(value) {
|
||||
if (!value || typeof value !== 'string') return null
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
if (!iconMap) loadIcons()
|
||||
if (!name || !iconMap) return null
|
||||
return iconMap[name] || null
|
||||
|
||||
// lucide 前缀:显式指定图标名,匹配不到返回 null
|
||||
if (trimmed.startsWith('lucide:')) {
|
||||
const name = normalizeIconName(trimmed.slice('lucide:'.length))
|
||||
return iconMap[name] || null
|
||||
}
|
||||
|
||||
// 无前缀:先尝试 lucide 图标名匹配
|
||||
const normalized = normalizeIconName(trimmed)
|
||||
if (iconMap[normalized]) return iconMap[normalized]
|
||||
|
||||
// 既非 lucide 名:仅当是有效 SVG 标记才渲染,否则视为无效不渲染
|
||||
return looksLikeSvg(trimmed) ? getSvgComponent(trimmed) : null
|
||||
}
|
||||
|
||||
// 把各种命名风格归一化为 lucide 的 PascalCase 导出名:
|
||||
// "chevron-down" / "chevron_down" / "chevronDown" / "CHEVRON DOWN" → "ChevronDown"
|
||||
function normalizeIconName(name) {
|
||||
const parts = String(name || '').split(/[-_\s]+/).filter(Boolean)
|
||||
if (parts.length === 0) return ''
|
||||
// 无分隔符(camelCase / 已是大写开头):直接首字母大写
|
||||
if (parts.length === 1) {
|
||||
const single = parts[0]
|
||||
return single.charAt(0).toUpperCase() + single.slice(1)
|
||||
}
|
||||
return parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
|
||||
}
|
||||
|
||||
// 判断字符串是否为有效 SVG 标记:用 DOMParser 按 SVG 文档解析,
|
||||
// 解析失败(存在 parsererror)或根元素不是 <svg> 时视为无效。
|
||||
// 无 DOMParser 环境(SSR/测试)回退到基础启发式判断。
|
||||
function looksLikeSvg(content) {
|
||||
const trimmed = String(content || '').trim()
|
||||
if (!trimmed) return false
|
||||
if (typeof DOMParser === 'undefined') {
|
||||
return /^<svg[\s>]/i.test(trimmed)
|
||||
}
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(trimmed, 'image/svg+xml')
|
||||
if (doc.querySelector('parsererror')) return false
|
||||
const root = doc.documentElement
|
||||
return root && root.tagName.toLowerCase() === 'svg'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const svgComponentCache = new Map()
|
||||
|
||||
function getSvgComponent(svg) {
|
||||
let component = svgComponentCache.get(svg)
|
||||
if (!component) {
|
||||
component = defineComponent({
|
||||
name: 'RawSvgIcon',
|
||||
render() {
|
||||
return h('span', {
|
||||
class: 'raw-svg-icon',
|
||||
style: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 0,
|
||||
},
|
||||
innerHTML: svg,
|
||||
})
|
||||
},
|
||||
})
|
||||
svgComponentCache.set(svg, component)
|
||||
}
|
||||
return component
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 lucide 图标名称(按字母排序)。
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getIconNames() {
|
||||
if (!iconMap) loadIcons()
|
||||
return Object.keys(iconMap).toSorted()
|
||||
}
|
||||
@@ -19,9 +19,24 @@ import {
|
||||
RefreshCw,
|
||||
Languages,
|
||||
} from '@lucide/vue'
|
||||
import { Button, Checkbox, Input, Select, Switch, Splitter, SplitterPanel, Tree, Modal, Message, Form, FormItem, Dropdown } from '@/components/ui'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
Splitter,
|
||||
SplitterPanel,
|
||||
Tree,
|
||||
Modal,
|
||||
Message,
|
||||
Form,
|
||||
FormItem,
|
||||
Dropdown,
|
||||
} from '@/components/ui'
|
||||
import IconPicker from '@/components/icon-picker/IconPicker.vue'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { loadDataApi, saveObjectApi, nextIdApi } from '@/services/api'
|
||||
import { loadDataApi, loadDataBySqlApi, saveObjectApi, nextIdApi } from '@/services/api'
|
||||
import {
|
||||
buildTree,
|
||||
sortTree,
|
||||
@@ -290,6 +305,24 @@ async function onMenuDrop({ dragKey, dropKey, dropPosition }) {
|
||||
}
|
||||
|
||||
/* ================= 新增菜单 ================= */
|
||||
/** 从树节点提取纯菜单行(剔除 children/isLeaf/icon 等树注入字段),用于未保存节点切回时重建草稿 */
|
||||
function menuRowOf(node) {
|
||||
return {
|
||||
b_id: node.b_id,
|
||||
b_code: node.b_code,
|
||||
b_parent_id: node.b_parent_id ? String(node.b_parent_id) : null,
|
||||
b_name: node.b_name,
|
||||
b_i18n: node.b_i18n ?? null,
|
||||
b_menu_type: node.b_menu_type,
|
||||
b_route: node.b_route ?? null,
|
||||
b_module_id: node.b_module_id ?? null,
|
||||
b_power_code: node.b_power_code ?? null,
|
||||
b_icon: node.b_icon ?? null,
|
||||
b_xh: node.b_xh,
|
||||
b_canuse: node.b_canuse,
|
||||
}
|
||||
}
|
||||
|
||||
async function addMenu(parentId, menuTypeValue = 'page') {
|
||||
const res = await nextIdApi()
|
||||
const newId = String(res.data)
|
||||
@@ -400,16 +433,17 @@ async function loadMenuConfig(menuId) {
|
||||
}
|
||||
i18nValues.value = i18nValuesInit
|
||||
originalI18nValues.value = { ...i18nValuesInit }
|
||||
// 操作/按钮权限点:按 b_power_type='action' + b_module_id=菜单 id 加载
|
||||
// 操作/按钮权限点:仅 page/data/external 才有(目录是导航容器,无按钮权限)
|
||||
powersLoading.value = true
|
||||
try {
|
||||
const powerRes = menu
|
||||
? await loadDataApi(
|
||||
's_power',
|
||||
`b_power_type = 'action' AND b_module_id = ${id}`,
|
||||
'b_xh ASC, b_id ASC',
|
||||
)
|
||||
: { data: [] }
|
||||
const powerRes =
|
||||
menu && menu.b_menu_type !== 'directory'
|
||||
? await loadDataApi(
|
||||
's_power',
|
||||
`b_power_type = 'action' AND b_module_id = ${id}`,
|
||||
'b_xh ASC, b_id ASC',
|
||||
)
|
||||
: { data: [] }
|
||||
powers.value = (powerRes.data || []).map((row) => ({
|
||||
b_id: row.b_id,
|
||||
b_code: row.b_code,
|
||||
@@ -446,6 +480,26 @@ watch(menuSelectedId, (id) => {
|
||||
drafts.value = null
|
||||
return
|
||||
}
|
||||
// 未落库的新节点:临时 id 在库里查不到行,不能走 loadMenuConfig
|
||||
// (会得到 menu 为 null 的「真值」drafts,模板按 null 渲染直接崩溃)。
|
||||
// 从树节点重建草稿继续编辑;originals 与之一致(未编辑态)。
|
||||
if (unsavedMenuIds.has(String(id))) {
|
||||
const node = findTreeNode(menuTreeData.value, String(id))
|
||||
configLoading.value = false
|
||||
if (!node) {
|
||||
loadedMenuId.value = null
|
||||
drafts.value = null
|
||||
return
|
||||
}
|
||||
drafts.value = cloneData({ menu: menuRowOf(node) })
|
||||
originals.value = cloneData(drafts.value)
|
||||
i18nValues.value = { 'zh-CN': node.b_name || '' }
|
||||
originalI18nValues.value = { ...i18nValues.value }
|
||||
powers.value = []
|
||||
powersBaseline.value = '[]'
|
||||
loadedMenuId.value = String(id)
|
||||
return
|
||||
}
|
||||
loadedMenuId.value = null
|
||||
drafts.value = null
|
||||
void loadMenuConfig(id)
|
||||
@@ -459,7 +513,10 @@ function hasUnsavedChanges() {
|
||||
if (!drafts.value || !originals.value) return false
|
||||
if (JSON.stringify(drafts.value.menu) !== JSON.stringify(originals.value.menu)) return true
|
||||
// 多语言非默认语言变化视为未保存
|
||||
if (nonDefaultLanguages.some((l) => i18nValues.value[l.code] !== originalI18nValues.value[l.code])) return true
|
||||
if (
|
||||
nonDefaultLanguages.some((l) => i18nValues.value[l.code] !== originalI18nValues.value[l.code])
|
||||
)
|
||||
return true
|
||||
// 操作/按钮权限点变化视为未保存
|
||||
const current = JSON.stringify(powers.value)
|
||||
if (current !== powersBaseline.value) return true
|
||||
@@ -514,7 +571,8 @@ const formRef = ref(null)
|
||||
|
||||
async function saveMenu() {
|
||||
if (!selectedMenu.value || busy.value) return false
|
||||
if (loadedMenuId.value == null || String(loadedMenuId.value) !== String(menuSelectedId.value)) return false
|
||||
if (loadedMenuId.value == null || String(loadedMenuId.value) !== String(menuSelectedId.value))
|
||||
return false
|
||||
const menuId = String(selectedMenu.value.b_id)
|
||||
|
||||
// Validate form
|
||||
@@ -535,8 +593,14 @@ async function saveMenu() {
|
||||
const menuI18nKey = `menu.${menuData.b_code}`
|
||||
menuData.b_i18n = menuI18nKey
|
||||
|
||||
// 权限编码留空时按 菜单编码 自动生成(权限跟着菜单走)
|
||||
if (!menuData.b_power_code) menuData.b_power_code = `menu.${menuData.b_code}`
|
||||
// 目录是纯导航容器:可见性由子菜单决定,自身不需要访问权限编码,也不生成权限点。
|
||||
// 仅 page/data/external 需要访问权限编码;留空时按菜单编码自动生成(权限跟着菜单走)
|
||||
const menuIsDirectory = menuData.b_menu_type === 'directory'
|
||||
if (menuIsDirectory) {
|
||||
menuData.b_power_code = null
|
||||
} else if (!menuData.b_power_code) {
|
||||
menuData.b_power_code = `menu.${menuData.b_code}`
|
||||
}
|
||||
|
||||
const isNew = unsavedMenuIds.has(menuId)
|
||||
const req = { table: 's_menu', key_field: 'b_id' }
|
||||
@@ -568,15 +632,32 @@ async function saveMenu() {
|
||||
reqs.push(i18nReq)
|
||||
}
|
||||
|
||||
// 目录是纯导航容器:自身不需要访问权限编码与权限点。
|
||||
// 若该菜单此前(或改为目录前)生成过权限记录,保存时一并清理,避免孤儿数据:
|
||||
// - 访问权限(s_power.b_power_type='menu',按原权限编码关联)
|
||||
// - 操作/按钮权限(s_power.b_power_type='action',按 b_module_id=菜单 id 关联)
|
||||
if (menuIsDirectory) {
|
||||
const prevPowerCode = originals.value?.menu?.b_power_code
|
||||
if (prevPowerCode) {
|
||||
reqs.push({
|
||||
table: 's_power',
|
||||
key_field: 'b_id',
|
||||
deletes: [{ b_power_type: 'menu', b_resource: prevPowerCode }],
|
||||
})
|
||||
}
|
||||
reqs.push({
|
||||
table: 's_power',
|
||||
key_field: 'b_id',
|
||||
deletes: [{ b_power_type: 'action', b_module_id: menuId }],
|
||||
})
|
||||
}
|
||||
|
||||
// 访问权限点(s_power.b_power_type='menu',自动同步)
|
||||
const powerCode = (menuData.b_power_code || '').trim()
|
||||
if (powerCode) {
|
||||
// 查是否有已存在的访问权限(按 b_resource = 菜单权限编码)
|
||||
const accessPowerRes = menuData.b_id
|
||||
? await loadDataApi(
|
||||
's_power',
|
||||
`b_power_type = 'menu' AND b_resource = '${powerCode}'`,
|
||||
)
|
||||
? await loadDataApi('s_power', `b_power_type = 'menu' AND b_resource = '${powerCode}'`)
|
||||
: { data: [] }
|
||||
const existingAccess = accessPowerRes.data?.[0]
|
||||
if (existingAccess) {
|
||||
@@ -620,63 +701,66 @@ async function saveMenu() {
|
||||
}
|
||||
|
||||
// 操作/按钮权限点(s_power.b_power_type='action',diff 保存)
|
||||
const baselineIds = new Set()
|
||||
try {
|
||||
for (const r of JSON.parse(powersBaseline.value)) {
|
||||
if (r.b_id) baselineIds.add(String(r.b_id))
|
||||
// 目录是导航容器,不保存操作/按钮权限点
|
||||
if (!menuIsDirectory && powers.value.length > 0) {
|
||||
const baselineIds = new Set()
|
||||
try {
|
||||
for (const r of JSON.parse(powersBaseline.value)) {
|
||||
if (r.b_id) baselineIds.add(String(r.b_id))
|
||||
}
|
||||
} catch {
|
||||
/* 空基线 */
|
||||
}
|
||||
} catch { /* 空基线 */ }
|
||||
const currentIds = new Set(
|
||||
powers.value.filter((r) => r.b_id).map((r) => String(r.b_id)),
|
||||
)
|
||||
// 被删除的行
|
||||
for (const id of baselineIds) {
|
||||
if (!currentIds.has(id)) {
|
||||
reqs.push({ table: 's_power', key_field: 'b_id', deletes: [{ b_id: id }] })
|
||||
const currentIds = new Set(powers.value.filter((r) => r.b_id).map((r) => String(r.b_id)))
|
||||
// 被删除的行
|
||||
for (const id of baselineIds) {
|
||||
if (!currentIds.has(id)) {
|
||||
reqs.push({ table: 's_power', key_field: 'b_id', deletes: [{ b_id: id }] })
|
||||
}
|
||||
}
|
||||
}
|
||||
// 新增/更新的行
|
||||
for (const row of powers.value) {
|
||||
if (!row.b_code || !String(row.b_code).trim()) continue // 未填编码的行不保存
|
||||
// 基线中已存在的 id 为真实主键 → 更新;临时 id(负数,未入库)→ 新增
|
||||
if (row.b_id && baselineIds.has(String(row.b_id))) {
|
||||
reqs.push({
|
||||
table: 's_power',
|
||||
key_field: 'b_id',
|
||||
updates: [
|
||||
{
|
||||
b_id: row.b_id,
|
||||
b_power_type: 'action',
|
||||
b_code: row.b_code,
|
||||
b_name: row.b_name,
|
||||
b_resource: row.b_code,
|
||||
b_operation: 'execute',
|
||||
b_module_id: menuId,
|
||||
b_field_id: null,
|
||||
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
} else {
|
||||
// 新增行需要分配主键
|
||||
const newId = String((await nextIdApi()).data)
|
||||
reqs.push({
|
||||
table: 's_power',
|
||||
key_field: 'b_id',
|
||||
inserts: [
|
||||
{
|
||||
b_id: newId,
|
||||
b_power_type: 'action',
|
||||
b_code: row.b_code,
|
||||
b_name: row.b_name,
|
||||
b_resource: row.b_code,
|
||||
b_operation: 'execute',
|
||||
b_module_id: menuId,
|
||||
b_field_id: null,
|
||||
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
// 新增/更新的行
|
||||
for (const row of powers.value) {
|
||||
if (!row.b_code || !String(row.b_code).trim()) continue // 未填编码的行不保存
|
||||
// 基线中已存在的 id 为真实主键 → 更新;临时 id(负数,未入库)→ 新增
|
||||
if (row.b_id && baselineIds.has(String(row.b_id))) {
|
||||
reqs.push({
|
||||
table: 's_power',
|
||||
key_field: 'b_id',
|
||||
updates: [
|
||||
{
|
||||
b_id: row.b_id,
|
||||
b_power_type: 'action',
|
||||
b_code: row.b_code,
|
||||
b_name: row.b_name,
|
||||
b_resource: row.b_code,
|
||||
b_operation: 'execute',
|
||||
b_module_id: menuId,
|
||||
b_field_id: null,
|
||||
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
} else {
|
||||
// 新增行需要分配主键
|
||||
const newId = String((await nextIdApi()).data)
|
||||
reqs.push({
|
||||
table: 's_power',
|
||||
key_field: 'b_id',
|
||||
inserts: [
|
||||
{
|
||||
b_id: newId,
|
||||
b_power_type: 'action',
|
||||
b_code: row.b_code,
|
||||
b_name: row.b_name,
|
||||
b_resource: row.b_code,
|
||||
b_operation: 'execute',
|
||||
b_module_id: menuId,
|
||||
b_field_id: null,
|
||||
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,6 +819,7 @@ function onMenuTypeChange(v) {
|
||||
if (v === 'directory') {
|
||||
drafts.value.menu.b_route = null
|
||||
drafts.value.menu.b_module_id = null
|
||||
drafts.value.menu.b_power_code = null
|
||||
} else if (v === 'page') {
|
||||
drafts.value.menu.b_module_id = null
|
||||
} else if (v === 'data') {
|
||||
@@ -758,7 +843,9 @@ async function onToggleEnable(node, checked) {
|
||||
// 未保存草稿仅本地,落库时生效
|
||||
if (unsavedMenuIds.has(id)) return
|
||||
try {
|
||||
await saveObjectApi([{ table: 's_menu', key_field: 'b_id', updates: [{ b_id: id, b_canuse: newVal }] }])
|
||||
await saveObjectApi([
|
||||
{ table: 's_menu', key_field: 'b_id', updates: [{ b_id: id, b_canuse: newVal }] },
|
||||
])
|
||||
Message.success(newVal ? '菜单已启用' : '菜单已停用')
|
||||
} catch (e) {
|
||||
node.b_canuse = newVal === 1 ? 0 : 1
|
||||
@@ -776,44 +863,34 @@ const powers = ref([])
|
||||
const powersBaseline = ref('')
|
||||
const powersLoading = ref(false)
|
||||
|
||||
/** 权限点表格列配置 */
|
||||
/** 权限点表格列配置
|
||||
* 列宽铺满约定(库文档「列宽铺满容器hack方式」):固定列设 width,
|
||||
* 弹性列(权限名称)设 minWidth 占满剩余宽度,并在 colResizable 中禁用其拖拽。 */
|
||||
const powerColumns = shallowRef([
|
||||
{
|
||||
dataIndex: 'b_code',
|
||||
title: '权限编码',
|
||||
width: 170,
|
||||
editor: { type: 'input', placeholder: '如 add' },
|
||||
width: 150,
|
||||
editor: { type: 'input' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_name',
|
||||
title: '权限名称',
|
||||
width: 170,
|
||||
editor: { type: 'input', placeholder: '如 新增' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_power_type',
|
||||
title: '类型',
|
||||
width: 130,
|
||||
editor: {
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '操作权限', value: 'action' },
|
||||
{ label: '按钮权限', value: 'button' },
|
||||
],
|
||||
},
|
||||
minWidth: 150,
|
||||
editor: { type: 'input' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_canuse',
|
||||
title: '启用',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
width: 70,
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '操作',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
width: 70,
|
||||
actions: [
|
||||
{
|
||||
type: 'delete',
|
||||
@@ -885,6 +962,81 @@ function batchDeletePowers() {
|
||||
Message.success(`已删除 ${keys.length} 条权限`)
|
||||
}
|
||||
|
||||
/* ================= 权限来源库(全库聚合,快速导入) ================= */
|
||||
/** 全库去重的操作权限(s_power.b_power_type='action',按编码聚合) */
|
||||
const powerLibrary = ref([])
|
||||
const powerLibraryLoading = ref(false)
|
||||
/** 来源库勾选的权限编码 */
|
||||
const selectedLibCodes = ref([])
|
||||
/** 来源库表格引用与行选择 */
|
||||
const libTableRef = ref(null)
|
||||
|
||||
const powerLibraryColumns = shallowRef([
|
||||
// 列宽铺满约定同 powerColumns:编码固定宽,名称弹性占满
|
||||
{ dataIndex: 'b_code', title: '权限编码', width: 100 },
|
||||
{ dataIndex: 'b_name', title: '权限名称', minWidth: 80 },
|
||||
])
|
||||
|
||||
const libRowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedLibCodes.value,
|
||||
onChange: (keys) => {
|
||||
selectedLibCodes.value = keys
|
||||
},
|
||||
}))
|
||||
|
||||
/** 聚合全库出现的操作权限(编码/名称,去重) */
|
||||
async function loadPowerLibrary() {
|
||||
powerLibraryLoading.value = true
|
||||
try {
|
||||
// 参考旧系统 module-powervalue:把全库权限按编码聚合为"来源库"
|
||||
const res = await loadDataBySqlApi(
|
||||
`SELECT b_code, MAX(b_name) AS b_name FROM s_power
|
||||
WHERE b_power_type = 'action' AND b_code IS NOT NULL AND b_code <> ''
|
||||
GROUP BY b_code ORDER BY b_code`,
|
||||
)
|
||||
powerLibrary.value = (res.data || []).map((row) => ({
|
||||
b_code: row.b_code,
|
||||
b_name: row.b_name,
|
||||
}))
|
||||
selectedLibCodes.value = []
|
||||
} catch (e) {
|
||||
Message.error('加载权限库失败:' + (e?.message || e))
|
||||
powerLibrary.value = []
|
||||
} finally {
|
||||
powerLibraryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 把来源库勾选的权限导入当前菜单(跳过已存在编码) */
|
||||
function importSelectedPowers() {
|
||||
const codes = selectedLibCodes.value
|
||||
if (!codes.length) {
|
||||
Message.warning('请先在权限库中勾选要导入的权限')
|
||||
return
|
||||
}
|
||||
const existing = new Set(powers.value.map((r) => String(r.b_code || '').trim()))
|
||||
const added = []
|
||||
for (const code of codes) {
|
||||
if (existing.has(String(code).trim())) continue
|
||||
const lib = powerLibrary.value.find((r) => String(r.b_code) === String(code))
|
||||
added.push({
|
||||
b_id: createTempId(),
|
||||
b_code: lib?.b_code || code,
|
||||
b_name: lib?.b_name || '',
|
||||
b_power_type: 'action',
|
||||
b_canuse: 1,
|
||||
})
|
||||
existing.add(String(code).trim())
|
||||
}
|
||||
if (!added.length) {
|
||||
Message.warning('勾选的权限均已存在,无需导入')
|
||||
return
|
||||
}
|
||||
powers.value = [...powers.value, ...added]
|
||||
selectedLibCodes.value = []
|
||||
Message.success(`已导入 ${added.length} 条权限`)
|
||||
}
|
||||
|
||||
/* ================= 加载模块列表(供 data 类型下拉选择) ================= */
|
||||
const moduleOptions = ref([])
|
||||
let moduleOptionsLoaded = false
|
||||
@@ -892,7 +1044,11 @@ let moduleOptionsLoaded = false
|
||||
async function loadModuleOptions() {
|
||||
if (moduleOptionsLoaded) return
|
||||
try {
|
||||
const res = await loadDataApi('s_module', "b_canuse = 1 AND b_module_type IN ('data', 'query')", 'b_xh ASC, b_id ASC')
|
||||
const res = await loadDataApi(
|
||||
's_module',
|
||||
"b_canuse = 1 AND b_module_type IN ('data', 'query')",
|
||||
'b_xh ASC, b_id ASC',
|
||||
)
|
||||
moduleOptions.value = (res.data || []).map((m) => ({
|
||||
label: `[${m.b_code}] ${m.b_name}`,
|
||||
value: String(m.b_id),
|
||||
@@ -937,6 +1093,7 @@ async function onConfirmOk() {
|
||||
/* ================= 初始化 ================= */
|
||||
loadMenuTree()
|
||||
loadModuleOptions()
|
||||
loadPowerLibrary()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -946,7 +1103,7 @@ loadModuleOptions()
|
||||
<SplitterPanel :default-size="248" :min="180" :max="420">
|
||||
<aside class="mm-tree-panel">
|
||||
<div class="mm-tree-header">
|
||||
<Input v-model="menuKeyword" placeholder="搜索菜单" allow-clear class="mm-tree-search">
|
||||
<Input v-model="menuKeyword" allow-clear class="mm-tree-search">
|
||||
<template #prefix><Search :size="14" class="mm-tree-search__icon" /></template>
|
||||
</Input>
|
||||
<div class="mm-tree-header__actions">
|
||||
@@ -960,7 +1117,12 @@ loadModuleOptions()
|
||||
<RefreshCw :size="14" />
|
||||
</Button>
|
||||
<Dropdown :items="toolbarAddOptions" @click="onToolbarAdd">
|
||||
<Button type="ghost" class="mm-tree-header__icon-btn" title="新增菜单" aria-label="新增菜单">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-tree-header__icon-btn"
|
||||
title="新增菜单"
|
||||
aria-label="新增菜单"
|
||||
>
|
||||
<Plus :size="14" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
@@ -1027,30 +1189,35 @@ loadModuleOptions()
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 编辑面板 -->
|
||||
<div class="mm-body__inner">
|
||||
<div v-if="drafts" class="mm-config-content">
|
||||
<div class="mm-config-pane">
|
||||
<Form
|
||||
<!-- 主体:三块 Splitter 分割 -->
|
||||
<div v-if="drafts" class="mm-edit-body">
|
||||
<Splitter class="mm-edit-splitter">
|
||||
<SplitterPanel :default-size="360">
|
||||
<div class="mm-form-col__card">
|
||||
<div class="mm-form-col__head">
|
||||
<div class="mm-pane__title">菜单配置</div>
|
||||
</div>
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="drafts.menu"
|
||||
:rules="{
|
||||
b_code: [{ required: true, message: '请填写菜单编码' }],
|
||||
b_name: [{ required: true, message: '请填写菜单名称' }],
|
||||
b_menu_type: [{ required: true, message: '请选择菜单类型' }],
|
||||
b_route: isPage || isExternal ? [{ required: true, message: '请填写路由地址' }] : [],
|
||||
b_route:
|
||||
isPage || isExternal
|
||||
? [{ required: true, message: '请填写路由地址' }]
|
||||
: [],
|
||||
b_module_id: isData ? [{ required: true, message: '请选择绑定模块' }] : [],
|
||||
}"
|
||||
layout="vertical"
|
||||
:columns="3"
|
||||
max-width="720px"
|
||||
:gap="20"
|
||||
>
|
||||
<FormItem label="菜单名称" name="b_name" required>
|
||||
<div class="mm-name-field">
|
||||
<Input
|
||||
class="mm-name-field__input"
|
||||
:model-value="drafts.menu.b_name"
|
||||
placeholder="请输入菜单名称(简体中文)"
|
||||
@update:model-value="(v) => setField('b_name', v)"
|
||||
/>
|
||||
<Button
|
||||
@@ -1066,7 +1233,6 @@ loadModuleOptions()
|
||||
<FormItem label="菜单编码" name="b_code" required>
|
||||
<Input
|
||||
:model-value="drafts.menu.b_code"
|
||||
placeholder="请输入菜单编码,全局唯一"
|
||||
@update:model-value="(v) => setField('b_code', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
@@ -1074,17 +1240,15 @@ loadModuleOptions()
|
||||
<Select
|
||||
:model-value="drafts.menu.b_menu_type"
|
||||
:options="menuTypeOptions"
|
||||
placeholder="请选择菜单类型"
|
||||
@update:model-value="onMenuTypeChange"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="上级菜单">
|
||||
<FormItem label="上级菜单" name="b_parent_id">
|
||||
<Input :model-value="parentMenuName || '根节点(顶级菜单)'" readonly />
|
||||
</FormItem>
|
||||
<FormItem label="图标" name="b_icon">
|
||||
<Input
|
||||
<IconPicker
|
||||
:model-value="drafts.menu.b_icon"
|
||||
placeholder="lucide 图标名,如 Settings"
|
||||
@update:model-value="(v) => setField('b_icon', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
@@ -1099,7 +1263,6 @@ loadModuleOptions()
|
||||
<FormItem v-if="isPage" label="路由地址" name="b_route" required>
|
||||
<Input
|
||||
:model-value="drafts.menu.b_route"
|
||||
placeholder="如 /system/module-management"
|
||||
@update:model-value="(v) => setField('b_route', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
@@ -1108,7 +1271,6 @@ loadModuleOptions()
|
||||
<Select
|
||||
:model-value="drafts.menu.b_module_id"
|
||||
:options="moduleOptions"
|
||||
placeholder="请选择绑定的数据/查询模块"
|
||||
@update:model-value="(v) => setField('b_module_id', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
@@ -1116,7 +1278,6 @@ loadModuleOptions()
|
||||
<FormItem v-if="isExternal" label="外链地址" name="b_route" required>
|
||||
<Input
|
||||
:model-value="drafts.menu.b_route"
|
||||
placeholder="如 https://example.com"
|
||||
@update:model-value="(v) => setField('b_route', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
@@ -1126,12 +1287,11 @@ loadModuleOptions()
|
||||
@update:model-value="(v) => setField('b_canuse', v ? 1 : 0)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="访问权限编码" name="b_power_code">
|
||||
<FormItem v-if="!isDirectory" label="访问权限编码" name="b_power_code">
|
||||
<div class="mm-power-code-field">
|
||||
<Input
|
||||
class="mm-power-code-field__input"
|
||||
:model-value="drafts.menu.b_power_code"
|
||||
placeholder="留空保存时自动生成 menu.编码"
|
||||
@update:model-value="(v) => setField('b_power_code', v)"
|
||||
/>
|
||||
<Button type="outline" size="small" @click="autoPowerCode">
|
||||
@@ -1140,51 +1300,99 @@ loadModuleOptions()
|
||||
</div>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- 权限点:访问(自动)+ 操作/按钮 -->
|
||||
<div class="mm-config-pane">
|
||||
<div class="mm-pane__title">权限点</div>
|
||||
<!-- 访问权限:随菜单自动同步(只读展示) -->
|
||||
<div class="mm-access-bar">
|
||||
<span class="mm-access-bar__tag">访问</span>
|
||||
<span class="mm-access-bar__code">{{ accessPowerDesc || '(待生成)' }}</span>
|
||||
<span class="mm-access-bar__hint">自动同步,随菜单保存</span>
|
||||
<!-- 权限面板:仅 page/data/external(目录无权限) -->
|
||||
<SplitterPanel v-if="!isDirectory">
|
||||
<div class="mm-power-card">
|
||||
<div class="mm-power-card__head">
|
||||
<div class="mm-pane__title">当前菜单权限</div>
|
||||
<div class="mm-power-toolbar">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="powersLoading"
|
||||
@click="addPowerRow"
|
||||
>
|
||||
<Plus :size="12" /> 新增
|
||||
</Button>
|
||||
<Button
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="powersLoading || !selectedPowerKeys.length"
|
||||
@click="batchDeletePowers"
|
||||
>
|
||||
<Trash2 :size="12" /> 删除{{
|
||||
selectedPowerKeys.length ? `(${selectedPowerKeys.length})` : ''
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="powersLoading" class="mm-power-loading">加载中...</div>
|
||||
<div v-else class="mm-power-table-wrap">
|
||||
<FmsTable
|
||||
ref="powerTableRef"
|
||||
v-model:columns="powerColumns"
|
||||
:data-source="powers"
|
||||
:editable="true"
|
||||
row-key="b_id"
|
||||
:row-selection="rowSelection"
|
||||
:col-resizable="{ disabled: (col) => col.dataIndex === 'b_name' }"
|
||||
@change="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mm-power-toolbar">
|
||||
<Button type="primary" size="small" :disabled="powersLoading" @click="addPowerRow">
|
||||
<Plus :size="12" /> 新增权限
|
||||
</Button>
|
||||
<Button
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="powersLoading || !selectedPowerKeys.length"
|
||||
@click="batchDeletePowers"
|
||||
>
|
||||
<Trash2 :size="12" /> 批量删除{{ selectedPowerKeys.length ? `(${selectedPowerKeys.length})` : '' }}
|
||||
</Button>
|
||||
<span class="mm-power-toolbar__hint">操作/按钮权限用于页面的按钮级控制。</span>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- 权限来源库:全库聚合,快速导入 -->
|
||||
<SplitterPanel v-if="!isDirectory" :default-size="260">
|
||||
<div class="mm-power-card">
|
||||
<div class="mm-power-card__head">
|
||||
<div class="mm-pane__title">权限库</div>
|
||||
<div class="mm-power-toolbar">
|
||||
<Button
|
||||
type="outline"
|
||||
size="small"
|
||||
:loading="powerLibraryLoading"
|
||||
@click="loadPowerLibrary"
|
||||
>
|
||||
<RefreshCw :size="12" /> 刷新
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!selectedLibCodes.length"
|
||||
@click="importSelectedPowers"
|
||||
>
|
||||
<Plus :size="12" /> 导入选中{{
|
||||
selectedLibCodes.length ? `(${selectedLibCodes.length})` : ''
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="powerLibraryLoading" class="mm-power-loading">加载中...</div>
|
||||
<div v-else class="mm-lib-table-wrap">
|
||||
<FmsTable
|
||||
ref="libTableRef"
|
||||
v-model:columns="powerLibraryColumns"
|
||||
:data-source="powerLibrary"
|
||||
:seq="false"
|
||||
row-key="b_code"
|
||||
:row-selection="libRowSelection"
|
||||
:col-resizable="{ disabled: (col) => col.dataIndex === 'b_name' }"
|
||||
@change="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="powersLoading" class="mm-power-loading">加载中...</div>
|
||||
<div v-else class="mm-power-table-wrap">
|
||||
<FmsTable
|
||||
ref="powerTableRef"
|
||||
v-model:columns="powerColumns"
|
||||
:data-source="powers"
|
||||
:editable="true"
|
||||
row-key="b_id"
|
||||
:row-selection="rowSelection"
|
||||
@change="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="configLoading" class="mm-config-state">
|
||||
<span class="mm-config-state__spinner"></span>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div v-else class="mm-config-state">暂无配置数据</div>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
<div v-else-if="configLoading" class="mm-config-state">
|
||||
<span class="mm-config-state__spinner"></span>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div v-else class="mm-config-state">暂无配置数据</div>
|
||||
</template>
|
||||
<div v-else class="mm-main__empty">
|
||||
<FolderTree :size="32" />
|
||||
@@ -1205,7 +1413,9 @@ loadModuleOptions()
|
||||
>
|
||||
<p style="margin: 0; line-height: 1.7; color: var(--fms-text)">{{ confirmState?.content }}</p>
|
||||
<template #footer>
|
||||
<Button type="outline" :disabled="confirmSaving" @click="confirmVisible = false">取消</Button>
|
||||
<Button type="outline" :disabled="confirmSaving" @click="confirmVisible = false"
|
||||
>取消</Button
|
||||
>
|
||||
<Button
|
||||
:type="confirmState?.okType || 'primary'"
|
||||
:loading="confirmSaving"
|
||||
@@ -1397,19 +1607,6 @@ loadModuleOptions()
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 各配置区块(多语言 / 权限)间距 */
|
||||
.mm-config-pane + .mm-config-pane {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* 区块标题 */
|
||||
.mm-pane__title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
/* 菜单名称 + 多语言图标按钮 */
|
||||
.mm-name-field {
|
||||
display: flex;
|
||||
@@ -1432,7 +1629,6 @@ loadModuleOptions()
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.mm-power-code-field__input {
|
||||
@@ -1440,69 +1636,6 @@ loadModuleOptions()
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 权限点列表 */
|
||||
.mm-power-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mm-power-toolbar__hint {
|
||||
font-size: 12px;
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.mm-power-loading {
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 访问权限条(自动同步展示) */
|
||||
.mm-access-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 10px;
|
||||
border: 1px dashed var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
background: var(--fms-secondary);
|
||||
font-size: 12px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.mm-access-bar__tag {
|
||||
flex-shrink: 0;
|
||||
padding: 0 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--fms-primary, #1677ff);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mm-access-bar__code {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
.mm-access-bar__hint {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
/* 权限点 FmsTable 容器(固定高度,FmsTable 需要显式高度渲染) */
|
||||
.mm-power-table-wrap {
|
||||
max-width: 720px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
/* ---- 右侧主区 ---- */
|
||||
.mm-main {
|
||||
display: flex;
|
||||
@@ -1513,10 +1646,7 @@ loadModuleOptions()
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
padding: 8px 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mm-main__empty {
|
||||
@@ -1528,6 +1658,9 @@ loadModuleOptions()
|
||||
flex: 1;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 14px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
|
||||
svg {
|
||||
color: var(--fms-text-secondary);
|
||||
@@ -1540,24 +1673,123 @@ loadModuleOptions()
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 8px 0;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 10px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
}
|
||||
|
||||
/* ---- 内容区 ---- */
|
||||
.mm-body__inner {
|
||||
/* ---- 主体三块 Splitter ---- */
|
||||
.mm-edit-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mm-config-content {
|
||||
.mm-edit-splitter :deep(> .fms-splitter-panel) {
|
||||
overflow: hidden;
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
/* 第一个面板(菜单配置)去左边距;最后一个面板(权限库)去右边距 */
|
||||
.mm-edit-splitter :deep(> .fms-splitter-panel:first-child) {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.mm-edit-splitter :deep(> .fms-splitter-panel:last-child) {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* 左栏:表单卡片 */
|
||||
.mm-form-col__card {
|
||||
height: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 左卡片头部 */
|
||||
.mm-form-col__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 0;
|
||||
|
||||
.mm-pane__title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 右栏:权限卡片 */
|
||||
.mm-power-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mm-power-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.mm-pane__title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 权限点 FmsTable 容器(撑满卡片剩余高度) */
|
||||
.mm-power-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mm-config-pane {
|
||||
max-width: 720px;
|
||||
/* 来源库表格容器(撑满卡片剩余高度) */
|
||||
.mm-lib-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 权限点列表工具条 */
|
||||
.mm-power-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mm-power-loading {
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 区块标题 */
|
||||
.mm-pane__title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
/* 各配置区块间距 */
|
||||
.mm-config-pane + .mm-config-pane {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.mm-config-state {
|
||||
@@ -1566,7 +1798,7 @@ loadModuleOptions()
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -1585,4 +1817,4 @@ loadModuleOptions()
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
-1
@@ -467,7 +467,6 @@ const treeFeatures = computed(() => ({
|
||||
:loading="loading"
|
||||
:data-columns="dataColumns"
|
||||
:active-key="activeKey"
|
||||
:row-drag-handle="false"
|
||||
@back="switchView('design')"
|
||||
@row-click="onDataRowClick"
|
||||
@change="() => emit('update', localRows.value)"
|
||||
|
||||
-3
@@ -12,8 +12,6 @@ defineProps({
|
||||
dataColumns: { type: Array, required: true },
|
||||
/** 高亮行 id(行点击由壳组件更新) */
|
||||
activeKey: { type: [String, Number], default: null },
|
||||
/** 是否显示行拖拽把手(表格编辑器关闭,避免与列宽拖拽视觉混淆) */
|
||||
rowDragHandle: { type: Boolean, default: true },
|
||||
/** 是否显示「返回设计视图」按钮(数据视图常驻的宿主传 false) */
|
||||
showBack: { type: Boolean, default: true },
|
||||
})
|
||||
@@ -32,7 +30,6 @@ const emit = defineEmits(['back', 'row-click', 'change', 'row-order-change'])
|
||||
:data-source="rows"
|
||||
:editable="!loading"
|
||||
:row-draggable="!loading"
|
||||
:row-drag-handle="rowDragHandle"
|
||||
row-key="b_id"
|
||||
:active-row-key="activeKey"
|
||||
@row-click="(_, row) => emit('row-click', row)"
|
||||
|
||||
@@ -38,12 +38,17 @@ import {
|
||||
Message,
|
||||
} from '@/components/ui'
|
||||
import { loadDataApi, saveObjectApi, nextIdApi, describeApi } from '@/services/api'
|
||||
import { buildTree, sortTree, findTreeNode, collectAllKeys, expandAncestors, filterTreeByText, insertTreeNode, removeTreeNode } from '@/utils/tree'
|
||||
import {
|
||||
buildModuleI18nKey,
|
||||
buildModuleItemI18nKey,
|
||||
syncModuleItemI18nKeys,
|
||||
} from '@/utils/i18n'
|
||||
buildTree,
|
||||
sortTree,
|
||||
findTreeNode,
|
||||
collectAllKeys,
|
||||
expandAncestors,
|
||||
filterTreeByText,
|
||||
insertTreeNode,
|
||||
removeTreeNode,
|
||||
} from '@/utils/tree'
|
||||
import { buildModuleI18nKey, buildModuleItemI18nKey, syncModuleItemI18nKeys } from '@/utils/i18n'
|
||||
import { createTempId } from '@/utils/tempId'
|
||||
import {
|
||||
cloneData,
|
||||
@@ -176,7 +181,11 @@ function validateAutoCode() {
|
||||
|
||||
async function saveModule() {
|
||||
if (!selectedModule.value || busy.value) return false
|
||||
if (loadedModuleId.value == null || String(loadedModuleId.value) !== String(moduleSelectedId.value)) return false
|
||||
if (
|
||||
loadedModuleId.value == null ||
|
||||
String(loadedModuleId.value) !== String(moduleSelectedId.value)
|
||||
)
|
||||
return false
|
||||
const moduleId = String(selectedModule.value.b_id)
|
||||
configSaving.value = true
|
||||
try {
|
||||
@@ -207,7 +216,10 @@ async function saveModule() {
|
||||
moduleData.b_i18n = buildModuleI18nKey(moduleData.b_module_type, moduleCode)
|
||||
|
||||
const isNew = unsavedModuleIds.has(moduleId)
|
||||
if (moduleData && (isNew || JSON.stringify(moduleData) !== JSON.stringify(originals.value?.module))) {
|
||||
if (
|
||||
moduleData &&
|
||||
(isNew || JSON.stringify(moduleData) !== JSON.stringify(originals.value?.module))
|
||||
) {
|
||||
const moduleRequest = { table: 's_module', key_field: 'b_id' }
|
||||
if (isNew) moduleRequest.inserts = [moduleData]
|
||||
else moduleRequest.updates = [moduleData]
|
||||
@@ -348,7 +360,9 @@ watch(moduleKeyword, (kw) => {
|
||||
moduleExpandedKeys.value = kw.trim() ? collectAllKeys(moduleTreeData.value) : []
|
||||
})
|
||||
|
||||
const filteredModuleTree = computed(() => filterTreeByText(moduleTreeData.value, moduleKeyword.value))
|
||||
const filteredModuleTree = computed(() =>
|
||||
filterTreeByText(moduleTreeData.value, moduleKeyword.value),
|
||||
)
|
||||
|
||||
// 是否存在「已保存」的子模块(未保存草稿子树不算,可随父一并移除)
|
||||
function hasSavedModuleDescendant(node) {
|
||||
@@ -404,6 +418,27 @@ function onModuleContextMenuClick({ item, node }) {
|
||||
else if (item.key === 'delete') void deleteModule(parentId)
|
||||
}
|
||||
|
||||
/** 从树节点提取纯模块行(剔除 children/isLeaf/icon 等树注入字段),用于未保存模块切回时重建草稿 */
|
||||
function moduleRowOf(node) {
|
||||
return {
|
||||
b_id: node.b_id,
|
||||
b_code: node.b_code,
|
||||
b_parent_id: node.b_parent_id ? String(node.b_parent_id) : null,
|
||||
b_name: node.b_name,
|
||||
b_i18n: node.b_i18n ?? null,
|
||||
b_module_type: node.b_module_type,
|
||||
b_viewtable: node.b_viewtable ?? null,
|
||||
b_savetable: node.b_savetable ?? null,
|
||||
b_keyfield: node.b_keyfield ?? null,
|
||||
b_orderfield: node.b_orderfield ?? null,
|
||||
b_delete_policy: node.b_delete_policy ?? null,
|
||||
b_edit_mode: node.b_edit_mode ?? null,
|
||||
b_canuse: node.b_canuse,
|
||||
b_xh: node.b_xh,
|
||||
b_bz: node.b_bz ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async function addModule(parentId, moduleType) {
|
||||
const res = await nextIdApi()
|
||||
const newId = String(res.data)
|
||||
@@ -627,7 +662,8 @@ async function loadModuleConfig(moduleId) {
|
||||
loadDataApi('s_autocode', `b_module_id = ${id}`, 'b_id ASC'),
|
||||
loadDataApi('s_i18n', `b_module_id = ${id}`, 'b_key ASC, b_locale ASC'),
|
||||
])
|
||||
;[moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, autoCodeRes, i18nRes] = responses
|
||||
;[moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, autoCodeRes, i18nRes] =
|
||||
responses
|
||||
} else if (type === 'query') {
|
||||
const responses = await Promise.all([
|
||||
loadDataApi('s_module', `b_id = ${id}`),
|
||||
@@ -657,7 +693,10 @@ async function loadModuleConfig(moduleId) {
|
||||
// 只取原始扁平行传给 ModuleI18nPanel,由面板自持 pivot/reconcile/保存 diff
|
||||
rawI18nRows.value = i18nRes
|
||||
? (i18nRes.data || []).map((row) => ({
|
||||
b_id: row.b_id, b_key: row.b_key, b_locale: row.b_locale, b_value: row.b_value,
|
||||
b_id: row.b_id,
|
||||
b_key: row.b_key,
|
||||
b_locale: row.b_locale,
|
||||
b_value: row.b_value,
|
||||
}))
|
||||
: []
|
||||
// 列表配置不再自动补全:行存在即展示,删除即真删,由用户通过字段库手动管理(与查询配置一致)。
|
||||
@@ -687,6 +726,30 @@ watch(moduleSelectedId, (id) => {
|
||||
rawI18nRows.value = []
|
||||
return
|
||||
}
|
||||
// 未落库的新模块:临时 id 查库无行,不走 loadModuleConfig(会得到 module 为 null 的草稿)。
|
||||
// 从树节点重建草稿(与 addModule 的初始空草稿同构),子配置(字段/分组等)从空开始。
|
||||
if (unsavedModuleIds.has(String(id))) {
|
||||
const node = findTreeNode(moduleTreeData.value, String(id))
|
||||
configLoading.value = false
|
||||
if (!node) {
|
||||
loadedModuleId.value = null
|
||||
drafts.value = null
|
||||
return
|
||||
}
|
||||
drafts.value = cloneData({
|
||||
module: moduleRowOf(node),
|
||||
fields: [],
|
||||
groups: [],
|
||||
listConfig: [],
|
||||
editConfig: [],
|
||||
queryConfig: [],
|
||||
autoCode: [],
|
||||
})
|
||||
rawI18nRows.value = []
|
||||
originals.value = cloneData(drafts.value)
|
||||
loadedModuleId.value = String(id)
|
||||
return
|
||||
}
|
||||
// 清空旧草稿,让右侧进入加载态(否则旧数据残留,v-if="drafts" 仍为真,看不到 loading)
|
||||
loadedModuleId.value = null
|
||||
drafts.value = null
|
||||
@@ -702,16 +765,10 @@ const switchSaving = ref(false)
|
||||
// 是否有未保存的修改(仅切换时按需检查,不做实时监听)
|
||||
function hasUnsavedChanges() {
|
||||
if (!drafts.value || !originals.value) return false
|
||||
const keys = [
|
||||
'module',
|
||||
'fields',
|
||||
'groups',
|
||||
'listConfig',
|
||||
'editConfig',
|
||||
'queryConfig',
|
||||
'autoCode',
|
||||
]
|
||||
return keys.some((key) => JSON.stringify(drafts.value[key]) !== JSON.stringify(originals.value[key]))
|
||||
const keys = ['module', 'fields', 'groups', 'listConfig', 'editConfig', 'queryConfig', 'autoCode']
|
||||
return keys.some(
|
||||
(key) => JSON.stringify(drafts.value[key]) !== JSON.stringify(originals.value[key]),
|
||||
)
|
||||
}
|
||||
|
||||
function onSwitchCancel() {
|
||||
@@ -827,7 +884,8 @@ function buildSyncPlan(module, columns) {
|
||||
for (const existing of drafts.value.fields || []) {
|
||||
if (!viewColumnSet.has(String(existing.b_field).toLowerCase())) {
|
||||
removedIds.add(String(existing.b_id))
|
||||
const key = existing.b_i18n || buildModuleItemI18nKey('field', module.b_code, existing.b_field)
|
||||
const key =
|
||||
existing.b_i18n || buildModuleItemI18nKey('field', module.b_code, existing.b_field)
|
||||
if (key) removedI18nIds.add(`field:${key}`)
|
||||
}
|
||||
}
|
||||
@@ -927,209 +985,224 @@ loadModuleTree()
|
||||
<SplitterPanel :default-size="248" :min="180" :max="420">
|
||||
<aside class="mm-tree-panel">
|
||||
<div class="mm-tree-header">
|
||||
<Input v-model="moduleKeyword" placeholder="搜索模块" allow-clear class="mm-tree-search">
|
||||
<Input
|
||||
v-model="moduleKeyword"
|
||||
placeholder="搜索模块"
|
||||
allow-clear
|
||||
class="mm-tree-search"
|
||||
>
|
||||
<template #prefix><Search :size="14" class="mm-tree-search__icon" /></template>
|
||||
</Input>
|
||||
<div class="mm-tree-header__actions">
|
||||
<Dropdown :items="moduleNewItems" @click="onModuleNewClick">
|
||||
<Button type="ghost" class="mm-tree-header__icon-btn" title="新建模块" aria-label="新建模块">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-tree-header__icon-btn"
|
||||
title="新建模块"
|
||||
aria-label="新建模块"
|
||||
>
|
||||
<Plus :size="14" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mm-tree-panel__scroll">
|
||||
<div v-if="moduleTreeLoading" class="mm-tree-empty">加载中...</div>
|
||||
<Tree
|
||||
v-else
|
||||
:tree-data="filteredModuleTree"
|
||||
:field-names="{ key: 'b_id', title: 'b_name', children: 'children' }"
|
||||
:expanded-keys="moduleExpandedKeys"
|
||||
@update:expanded-keys="(keys) => (moduleExpandedKeys = keys)"
|
||||
:selected-keys="moduleSelectedId ? [moduleSelectedId] : []"
|
||||
:context-menu="buildModuleContextMenu"
|
||||
:draggable="!moduleKeyword.trim()"
|
||||
@select="onModuleSelect"
|
||||
@context-menu-click="onModuleContextMenuClick"
|
||||
@drop="onModuleDrop"
|
||||
>
|
||||
<template #title="{ node }">
|
||||
<span class="mm-tree-node">
|
||||
<span
|
||||
class="mm-tree-node__name"
|
||||
:class="{ 'is-disabled': Number(node.b_canuse) !== 1 }"
|
||||
>
|
||||
{{ node.b_name || node.b_code }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</Tree>
|
||||
</div>
|
||||
<div class="mm-tree-panel__scroll">
|
||||
<div v-if="moduleTreeLoading" class="mm-tree-empty">加载中...</div>
|
||||
<Tree
|
||||
v-else
|
||||
:tree-data="filteredModuleTree"
|
||||
:field-names="{ key: 'b_id', title: 'b_name', children: 'children' }"
|
||||
:expanded-keys="moduleExpandedKeys"
|
||||
@update:expanded-keys="(keys) => (moduleExpandedKeys = keys)"
|
||||
:selected-keys="moduleSelectedId ? [moduleSelectedId] : []"
|
||||
:context-menu="buildModuleContextMenu"
|
||||
:draggable="!moduleKeyword.trim()"
|
||||
@select="onModuleSelect"
|
||||
@context-menu-click="onModuleContextMenuClick"
|
||||
@drop="onModuleDrop"
|
||||
>
|
||||
<template #title="{ node }">
|
||||
<span class="mm-tree-node">
|
||||
<span
|
||||
class="mm-tree-node__name"
|
||||
:class="{ 'is-disabled': Number(node.b_canuse) !== 1 }"
|
||||
>
|
||||
{{ node.b_name || node.b_code }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</Tree>
|
||||
</div>
|
||||
</aside>
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel>
|
||||
<main class="mm-main">
|
||||
<template v-if="selectedModule">
|
||||
<!-- 顶部通栏操作:保存 / 同步字段(全 tab 生效,不随内容区切换) -->
|
||||
<div class="mm-main-toolbar">
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="configSaving"
|
||||
:disabled="configLoading || !drafts"
|
||||
@click="saveModule"
|
||||
>
|
||||
<Save :size="14" /> 保存
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isDataModule || isQueryModule"
|
||||
type="outline"
|
||||
:disabled="busy"
|
||||
title="按数据库视图结构对齐字段定义"
|
||||
@click="syncFields"
|
||||
>
|
||||
同步字段
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
v-if="drafts"
|
||||
v-model="activeSection"
|
||||
nav-only
|
||||
class="mm-config-nav"
|
||||
:items="configNavItems"
|
||||
aria-label="模块配置导航"
|
||||
>
|
||||
<template #label="{ item }">
|
||||
<component :is="item.icon" :size="14" class="mm-config-nav__icon" />
|
||||
<span class="mm-config-nav__label">{{ item.label }}</span>
|
||||
<span v-if="item.badge" class="mm-config-nav__badge">{{ item.badge }}</span>
|
||||
</template>
|
||||
</Tabs>
|
||||
|
||||
<div class="mm-body__inner">
|
||||
<div v-if="drafts" class="mm-config-content">
|
||||
<div v-show="activeSection === 'basic'" class="mm-config-pane">
|
||||
<ModuleBasicPanel
|
||||
ref="basicPanelRef"
|
||||
:data="drafts?.module"
|
||||
:parent-name="parentModuleName"
|
||||
:loading="busy"
|
||||
@update="(v) => onModuleUpdate('module', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<main class="mm-main">
|
||||
<template v-if="selectedModule">
|
||||
<!-- 顶部通栏操作:保存 / 同步字段(全 tab 生效,不随内容区切换) -->
|
||||
<div class="mm-main-toolbar">
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="configSaving"
|
||||
:disabled="configLoading || !drafts"
|
||||
@click="saveModule"
|
||||
>
|
||||
<Save :size="14" /> 保存
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isDataModule || isQueryModule"
|
||||
v-show="activeSection === 'fields'"
|
||||
class="mm-config-pane"
|
||||
type="outline"
|
||||
:disabled="busy"
|
||||
title="按数据库视图结构对齐字段定义"
|
||||
@click="syncFields"
|
||||
>
|
||||
<ModuleFieldsPanel
|
||||
:module-id="String(selectedModule.b_id)"
|
||||
:module-code="drafts?.module?.b_code || ''"
|
||||
:fields="drafts?.fields"
|
||||
:edit-config="drafts?.editConfig"
|
||||
:list-config="drafts?.listConfig"
|
||||
:query-config="drafts?.queryConfig"
|
||||
:module-type="moduleType"
|
||||
:loading="busy"
|
||||
@update="onFieldsUpdate"
|
||||
@update-edit-config="(v) => onModuleUpdate('editConfig', v)"
|
||||
@update-list-config="(v) => onModuleUpdate('listConfig', v)"
|
||||
@update-query-config="(v) => onModuleUpdate('queryConfig', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isDataModule || isQueryModule"
|
||||
v-show="activeSection === 'list'"
|
||||
class="mm-config-pane"
|
||||
>
|
||||
<ModuleListConfigPanel
|
||||
:module-id="String(selectedModule.b_id)"
|
||||
:list-config="drafts?.listConfig"
|
||||
:fields="drafts?.fields"
|
||||
:groups="drafts?.groups"
|
||||
:loading="busy"
|
||||
@update="(v) => onModuleUpdate('listConfig', v)"
|
||||
@update-groups="onGroupsUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isDataModule" v-show="activeSection === 'edit'" class="mm-config-pane">
|
||||
<component
|
||||
:is="activeEditPanel"
|
||||
:module-id="String(selectedModule.b_id)"
|
||||
:edit-config="drafts?.editConfig"
|
||||
:list-config="drafts?.listConfig"
|
||||
:fields="drafts?.fields"
|
||||
:groups="drafts?.groups"
|
||||
:edit-mode="editMode"
|
||||
:active="activeSection === 'edit'"
|
||||
:loading="busy"
|
||||
@update="(v) => onModuleUpdate('editConfig', v)"
|
||||
@update:edit-mode="
|
||||
(v) => onModuleUpdate('module', { ...(drafts?.module || {}), b_edit_mode: v })
|
||||
"
|
||||
@update-groups="onGroupsUpdate"
|
||||
@update-list-config="(v) => onModuleUpdate('listConfig', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isDataModule || isQueryModule"
|
||||
v-show="activeSection === 'query'"
|
||||
class="mm-config-pane"
|
||||
>
|
||||
<ModuleQueryConfigPanel
|
||||
:module-id="String(selectedModule.b_id)"
|
||||
:query-config="drafts?.queryConfig"
|
||||
:fields="drafts?.fields"
|
||||
:loading="busy"
|
||||
@update="(v) => onModuleUpdate('queryConfig', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isDataModule" v-show="activeSection === 'auto-code'" class="mm-config-pane">
|
||||
<ModuleAutoCodePanel
|
||||
ref="autoCodePanelRef"
|
||||
:module-id="String(selectedModule.b_id)"
|
||||
:auto-code="drafts?.autoCode"
|
||||
:fields="drafts?.fields"
|
||||
:loading="busy"
|
||||
@update="onAutoCodeUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="moduleType && moduleType !== 'category'"
|
||||
v-show="activeSection === 'i18n'"
|
||||
class="mm-config-pane"
|
||||
>
|
||||
<ModuleI18nPanel
|
||||
ref="i18nPanelRef"
|
||||
:module-id="String(selectedModule.b_id)"
|
||||
:module="drafts?.module"
|
||||
:fields="drafts?.fields"
|
||||
:groups="drafts?.groups"
|
||||
:raw-rows="rawI18nRows"
|
||||
:loading="busy"
|
||||
/>
|
||||
</div>
|
||||
同步字段
|
||||
</Button>
|
||||
</div>
|
||||
<!-- 加载中/无数据占位:避免配置区一片空白 -->
|
||||
<div v-else-if="configLoading" class="mm-config-state">
|
||||
<span class="mm-config-state__spinner"></span>
|
||||
<span>加载中...</span>
|
||||
|
||||
<div class="mm-body__inner">
|
||||
<Tabs
|
||||
v-if="drafts"
|
||||
v-model="activeSection"
|
||||
nav-only
|
||||
class="mm-config-nav"
|
||||
:items="configNavItems"
|
||||
aria-label="模块配置导航"
|
||||
>
|
||||
<template #label="{ item }">
|
||||
<component :is="item.icon" :size="14" class="mm-config-nav__icon" />
|
||||
<span class="mm-config-nav__label">{{ item.label }}</span>
|
||||
<span v-if="item.badge" class="mm-config-nav__badge">{{ item.badge }}</span>
|
||||
</template>
|
||||
</Tabs>
|
||||
|
||||
<div v-if="drafts" class="mm-config-content">
|
||||
<div v-show="activeSection === 'basic'" class="mm-config-pane">
|
||||
<ModuleBasicPanel
|
||||
ref="basicPanelRef"
|
||||
:data="drafts?.module"
|
||||
:parent-name="parentModuleName"
|
||||
:loading="busy"
|
||||
@update="(v) => onModuleUpdate('module', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isDataModule || isQueryModule"
|
||||
v-show="activeSection === 'fields'"
|
||||
class="mm-config-pane"
|
||||
>
|
||||
<ModuleFieldsPanel
|
||||
:module-id="String(selectedModule.b_id)"
|
||||
:module-code="drafts?.module?.b_code || ''"
|
||||
:fields="drafts?.fields"
|
||||
:edit-config="drafts?.editConfig"
|
||||
:list-config="drafts?.listConfig"
|
||||
:query-config="drafts?.queryConfig"
|
||||
:module-type="moduleType"
|
||||
:loading="busy"
|
||||
@update="onFieldsUpdate"
|
||||
@update-edit-config="(v) => onModuleUpdate('editConfig', v)"
|
||||
@update-list-config="(v) => onModuleUpdate('listConfig', v)"
|
||||
@update-query-config="(v) => onModuleUpdate('queryConfig', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isDataModule || isQueryModule"
|
||||
v-show="activeSection === 'list'"
|
||||
class="mm-config-pane"
|
||||
>
|
||||
<ModuleListConfigPanel
|
||||
:module-id="String(selectedModule.b_id)"
|
||||
:list-config="drafts?.listConfig"
|
||||
:fields="drafts?.fields"
|
||||
:groups="drafts?.groups"
|
||||
:loading="busy"
|
||||
@update="(v) => onModuleUpdate('listConfig', v)"
|
||||
@update-groups="onGroupsUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isDataModule" v-show="activeSection === 'edit'" class="mm-config-pane">
|
||||
<component
|
||||
:is="activeEditPanel"
|
||||
:module-id="String(selectedModule.b_id)"
|
||||
:edit-config="drafts?.editConfig"
|
||||
:list-config="drafts?.listConfig"
|
||||
:fields="drafts?.fields"
|
||||
:groups="drafts?.groups"
|
||||
:edit-mode="editMode"
|
||||
:active="activeSection === 'edit'"
|
||||
:loading="busy"
|
||||
@update="(v) => onModuleUpdate('editConfig', v)"
|
||||
@update:edit-mode="
|
||||
(v) =>
|
||||
onModuleUpdate('module', { ...(drafts?.module || {}), b_edit_mode: v })
|
||||
"
|
||||
@update-groups="onGroupsUpdate"
|
||||
@update-list-config="(v) => onModuleUpdate('listConfig', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isDataModule || isQueryModule"
|
||||
v-show="activeSection === 'query'"
|
||||
class="mm-config-pane"
|
||||
>
|
||||
<ModuleQueryConfigPanel
|
||||
:module-id="String(selectedModule.b_id)"
|
||||
:query-config="drafts?.queryConfig"
|
||||
:fields="drafts?.fields"
|
||||
:loading="busy"
|
||||
@update="(v) => onModuleUpdate('queryConfig', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isDataModule"
|
||||
v-show="activeSection === 'auto-code'"
|
||||
class="mm-config-pane"
|
||||
>
|
||||
<ModuleAutoCodePanel
|
||||
ref="autoCodePanelRef"
|
||||
:module-id="String(selectedModule.b_id)"
|
||||
:auto-code="drafts?.autoCode"
|
||||
:fields="drafts?.fields"
|
||||
:loading="busy"
|
||||
@update="onAutoCodeUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="moduleType && moduleType !== 'category'"
|
||||
v-show="activeSection === 'i18n'"
|
||||
class="mm-config-pane"
|
||||
>
|
||||
<ModuleI18nPanel
|
||||
ref="i18nPanelRef"
|
||||
:module-id="String(selectedModule.b_id)"
|
||||
:module="drafts?.module"
|
||||
:fields="drafts?.fields"
|
||||
:groups="drafts?.groups"
|
||||
:raw-rows="rawI18nRows"
|
||||
:loading="busy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 加载中/无数据占位:避免配置区一片空白 -->
|
||||
<div v-else-if="configLoading" class="mm-config-state">
|
||||
<span class="mm-config-state__spinner"></span>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div v-else class="mm-config-state">暂无配置数据</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="mm-main__empty">
|
||||
<FolderTree :size="32" />
|
||||
<span>请选择左侧模块节点</span>
|
||||
</div>
|
||||
<div v-else class="mm-config-state">暂无配置数据</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="mm-main__empty">
|
||||
<FolderTree :size="32" />
|
||||
<span>请选择左侧模块节点</span>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
@@ -1144,7 +1217,9 @@ loadModuleTree()
|
||||
>
|
||||
<p style="margin: 0; line-height: 1.7; color: var(--fms-text)">{{ confirmState?.content }}</p>
|
||||
<template #footer>
|
||||
<Button type="outline" :disabled="confirmSaving" @click="confirmVisible = false">取消</Button>
|
||||
<Button type="outline" :disabled="confirmSaving" @click="confirmVisible = false"
|
||||
>取消</Button
|
||||
>
|
||||
<Button
|
||||
:type="confirmState?.okType || 'primary'"
|
||||
:loading="confirmSaving"
|
||||
@@ -1320,10 +1395,7 @@ loadModuleTree()
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
/* 灰底画布上的白卡:与左侧树卡用灰缝分隔 */
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mm-main__empty {
|
||||
@@ -1335,6 +1407,13 @@ loadModuleTree()
|
||||
flex: 1;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
|
||||
svg {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* 顶部通栏操作条:保存 / 同步字段(位于 tabs 之上,全 tab 生效) */
|
||||
@@ -1344,6 +1423,8 @@ loadModuleTree()
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
padding: 8px 10px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
}
|
||||
|
||||
/* ---- 右侧:配置工作区 ---- */
|
||||
@@ -1352,16 +1433,21 @@ loadModuleTree()
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- 配置导航:横向 Tab 导航(line 类型) ---- */
|
||||
/* ---- 配置导航:横向 Tab 导航(line 类型,使用默认 tabs 底边线 ---- */
|
||||
.mm-config-nav.tabs {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* 页签高度(42px):仅作用于本页 tab 项,不改变组件内部样式,也不影响按钮 */
|
||||
/* 页签高度:仅作用于本页 tab 项,不改变组件内部样式,也不影响按钮 */
|
||||
.mm-config-nav.tabs :deep(.tabs-tab) {
|
||||
height: 42px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
/* 标签内布局:图标 + 文字 + badge */
|
||||
|
||||
Reference in new issue
Block a user