20260915221013
This commit is contained in:
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>
|
||||
+9
-6
@@ -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]
|
||||
|
||||
+17
-17
@@ -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'))
|
||||
|
||||
+6
-6
@@ -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}
|
||||
*/
|
||||
|
||||
+3
-3
@@ -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>
|
||||
Reference in new issue
Block a user