20260915170028

This commit is contained in:
oneao committed 2026-09-15 17:00:29 +08:00
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"
+118
View File
@@ -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) {