20260902173133

This commit is contained in:
oneao committed 2026-09-02 17:31:35 +08:00
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,
+19 -7
View File
@@ -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>
+91 -5
View File
@@ -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>
@@ -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)"
@@ -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 */