20260915221013

This commit is contained in:
oneao committed 2026-09-15 22:10:13 +08:00
1 parent 1f56cc3efb
commit dce29f250e
54 files changed
+3732 -574

No files matched your search

@@ -18,7 +18,6 @@ import {
Input,
Message,
Modal,
Spin,
Splitter,
SplitterPanel,
} from '@/components/ui'
@@ -28,21 +27,17 @@ import FmsTree from '@/components/fms-tree/FmsTree.vue'
import { loadDataApi, pageDataApi, saveObjectApi } from '@/services/api'
import { usePermissionStore } from '@/stores/permissions'
import {
addRowUpdate,
applyTreePosition,
buildSortedTree,
collectAllKeys,
collectDescendantIds,
computeTreeMove,
filterTreeByText,
findNodePosition,
findTreeNode,
rebuildTreePositions,
renumberSiblings,
} from '@/utils/tree'
const MODULE_CODE = 'v_b_othercompany' // 右侧列表模块
const CATEGORY_TABLE = 'b_othercompany_category' // 左侧分类树表
/** 必须与 FmsTree 的 rootKey 默认值一致 */
const ALL_CATEGORY_KEY = '__fms_tree_root__'
/* ===================== 分类树纯函数 ===================== */
@@ -74,7 +69,8 @@ const listRef = ref(null)
/* ================= 分类树:状态与加载 ================= */
const categories = ref([])
const categoryLoading = ref(false)
const selectedCategoryKey = ref(ALL_CATEGORY_KEY)
/** 选中的分类;空串表示「全部分类」(FmsTree 用空选中键表达虚拟根,本页不需感知 rootKey) */
const selectedCategoryKey = ref('')
const expandedCategoryKeys = ref([])
const categoryKeyword = ref('')
@@ -108,11 +104,8 @@ async function loadCategories() {
// 选中的分类已被删除时回到「全部」
const existingKeys = new Set(categories.value.map((item) => String(item.b_id)))
if (
selectedCategoryKey.value !== ALL_CATEGORY_KEY &&
!existingKeys.has(selectedCategoryKey.value)
) {
selectedCategoryKey.value = ALL_CATEGORY_KEY
if (selectedCategoryKey.value && !existingKeys.has(selectedCategoryKey.value)) {
selectedCategoryKey.value = ''
}
// 展开所有含子节点的分类,并保留用户已手动展开的
@@ -127,40 +120,15 @@ async function loadCategories() {
}
}
function onCategorySelect(keys, info) {
if (info?.node?.isFmsRoot) {
selectedCategoryKey.value = ALL_CATEGORY_KEY
return
}
if (keys.length) selectedCategoryKey.value = String(keys[0])
}
/** 收集某个分类及其全部下级分类的编码 */
function collectDescendantIds(rootId) {
const childrenByParent = new Map()
for (const item of categories.value) {
const parentId = item.b_parent_id == null ? '' : String(item.b_parent_id)
if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, [])
childrenByParent.get(parentId).push(String(item.b_id))
}
const result = []
const visited = new Set()
const pending = [String(rootId)]
while (pending.length) {
const id = pending.pop()
if (visited.has(id)) continue
visited.add(id)
result.push(id)
for (const childId of childrenByParent.get(id) || []) pending.push(childId)
}
return result
/** FmsTree 已把点中虚拟根归一化成空数组,这里只需处理业务分类 */
function onCategorySelect(keys) {
selectedCategoryKey.value = keys.length ? String(keys[0]) : ''
}
/** b_cate_id 是 varchar:条件值不加 N 前缀,避免列被隐式转成 nvarchar 而用不上索引 */
const categoryFilterCondition = computed(() => {
if (selectedCategoryKey.value === ALL_CATEGORY_KEY) return ''
const ids = collectDescendantIds(selectedCategoryKey.value)
if (!selectedCategoryKey.value) return ''
const ids = collectDescendantIds(categories.value, selectedCategoryKey.value)
if (!ids.length) return '1 = 0'
const literals = ids.map((id) => `'${escapeSqlText(id)}'`)
return `[b_cate_id] IN (${literals.join(', ')})`
@@ -209,9 +177,7 @@ function onCategoryMenuClick({ item, node }) {
/** 工具栏新增挂到当前选中分类下;选中「全部分类」或未选中时建为顶级 */
function onCategoryToolbarAdd() {
const selected = String(selectedCategoryKey.value ?? '')
onCategoryAdd({
parentId: !selected || selected === ALL_CATEGORY_KEY ? null : selected,
})
onCategoryAdd({ parentId: selected || null })
}
/* ================= 新增:跳转详情页 ================= */
@@ -443,55 +409,19 @@ async function onCategoryDelete(node) {
/* ================= 拖拽排序(同级调序 / 跨级换父) ================= */
async function onCategoryDrop({ dragKey, dropKey, dropPosition }) {
const dragId = String(dragKey)
const dropId = String(dropKey)
if (dragId === dropId) return
// 树只是 categories 的派生视图,所以一贯「在副本上算差异 → 落库 → 重载」
const tree = buildSortedTree(categories.value)
const dragPos = findNodePosition(tree, dragId)
const dropPos = findNodePosition(tree, 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('不能把分类移动到它自己的下级')
// 树只是 categories 的派生视图,所以一贯「在副本上算差异 → 落库 → 重载」。
// 成环守卫与 b_xh、b_depth、b_path、b_parent_id 的重算都在 computeTreeMove 内
const result = computeTreeMove(buildSortedTree(categories.value), {
dragKey,
dropKey,
dropPosition,
messages: { cycle: '不能把分类移动到它自己的下级' },
})
if (!result.ok) {
if (result.message) Message.warning(result.message)
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()
if (dropPosition === 'inside') {
moved.b_parent_id = dropNode.b_id
if (!dropNode.children) dropNode.children = []
dropNode.children.push(moved)
renumberSets.add(dropNode.children)
renumberSets.add(dragPos.list)
} else {
moved.b_parent_id = dropParentId
const targetList = dropPos.list
let targetIndex = dropPos.index + (dropPosition === 'after' ? 1 : 0)
// 同一组内拖拽且被拖节点在落点之前:移除后索引需前移一位
if (dragPos.list === dropPos.list && dragPos.index < dropPos.index) targetIndex -= 1
targetList.splice(targetIndex, 0, moved)
renumberSets.add(targetList)
}
renumberSets.forEach((list) => renumberSiblings(list, updatedRows))
rebuildTreePositions(tree, updatedRows)
// b_parent_id 是权威关系,必须与派生字段同批落库:
// 上面两个函数只收集变化的派生字段,都不负责父级列本身
if (String(moved.b_parent_id ?? '') !== String(movedParentId ?? '')) {
addRowUpdate(updatedRows, { b_id: moved.b_id, b_parent_id: moved.b_parent_id ?? null })
}
const { updatedRows } = result
if (!updatedRows.length) return
try {
@@ -543,6 +473,7 @@ void loadCategories()
show-root
root-title="全部分类"
:tree-data="filteredCategoryTree"
:loading="categoryLoading"
:selected-keys="selectedCategoryKey ? [String(selectedCategoryKey)] : []"
:context-menu="buildCategoryContextMenu"
:draggable="!categoryKeyword.trim()"
@@ -555,10 +486,6 @@ void loadCategories()
</template>
</FmsTree>
</div>
<div v-if="categoryLoading" class="oc-tree-panel__loading">
<Spin />
</div>
</aside>
</SplitterPanel>
@@ -748,14 +675,6 @@ void loadCategories()
white-space: nowrap;
}
.oc-tree-panel__loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgb(255 255 255 / 60%);
}
/* 列表卡自带白底(.fms-module-card),外层不再铺白底,避免灰缝被染色 */
.oc-list {
display: flex;
@@ -23,12 +23,17 @@
*/
import { computed, ref, watch } from 'vue'
import { Plus, Save, Search, Trash2 } from '@lucide/vue'
import { Button, Message, Modal, Spin, Splitter, SplitterPanel, Input } from '@/components/ui'
import { Button, Message, Modal, Splitter, SplitterPanel, Input } from '@/components/ui'
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 { buildSortedTree, collectAllKeys, filterTreeByText } from '@/utils/tree'
import {
buildSortedTree,
collectAllKeys,
collectDescendantIds,
filterTreeByText,
} from '@/utils/tree'
/** 根模块编码:本页所有分类与数据模块都挂在此模块下 */
const ROOT_MODULE_ID = 'base_otherdata'
@@ -81,19 +86,9 @@ function isDataModule(item) {
return item?.b_module_type === 'data'
}
/**
* 判断模块是否属于根模块的子孙(按 b_path 前缀匹配)。
* b_path 形如 `/base_otherdata/odc_123/b_some_data/`,前缀匹配即覆盖所有层级。
*/
function isDescendantOfRoot(item, rootPath) {
if (!rootPath) return false
const path = String(item.b_path || '')
return path.startsWith(rootPath) && String(item.b_id) !== ROOT_MODULE_ID
}
/**
* 加载模块树。
* 步骤:拉全量启用模块 → 定位根 → 按 b_path 前缀过滤子孙 → 按权限裁剪可见集合。
* 步骤:拉全量启用模块 → 定位根 → 按 b_parent_id 展开根的子孙 → 按权限裁剪可见集合。
*/
async function loadCategories() {
categoryLoading.value = true
@@ -106,10 +101,15 @@ async function loadCategories() {
const root = all.find(
(item) => String(item.b_id) === ROOT_MODULE_ID && item.b_module_type === 'module',
)
const rootPath = String(root?.b_path || '')
// 根之下的所有子孙(module + data + virtual,virtual 在渲染时过滤)
const scoped = rootPath ? all.filter((item) => isDescendantOfRoot(item, rootPath)) : []
// 根之下的所有子孙(module + data + virtual,virtual 在渲染时过滤)。
// 按 b_parent_id 展开:与下方向上回溯祖先用同一个权威字段。
// 原先按 b_path 前缀匹配,而 b_path 只是派生字段,一旦与 b_parent_id 不一致,
// 「筛选子树」和「回溯祖先」两段逻辑会算出矛盾结果。
// 根不存在时集合为空,与原先「找不到根就整树为空」一致。
const scopedIds = root ? new Set(collectDescendantIds(all, ROOT_MODULE_ID)) : new Set()
scopedIds.delete(ROOT_MODULE_ID) // 根是本页入口,不进树
const scoped = all.filter((item) => scopedIds.has(String(item.b_id)))
const scopedById = new Map(scoped.map((item) => [String(item.b_id), item]))
// 权限裁剪:无分类维护权限时,只保留「至少含一个可访问数据模块」的分类分支
@@ -173,8 +173,8 @@ function applySelectedModule(key) {
}
function onCategorySelect(keys, info) {
// 点「全部分类」虚拟根:清空右侧数据源
if (info?.node?.isFmsRoot) {
// 点「全部分类」虚拟根:FmsTree 已归一化成空 keys,清空右侧数据源
if (!keys.length) {
applySelectedModule('')
return
}
@@ -285,6 +285,7 @@ void loadCategories()
show-root
root-title="全部分类"
:tree-data="filteredCategoryTree"
:loading="categoryLoading"
:selected-keys="selectedCategoryKey ? [String(selectedCategoryKey)] : []"
@select="onCategorySelect"
>
@@ -293,10 +294,6 @@ void loadCategories()
</template>
</FmsTree>
</div>
<div v-if="categoryLoading" class="od-tree-panel__loading">
<Spin />
</div>
</aside>
</SplitterPanel>
@@ -433,14 +430,6 @@ void loadCategories()
white-space: nowrap;
}
.od-tree-panel__loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgb(255 255 255 / 60%);
}
/* 列表卡自带白底(.fms-module-card),外层不再铺白底,避免灰缝被染色 */
.od-list {
display: flex;
@@ -99,11 +99,10 @@ function onToolbarAdd({ key }) {
</div>
</div>
<div class="mm-tree-panel__scroll">
<div v-if="loading" class="mm-tree-empty">加载中...</div>
<!-- 过滤(搜索)状态下禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
<FmsTree
v-else
:tree-data="filteredTree"
:loading="loading"
:expanded-keys="expandedKeys"
@update:expanded-keys="(keys) => emit('update:expandedKeys', keys)"
:selected-keys="selectedId ? [selectedId] : []"
@@ -235,11 +234,4 @@ function onToolbarAdd({ key }) {
flex-shrink: 0;
margin-left: auto;
}
.mm-tree-empty {
padding: 20px 0;
text-align: center;
color: var(--fms-text-secondary);
font-size: 13px;
}
</style>
@@ -21,17 +21,14 @@ import { FolderTree, Save, RefreshCw } from '@lucide/vue'
import { Button, Splitter, SplitterPanel, Modal, Message, Spin } from '@/components/ui'
import { loadDataApi, saveObjectApi } from '@/services/api'
import {
addRowUpdate,
applyTreePosition,
buildSortedTree,
computeTreeMove,
decorateTreeNodes,
expandAncestors,
findNodePosition,
findTreeNode,
insertTreeNode,
rebuildTreePositions,
removeTreeNode,
renumberSiblings,
sortTree,
} from '@/utils/tree'
import { cloneData, diffRow, diffRows } from '@/utils/dataChanges'
@@ -41,12 +38,7 @@ import I18nNameModal from '@/components/I18nNameModal.vue'
import MenuTreePanel from './MenuTreePanel.vue'
import MenuBasicPanel from './MenuBasicPanel.vue'
import ModulePickerModal from './ModulePickerModal.vue'
import {
MENU_NON_LEAF_TYPES,
MENU_TYPE_ICON_MAP,
buildMenuI18nKey,
menuRowOf,
} from './utils'
import { MENU_NON_LEAF_TYPES, MENU_TYPE_ICON_MAP, buildMenuI18nKey, menuRowOf } from './utils'
import { useI18nStore } from '@/stores/i18n'
/** 生成安全 SQL 字符串字面量(search_condition 为后端拼 SQL,需要转义引号) */
@@ -210,73 +202,25 @@ function onMenuAdd({ parentId, menuType: newType }) {
}
async function onMenuDrop({ dragKey, dropKey, dropPosition }) {
const dragId = String(dragKey)
const dropId = String(dropKey)
if (dragId === dropId) return
const dragPos = findNodePosition(menuTreeData.value, dragId)
const dropPos = findNodePosition(menuTreeData.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('不能把菜单移动到它自己的下级')
// 成环 / 类型 / 未保存守卫与 b_xh、b_depth、b_path、b_parent_id 的重算都在 computeTreeMove 内
const result = computeTreeMove(menuTreeData.value, {
dragKey,
dropKey,
dropPosition,
typeField: 'b_menu_type',
nonLeafValues: MENU_NON_LEAF_TYPES,
isUnsaved: (id) => unsavedMenuIds.has(id),
messages: {
cycle: '不能把菜单移动到它自己的下级',
notContainer: '只能将菜单放入目录下',
unsavedTarget: '请先保存目标菜单,再调整层级',
},
})
if (!result.ok) {
if (result.message) Message.warning(result.message)
return
}
// 只有目录能承载子菜单:页面/外链是末端节点,放入会形成「页面挂子菜单」的非法层级。
// 必须在移除被拖节点之前拦截,否则中途 return 会让节点凭空消失。
if (dropPosition === 'inside' && !MENU_NON_LEAF_TYPES.includes(dropNode.b_menu_type)) {
Message.warning('只能将菜单放入目录下')
return
}
// 未保存的目录尚未落库,其临时 id 不能作为持久化的 b_parent_id
if (dropPosition === 'inside' && unsavedMenuIds.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()
if (dropPosition === 'inside') {
// 成为落点节点的子节点
moved.b_parent_id = dropNode.b_id
if (!dropNode.children) {
dropNode.children = []
dropNode.isLeaf = false
}
dropNode.children.push(moved)
renumberSets.add(dropNode.children)
renumberSets.add(dragPos.list)
} else {
// 同级插入:落点节点前 / 后
moved.b_parent_id = dropParentId
let targetList = dropPos.list
let targetIndex = dropPos.index + (dropPosition === 'after' ? 1 : 0)
// 同一组内拖拽且被拖节点在落点之前:移除后索引需前移一位
if (dragPos.list === dropPos.list && dragPos.index < dropPos.index) {
targetIndex -= 1
}
targetList.splice(targetIndex, 0, moved)
renumberSets.add(targetList)
}
renumberSets.forEach((list) => renumberSiblings(list, updatedRows))
// 父子关系变化后整树重建 b_depth / b_path,仅收集实际变化的行
rebuildTreePositions(menuTreeData.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 })
}
const { updatedRows } = result
// 排序即时落库(下方 saveObjectApi),草稿与基线需一起同步:
// 只改 maindata 会让已落库的 b_xh / b_parent_id / b_depth / b_path 被
@@ -95,17 +95,12 @@ function toggleQuery(field) {
: addQueryCondition(querySchema.value, field)
}
/** 字段 i18n 键随模块编码/字段名变化同步 */
watch(
() => [fields.value, moduleCode.value],
() => {
if (!fields.value?.length) return
if (syncModuleItemI18nKeys(fields.value, 'field', moduleCode.value, 'b_field')) {
fields.value = [...fields.value]
}
},
{ immediate: true },
)
// 字段 i18n 键(field.{模块编码}.{字段名})是派生值,不在此随加载自动同步:
// 该 watch 会在模块加载后立刻把库中的空 b_i18n 补齐,让草稿与基线不一致,
// 表现为「打开模块什么都没改,切换节点却提示有未保存的修改」。
// 与 ModulePowerPanel.normalizeRows 的处理保持一致:派生键只在用户编辑
// (onCellChange)与父级保存前(beforeSave)物化,加载阶段不动草稿。
// 参与状态物化到字段行上的 __ 虚拟字段(就地 patch),供 FmsTable 内置 checkbox 渲染;
// __ 前缀被 diffRows / hasUnsavedChanges 忽略,不进保存请求。
@@ -95,11 +95,10 @@ function onToolbarAdd({ key }) {
</div>
</div>
<div class="mm-tree-panel__scroll">
<div v-if="loading" class="mm-tree-empty">加载中...</div>
<!-- 过滤(搜索)状态下禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
<FmsTree
v-else
:tree-data="filteredTree"
:loading="loading"
:expanded-keys="expandedKeys"
@update:expanded-keys="(keys) => emit('update:expandedKeys', keys)"
:selected-keys="selectedId ? [selectedId] : []"
@@ -222,11 +221,4 @@ function onToolbarAdd({ key }) {
color: var(--fms-text-disabled, #9ca3af);
}
}
.mm-tree-empty {
padding: 20px 0;
text-align: center;
color: var(--fms-text-secondary);
font-size: 13px;
}
</style>
@@ -1,12 +1,15 @@
<script setup>
/**
* 表单设计器(FormDesignEditor)——主表表单画布设计(s_field_edit)。
* 表单设计器(FormDesignEditor)——表单画布设计(s_module_schema.b_schema_type = 'edit')。
* 纯编辑器逻辑,不与业务打交道:数据经 props 注入、变更经 emit 上抛。
* 提供「设计视图 / 数据视图」两种形态,共享同一份草稿数据:
* - 设计视图:字段库 | 表单画布 | 属性面板。字段可添加、任意位置拖拽、跨分组移动,
* 支持 24 栅格、动态栅格分配、textarea 高度、分组方向和删除草稿项。
* - 数据视图:保留原有 FmsTable 高密度编辑表格(行拖拽排序)。
* 编辑方式(主表表单/子表表格)切换器在头部,变更经 update:editMode 上抛由父壳路由。
*
* 对外只读写 s_module_schema.b_schema_json 的解析对象,编辑器内部经 schemaModel 适配层
* 把 JSON 节点树转成「行 + 分组」草稿;V2 已不再有 s_field_edit / s_field_group 这类独立表,
* 也没有 b_edit_mode 编辑方式切换(表单设计统一走画布)。
*/
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Trash2, Asterisk, Eye, EyeOff, GripVertical, Check, X, Plus, Settings2 } from '@lucide/vue'
@@ -26,8 +29,8 @@ import {
FormItem,
Modal,
} from '@/components/ui'
/* 新建分组的主键 = 分组编码(s_field_group.b_id,模块内唯一、创建后不可修改)。
* 不能使用负数临时 id(会被写入库),用时间基数 + 递增序号生成稳定编码 */
/* 新建分组的标识 = 分组编码(JSON 节点的 id,模块内唯一、创建后不可修改)。
* 不能使用负数临时 id(会被写入 JSON 落库),用时间基数 + 递增序号生成稳定编码 */
let formGroupSequence = 0
function createFormGroupCode() {
formGroupSequence += 1
@@ -143,11 +146,11 @@ const dragOverSection = ref(null)
// 分组拖拽状态:同字段拖拽的 dragPayload,用于拖拽时其他分区变淡
const sectionDragging = ref(false)
/* ================= 分组宽度(外层 48 宫格,落库 s_field_group.b_colspan) =================
/* ================= 分组宽度(外层 48 宫格,写回分组 JSON 节点的 b_colspan) ================
* 分组像字段一样按占格数流式排布:默认 48(独占一行),调小后自动并排换行;
* 分组内部字段仍是独立 24 宫格。
* sectionCols 为内存缓存:加载时由 group.b_colspan 回填,修改时同步写回 group 并 emit,
* 由父级 diff 落库;未分组无真实分组记录,其宽度仅前端暂存。 */
* 由父级 diff 落库;未分组不是真实分组节点,其宽度仅前端暂存。 */
const GROUP_GRID = 48
// 分组设置弹窗的常用档位:分组宽度(48 宫格)与新建字段宽度(24 宫格)
const GROUP_STEP_COLS = [48, 24, 16, 12]
@@ -1,29 +1,29 @@
<script setup>
/**
* 表格设计器(TableDesignEditor)——子表编辑与列表配置共用。
* 表格设计器(TableDesignEditor)——列表配置(s_module_schema.b_schema_type = 'view')。
*
* 两套场景共用同一套机制(结构编辑内核 useStructureEditing、多级表头推导
* 与 FormDesignEditor 共用同一套机制(结构编辑内核 useStructureEditing、多级表头推导
* useBandLayout、字段库 FieldLibrary、表头预览 TablePreview、分组树
* StructureTree、属性面板 TableInspector、数据视图 EditDataView),
* 差异全部由 mode + features props 参数化:
* 差异全部由 mode + props 参数化。
*
* mode = 'subtable'(子表编辑,s_field_edit 行):
* - 头部显示「编辑方式」切换器(主表表单 / 子表表格,emit update:editMode)
* - 删除列 = 真删;分组可启停;剪贴板全量键集;数据视图含 必填/只读/禁用 列
* mode = 'list'(列表配置,s_field_view 行):
* - 头部无编辑方式切换器
* - 删除列 = 真删行(行存在即展示,与查询配置同语义);无分组启停;
* 剪贴板仅 列宽;数据视图含 标题多语言 列
* 数据模型:对外只读写 s_module_schema.b_schema_json 的解析对象,schemaType 由 mode 决定;
* 编辑器内部经 schemaModel 适配层把 JSON 节点树转成「行 + 分组」草稿,回写时再转回 JSON。
* V2 已不再有 s_field_view / s_field_edit 这类独立配置表,行与分组都是编辑器内部草稿。
*
* 用法(数据全部走 v-model 双向绑定,本编辑器直接读写父级持有的对应数据):
* mode:
* - 'list'(列表配置,schemaType=view):当前唯一在用的场景,宿主是 ModuleListConfigPanel。
* 删除列 = 真删行(行存在即展示,与查询配置同语义);剪贴板仅 列宽;
* 数据视图含 标题多语言 列。
* - 'subtable'(schemaType=edit):V2 移除模块编辑方式(b_edit_mode)后已无宿主,暂未启用。
*
* 用法(数据全部走 v-model 双向绑定,本编辑器直接读写父级持有的配置对象):
* <TableDesignEditor
* mode="subtable|list"
* v-model="edit_config|list_config"
* v-model:groups="groups"
* v-model:edit-mode="maindata.b_edit_mode" // 仅 subtable 用,用于「编辑方式」切换器
* mode="list"
* v-model="view_schema"
* :module-id="String(...)"
* :fields="fields"
* :active="activeSection === 'edit'" // 仅 subtable 用:非激活 tab 不响应键盘删除
* :create-row="createRow"
* :loading="busy"
* />
*/
@@ -67,7 +67,7 @@ const schemaModel = defineModel({ type: Object, default: () => createDefaultSche
const rows = ref([])
/** 编辑器内部分组草稿 */
const groups = ref([])
/** 编辑方式(subtable 专用),保留以兼容内部切换器 */
/** 编辑方式:仅 subtable 模式的头部切换器消费;该模式暂无宿主,当前不渲染 */
const editMode = ref('form')
const schemaType = computed(() => (props.mode === 'subtable' ? 'edit' : 'view'))
@@ -1,6 +1,6 @@
/**
* 设计器内核(design-editor 唯一共享 js,纯编辑器逻辑、不与业务打交道)。
* 由 TableDesignEditor(子表/列表表格设计)与 FormEditPanel(表单画布设计)共用,按区块划分:
* 由 TableDesignEditor(列表表格设计)与 FormDesignEditor(表单画布设计)共用,按区块划分:
*
* 1. 分组树推导 buildGroupTree / groupIdOfRow / useGroupTree
* 2. 字段查询 useFieldLookup(字段名/字段对象 Map、控件类型标签)
@@ -21,8 +21,8 @@ import { computed, nextTick, ref, watch } from 'vue'
import { Message } from '@/components/ui'
import { createTempId } from '@/utils/common'
/* 新建分组的主键 = 分组编码(s_field_group.b_id,模块内唯一、创建后不可修改)。
* 无法使用负数临时 id(会被写入库),故生成稳定编码:时间基数 + 递增序号。 */
/* 新建分组的标识 = 分组编码(JSON 节点的 id,模块内唯一、创建后不可修改)。
* 无法使用负数临时 id(会被写入 JSON 落库),故生成稳定编码:时间基数 + 递增序号。 */
let groupSequence = 0
function createGroupCode() {
groupSequence += 1
@@ -30,7 +30,7 @@ function createGroupCode() {
}
/* ============================================================
* 1. 分组树推导(s_field_group 分组扁平数组 → 树 + 多级表头 band 元信息)
* 1. 分组树推导(编辑器内部分组扁平数组 → 树 + 多级表头 band 元信息)
* ============================================================ */
// toSorted 返回新数组,必须回写原数组,否则 buildGroupTree 的排序不生效
@@ -351,11 +351,11 @@ export function filterLibraryFields(fields, keyword) {
}
/**
* 创建编辑行工厂函数(s_field_edit 行,PK b_module_id+b_field)。
* 创建编辑行工厂函数(编辑器内部行草稿,最终经 schemaModel 转成 JSON 节点)。
* 共享:b_module_id / b_field / b_group_id / b_visible / b_required / b_readonly / b_disabled
* / b_colspan(默认24) / b_height(textarea 120) / b_placeholder / b_xh
* 差异:由 getExtra(field, groupId) 补充(如 b_width / b_colspan 覆盖)。
* 行无独立雪花主键:以字段列名 b_field 为行身份与定位键。
* 行没有自己的表,也不取雪花主键:以字段编码 b_field 为行身份与定位键。
* @param {{ moduleId: string, getExtra?: (field: object, groupId: string|null) => object }} opts
* @returns {(field: object, groupId: string|null) => object}
*/
@@ -1,7 +1,7 @@
<script setup>
/**
* 表单运行时预览:把「分组 + 编辑配置行」渲染为运行时表单形态。
* 接收原始设计数据(s_field_group / s_field_edit / s_field),
* 接收编辑器草稿(内部分组数组 / 编辑配置行 / s_field 字段定义),
* 内部自行推导分组结构与字段渲染,用 Form/FormGroup/FormItem 输出。
* 纯展示(mock 值),无编辑交互;未来运行时表单引擎可复用同一渲染结构。
*/
@@ -12,9 +12,9 @@ import { useFieldLookup } from '../common/useStructureEditing'
import { spanOf } from './useFormGrid'
const props = defineProps({
/** 分组扁平数组(s_field_group) */
/** 分组扁平数组(编辑器内部草稿,由 edit JSON 的 group 节点解析而来) */
groups: { type: Array, default: () => [] },
/** 表单编辑配置行(s_field_edit,含 b_group_id / b_colspan / b_required 等) */
/** 表单编辑配置行(编辑器内部草稿,含 b_group_id / b_colspan / b_required 等) */
rows: { type: Array, default: () => [] },
/** 字段定义(s_field,用于取名称/类型/默认值) */
fields: { type: Array, default: () => [] },
@@ -67,7 +67,7 @@ function nextXh(rows) {
return max + 10
}
/** 字段 → 列表列配置行(s_field_view,PK b_module_id+b_field),追加到末尾时使用 */
/** 字段 → 列表列配置行(编辑器内部行草稿,身份为 b_field),追加到末尾时使用 */
export function createListConfigRow(moduleId, field, rows) {
return {
b_module_id: String(moduleId),
@@ -79,7 +79,7 @@ export function createListConfigRow(moduleId, field, rows) {
}
}
/** 字段 → 查询条件配置行(s_field_query,PK b_module_id+b_field+b_condition_no) */
/** 字段 → 查询条件配置行(编辑器内部行草稿,身份为 b_field + b_condition_no) */
export function createQueryConfigRow(moduleId, field, rows) {
return {
b_module_id: String(moduleId),
@@ -34,23 +34,19 @@ import {
import { Button, Splitter, SplitterPanel, Tabs, Modal, Message, Spin } from '@/components/ui'
import { loadDataApi, saveObjectApi } from '@/services/api'
import {
addRowUpdate,
applyTreePosition,
buildSortedTree,
computeTreeMove,
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,
buildI18nChangeRequest,
buildI18nPivot,
@@ -210,8 +206,6 @@ const isVirtualModule = computed(() => moduleType.value === 'virtual')
/** data / virtual 拥有字段定义与界面配置 */
const supportsConfig = computed(() => isDataModule.value || isVirtualModule.value)
const moduleTypeLabel = computed(() => MODULE_TYPE_LABELS[moduleType.value] || '')
const parentModuleName = computed(() => {
if (!selectedModule.value?.b_parent_id) return ''
return findTreeNode(moduleTreeData.value, selectedModule.value.b_parent_id)?.b_name || ''
@@ -649,67 +643,25 @@ async function deleteModule(id) {
/* ================= 排序(拖拽) ================= */
async function onModuleDrop({ dragKey, dropKey, dropPosition }) {
const dragId = String(dragKey)
const dropId = String(dropKey)
if (dragId === dropId) return
const dragPos = findNodePosition(moduleTreeData.value, dragId)
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('不能把模块移动到它自己的下级')
// 成环 / 类型 / 未保存守卫与 b_xh、b_depth、b_path、b_parent_id 的重算都在 computeTreeMove 内
const result = computeTreeMove(moduleTreeData.value, {
dragKey,
dropKey,
dropPosition,
typeField: 'b_module_type',
nonLeafValues: MODULE_NON_LEAF_TYPES,
isUnsaved: (id) => unsavedModuleIds.has(id),
messages: {
cycle: '不能把模块移动到它自己的下级',
notContainer: '只能把模块放入模块类型的节点下',
unsavedTarget: '请先保存目标模块,再调整层级',
},
})
if (!result.ok) {
if (result.message) Message.warning(result.message)
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()
if (dropPosition === 'inside') {
moved.b_parent_id = dropNode.b_id
if (!dropNode.children) {
dropNode.children = []
dropNode.isLeaf = false
}
dropNode.children.push(moved)
renumberSets.add(dropNode.children)
renumberSets.add(dragPos.list)
} else {
moved.b_parent_id = dropParentId
const targetList = dropPos.list
let targetIndex = dropPos.index + (dropPosition === 'after' ? 1 : 0)
if (dragPos.list === dropPos.list && dragPos.index < dropPos.index) targetIndex -= 1
targetList.splice(targetIndex, 0, moved)
renumberSets.add(targetList)
}
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 })
}
const { updatedRows } = result
// 排序即时落库(下方 saveObjectApi),草稿与基线需一起同步:
// 只改 maindata 会让已落库的 b_xh / b_parent_id / b_depth / b_path 被
@@ -1007,9 +959,6 @@ loadModuleTree()
>
<Save :size="14" /> 保存
</Button>
<span v-if="moduleTypeLabel" class="mm-main-toolbar__type">{{
moduleTypeLabel
}}</span>
</div>
<div class="mm-body__inner">
@@ -1249,11 +1198,6 @@ loadModuleTree()
border-radius: var(--fms-control-radius);
}
.mm-main-toolbar__type {
color: var(--fms-text-secondary);
font-size: 12px;
}
.mm-body__inner {
display: flex;
flex-direction: column;
@@ -9,12 +9,6 @@ export const MODULE_TYPE_ICON_MAP = {
virtual: Search,
}
export const MODULE_TYPE_LABELS = {
module: '模块',
data: '数据模块',
virtual: '虚拟模块',
}
/** 模块树里能承载子模块的类型:data / virtual 是叶子节点 */
export const MODULE_NON_LEAF_TYPES = ['module']
@@ -0,0 +1,28 @@
<script setup>
/**
* 系统管理 —— 操作日志 / 审计(v_s_log_audit)。
*
* 只读列表:模块的 b_save_table 留空,列表页不进入可编辑模式。
* 记录「谁在何时对哪条数据做了什么」,字段级的前后值在 s_log_audit_field 表,
* 需要时按 b_id 关联查询;本页只呈现事件级信息。
*/
import { FmsModuleListPage } from '@/components/fms-module-list'
</script>
<template>
<div class="log-page">
<FmsModuleListPage data-code="v_s_log_audit" />
</div>
</template>
<style scoped lang="scss">
.log-page {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
</style>
@@ -0,0 +1,28 @@
<script setup>
/**
* 系统管理 —— 登录日志(v_s_log_login)。
*
* 只读列表:模块的 b_save_table 留空,列表页因此不进入可编辑模式,
* 不提供新增 / 编辑 / 删除入口 —— 日志只能由后端写入。
* 展示字段与查询条件全部来自模块元数据(s_field / s_module_schema),本页不做任何数据加工。
*/
import { FmsModuleListPage } from '@/components/fms-module-list'
</script>
<template>
<div class="log-page">
<FmsModuleListPage data-code="v_s_log_login" />
</div>
</template>
<style scoped lang="scss">
.log-page {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
</style>
@@ -0,0 +1,30 @@
<script setup>
/**
* 系统管理 —— 技术日志(v_s_log_technical)。
*
* 只读列表:模块的 b_save_table 留空,列表页不进入可编辑模式。
* 面向开发 / 运维排查:b_trace_id 可与登录日志、操作日志互相串起来看同一次请求。
* b_exception_text / b_context_text 是大文本,未进列表字段,按 trace_id 查库获取。
*
* 注:该路由已注册,但技术日志默认不放进侧栏菜单(见 sql/fms_module_log.sql 第 7 节)。
*/
import { FmsModuleListPage } from '@/components/fms-module-list'
</script>
<template>
<div class="log-page">
<FmsModuleListPage data-code="v_s_log_technical" />
</div>
</template>
<style scoped lang="scss">
.log-page {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
</style>
@@ -0,0 +1,519 @@
<script setup>
/**
* 用户管理 —— 左侧部门树面板(b_dept)。
*
* 只负责部门树的加载与维护(新增 / 修改 / 删除 / 搜索 / 拖拽调层级与顺序),
* 选中结果通过 v-model 抛给编排层,由编排层转成右侧用户列表的固定查询条件。
*
* b_dept 遵循核心表结构的树规范:b_parent_id 是权威关系,b_depth / b_path 是派生字段;
* 成环守卫与 b_xh / b_depth / b_path / b_parent_id 的重算全部由 @/utils/tree 的
* computeTreeMove 承担,本组件只做「算差异 → 落库 → 重载」。
*/
import { computed, ref, watch } from 'vue'
import { Pencil, Plus, Search, Trash2 } from '@lucide/vue'
import { Button, Form, FormItem, Input, Message, Modal } from '@/components/ui'
import FmsTree from '@/components/fms-tree/FmsTree.vue'
import { loadDataApi, saveObjectApi } from '@/services/api'
import {
applyTreePosition,
buildSortedTree,
collectAllKeys,
computeTreeMove,
filterTreeByText,
findTreeNode,
} from '@/utils/tree'
const DEPT_TABLE = 'b_dept'
/** 选中的部门编码;空串表示「未选中」(FmsTree 用空选中键表达虚拟根) */
const selectedKey = defineModel('selectedKey', { type: String, default: '' })
const emit = defineEmits(['loaded'])
/* ===================== 纯函数 ===================== */
/** 同级下一个排序号(最大 b_xh + 10,无同级时取 10) */
function nextSiblingXh(rows, parentId) {
const normalizedParentId = parentId == null ? null : String(parentId)
let maxXh = 0
for (const row of rows || []) {
const rowParentId = row.b_parent_id == null ? null : String(row.b_parent_id)
if (rowParentId !== normalizedParentId) continue
maxXh = Math.max(maxXh, Number(row.b_xh) || 0)
}
return maxXh + 10
}
/* ===================== 状态与加载 ===================== */
const depts = ref([])
const loading = ref(false)
const expandedKeys = ref([])
const keyword = ref('')
const deptById = computed(() => new Map(depts.value.map((item) => [String(item.b_id), item])))
/** 树是 depts 的派生视图(在浅拷贝上构建) */
const deptTree = computed(() => buildSortedTree(depts.value))
/** 部门表只有名称,匹配「名称 + 编码」两列 */
const filteredTree = computed(() =>
filterTreeByText(deptTree.value, keyword.value, ['b_name', 'b_id']),
)
// 搜索时展开全部(保证命中节点可见),清空时收起
watch(keyword, (kw) => {
expandedKeys.value = kw.trim() ? collectAllKeys(deptTree.value) : []
})
async function loadDepts() {
loading.value = true
try {
// 只加载启用部门(未启用的不展示)
const response = await loadDataApi(DEPT_TABLE, 'b_canuse = 1', 'b_path ASC, b_xh ASC, b_id ASC')
depts.value = response.data || []
// 选中的部门已被删除时清空选中
const existingKeys = new Set(depts.value.map((item) => String(item.b_id)))
if (selectedKey.value && !existingKeys.has(selectedKey.value)) {
selectedKey.value = ''
}
// 展开所有含子节点的部门,并保留用户已手动展开的
const parentKeys = depts.value
.map((item) => (item.b_parent_id == null ? '' : String(item.b_parent_id)))
.filter(Boolean)
expandedKeys.value = [...new Set([...expandedKeys.value, ...parentKeys])]
emit('loaded', depts.value)
} catch (error) {
Message.error('部门加载失败:' + (error?.message || error))
} finally {
loading.value = false
}
}
/** FmsTree 已把点中虚拟根归一化成空数组,这里只需处理业务部门 */
function onSelect(keys) {
selectedKey.value = keys.length ? String(keys[0]) : ''
}
/* ===================== 右键菜单与工具栏 ===================== */
/** node 为 null 表示右键点在空白处,一并按虚拟根处理 */
function isRootNode(node) {
return !node || node.isFmsRoot
}
/** 根节点只能新增顶级部门 */
function buildContextMenu(node) {
if (isRootNode(node)) {
return [{ key: 'add', label: '新增部门', icon: Plus }]
}
return [
{ key: 'add', label: '新增子部门', icon: Plus },
{ divider: true },
{ key: 'edit', label: '修改部门', icon: Pencil },
{ key: 'delete', label: '删除', icon: Trash2, danger: true },
]
}
function onMenuClick({ item, node }) {
if (item.key === 'add') {
onAdd({ parentId: isRootNode(node) ? null : String(node.b_id) })
} else if (item.key === 'edit') {
onEdit(node)
} else if (item.key === 'delete') {
void onDelete(node)
}
}
/** 工具栏新增挂到当前选中部门下;未选中时建为顶级 */
function onToolbarAdd() {
const selected = String(selectedKey.value ?? '')
onAdd({ parentId: selected || null })
}
/* ===================== 新增 / 修改弹窗 ===================== */
const modalOpen = ref(false)
const modalSaving = ref(false)
/** 修改时的部门行;新增时为 null */
const editingDept = ref(null)
/** 新增部门的上级编码;null 表示顶级 */
const parentId = ref(null)
const isEdit = computed(() => !!editingDept.value)
/** 本地草稿:打开时快照,确定时提交 */
const draft = ref({ b_id: '', b_name: '', b_xh: 10 })
const parentName = computed(() => {
if (parentId.value == null || parentId.value === '') return '顶级部门'
const parent = depts.value.find((row) => String(row.b_id) === String(parentId.value))
return parent?.b_name || String(parentId.value)
})
watch(modalOpen, (open) => {
if (!open) return
if (editingDept.value) {
draft.value = {
b_id: editingDept.value.b_id ?? '',
b_name: editingDept.value.b_name ?? '',
b_xh: Number(editingDept.value.b_xh) || 0,
}
return
}
draft.value = {
b_id: '',
b_name: '',
b_xh: nextSiblingXh(depts.value, parentId.value),
}
})
function setField(field, value) {
draft.value = { ...draft.value, [field]: value }
}
const formRef = ref(null)
const formRules = computed(() => ({
b_id: [
{ required: true, message: '请填写部门编码' },
{ pattern: /^[A-Za-z0-9_-]+$/, message: '部门编码只能包含字母、数字、下划线或短横线' },
{ max: 50, message: '部门编码长度不能超过 50' },
{
// b_id 是主键,重复即入库失败;这里提前拦下(修改自身时排除)
validator: (value) => {
const code = String(value ?? '').trim()
if (!code) return true
const selfId = String(editingDept.value?.b_id ?? '')
const duplicated = depts.value.some(
(row) => String(row.b_id) === code && String(row.b_id) !== selfId,
)
return duplicated ? '部门编码已存在' : true
},
},
],
b_name: [
{ required: true, message: '请填写部门名称' },
{ max: 200, message: '部门名称长度不能超过 200' },
],
}))
async function onOk() {
const errors = (await formRef.value?.validateAll()) ?? []
if (errors.length) {
Message.warning('请修正表单中标红的错误项')
return
}
void onSubmit({
b_id: String(draft.value.b_id ?? '').trim(),
b_name: String(draft.value.b_name ?? '').trim(),
b_xh: Number(draft.value.b_xh) || 0,
})
}
function onAdd({ parentId: targetParentId }) {
editingDept.value = null
parentId.value = targetParentId == null ? null : String(targetParentId)
modalOpen.value = true
}
function onEdit(node) {
const dept = deptById.value.get(String(node?.b_id ?? ''))
if (!dept) {
Message.error('未找到需要修改的部门')
return
}
editingDept.value = dept
parentId.value = dept.b_parent_id == null ? null : String(dept.b_parent_id)
modalOpen.value = true
}
/** 新增补 b_depth / b_path 后 insert;修改只提交名称与排序号(编码是主键,创建后不可改) */
async function onSubmit(payload) {
if (modalSaving.value) return
const isEditing = isEdit.value
modalSaving.value = true
try {
if (isEditing) {
await saveObjectApi([
{
table: DEPT_TABLE,
key_field: 'b_id',
updates: [{ b_id: payload.b_id, b_name: payload.b_name, b_xh: payload.b_xh }],
},
])
} else {
const row = {
b_id: payload.b_id,
b_parent_id: parentId.value,
b_name: payload.b_name,
b_xh: payload.b_xh,
b_canuse: 1,
}
const parentNode = parentId.value ? findTreeNode(deptTree.value, parentId.value) : null
applyTreePosition(row, parentNode)
await saveObjectApi([{ table: DEPT_TABLE, key_field: 'b_id', inserts: [row] }])
selectedKey.value = String(row.b_id)
}
modalOpen.value = false
Message.success(isEditing ? '部门已修改' : '部门已添加')
await loadDepts()
} catch (error) {
Message.error((isEditing ? '修改' : '添加') + '失败:' + (error?.message || error))
} finally {
modalSaving.value = false
}
}
/* ===================== 删除部门 ===================== */
/** 守卫查询进行中:阻止右键重复触发 */
const checking = ref(false)
async function onDelete(node) {
const dept = deptById.value.get(String(node?.b_id ?? ''))
if (!dept || checking.value) return
checking.value = true
// 子部门与用户都查库,不能用本地 depts 判断:它只含启用部门(b_canuse = 1),
// 会放过「停用的子部门」「停用部门下的用户」,删完就留下 b_parent_id / b_dept_id 悬空的孤儿行。
const deptId = String(dept.b_id).replaceAll("'", "''")
let childCount = 0
let userCount = 0
try {
const childResponse = await loadDataApi('b_dept', `b_parent_id = '${deptId}'`, 'b_id ASC')
childCount = (childResponse.data || []).length
if (!childCount) {
// 部门下有用户时不允许删除(b_user.b_dept_id 指向本部门)
const userResponse = await loadDataApi('b_user', `b_dept_id = '${deptId}'`, 'b_id ASC')
userCount = (userResponse.data || []).length
}
} catch (error) {
Message.error('部门数据检查失败:' + (error?.message || error))
return
} finally {
checking.value = false
}
if (childCount > 0) {
Message.warning('该部门下还有子部门,请先删除子部门')
return
}
if (userCount > 0) {
Message.warning(`该部门下还有 ${userCount} 个用户,不能删除`)
return
}
Modal.confirm({
title: `确定删除部门「${dept.b_name || dept.b_id}」?`,
content: '此操作不可恢复。',
okText: '删除',
okButtonProps: { danger: true },
onOk: async () => {
try {
await saveObjectApi([
{ table: DEPT_TABLE, key_field: 'b_id', deletes: [{ b_id: dept.b_id }] },
])
} catch (error) {
Message.error('删除失败:' + (error?.message || error))
throw error // 保持确认框打开,便于重试
}
Message.success('部门已删除')
await loadDepts()
},
})
}
/* ===================== 拖拽排序(同级调序 / 跨级换父) ===================== */
async function onDrop({ dragKey, dropKey, dropPosition }) {
// 树只是 depts 的派生视图,所以一贯「在副本上算差异 → 落库 → 重载」。
// 成环守卫与 b_xh、b_depth、b_path、b_parent_id 的重算都在 computeTreeMove 内
const result = computeTreeMove(buildSortedTree(depts.value), {
dragKey,
dropKey,
dropPosition,
messages: { cycle: '不能把部门移动到它自己的下级' },
})
if (!result.ok) {
if (result.message) Message.warning(result.message)
return
}
const { updatedRows } = result
if (!updatedRows.length) return
try {
await saveObjectApi(
updatedRows.map((row) => ({ table: DEPT_TABLE, key_field: 'b_id', updates: [row] })),
)
Message.success('排序已更新')
} catch (error) {
Message.error('排序更新失败:' + (error?.message || error))
}
// 失败也重载:以库里为准,避免界面停在一次没有落库的排序上
await loadDepts()
}
void loadDepts()
defineExpose({ reload: loadDepts })
</script>
<template>
<aside class="dept-panel">
<div class="dept-panel__header">
<Input v-model="keyword" placeholder="搜索部门" allow-clear class="dept-panel__search">
<template #prefix><Search :size="14" class="dept-panel__search-icon" /></template>
</Input>
<div class="dept-panel__actions">
<Button
type="ghost"
class="dept-panel__icon-btn"
title="新增部门"
aria-label="新增部门"
@click="onToolbarAdd"
>
<Plus :size="14" />
</Button>
</div>
</div>
<div class="dept-panel__scroll">
<!-- 搜索过滤时禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
<FmsTree
v-model:expanded-keys="expandedKeys"
show-root
root-title="全部部门"
:tree-data="filteredTree"
:loading="loading"
:selected-keys="selectedKey ? [String(selectedKey)] : []"
:context-menu="buildContextMenu"
:draggable="!keyword.trim()"
@select="onSelect"
@context-menu-click="onMenuClick"
@drop="(payload) => void onDrop(payload)"
>
<template #title="{ title }">
<span class="dept-panel__node-name">{{ title }}</span>
</template>
</FmsTree>
</div>
<!-- 新增 / 修改:确定先校验再落库 -->
<Modal
v-model:open="modalOpen"
:title="isEdit ? '修改部门' : '新增部门'"
:width="520"
:mask-closable="false"
:confirm-loading="modalSaving"
@ok="onOk"
>
<Form ref="formRef" :model="draft" :rules="formRules" layout="vertical" :gap="16">
<FormItem label="上级部门" name="b_parent_id">
<Input :model-value="parentName" disabled />
</FormItem>
<FormItem label="部门编码" name="b_id" required>
<Input
:model-value="draft.b_id"
:disabled="isEdit"
placeholder="主键即编码,保存后不可修改"
@update:model-value="(v) => setField('b_id', v)"
/>
</FormItem>
<FormItem label="部门名称" name="b_name" required>
<Input :model-value="draft.b_name" @update:model-value="(v) => setField('b_name', v)" />
</FormItem>
<FormItem label="排序号" name="b_xh">
<Input
:model-value="draft.b_xh"
type="number"
@update:model-value="(v) => setField('b_xh', Number(v) || 0)"
/>
</FormItem>
</Form>
</Modal>
</aside>
</template>
<style scoped lang="scss">
.dept-panel {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
height: 100%;
min-height: 0;
padding: 8px;
background: var(--fms-card);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-shadow);
overflow: hidden;
}
.dept-panel__header {
display: flex;
align-items: center;
gap: 6px;
flex: none;
}
.dept-panel__search {
flex: 1;
min-width: 0;
}
.dept-panel__search-icon {
color: var(--fms-text-secondary);
}
.dept-panel__actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.dept-panel__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
background: var(--fms-card);
color: var(--fms-text-secondary);
cursor: pointer;
transition:
border-color 150ms ease,
background 150ms ease,
color 150ms ease;
&:hover:not(:disabled) {
background: var(--fms-secondary);
color: var(--fms-text);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.dept-panel__scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
.dept-panel__scroll :deep(.tree) {
min-width: 100%;
}
.dept-panel__node-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,257 @@
<script setup>
/**
* 系统管理 —— 用户管理(b_user)。
*
* 左侧部门树(b_dept)+ 右侧用户列表,与 base/othercompany 的「左树右表」同构:
* - 左树由 DeptTreePanel 自管(加载 / 增删改 / 拖拽),本页只持有「选中部门」;
* - 右表由 FmsModuleListPage 按 data-code 的模块配置驱动;
* - 本页只做三件事:选中部门 → 固定查询条件(含全部下级)、
* 新增用户时默认归入选中部门、用户的新增与编辑走 FmsModuleEditModal。
*/
import { computed, ref } from 'vue'
import { Plus, Trash2 } from '@lucide/vue'
import { Button, Message, Modal, Splitter, SplitterPanel } from '@/components/ui'
import { FmsModuleListPage } from '@/components/fms-module-list'
import { FmsModuleEditModal } from '@/components/fms-module-edit'
import { escapeSqlText } from '@/components/fms-module-common/queryUtils'
import { usePermissionStore } from '@/stores/permissions'
import { collectDescendantIds } from '@/utils/tree'
import DeptTreePanel from './DeptTreePanel.vue'
const MODULE_CODE = 'v_b_user'
const permissionStore = usePermissionStore()
const canCreateRow = computed(() => permissionStore.canPower(MODULE_CODE, 'create'))
const canDeleteRow = computed(() => permissionStore.canPower(MODULE_CODE, 'delete'))
const listRef = ref(null)
/* ================= 左侧部门树 ================= */
/** 选中的部门编码;空串表示「全部部门」。树数据留在面板内,本页只需要它算子树范围 */
const selectedDeptKey = ref('')
const depts = ref([])
function onDeptLoaded(rows) {
depts.value = rows
}
/**
* 选中部门 → 固定查询条件(含全部下级部门)。
* b_dept_id 是 varchar:条件值不加 N 前缀,避免列被隐式转成 nvarchar 而用不上索引。
*/
const deptFilterCondition = computed(() => {
if (!selectedDeptKey.value) return ''
const ids = collectDescendantIds(depts.value, selectedDeptKey.value)
if (!ids.length) return '1 = 0'
const literals = ids.map((id) => `'${escapeSqlText(id)}'`)
return `[b_dept_id] IN (${literals.join(', ')})`
})
/* ================= 用户新增 / 编辑 ================= */
const editOpen = ref(false)
/** 编辑的用户行;null 表示新增 */
const editingUser = ref(null)
function onCreate() {
editingUser.value = null
editOpen.value = true
}
function onEdit(row) {
editingUser.value = row
editOpen.value = true
}
/**
* 新增用户时把选中部门设为默认值。
*
* 钩子收到的 row 是合并后的完整行,且在组装 inserts 之前被调用,
* 所以直接给它补 b_dept_id 即可(不用 extraUpdates:那条路径只作用于 updates)。
* 只在「新增 + 选中了部门 + 表单没带该字段值」时补,编辑时不干预。
*/
function onBeforeSave(row, { isNew }) {
if (isNew && selectedDeptKey.value && !row.b_dept_id) {
row.b_dept_id = selectedDeptKey.value
}
// 密码:视图 v_b_user 刻意不含该列(避免全员口令随列表接口下发),所以编辑态表单恒为空。
// 留空 = 保持原密码,必须把字段摘掉再提交 —— 否则会被 diff 当成「改成空」覆盖库中原值;
// 新增态则必填(edit 配置不再设 required,改由这里按场景校验)。
if (!String(row.b_password ?? '').trim()) {
if (isNew) {
Message.warning('请填写密码')
return false
}
delete row.b_password
}
return true
}
async function onSaved() {
await listRef.value?.reload()
}
/* ================= 行多选与批量删除 ================= */
const selectedRowKeys = ref([])
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys
},
}))
/* ================= 行操作:编辑 / 删除 ================= */
/** 单行删除进行中:阻止行操作按钮重复触发 */
const rowSaving = ref(false)
// 删除走 FmsTable 内置的 confirm 状态化弹窗(与 i18n-type / contact 一致),
// 不再手工 Modal.confirm,少一层控制流。
const rowActions = computed(() => {
const actions = [
{
key: 'edit',
label: '编辑',
disabled: () => rowSaving.value,
onClick: onEdit,
},
]
if (canDeleteRow.value) {
actions.push({
key: 'delete',
type: 'delete',
label: '删除',
confirm: { title: '删除用户', content: '确定删除该用户吗?此操作不可恢复。' },
disabled: () => rowSaving.value,
onClick: (row) => removeRow(row),
})
}
return actions
})
async function removeRow(row) {
if (rowSaving.value) return
rowSaving.value = true
try {
// 走列表组件的删除通道:主键 / 保存表由 FmsModuleListPage 内部解析
await listRef.value?.deleteSelected([row.b_id])
selectedRowKeys.value = selectedRowKeys.value.filter((key) => key !== row.b_id)
} catch (error) {
Message.error('删除失败:' + (error?.message || error))
} finally {
rowSaving.value = false
}
}
function confirmBatchDelete() {
const keys = [...selectedRowKeys.value]
if (!keys.length) return
Modal.confirm({
title: `确定删除选中的 ${keys.length} 个用户?`,
content: '此操作不可恢复。',
okText: '删除',
okButtonProps: { danger: true },
onOk: async () => {
try {
await listRef.value?.deleteSelected(keys)
} catch (error) {
Message.error('删除失败:' + (error?.message || error))
throw error // 保持确认框打开,便于重试
}
Message.success('删除成功')
selectedRowKeys.value = []
},
})
}
</script>
<template>
<div class="su-page">
<Splitter class="su-splitter">
<SplitterPanel :default-size="248" :min="180" :max="420">
<DeptTreePanel v-model:selected-key="selectedDeptKey" @loaded="onDeptLoaded" />
</SplitterPanel>
<SplitterPanel>
<main class="su-list">
<FmsModuleListPage
ref="listRef"
:data-code="MODULE_CODE"
:fixed-search-condition="deptFilterCondition"
:row-selection="rowSelection"
:row-actions="rowActions"
:inline-action-limit="2"
>
<template #actions="{ loading }">
<Button
v-if="canCreateRow"
type="primary"
:disabled="loading || rowSaving"
@click="onCreate"
>
<Plus :size="14" />
新增
</Button>
<Button
v-if="canDeleteRow"
type="danger"
:disabled="loading || rowSaving || !selectedRowKeys.length"
@click="confirmBatchDelete"
>
<Trash2 :size="14" />
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
</Button>
</template>
</FmsModuleListPage>
</main>
</SplitterPanel>
</Splitter>
<!-- 用户新增 / 编辑:由模块的 edit 配置自动渲染表单 -->
<FmsModuleEditModal
v-model:open="editOpen"
:data-code="MODULE_CODE"
:record="editingUser"
:on-before-save="onBeforeSave"
@saved="onSaved"
/>
</div>
</template>
<style scoped lang="scss">
.su-page,
.su-splitter {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.su-page {
overflow: hidden;
}
.su-splitter :deep(> .fms-splitter-panel) {
overflow: hidden;
}
.su-splitter :deep(.fms-splitter-bar::before) {
opacity: 0;
}
.su-splitter :deep(.fms-splitter-bar:hover::before),
.su-splitter :deep(.fms-splitter-bar-active::before),
.su-splitter :deep(.fms-splitter-bar:focus-visible::before) {
opacity: 1;
}
/* 列表卡自带白底(.fms-module-card),外层不再铺白底,避免灰缝被染色 */
.su-list {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
</style>