20260915170028
This commit is contained in:
1 parent
d26afb5fec
commit
1f56cc3efb
89 files changed
+1135
-397
No files matched your search
@@ -650,7 +650,7 @@ defineExpose({
|
||||
:show-overflow="showOverflow"
|
||||
:empty-cell-text="''"
|
||||
:area-selection="editable"
|
||||
:bordered="false"
|
||||
:bordered="bordered ? 'v' : false"
|
||||
@area-selection-change="handleAreaSelectionChange"
|
||||
@row-order-change="handleRowOrderChange"
|
||||
>
|
||||
@@ -710,9 +710,8 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
/* 自绘行分隔线(库 bordered=false 时不画任何边框):
|
||||
单元格只画 border-bottom,外框(含四角圆弧描边)由外层 .fms-table--bordered 统一承担,
|
||||
避免库渐变线叠加与最后一行/外框的双线问题 */
|
||||
/* 外框由 .fms-table--bordered 负责;StkTable 的 v 模式负责竖向分割线,
|
||||
本组件只补充库 v 模式关闭的横向行分隔线。 */
|
||||
.fms-table__inner.stk-table {
|
||||
th,
|
||||
td {
|
||||
@@ -720,6 +719,20 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
/* bordered="v" 仍会给 StkTable 根节点绘制方形外框背景,且最后一列会绘制右侧渐变线;
|
||||
这些边缘由外层圆角容器提供,去掉库的外边缘后只保留单元格之间的竖线。 */
|
||||
.fms-table__inner.stk-table.bordered-v {
|
||||
border-left: none;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.fms-table__inner.stk-table.bordered-v {
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fms-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -182,19 +182,11 @@ function onContextMenu(e) {
|
||||
@dragend="onDragEnd"
|
||||
@contextmenu.stop="onContextMenu"
|
||||
>
|
||||
<!-- 拖拽手柄:整行唯一可发起拖拽的区域(按下时才把本行置为 draggable);不可拖拽的节点不渲染 -->
|
||||
<span
|
||||
v-if="ctx.props.draggable && !dragDisabled"
|
||||
class="tree-node-grip"
|
||||
title="拖拽调整顺序"
|
||||
@mousedown="armDrag"
|
||||
>
|
||||
<GripVertical :aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
<!-- 展开/收起箭头:非叶子渲染真实箭头,叶子渲染等宽透明占位。
|
||||
占位不是多占地方,而是对齐机制:没有它,叶子标题会左移一个箭头宽,
|
||||
比同层非叶子靠左、并与父级标题齐平(缩进不足),层级关系丢失。 -->
|
||||
比同层非叶子靠左、并与父级标题齐平(缩进不足),层级关系丢失。
|
||||
位置在拖拽手柄之前:占位是「缩进带」的一部分(叶子行它是空白),
|
||||
放到手柄之后会让手柄与标题之间空出一个箭头宽,读起来像多余空隙。 -->
|
||||
<span
|
||||
v-if="!leaf"
|
||||
class="tree-node-switcher"
|
||||
@@ -213,6 +205,16 @@ function onContextMenu(e) {
|
||||
</span>
|
||||
<span v-else class="tree-node-switcher is-placeholder" :aria-hidden="true"></span>
|
||||
|
||||
<!-- 拖拽手柄:整行唯一可发起拖拽的区域(按下时才把本行置为 draggable);不可拖拽的节点不渲染 -->
|
||||
<span
|
||||
v-if="ctx.props.draggable && !dragDisabled"
|
||||
class="tree-node-grip"
|
||||
title="拖拽调整顺序"
|
||||
@mousedown="armDrag"
|
||||
>
|
||||
<GripVertical :aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
<span v-if="ctx.props.checkable" class="tree-node-checkbox">
|
||||
<Checkbox
|
||||
:model-value="isChecked"
|
||||
|
||||
@@ -187,4 +187,122 @@ export function removeTreeNode(nodes, id, idKey = 'b_id', childrenKey = 'childre
|
||||
}
|
||||
if (nodes[i][childrenKey]) removeTreeNode(nodes[i][childrenKey], id, idKey, childrenKey)
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 可编辑树的位置派生与就地变更
|
||||
*
|
||||
* 以下函数面向「FMS 树结构规范」的固定字段(b_id / b_parent_id / b_xh /
|
||||
* b_depth / b_path),模块树、菜单树、分类树共用同一套约定,因此不再按目录各写一份。
|
||||
* 约定:b_parent_id 是权威关系,b_depth 与 b_path 是由业务层维护的派生字段,
|
||||
* b_path 形如 /base/base_otherdata/(节点编码前后均带 /)。
|
||||
* ============================================================================ */
|
||||
|
||||
/**
|
||||
* 从一维行构建已排序的树。
|
||||
* 必须在浅拷贝上构建:buildTree 会就地往行上挂 children,直接构建会累积出重复
|
||||
* 子节点、并污染调用方的唯一数据源。
|
||||
* @param {Array} list 原始行数组(不被修改)
|
||||
* @param {string} idKey 节点 ID 字段名,默认 "b_id"
|
||||
* @param {string} parentKey 父节点关联字段名,默认 "b_parent_id"
|
||||
* @returns {Array} 排序后的树
|
||||
*/
|
||||
export function buildSortedTree(list, idKey = 'b_id', parentKey = 'b_parent_id') {
|
||||
const copied = (list || []).map((row) => ({ ...row }))
|
||||
return sortTree(buildTree(copied, idKey, parentKey))
|
||||
}
|
||||
|
||||
/**
|
||||
* 预载节点图标并指定非叶子类型(即使暂无子节点也显示展开箭头,便于新增子节点)。
|
||||
* 就地修改并返回原数组。
|
||||
* @param {Array} nodes 树节点数组
|
||||
* @param {Object} options
|
||||
* @param {string} options.typeField 类型字段名,如 "b_module_type"
|
||||
* @param {Array<string>} options.nonLeafValues 取这些类型值时强制非叶子
|
||||
* @param {Object} options.iconMap 类型值 → 图标组件
|
||||
* @returns {Array} 原数组
|
||||
*/
|
||||
export function decorateTreeNodes(nodes, { typeField, nonLeafValues = [], iconMap = {} }) {
|
||||
for (const node of nodes) {
|
||||
if (nonLeafValues.includes(node[typeField])) node.isLeaf = false
|
||||
const icon = iconMap[node[typeField]]
|
||||
if (icon) node.icon = icon
|
||||
if (node.children) decorateTreeNodes(node.children, { typeField, nonLeafValues, iconMap })
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* 在树中查找节点所在的父数组及索引(就地移动节点时配合 splice 使用)。
|
||||
* @param {Array} nodes 树节点数组
|
||||
* @param {string|number} id 目标节点 ID
|
||||
* @returns {{ list: Array, index: number }|null}
|
||||
*/
|
||||
export function findNodePosition(nodes, id) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
|
||||
if (nodes[i].children) {
|
||||
const found = findNodePosition(nodes[i].children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 向 updatedRows 合并一行变更(同一 b_id 累加字段,避免同节点产生多条 update 请求)。
|
||||
* @param {Array} updatedRows 变更行收集器(就地修改)
|
||||
* @param {Object} row 含 b_id 的行
|
||||
*/
|
||||
export function addRowUpdate(updatedRows, row) {
|
||||
const existing = updatedRows.find((item) => String(item.b_id) === String(row.b_id))
|
||||
if (existing) Object.assign(existing, row)
|
||||
else updatedRows.push({ ...row })
|
||||
}
|
||||
|
||||
/**
|
||||
* 按父节点派生行的 b_depth / b_path(b_parent_id 是权威关系,二者为派生字段)。
|
||||
* b_path 非空且无默认值,新增时必须随行写入。
|
||||
* @param {Object} row 目标行(就地修改,b_id 必须是最终编码)
|
||||
* @param {Object|null} parentNode 树中的父节点;null 表示根节点
|
||||
*/
|
||||
export function applyTreePosition(row, parentNode) {
|
||||
row.b_depth = parentNode ? Number(parentNode.b_depth ?? 0) + 1 : 0
|
||||
row.b_path = parentNode ? `${String(parentNode.b_path || '')}${row.b_id}/` : `/${row.b_id}/`
|
||||
}
|
||||
|
||||
/**
|
||||
* 重排某组兄弟的 b_xh(10 起步、步长 10),把变化的行收集到 updatedRows。
|
||||
* @param {Array} list 兄弟节点数组(就地修改 b_xh)
|
||||
* @param {Array} updatedRows 变化行收集器
|
||||
*/
|
||||
export function renumberSiblings(list, updatedRows) {
|
||||
list.forEach((sibling, index) => {
|
||||
const nextXh = (index + 1) * 10
|
||||
if (Number(sibling.b_xh) !== nextXh) {
|
||||
sibling.b_xh = nextXh
|
||||
addRowUpdate(updatedRows, { b_id: sibling.b_id, b_xh: nextXh })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归重建整树的 b_depth / b_path(b_parent_id 为权威关系,二者为派生字段)。
|
||||
* 仅在值实际变化时收集,避免打开即脏;父子关系变化后调用即可整树收敛。
|
||||
* @param {Array} nodes 树节点数组(就地修改)
|
||||
* @param {Array} updatedRows 变化行收集器
|
||||
* @param {string} [parentPath] 父节点路径,根层传空串
|
||||
* @param {number} [depth] 当前层级
|
||||
*/
|
||||
export function rebuildTreePositions(nodes, updatedRows, parentPath = '', depth = 0) {
|
||||
for (const node of nodes) {
|
||||
// 根层没有 parentPath,必须显式补前导 /:b_path 的约定是节点编码前后均带 /
|
||||
const path = parentPath ? `${parentPath}${node.b_id}/` : `/${node.b_id}/`
|
||||
if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) {
|
||||
node.b_depth = depth
|
||||
node.b_path = path
|
||||
addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path })
|
||||
}
|
||||
if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,17 @@ import { escapeSqlText } from '@/components/fms-module-common/queryUtils'
|
||||
import FmsTree from '@/components/fms-tree/FmsTree.vue'
|
||||
import { loadDataApi, pageDataApi, saveObjectApi } from '@/services/api'
|
||||
import { usePermissionStore } from '@/stores/permissions'
|
||||
import { buildTree, collectAllKeys, filterTreeByText, findTreeNode, sortTree } from '@/utils/tree'
|
||||
import {
|
||||
addRowUpdate,
|
||||
applyTreePosition,
|
||||
buildSortedTree,
|
||||
collectAllKeys,
|
||||
filterTreeByText,
|
||||
findNodePosition,
|
||||
findTreeNode,
|
||||
rebuildTreePositions,
|
||||
renumberSiblings,
|
||||
} from '@/utils/tree'
|
||||
|
||||
const MODULE_CODE = 'v_b_othercompany' // 右侧列表模块
|
||||
const CATEGORY_TABLE = 'b_othercompany_category' // 左侧分类树表
|
||||
@@ -36,25 +46,6 @@ const ALL_CATEGORY_KEY = '__fms_tree_root__'
|
||||
|
||||
/* ===================== 分类树纯函数 ===================== */
|
||||
|
||||
/**
|
||||
* 必须在浅拷贝上构建:buildTree 会就地往行上挂 children,
|
||||
* 直接构建会累积出重复子节点、并污染唯一数据源 categories。
|
||||
*/
|
||||
function buildCategoryTree(rows) {
|
||||
const tree = buildTree((rows || []).map((row) => ({ ...row })))
|
||||
sortTree(tree)
|
||||
return tree
|
||||
}
|
||||
|
||||
/**
|
||||
* 派生 b_depth / b_path(b_parent_id 是权威关系,二者为派生字段)。
|
||||
* b_path 非空且无默认值,新增时必须随行写入。
|
||||
*/
|
||||
function applyCategoryPosition(row, parentNode) {
|
||||
row.b_depth = parentNode ? Number(parentNode.b_depth ?? 0) + 1 : 0
|
||||
row.b_path = parentNode ? `${String(parentNode.b_path || '')}${row.b_id}/` : `/${row.b_id}/`
|
||||
}
|
||||
|
||||
/** 同级下一个排序号(最大 b_xh + 10,无同级时取 10) */
|
||||
function nextSiblingXh(rows, parentId) {
|
||||
const normalizedParentId = parentId == null ? null : String(parentId)
|
||||
@@ -73,49 +64,6 @@ function hasChildCategory(rows, id) {
|
||||
return (rows || []).some((row) => row.b_parent_id != null && String(row.b_parent_id) === targetId)
|
||||
}
|
||||
|
||||
/** 查找节点所在的父数组与索引 */
|
||||
function findNodePosition(nodes, id) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
|
||||
if (nodes[i].children) {
|
||||
const found = findNodePosition(nodes[i].children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 同一 b_id 的字段合并进一行,避免同节点产生多条 update */
|
||||
function addRowUpdate(updatedRows, row) {
|
||||
const existing = updatedRows.find((item) => String(item.b_id) === String(row.b_id))
|
||||
if (existing) Object.assign(existing, row)
|
||||
else updatedRows.push({ ...row })
|
||||
}
|
||||
|
||||
/** 重排兄弟 b_xh(10 起步、步长 10),只收集实际变化的行 */
|
||||
function renumberSiblings(list, updatedRows) {
|
||||
list.forEach((sibling, index) => {
|
||||
const nextXh = (index + 1) * 10
|
||||
if (Number(sibling.b_xh) !== nextXh) {
|
||||
sibling.b_xh = nextXh
|
||||
addRowUpdate(updatedRows, { b_id: sibling.b_id, b_xh: nextXh })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 整树重建 b_depth / b_path(b_parent_id 为权威关系),只收集实际变化的行 */
|
||||
function rebuildTreePositions(nodes, updatedRows, parentPath = '', depth = 0) {
|
||||
for (const node of nodes) {
|
||||
const path = parentPath ? `${parentPath}${node.b_id}/` : `/${node.b_id}/`
|
||||
if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) {
|
||||
node.b_depth = depth
|
||||
node.b_path = path
|
||||
addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path })
|
||||
}
|
||||
if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 权限 ================= */
|
||||
const permissionStore = usePermissionStore()
|
||||
const canCreateRow = computed(() => permissionStore.canPower(MODULE_CODE, 'create'))
|
||||
@@ -135,7 +83,7 @@ const categoryById = computed(
|
||||
)
|
||||
|
||||
/** 树是 categories 的派生视图(在浅拷贝上构建) */
|
||||
const categoryTree = computed(() => buildCategoryTree(categories.value))
|
||||
const categoryTree = computed(() => buildSortedTree(categories.value))
|
||||
|
||||
/** 分类没有 b_code,匹配「名称 + 编码」两列 */
|
||||
const filteredCategoryTree = computed(() =>
|
||||
@@ -424,7 +372,7 @@ async function onCategorySubmit(payload) {
|
||||
const parentNode = categoryParentId.value
|
||||
? findTreeNode(categoryTree.value, categoryParentId.value)
|
||||
: null
|
||||
applyCategoryPosition(row, parentNode)
|
||||
applyTreePosition(row, parentNode)
|
||||
await saveObjectApi([{ table: CATEGORY_TABLE, key_field: 'b_id', inserts: [row] }])
|
||||
selectedCategoryKey.value = String(row.b_id)
|
||||
}
|
||||
@@ -500,7 +448,7 @@ async function onCategoryDrop({ dragKey, dropKey, dropPosition }) {
|
||||
if (dragId === dropId) return
|
||||
|
||||
// 树只是 categories 的派生视图,所以一贯「在副本上算差异 → 落库 → 重载」
|
||||
const tree = buildCategoryTree(categories.value)
|
||||
const tree = buildSortedTree(categories.value)
|
||||
const dragPos = findNodePosition(tree, dragId)
|
||||
const dropPos = findNodePosition(tree, dropId)
|
||||
if (!dragPos || !dropPos) return
|
||||
|
||||
@@ -28,7 +28,7 @@ import { FmsModuleListPage } from '@/components/fms-module-list'
|
||||
import FmsTree from '@/components/fms-tree/FmsTree.vue'
|
||||
import { loadDataApi } from '@/services/api'
|
||||
import { usePermissionStore } from '@/stores/permissions'
|
||||
import { buildTree, collectAllKeys, filterTreeByText, sortTree } from '@/utils/tree'
|
||||
import { buildSortedTree, collectAllKeys, filterTreeByText } from '@/utils/tree'
|
||||
|
||||
/** 根模块编码:本页所有分类与数据模块都挂在此模块下 */
|
||||
const ROOT_MODULE_ID = 'base_otherdata'
|
||||
@@ -53,16 +53,8 @@ const categoryKeyword = ref('')
|
||||
|
||||
const moduleById = computed(() => new Map(modules.value.map((item) => [String(item.b_id), item])))
|
||||
|
||||
/**
|
||||
* 树数据:把根之下的所有子孙按 b_parent_id 组织成树。
|
||||
* 必须在浅拷贝上构建:buildTree 会就地往行上挂 children,直接构建会污染 modules。
|
||||
*/
|
||||
const categoryTree = computed(() => {
|
||||
const rows = modules.value.map((row) => ({ ...row, children: undefined }))
|
||||
const tree = buildTree(rows)
|
||||
sortTree(tree)
|
||||
return tree
|
||||
})
|
||||
/** 树数据:把根之下的所有子孙按 b_parent_id 组织成树(在浅拷贝上构建,不污染 modules) */
|
||||
const categoryTree = computed(() => buildSortedTree(modules.value))
|
||||
|
||||
/** 搜索过滤:命中名称 / 编码,保留命中节点的子树 */
|
||||
const filteredCategoryTree = computed(() =>
|
||||
|
||||
@@ -860,7 +860,6 @@ function flattenTree(nodes, depth = 0) {
|
||||
<FmsTable
|
||||
:columns="columns"
|
||||
:data-source="filteredRows"
|
||||
:row-class-name="rowClassName"
|
||||
:row-selection="rowSelection"
|
||||
row-key="id"
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 落库只登记 data / virtual 模块编码:module 类型只是模块树上的分类,不参与权限,
|
||||
* 确定时统一过滤掉;勾选草稿(含分类节点)仅存在于弹层内部。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { Modal } from '@/components/ui'
|
||||
import FmsTree from '@/components/fms-tree/FmsTree.vue'
|
||||
|
||||
@@ -39,9 +39,6 @@ function isBindable(key) {
|
||||
return type == null || type === 'data' || type === 'virtual'
|
||||
}
|
||||
|
||||
/** 初始展开:只展开第一层根节点,深层分类按需点开,避免一次铺开整棵树 */
|
||||
const rootKeys = computed(() => (props.treeData || []).map((node) => node.b_id))
|
||||
|
||||
function onOk() {
|
||||
emit(
|
||||
'confirm',
|
||||
@@ -60,10 +57,10 @@ function onOk() {
|
||||
@ok="onOk"
|
||||
>
|
||||
<div class="mp-tree">
|
||||
<!-- 不设 default-expanded-keys:整棵树默认全部收起,由用户按需逐层点开 -->
|
||||
<FmsTree
|
||||
v-model:checked-keys="draftChecked"
|
||||
:tree-data="treeData"
|
||||
:default-expanded-keys="rootKeys"
|
||||
checkable
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -21,12 +21,18 @@ import { FolderTree, Save, RefreshCw } from '@lucide/vue'
|
||||
import { Button, Splitter, SplitterPanel, Modal, Message, Spin } from '@/components/ui'
|
||||
import { loadDataApi, saveObjectApi } from '@/services/api'
|
||||
import {
|
||||
buildTree,
|
||||
sortTree,
|
||||
findTreeNode,
|
||||
addRowUpdate,
|
||||
applyTreePosition,
|
||||
buildSortedTree,
|
||||
decorateTreeNodes,
|
||||
expandAncestors,
|
||||
findNodePosition,
|
||||
findTreeNode,
|
||||
insertTreeNode,
|
||||
rebuildTreePositions,
|
||||
removeTreeNode,
|
||||
renumberSiblings,
|
||||
sortTree,
|
||||
} from '@/utils/tree'
|
||||
import { cloneData, diffRow, diffRows } from '@/utils/dataChanges'
|
||||
import { createTempId } from '@/utils/common'
|
||||
@@ -36,14 +42,10 @@ import MenuTreePanel from './MenuTreePanel.vue'
|
||||
import MenuBasicPanel from './MenuBasicPanel.vue'
|
||||
import ModulePickerModal from './ModulePickerModal.vue'
|
||||
import {
|
||||
addRowUpdate,
|
||||
applyMenuPosition,
|
||||
MENU_NON_LEAF_TYPES,
|
||||
MENU_TYPE_ICON_MAP,
|
||||
buildMenuI18nKey,
|
||||
findNodePosition,
|
||||
markDirectoryNonLeaf,
|
||||
menuRowOf,
|
||||
rebuildTreePositions,
|
||||
renumberSiblings,
|
||||
} from './utils'
|
||||
import { useI18nStore } from '@/stores/i18n'
|
||||
|
||||
@@ -158,7 +160,11 @@ async function loadMenuTree() {
|
||||
menuTreeLoading.value = true
|
||||
try {
|
||||
const res = await loadDataApi('s_menu', '1 = 1', 'b_xh ASC, b_id ASC')
|
||||
menuTreeData.value = markDirectoryNonLeaf(sortTree(buildTree(res.data || [])))
|
||||
menuTreeData.value = decorateTreeNodes(buildSortedTree(res.data || []), {
|
||||
typeField: 'b_menu_type',
|
||||
nonLeafValues: MENU_NON_LEAF_TYPES,
|
||||
iconMap: MENU_TYPE_ICON_MAP,
|
||||
})
|
||||
unsavedMenuIds.clear()
|
||||
} catch (e) {
|
||||
Message.error('加载菜单树失败:' + (e?.message || e))
|
||||
@@ -221,7 +227,7 @@ async function onMenuDrop({ dragKey, dropKey, dropPosition }) {
|
||||
}
|
||||
// 只有目录能承载子菜单:页面/外链是末端节点,放入会形成「页面挂子菜单」的非法层级。
|
||||
// 必须在移除被拖节点之前拦截,否则中途 return 会让节点凭空消失。
|
||||
if (dropPosition === 'inside' && dropNode.b_menu_type !== 'directory') {
|
||||
if (dropPosition === 'inside' && !MENU_NON_LEAF_TYPES.includes(dropNode.b_menu_type)) {
|
||||
Message.warning('只能将菜单放入目录下')
|
||||
return
|
||||
}
|
||||
@@ -330,7 +336,7 @@ async function addMenu(parentId, menuTypeValue = 'page') {
|
||||
}
|
||||
// b_depth / b_path 是 b_parent_id 的派生字段(b_path 非空且无默认值,缺失会新增失败)。
|
||||
// 此处先按父级填一次占位值;保存前会用最终菜单编码重算(临时 id 不落库)。
|
||||
applyMenuPosition(newMenu, parentNode)
|
||||
applyTreePosition(newMenu, parentNode)
|
||||
insertTreeNode(menuTreeData.value, newMenu, { sort: sortTree })
|
||||
const ancestors = expandAncestors(menuTreeData.value, newId)
|
||||
if (ancestors.length) {
|
||||
@@ -611,10 +617,10 @@ async function beforeSave() {
|
||||
// 否则会把 /-1/ 这类临时路径写进库。父级不在树中(数据异常)时无法派生,保持原值不动。
|
||||
const parentId = menuData.b_parent_id ? String(menuData.b_parent_id) : null
|
||||
if (!parentId) {
|
||||
applyMenuPosition(menuData, null)
|
||||
applyTreePosition(menuData, null)
|
||||
} else {
|
||||
const parentNode = findTreeNode(menuTreeData.value, parentId)
|
||||
if (parentNode) applyMenuPosition(menuData, parentNode)
|
||||
if (parentNode) applyTreePosition(menuData, parentNode)
|
||||
}
|
||||
// 受管 i18n 键:菜单统一 menu. 前缀
|
||||
menuData.b_i18n = buildMenuI18nKey(menuCode)
|
||||
@@ -828,7 +834,7 @@ async function loadModuleOptions() {
|
||||
const res = await loadDataApi('s_module', 'b_canuse = 1', 'b_path ASC, b_xh ASC, b_id ASC')
|
||||
const rows = res.data || []
|
||||
moduleRows.value = rows
|
||||
moduleTree.value = sortTree(buildTree(rows.map((row) => ({ ...row }))))
|
||||
moduleTree.value = buildSortedTree(rows)
|
||||
moduleOptionsLoaded = true
|
||||
} catch (e) {
|
||||
// Silently fail; modules will be empty
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 菜单管理(menu-management)页面级纯函数。
|
||||
* 只放无 Vue 状态依赖的逻辑:菜单类型转换、树节点定位、排序号重排、目录节点标记。
|
||||
* 树本身的通用查找/过滤/插入/移除见 @/utils/tree,不在此重复。
|
||||
* 只放无 Vue 状态依赖的逻辑:菜单类型选项、菜单行提取、多语言资源键。
|
||||
* 树本身的通用查找/过滤/插入/移除/位置派生见 @/utils/tree,不在此重复。
|
||||
*/
|
||||
import { ExternalLink, FileText, Folder } from '@lucide/vue'
|
||||
|
||||
@@ -19,21 +19,8 @@ export const MENU_TYPE_ICON_MAP = {
|
||||
external: ExternalLink,
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录节点强制非叶子(即使暂无子菜单也显示展开箭头,便于新增子菜单),
|
||||
* 并预载类型图标到 node.icon。就地修改并返回原数组。
|
||||
* @param {Array} nodes 树节点数组
|
||||
* @returns {Array} 原数组
|
||||
*/
|
||||
export function markDirectoryNonLeaf(nodes) {
|
||||
for (const node of nodes) {
|
||||
if (node.b_menu_type === 'directory') node.isLeaf = false
|
||||
const icon = MENU_TYPE_ICON_MAP[node.b_menu_type]
|
||||
if (icon) node.icon = icon
|
||||
if (node.children) markDirectoryNonLeaf(node.children)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
/** 菜单树里能承载子节点的类型:页面 / 外链是末端节点 */
|
||||
export const MENU_NON_LEAF_TYPES = ['directory']
|
||||
|
||||
/** s_menu 主表列(树节点 → 主表草稿行时按此提取,剔除 children/isLeaf/icon 等树注入字段) */
|
||||
const MENU_ROW_FIELDS = [
|
||||
@@ -67,18 +54,6 @@ export function menuRowOf(node) {
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* 按父节点派生菜单行的 b_depth / b_path(b_parent_id 是权威关系,二者为派生字段)。
|
||||
* 根节点 b_depth = 0,b_path 形如 /system/user/(节点编码前后均带 /)。
|
||||
* b_path 非空且无默认值,新增时必须随行写入。
|
||||
* @param {Object} row s_menu 行(就地修改,b_id 必须是最终菜单编码)
|
||||
* @param {Object|null} parentNode 树中的父节点;null 表示根节点
|
||||
*/
|
||||
export function applyMenuPosition(row, parentNode) {
|
||||
row.b_depth = parentNode ? Number(parentNode.b_depth ?? 0) + 1 : 0
|
||||
row.b_path = parentNode ? `${String(parentNode.b_path || '')}${row.b_id}/` : `/${row.b_id}/`
|
||||
}
|
||||
|
||||
/* ================= 多语言资源键(s_i18n,主键 b_key + b_locale) ================= */
|
||||
|
||||
/**
|
||||
@@ -91,63 +66,3 @@ export function buildMenuI18nKey(menuId) {
|
||||
const normalizedMenuId = String(menuId || '').trim()
|
||||
return normalizedMenuId ? `menu.${normalizedMenuId}` : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 在树中查找节点所在的父数组及索引。
|
||||
* @param {Array} nodes 树节点数组
|
||||
* @param {string|number} id 目标节点 ID
|
||||
* @returns {{ list: Array, index: number }|null}
|
||||
*/
|
||||
export function findNodePosition(nodes, id) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
|
||||
if (nodes[i].children) {
|
||||
const found = findNodePosition(nodes[i].children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 向 updatedRows 合并一行变更(同一 b_id 累加字段,避免同节点产生多条 update 请求) */
|
||||
export function addRowUpdate(updatedRows, row) {
|
||||
const existing = updatedRows.find((item) => String(item.b_id) === String(row.b_id))
|
||||
if (existing) Object.assign(existing, row)
|
||||
else updatedRows.push({ ...row })
|
||||
}
|
||||
|
||||
/**
|
||||
* 重排某组兄弟的 b_xh(10 起步、步长 10),并把变化的行收集到 updatedRows。
|
||||
* @param {Array} list 兄弟节点数组(就地修改 b_xh)
|
||||
* @param {Array} updatedRows 变化行收集器
|
||||
*/
|
||||
export function renumberSiblings(list, updatedRows) {
|
||||
list.forEach((s, i) => {
|
||||
const nextXh = (i + 1) * 10
|
||||
if (s.b_xh !== nextXh) {
|
||||
s.b_xh = nextXh
|
||||
addRowUpdate(updatedRows, { b_id: s.b_id, b_xh: nextXh })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归重建菜单树的 b_depth / b_path(b_parent_id 为权威关系,二者为派生字段)。
|
||||
* 根节点 b_depth = 0,b_path 形如 /system/user/(节点编码前后均带 /)。
|
||||
* 仅在值实际变化时收集,避免打开即脏;父子关系变化后调用即可整树收敛。
|
||||
* @param {Array} nodes 树节点数组(就地修改)
|
||||
* @param {Array} updatedRows 变化行收集器
|
||||
* @param {string} [parentPath] 父节点路径
|
||||
* @param {number} [depth] 当前层级
|
||||
*/
|
||||
export function rebuildTreePositions(nodes, updatedRows, parentPath = '', depth = 0) {
|
||||
for (const node of nodes) {
|
||||
const path = `${parentPath}${node.b_id}/`
|
||||
if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) {
|
||||
node.b_depth = depth
|
||||
node.b_path = path
|
||||
addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path })
|
||||
}
|
||||
if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1)
|
||||
}
|
||||
}
|
||||
@@ -34,14 +34,21 @@ import {
|
||||
import { Button, Splitter, SplitterPanel, Tabs, Modal, Message, Spin } from '@/components/ui'
|
||||
import { loadDataApi, saveObjectApi } from '@/services/api'
|
||||
import {
|
||||
buildTree,
|
||||
sortTree,
|
||||
findTreeNode,
|
||||
addRowUpdate,
|
||||
applyTreePosition,
|
||||
buildSortedTree,
|
||||
decorateTreeNodes,
|
||||
expandAncestors,
|
||||
findNodePosition,
|
||||
findTreeNode,
|
||||
insertTreeNode,
|
||||
rebuildTreePositions,
|
||||
removeTreeNode,
|
||||
renumberSiblings,
|
||||
sortTree,
|
||||
} from '@/utils/tree'
|
||||
import {
|
||||
MODULE_NON_LEAF_TYPES,
|
||||
MODULE_TYPE_ICON_MAP,
|
||||
MODULE_TYPE_LABELS,
|
||||
SCHEMA_TYPES,
|
||||
@@ -54,13 +61,9 @@ import {
|
||||
collectManagedI18nItems,
|
||||
collectSchemaGroupKeys,
|
||||
createDefaultSchema,
|
||||
decorateModuleTree,
|
||||
findNodePosition,
|
||||
moduleRowOf,
|
||||
parseSchema,
|
||||
rebuildTreePositions,
|
||||
reconcileI18nRows,
|
||||
renumberSiblings,
|
||||
schemaToJsonText,
|
||||
syncModuleItemI18nKeys,
|
||||
} from './utils'
|
||||
@@ -504,7 +507,11 @@ async function loadModuleTree() {
|
||||
moduleTreeLoading.value = true
|
||||
try {
|
||||
const res = await loadDataApi('s_module', '1 = 1', 'b_xh ASC, b_id ASC')
|
||||
moduleTreeData.value = decorateModuleTree(sortTree(buildTree(res.data || [])))
|
||||
moduleTreeData.value = decorateTreeNodes(buildSortedTree(res.data || []), {
|
||||
typeField: 'b_module_type',
|
||||
nonLeafValues: MODULE_NON_LEAF_TYPES,
|
||||
iconMap: MODULE_TYPE_ICON_MAP,
|
||||
})
|
||||
unsavedModuleIds.clear()
|
||||
} catch (e) {
|
||||
Message.error('加载模块树失败:' + (e?.message || e))
|
||||
@@ -518,13 +525,9 @@ async function addModule(parentId, newType) {
|
||||
// 未落库节点先用本地临时负数 id 占位,仅用于树 key / 未保存判定,不写入数据库。
|
||||
const newId = createTempId()
|
||||
const parentNode = parentId ? findTreeNode(moduleTreeData.value, String(parentId)) : null
|
||||
const depth = parentNode ? Number(parentNode.b_depth || 0) + 1 : 0
|
||||
const path = parentNode ? `${String(parentNode.b_path || '')}${newId}/` : `/${newId}/`
|
||||
const newModule = {
|
||||
b_id: newId,
|
||||
b_parent_id: parentId ? String(parentId) : null,
|
||||
b_depth: depth,
|
||||
b_path: path,
|
||||
b_name: '',
|
||||
b_i18n: null,
|
||||
b_module_type: newType,
|
||||
@@ -538,8 +541,9 @@ async function addModule(parentId, newType) {
|
||||
b_xh: 10,
|
||||
b_bz: null,
|
||||
// 模块类型强制非叶子,即使暂无子模块也显示折叠箭头
|
||||
isLeaf: newType === 'module' ? false : undefined,
|
||||
isLeaf: MODULE_NON_LEAF_TYPES.includes(newType) ? false : undefined,
|
||||
}
|
||||
applyTreePosition(newModule, parentNode)
|
||||
insertTreeNode(moduleTreeData.value, newModule, { sort: sortTree })
|
||||
const ancestors = expandAncestors(moduleTreeData.value, newId)
|
||||
if (ancestors.length) {
|
||||
@@ -653,9 +657,27 @@ async function onModuleDrop({ dragKey, dropKey, dropPosition }) {
|
||||
const dropPos = findNodePosition(moduleTreeData.value, dropId)
|
||||
if (!dragPos || !dropPos) return
|
||||
|
||||
const dragNode = dragPos.list[dragPos.index]
|
||||
const dropNode = dropPos.list[dropPos.index]
|
||||
// 落点不能是被拖节点自身或它的后代:父子关系成环后树与 b_path 都会失真
|
||||
if (findTreeNode([dragNode], dropId)) {
|
||||
Message.warning('不能把模块移动到它自己的下级')
|
||||
return
|
||||
}
|
||||
// 只有 module 类型能承载子模块:data / virtual 是叶子,放入会形成非法层级。
|
||||
// 必须在移除被拖节点之前拦截,否则中途 return 会让节点凭空消失。
|
||||
if (dropPosition === 'inside' && !MODULE_NON_LEAF_TYPES.includes(dropNode.b_module_type)) {
|
||||
Message.warning('只能把模块放入模块类型的节点下')
|
||||
return
|
||||
}
|
||||
// 未保存的模块尚未落库,其临时 id 不能作为持久化的 b_parent_id
|
||||
if (dropPosition === 'inside' && unsavedModuleIds.has(dropId)) {
|
||||
Message.warning('请先保存目标模块,再调整层级')
|
||||
return
|
||||
}
|
||||
const dropParentId = dropNode.b_parent_id ?? null
|
||||
const [moved] = dragPos.list.splice(dragPos.index, 1)
|
||||
const movedParentId = moved.b_parent_id ?? null
|
||||
|
||||
const updatedRows = []
|
||||
const renumberSets = new Set()
|
||||
@@ -681,11 +703,30 @@ async function onModuleDrop({ dragKey, dropKey, dropPosition }) {
|
||||
renumberSets.forEach((list) => renumberSiblings(list, updatedRows))
|
||||
// 父子关系变化后整树重建 b_depth / b_path,仅收集实际变化的行
|
||||
rebuildTreePositions(moduleTreeData.value, updatedRows)
|
||||
// b_parent_id 是权威关系,必须与本节点的派生字段同批落库,
|
||||
// 否则会留下「路径按新父级、父级列还是旧值」的不一致。
|
||||
// 单独补一条:renumberSiblings / rebuildTreePositions 只收集实际变化的字段值,
|
||||
// 它俩不负责父级列本身。
|
||||
if (String(moved.b_parent_id ?? '') !== String(movedParentId ?? '')) {
|
||||
addRowUpdate(updatedRows, { b_id: moved.b_id, b_parent_id: moved.b_parent_id ?? null })
|
||||
}
|
||||
|
||||
// 若当前编辑的节点排序号/父级变化,同步到主表草稿
|
||||
// 排序即时落库(下方 saveObjectApi),草稿与基线需一起同步:
|
||||
// 只改 maindata 会让已落库的 b_xh / b_parent_id / b_depth / b_path 被
|
||||
// hasUnsavedChanges 误判为未保存改动。未保存草稿节点例外:
|
||||
// 它还没有基线可比,改动留在草稿里等保存流程处理。
|
||||
if (maindata.value) {
|
||||
const draftRow = updatedRows.find((row) => String(row.b_id) === String(maindata.value.b_id))
|
||||
if (draftRow) Object.assign(maindata.value, draftRow)
|
||||
const draftId = String(maindata.value.b_id)
|
||||
const draftUnsaved = unsavedModuleIds.has(draftId)
|
||||
const syncBaseline = !draftUnsaved && !!maindata_org.value
|
||||
const draftRow = updatedRows.find((row) => String(row.b_id) === draftId)
|
||||
if (draftRow) {
|
||||
for (const [field, value] of Object.entries(draftRow)) {
|
||||
if (field === 'b_id') continue
|
||||
maindata.value[field] = value
|
||||
if (syncBaseline) maindata_org.value[field] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!updatedRows.length) return
|
||||
|
||||
@@ -15,6 +15,9 @@ export const MODULE_TYPE_LABELS = {
|
||||
virtual: '虚拟模块',
|
||||
}
|
||||
|
||||
/** 模块树里能承载子模块的类型:data / virtual 是叶子节点 */
|
||||
export const MODULE_NON_LEAF_TYPES = ['module']
|
||||
|
||||
/** 工具栏 / 右键「新增」菜单项(key 即 b_module_type) */
|
||||
export const MODULE_ADD_OPTIONS = [
|
||||
{ key: 'module', label: '新增模块', icon: FolderPlus },
|
||||
@@ -51,20 +54,6 @@ export {
|
||||
schemaToJsonText,
|
||||
} from '@/utils/schemaJson'
|
||||
|
||||
/**
|
||||
* 模块类型装饰:module 类型默认可展开(显示折叠箭头,即使暂无子模块),并附加类型图标。
|
||||
* 就地修改并返回原数组。
|
||||
*/
|
||||
export function decorateModuleTree(nodes) {
|
||||
for (const node of nodes) {
|
||||
if (node.b_module_type === 'module') node.isLeaf = false
|
||||
const icon = MODULE_TYPE_ICON_MAP[node.b_module_type]
|
||||
if (icon) node.icon = icon
|
||||
if (node.children) decorateModuleTree(node.children)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
/** 从树节点提取纯模块行(剔除 children/isLeaf/icon 等树注入字段),供未落库模块切回时重建主表草稿 */
|
||||
export function moduleRowOf(node) {
|
||||
const row = {}
|
||||
@@ -72,60 +61,6 @@ export function moduleRowOf(node) {
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* 在树中查找节点所在的父数组及索引。
|
||||
* @returns {{ list: Array, index: number }|null}
|
||||
*/
|
||||
export function findNodePosition(nodes, id) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
|
||||
if (nodes[i].children) {
|
||||
const found = findNodePosition(nodes[i].children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 向 updatedRows 合并一行变更(同一 b_id 累加字段,避免同节点产生多条 update 请求) */
|
||||
function addRowUpdate(updatedRows, row) {
|
||||
const existing = updatedRows.find((item) => String(item.b_id) === String(row.b_id))
|
||||
if (existing) Object.assign(existing, row)
|
||||
else updatedRows.push({ ...row })
|
||||
}
|
||||
|
||||
/**
|
||||
* 重排某组兄弟的 b_xh(10 起步、步长 10),把变化的行收集到 updatedRows。
|
||||
* @param {Array} list 兄弟节点数组(就地修改 b_xh)
|
||||
* @param {Array} updatedRows 变化行收集器
|
||||
*/
|
||||
export function renumberSiblings(list, updatedRows) {
|
||||
list.forEach((sibling, index) => {
|
||||
const nextXh = (index + 1) * 10
|
||||
if (sibling.b_xh !== nextXh) {
|
||||
sibling.b_xh = nextXh
|
||||
addRowUpdate(updatedRows, { b_id: sibling.b_id, b_xh: nextXh })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归重建模块树的 b_depth / b_path(b_parent_id 为权威关系,二者为派生字段)。
|
||||
* 根节点 b_depth = 0,b_path 形如 /sea/container/(节点编码前后均带 /)。
|
||||
* 仅在值实际变化时收集,避免打开即脏;父子关系变化后调用即可整树收敛。
|
||||
*/
|
||||
export function rebuildTreePositions(nodes, updatedRows, parentPath = '', depth = 0) {
|
||||
for (const node of nodes) {
|
||||
const path = `${parentPath}${node.b_id}/`
|
||||
if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) {
|
||||
node.b_depth = depth
|
||||
node.b_path = path
|
||||
addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path })
|
||||
}
|
||||
if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 多语言资源键(s_i18n,主键 b_key + b_locale) ================= */
|
||||
|
||||
export function buildModuleItemI18nKey(prefix, moduleCode, itemCode) {
|
||||
|
||||
Reference in new issue
Block a user