这是一个基础模态框。拖动标题栏可移动位置,拖动右下角可调整尺寸;右上角可切换全屏;点击遮罩、关闭按钮或按
diff --git a/code/fms/fms-vue/src/components/ui/demo/space.vue b/code/fms/fms-vue/src/components/ui/demo/space.vue
new file mode 100644
index 00000000..84e8f49c
--- /dev/null
+++ b/code/fms/fms-vue/src/components/ui/demo/space.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+
Components
+
Space 间距
+
设置并排子元素之间的统一间距,避免它们紧贴在一起;支持方向、间距、对齐、换行与分隔符。
+
+
+ 基础用法(水平排列按钮)
+
+
+
+
+
+
+
+
+ 间距大小 size:small / middle / large / 自定义数字
+
+
+ small
+ 8px
+ 间距
+
+
+ middle
+ 16px
+ 间距
+
+
+ large
+ 24px
+ 间距
+
+
+ 数字
+ 32px
+ 间距
+
+
+
+ 垂直方向 vertical(size 数组 [水平, 垂直])
+
+
+ 第一行
+ 第二行
+ 第三行
+
+
+
+ 对齐 align:start / center / end / baseline(子项高度不一)
+
+
+ start
+ 高
+ 对齐
+
+
+ center
+ 高
+ 居中
+
+
+ end
+ 高
+ 对齐
+
+
+
+ 自动换行 wrap(容器宽度不足时折行)
+
+
+ 标签 {{ n }}
+
+
+
+ 分隔符 separator(插槽)
+
+
+ 编辑
+ 删除
+ 更多
+ |
+
+
+ 首页
+ 分类
+ 详情
+ /
+
+
+
+
+
+
diff --git a/code/fms/fms-vue/src/components/ui/index.js b/code/fms/fms-vue/src/components/ui/index.js
index 62de563a..ccd44e73 100644
--- a/code/fms/fms-vue/src/components/ui/index.js
+++ b/code/fms/fms-vue/src/components/ui/index.js
@@ -15,6 +15,7 @@ import FormGroup from './form/FormGroup.vue'
import FormItem from './form/form-item.vue'
import Input from './input/input.vue'
import Modal from './modal/modal.vue'
+import { confirm as modalConfirm } from './modal/modal-confirm.js'
import Pagination from './pagination/pagination.vue'
import Popover from './popover/popover.vue'
import Radio from './radio/radio.vue'
@@ -34,6 +35,9 @@ import Tree from './tree/tree.vue'
import { Message } from './message/message-manager'
+/** 命令式确认对话框:Modal.confirm({ title, content, okText, onOk, ... }) */
+Modal.confirm = modalConfirm
+
export {
Button,
ButtonGroup,
diff --git a/code/fms/fms-vue/src/components/ui/modal/modal-confirm.js b/code/fms/fms-vue/src/components/ui/modal/modal-confirm.js
new file mode 100644
index 00000000..047fde0a
--- /dev/null
+++ b/code/fms/fms-vue/src/components/ui/modal/modal-confirm.js
@@ -0,0 +1,37 @@
+import { createVNode, render } from 'vue'
+import ModalConfirm from './modal-confirm.vue'
+
+/**
+ * 命令式确认对话框:自建容器挂到 body,关闭动画结束(Modal 的 closed 事件)
+ * 后才卸载容器,保证退场动画完整播放。
+ *
+ * 用法:
+ * Modal.confirm({
+ * title: '确定删除?',
+ * content: '此操作不可恢复',
+ * okText: '删除',
+ * okButtonProps: { danger: true },
+ * onOk: async () => { ... }, // 返回 Promise 时按钮进入 loading;reject 保持打开
+ * onCancel: () => {},
+ * })
+ *
+ * @returns {{ destroy: () => void }} destroy 立即关闭并卸载(不等退场动画)
+ */
+export function confirm(options = {}) {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ let destroyed = false
+ function destroy() {
+ if (destroyed) return
+ destroyed = true
+ render(null, container)
+ container.remove()
+ }
+
+ render(createVNode(ModalConfirm, { ...options, onClosed: destroy }), container)
+
+ return { destroy }
+}
+
+export default confirm
diff --git a/code/fms/fms-vue/src/components/ui/modal/modal-confirm.vue b/code/fms/fms-vue/src/components/ui/modal/modal-confirm.vue
new file mode 100644
index 00000000..eec0132a
--- /dev/null
+++ b/code/fms/fms-vue/src/components/ui/modal/modal-confirm.vue
@@ -0,0 +1,84 @@
+
+
+
+
+
+ {{ content }}
+
+
+
+
+
+
+
+
diff --git a/code/fms/fms-vue/src/components/ui/modal/modal.vue b/code/fms/fms-vue/src/components/ui/modal/modal.vue
index 700041c6..fec610fe 100644
--- a/code/fms/fms-vue/src/components/ui/modal/modal.vue
+++ b/code/fms/fms-vue/src/components/ui/modal/modal.vue
@@ -40,7 +40,7 @@ const props = defineProps({
confirmLoading: { type: Boolean, default: false },
})
-const emit = defineEmits(['update:open', 'ok', 'cancel'])
+const emit = defineEmits(['update:open', 'ok', 'cancel', 'closed'])
/* ---------- 公共状态 ---------- */
const innerOpen = ref(props.defaultOpen)
@@ -137,54 +137,61 @@ function onPanelKeydown(event) {
trapFocus(event, panelRef.value)
}
-/* ---------- 生命周期:打开/关闭时接管焦点与滚动 ---------- */
-watch(isOpen, (value) => {
- if (value) {
- // 记录触发元素,关闭后还原焦点
- triggerEl = document.activeElement
- lockScroll()
- scrollLocked = true
- nextTick(() => {
- if (!isOpen.value || !panelRef.value) return
- focusFirst(panelRef.value)
- panelRef.value.addEventListener('keydown', onPanelKeydown)
- // 标题栏作为拖拽手柄(面板与手柄此刻已渲染完成)
- dragHandle = bindDrag(panelRef.value, headerRef.value)
- // 四边缩放手柄:e/w 调宽度、s/n 调高度,均不超过视口(wrap 留出 32px 边距 / 124px 顶底)
- if (props.resizable) {
- resizeHandles = [
- { el: resizeERef.value, direction: 'e' },
- { el: resizeWRef.value, direction: 'w' },
- { el: resizeSRef.value, direction: 's' },
- { el: resizeNRef.value, direction: 'n' },
- ]
- .filter((item) => item.el)
- .map(({ el, direction }) =>
- bindResize(panelRef.value, el, {
- direction,
- maxWidth: window.innerWidth - 32,
- maxHeight: window.innerHeight - 124,
- onResize: (size) => {
- resizeSize.value = size
- },
- }),
- )
+/* ---------- 生命周期:打开/关闭时接管焦点与滚动 ----------
+ immediate:命令式挂载(Modal.confirm)时 open 初始即为 true,watch 不会因「变化」触发;
+ 加 immediate 让打开态初始化(滚动锁 / 焦点陷阱 / Esc / 拖拽)在挂载时执行一次,
+ 与受控用法(open 由 false→true)走同一段逻辑。 */
+watch(
+ isOpen,
+ (value) => {
+ if (value) {
+ // 记录触发元素,关闭后还原焦点
+ triggerEl = document.activeElement
+ lockScroll()
+ scrollLocked = true
+ nextTick(() => {
+ if (!isOpen.value || !panelRef.value) return
+ focusFirst(panelRef.value)
+ panelRef.value.addEventListener('keydown', onPanelKeydown)
+ // 标题栏作为拖拽手柄(面板与手柄此刻已渲染完成)
+ dragHandle = bindDrag(panelRef.value, headerRef.value)
+ // 四边缩放手柄:e/w 调宽度、s/n 调高度,均不超过视口(wrap 留出 32px 边距 / 124px 顶底)
+ if (props.resizable) {
+ resizeHandles = [
+ { el: resizeERef.value, direction: 'e' },
+ { el: resizeWRef.value, direction: 'w' },
+ { el: resizeSRef.value, direction: 's' },
+ { el: resizeNRef.value, direction: 'n' },
+ ]
+ .filter((item) => item.el)
+ .map(({ el, direction }) =>
+ bindResize(panelRef.value, el, {
+ direction,
+ maxWidth: window.innerWidth - 32,
+ maxHeight: window.innerHeight - 124,
+ onResize: (size) => {
+ resizeSize.value = size
+ },
+ }),
+ )
+ }
+ })
+ } else {
+ // 这里只做与动画无关的拆卸(键盘监听、拖拽/缩放手柄)。
+ // 解锁滚动、复位尺寸、还原焦点一律等退场动画结束再做(见 onAfterLeave)——
+ // 否则动画还在播时背景就已经能滚动、焦点也提前跳回触发按钮,屏幕阅读器会跟丢。
+ if (panelRef.value) panelRef.value.removeEventListener('keydown', onPanelKeydown)
+ // 不清除 transform:面板保持拖拽位置播放消失动画(v-if 随后销毁 DOM,无残留)
+ if (dragHandle) {
+ dragHandle.destroy()
+ dragHandle = null
}
- })
- } else {
- // 这里只做与动画无关的拆卸(键盘监听、拖拽/缩放手柄)。
- // 解锁滚动、复位尺寸、还原焦点一律等退场动画结束再做(见 onAfterLeave)——
- // 否则动画还在播时背景就已经能滚动、焦点也提前跳回触发按钮,屏幕阅读器会跟丢。
- if (panelRef.value) panelRef.value.removeEventListener('keydown', onPanelKeydown)
- // 不清除 transform:面板保持拖拽位置播放消失动画(v-if 随后销毁 DOM,无残留)
- if (dragHandle) {
- dragHandle.destroy()
- dragHandle = null
+ resizeHandles.forEach((h) => h.destroy())
+ resizeHandles = []
}
- resizeHandles.forEach((h) => h.destroy())
- resizeHandles = []
- }
-})
+ },
+ { immediate: true },
+)
/** 退场动画结束后收尾:解锁滚动、复位状态、把焦点还给触发元素 */
function onAfterLeave() {
@@ -199,6 +206,7 @@ function onAfterLeave() {
resizeSize.value = null
if (triggerEl && typeof triggerEl.focus === 'function') triggerEl.focus()
triggerEl = null
+ emit('closed')
}
// 全屏与拖拽互斥:进入全屏时停用拖拽并复位位置,退出后重新启用
@@ -230,11 +238,13 @@ onBeforeUnmount(() => {
-
+ 退场不错峰,两者同时走 —— 错峰退场会闪。after-leave 里做收尾(解锁滚动、还原焦点)。
+ appear:命令式挂载(Modal.confirm)时 open 初始即为 true,元素首帧就在,
+ 默认 Transition 不播入场动画,加 appear 让初次渲染也走一次进入过渡。 -->
+
-
+
+/**
+ * Space 间距 —— 为并排的子元素设置统一间距,避免它们紧贴在一起。
+ * 参考 antdv-next 的 Space:方向 / 间距大小(预设、数字、[水平, 垂直])/ 对齐 / 自动换行 / 分隔符。
+ *
+ * 与 Grid 的分工:Space 面向「一行内联元素的等距排列」(按钮组、标签行、操作区),
+ * 会给每个子元素包一层 .space-item 以支持分隔符与空节点隐藏;整块页面布局请用 Grid。
+ *
+ * 为何用 render 函数而非
+
+
diff --git a/code/fms/fms-vue/src/components/ui/tree/index.scss b/code/fms/fms-vue/src/components/ui/tree/index.scss
index b347b22f..9f2e341a 100644
--- a/code/fms/fms-vue/src/components/ui/tree/index.scss
+++ b/code/fms/fms-vue/src/components/ui/tree/index.scss
@@ -74,6 +74,7 @@
.tree-node-switcher:hover:not(.is-placeholder) {
background: color-mix(in srgb, var(--fms-primary) 10%, transparent);
}
+/* 叶子占位:纯对齐用,无交互反馈 */
.tree-node-switcher.is-placeholder {
cursor: default;
}
diff --git a/code/fms/fms-vue/src/components/ui/tree/tree-node.vue b/code/fms/fms-vue/src/components/ui/tree/tree-node.vue
index b940e0ea..b88e2a42 100644
--- a/code/fms/fms-vue/src/components/ui/tree/tree-node.vue
+++ b/code/fms/fms-vue/src/components/ui/tree/tree-node.vue
@@ -20,6 +20,8 @@ const leaf = computed(() =>
isLeafNode(props.node, fn.value, { lazy: ctx.props.lazy, loadedKeys: ctx.loadedSet.value }),
)
const disabled = computed(() => !!(ctx.props.disabled || props.node.disabled))
+/** 不可拖拽排序:节点禁用,或节点显式标记 draggable === false(如「全部分类」虚拟根,仍可选中/作为落点) */
+const dragDisabled = computed(() => disabled.value || props.node?.draggable === false)
const disableCheckbox = computed(() => !!props.node.disableCheckbox)
const loading = computed(
() =>
@@ -115,12 +117,12 @@ function onCheckChange() {
// 节点一直挂着 is-dragging(opacity .4),表现就是「点了发灰、移开也灰」。
const gripArmed = ref(false)
const draggableAttr = computed(() =>
- ctx.props.draggable && !disabled.value && gripArmed.value ? 'true' : 'false',
+ ctx.props.draggable && !dragDisabled.value && gripArmed.value ? 'true' : 'false',
)
/** grip 按下:武装本行拖拽(同步写 DOM,dragstart 可能早于 Vue 的 patch) */
function armDrag() {
- if (disabled.value || !ctx.props.draggable) return
+ if (dragDisabled.value || !ctx.props.draggable) return
gripArmed.value = true
rowRef.value?.setAttribute('draggable', 'true')
// 按下但没真拖(不会产生 dragend)时的兜底解除
@@ -132,7 +134,7 @@ function disarmDrag() {
rowRef.value?.setAttribute('draggable', 'false')
}
function onDragStart(e) {
- if (disabled.value || !gripArmed.value) {
+ if (dragDisabled.value || !gripArmed.value) {
e.preventDefault()
return
}
@@ -180,9 +182,9 @@ function onContextMenu(e) {
@dragend="onDragEnd"
@contextmenu.stop="onContextMenu"
>
-
+
-
+
import('@/views/base/othercompany/detail.vue'),
meta: {
@@ -119,6 +119,40 @@ export const routes = [
moduleId: 'v_b_othercompany',
},
},
+ // 基础数据:联系人 —— 元数据驱动列表,新增 / 编辑跳详情页(v_b_contact)
+ {
+ path: '/base/contact',
+ name: 'base-contact',
+ component: () => import('@/views/base/contact/index.vue'),
+ meta: {
+ title: '联系人',
+ componentName: 'BaseContact',
+ moduleId: 'v_b_contact',
+ },
+ },
+ // 联系人详情:新增与编辑都在此页完成(不带参数为新增,?id=xxx 为编辑)。
+ // 静态路径,靠 EXCLUDED_PATHS 排除出菜单管理的「路由地址」下拉。
+ {
+ path: '/base/contact/detail',
+ name: 'base-contact-detail',
+ component: () => import('@/views/base/contact/detail.vue'),
+ meta: {
+ title: '联系人详情',
+ componentName: 'BaseContactDetail',
+ moduleId: 'v_b_contact',
+ },
+ },
+ // 基础数据:其他数据 —— 左分类树 + 右可编辑列表(根模块 base_otherdata)
+ {
+ path: '/base/otherdata',
+ name: 'base-otherdata',
+ component: () => import('@/views/base/otherdata/index.vue'),
+ meta: {
+ title: '其他数据',
+ componentName: 'BaseOtherdata',
+ moduleId: 'base_otherdata',
+ },
+ },
],
},
{
@@ -127,8 +161,8 @@ export const routes = [
},
]
-/** 不作为菜单入口的路由:独立布局、无需登录即可访问的页面 */
-const EXCLUDED_PATHS = new Set(['/login'])
+/** 不作为菜单入口的路由:独立布局页(/login)与详情页(静态路径,无法靠动态段规则排除) */
+const EXCLUDED_PATHS = new Set(['/login', '/base/othercompany/detail', '/base/contact/detail'])
/**
* 收集可作为菜单入口的页面路由(递归展开布局容器与分组节点)。
diff --git a/code/fms/fms-vue/src/views/base/contact/detail.vue b/code/fms/fms-vue/src/views/base/contact/detail.vue
new file mode 100644
index 00000000..9fc9efbc
--- /dev/null
+++ b/code/fms/fms-vue/src/views/base/contact/detail.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/code/fms/fms-vue/src/views/base/contact/index.vue b/code/fms/fms-vue/src/views/base/contact/index.vue
index 3282ff1a..5a5549d1 100644
--- a/code/fms/fms-vue/src/views/base/contact/index.vue
+++ b/code/fms/fms-vue/src/views/base/contact/index.vue
@@ -1,12 +1,124 @@
-
+
+
+
+
+
+
+
+
diff --git a/code/fms/fms-vue/src/views/base/othercompany/detail.vue b/code/fms/fms-vue/src/views/base/othercompany/detail.vue
index d1eb2fb4..b27559fc 100644
--- a/code/fms/fms-vue/src/views/base/othercompany/detail.vue
+++ b/code/fms/fms-vue/src/views/base/othercompany/detail.vue
@@ -2,8 +2,9 @@
/**
* 往来单位(合作伙伴)详情 —— 新增与编辑都在本页完成。
*
- * 路由契约:`/base/othercompany/detail/:id`
- * - `id = 'new'` 表示新建,其余为 b_othercompany.b_id;
+ * 路由契约:`/base/othercompany/detail`
+ * - 不带参数(`route.query.id` 为空)表示新建;
+ * - `?id=xxx` 表示编辑 b_othercompany.b_id = xxx;
* - 顶部工具栏的保存 / 重置 / 删除、主表与子表(银行账号等)均尚未实现。
*/
diff --git a/code/fms/fms-vue/src/views/base/othercompany/index.vue b/code/fms/fms-vue/src/views/base/othercompany/index.vue
index 2130fe87..97971e2f 100644
--- a/code/fms/fms-vue/src/views/base/othercompany/index.vue
+++ b/code/fms/fms-vue/src/views/base/othercompany/index.vue
@@ -2,69 +2,160 @@
/**
* 往来单位(合作伙伴)—— 左侧分类树 + 右侧模块列表。
*
- * 数据来源:
- * - 左树:b_othercompany_bmfl(邻接表树,b_parent_id 是权威关系,b_path 为派生路径)
- * - 右表:模块 v_b_othercompany(查询栏 / 列 / 分页全部由 FmsModuleListPage 按模块配置驱动)
- *
- * 页面负责三件事:
- * - 把左侧选中的分类转成列表的固定查询条件(含全部下级分类);
- * - 列表列 b_cate_id 显示分类名称而不是节点编码;
- * - 左侧分类树的维护:新增 / 修改 / 删除 / 刷新 / 拖拽调层级与顺序
- * (CategoryTreePanel + CategoryEditModal,写 b_othercompany_bmfl 后重载整棵树)。
- *
- * 分类行的 b_depth / b_path 是 b_parent_id 的派生字段,统一由 ./utils.js 派生;
- * 往来单位的新增与编辑在详情页(base-othercompany-detail)完成,本页只做跳转。
+ * 左树是 b_othercompany_bmfl(b_parent_id 是权威关系,b_depth / b_path 为派生字段),
+ * 右表由 FmsModuleListPage 按模块 v_b_othercompany 的配置驱动。页面负责三件事:
+ * 选中分类 → 固定查询条件(含全部下级)、b_cate_id 列显示分类名称、分类树的维护
+ * (新增 / 修改 / 删除 / 刷新 / 拖拽调层级与顺序)。
+ * 新增与编辑往来单位在详情页(base-othercompany-detail)完成,本页只做跳转。
*/
-import { computed, ref } from 'vue'
+import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
-import { Plus, Trash2 } from '@lucide/vue'
-import { Button, Message, Modal, Splitter, SplitterPanel } from '@/components/ui'
+import { Pencil, Plus, Search, Trash2 } from '@lucide/vue'
+import {
+ Button,
+ Form,
+ FormItem,
+ Input,
+ Message,
+ Modal,
+ Spin,
+ Splitter,
+ SplitterPanel,
+} from '@/components/ui'
import { FmsModuleListPage } from '@/components/fms-module-list'
import { escapeSqlText } from '@/components/fms-module-common/queryUtils'
+import FmsTree from '@/components/fms-tree/FmsTree.vue'
import { loadDataApi, pageDataApi, saveObjectApi } from '@/services/api'
import { usePermissionStore } from '@/stores/permissions'
-import { findTreeNode } from '@/utils/tree'
-import CategoryTreePanel from './CategoryTreePanel.vue'
-import CategoryEditModal from './CategoryEditModal.vue'
-import {
- ALL_CATEGORY_KEY,
- addRowUpdate,
- applyCategoryPosition,
- buildCategoryTree,
- findNodePosition,
- hasChildCategory,
- rebuildTreePositions,
- renumberSiblings,
-} from './utils'
+import { buildTree, collectAllKeys, filterTreeByText, findTreeNode, sortTree } from '@/utils/tree'
-/** 右侧列表使用的模块编码(s_module.b_id / s_field.b_module_id) */
-const MODULE_CODE = 'v_b_othercompany'
-/** 左侧分类树表 */
-const CATEGORY_TABLE = 'b_othercompany_bmfl'
+const MODULE_CODE = 'v_b_othercompany' // 右侧列表模块
+const CATEGORY_TABLE = 'b_othercompany_bmfl' // 左侧分类树表
+/** 必须与 FmsTree 的 rootKey 默认值一致 */
+const ALL_CATEGORY_KEY = '__fms_tree_root__'
+/* ===================== 分类树纯函数 ===================== */
+
+/**
+ * 必须在浅拷贝上构建:buildTree 会就地往行上挂 children,
+ * 直接构建会累积出重复子节点、并污染唯一数据源 categories。
+ */
+function buildCategoryTree(rows) {
+ const tree = buildTree((rows || []).map((row) => ({ ...row })))
+ sortTree(tree)
+ return tree
+}
+
+/**
+ * 派生 b_depth / b_path(b_parent_id 是权威关系,二者为派生字段)。
+ * b_path 非空且无默认值,新增时必须随行写入。
+ */
+function applyCategoryPosition(row, parentNode) {
+ row.b_depth = parentNode ? Number(parentNode.b_depth ?? 0) + 1 : 0
+ row.b_path = parentNode ? `${String(parentNode.b_path || '')}${row.b_id}/` : `/${row.b_id}/`
+}
+
+/** 同级下一个排序号(最大 b_xh + 10,无同级时取 10) */
+function nextSiblingXh(rows, parentId) {
+ const normalizedParentId = parentId == null ? null : String(parentId)
+ 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
+}
+
+/** 有子分类的分类不允许删除 */
+function hasChildCategory(rows, id) {
+ const targetId = String(id)
+ return (rows || []).some((row) => row.b_parent_id != null && String(row.b_parent_id) === targetId)
+}
+
+/** 查找节点所在的父数组与索引 */
+function findNodePosition(nodes, id) {
+ for (let i = 0; i < nodes.length; i++) {
+ if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
+ if (nodes[i].children) {
+ const found = findNodePosition(nodes[i].children, id)
+ if (found) return found
+ }
+ }
+ return null
+}
+
+/** 同一 b_id 的字段合并进一行,避免同节点产生多条 update */
+function addRowUpdate(updatedRows, row) {
+ const existing = updatedRows.find((item) => String(item.b_id) === String(row.b_id))
+ if (existing) Object.assign(existing, row)
+ else updatedRows.push({ ...row })
+}
+
+/** 重排兄弟 b_xh(10 起步、步长 10),只收集实际变化的行 */
+function renumberSiblings(list, updatedRows) {
+ list.forEach((sibling, index) => {
+ const nextXh = (index + 1) * 10
+ if (Number(sibling.b_xh) !== nextXh) {
+ sibling.b_xh = nextXh
+ addRowUpdate(updatedRows, { b_id: sibling.b_id, b_xh: nextXh })
+ }
+ })
+}
+
+/** 整树重建 b_depth / b_path(b_parent_id 为权威关系),只收集实际变化的行 */
+function rebuildTreePositions(nodes, updatedRows, parentPath = '', depth = 0) {
+ for (const node of nodes) {
+ const path = parentPath ? `${parentPath}${node.b_id}/` : `/${node.b_id}/`
+ if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) {
+ node.b_depth = depth
+ node.b_path = path
+ addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path })
+ }
+ if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1)
+ }
+}
+
+/* ================= 权限 ================= */
const permissionStore = usePermissionStore()
const canCreateRow = computed(() => permissionStore.canPower(MODULE_CODE, 'create'))
const canDeleteRow = computed(() => permissionStore.canPower(MODULE_CODE, 'delete'))
const listRef = ref(null)
-/* ================= 分类树 ================= */
+/* ================= 分类树:状态与加载 ================= */
const categories = ref([])
const categoryLoading = ref(false)
const selectedCategoryKey = ref(ALL_CATEGORY_KEY)
const expandedCategoryKeys = ref([])
+const categoryKeyword = ref('')
const categoryById = computed(
() => new Map(categories.value.map((item) => [String(item.b_id), item])),
)
-/** 邻接表 → 树;顺序由 b_xh 决定。树是 categories 的派生视图(在浅拷贝上构建) */
+/** 树是 categories 的派生视图(在浅拷贝上构建) */
const categoryTree = computed(() => buildCategoryTree(categories.value))
+/** 分类没有 b_code,匹配「名称 + 编码」两列 */
+const filteredCategoryTree = computed(() =>
+ filterTreeByText(categoryTree.value, categoryKeyword.value, ['b_name', 'b_id']),
+)
+
+// 搜索时展开全部(保证命中节点可见),清空时收起
+watch(categoryKeyword, (kw) => {
+ expandedCategoryKeys.value = kw.trim() ? collectAllKeys(categoryTree.value) : []
+})
+
async function loadCategories() {
categoryLoading.value = true
try {
- const response = await loadDataApi(CATEGORY_TABLE, '', 'b_path ASC, b_xh ASC, b_id ASC')
+ // 只加载启用分类(未启用的不展示)
+ const response = await loadDataApi(
+ CATEGORY_TABLE,
+ 'b_canuse = 1',
+ 'b_path ASC, b_xh ASC, b_id ASC',
+ )
categories.value = response.data || []
// 选中的分类已被删除时回到「全部」
@@ -76,7 +167,7 @@ async function loadCategories() {
selectedCategoryKey.value = ALL_CATEGORY_KEY
}
- // 展开所有含子节点的分类;保留用户已手动展开的节点
+ // 展开所有含子节点的分类,并保留用户已手动展开的
const parentKeys = categories.value
.map((item) => (item.b_parent_id == null ? '' : String(item.b_parent_id)))
.filter(Boolean)
@@ -96,7 +187,7 @@ function onCategorySelect(keys, info) {
if (keys.length) selectedCategoryKey.value = String(keys[0])
}
-/** 收集某个分类及其全部下级分类的节点编码 */
+/** 收集某个分类及其全部下级分类的编码 */
function collectDescendantIds(rootId) {
const childrenByParent = new Map()
for (const item of categories.value) {
@@ -118,11 +209,7 @@ function collectDescendantIds(rootId) {
return result
}
-/**
- * 分类 → 列表固定查询条件。
- * b_cate_id 是 varchar,条件值用 varchar 字面量(不加 N 前缀),避免列被隐式转成
- * nvarchar 而用不上 ix_b_othercompany_cate。
- */
+/** b_cate_id 是 varchar:条件值不加 N 前缀,避免列被隐式转成 nvarchar 而用不上索引 */
const categoryFilterCondition = computed(() => {
if (selectedCategoryKey.value === ALL_CATEGORY_KEY) return ''
const ids = collectDescendantIds(selectedCategoryKey.value)
@@ -131,7 +218,7 @@ const categoryFilterCondition = computed(() => {
return `[b_cate_id] IN (${literals.join(', ')})`
})
-/* ================= 列扩展:分类编码显示为分类名称 ================= */
+/* ================= 列扩展:b_cate_id 显示分类名称 ================= */
const columnExtensions = computed(() => ({
b_cate_id: {
formatter: ({ value }) => {
@@ -141,12 +228,50 @@ const columnExtensions = computed(() => ({
},
}))
+/* ================= 分类树:右键菜单与工具栏 ================= */
+
+/** node 为 null 表示右键点在空白处,一并按虚拟根处理 */
+function isRootCategoryNode(node) {
+ return !node || node.isFmsRoot
+}
+
+/** 根节点只能新增顶级分类 */
+function buildCategoryContextMenu(node) {
+ if (isRootCategoryNode(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 onCategoryMenuClick({ item, node }) {
+ if (item.key === 'add') {
+ onCategoryAdd({ parentId: isRootCategoryNode(node) ? null : String(node.b_id) })
+ } else if (item.key === 'edit') {
+ onCategoryEdit(node)
+ } else if (item.key === 'delete') {
+ void onCategoryDelete(node)
+ }
+}
+
+/** 工具栏新增挂到当前选中分类下;选中「全部分类」或未选中时建为顶级 */
+function onCategoryToolbarAdd() {
+ const selected = String(selectedCategoryKey.value ?? '')
+ onCategoryAdd({
+ parentId: !selected || selected === ALL_CATEGORY_KEY ? null : selected,
+ })
+}
+
/* ================= 新增:跳转详情页 ================= */
const router = useRouter()
-/** 详情页按路由参数 :id 区分,'new' 表示新建 */
+/** 新增:不带参数跳详情页;编辑同页完成,用 ?id=xxx 传主键 */
function onCreate() {
- router.push({ name: 'base-othercompany-detail', params: { id: 'new' } })
+ router.push({ name: 'base-othercompany-detail' })
}
/* ================= 行多选与批量删除 ================= */
@@ -159,24 +284,26 @@ const rowSelection = computed(() => ({
},
}))
-const confirmOpen = ref(false)
-const deleting = ref(false)
-
-async function confirmBatchDelete() {
- deleting.value = true
- try {
- await listRef.value?.deleteSelected(selectedRowKeys.value)
- Message.success('删除成功')
- selectedRowKeys.value = []
- confirmOpen.value = false
- } catch (error) {
- Message.error('删除失败:' + (error?.message || error))
- } finally {
- deleting.value = false
- }
+function confirmBatchDelete() {
+ Modal.confirm({
+ title: `确定删除选中的 ${selectedRowKeys.value.length} 条往来单位?`,
+ content: '此操作不可恢复。',
+ okText: '删除',
+ okButtonProps: { danger: true },
+ onOk: async () => {
+ try {
+ await listRef.value?.deleteSelected(selectedRowKeys.value)
+ } catch (error) {
+ Message.error('删除失败:' + (error?.message || error))
+ throw error // 保持确认框打开,便于重试
+ }
+ Message.success('删除成功')
+ selectedRowKeys.value = []
+ },
+ })
}
-/* ================= 分类维护:新增 / 修改 ================= */
+/* ================= 分类维护:弹窗与表单 ================= */
const categoryModalOpen = ref(false)
const categoryModalSaving = ref(false)
/** 修改时的分类行;新增时为 null */
@@ -184,6 +311,77 @@ const editingCategory = ref(null)
/** 新增分类的上级编码;null 表示顶级 */
const categoryParentId = ref(null)
+const isCategoryEdit = computed(() => !!editingCategory.value)
+
+/** 本地草稿:打开时快照,确定时提交 */
+const categoryDraft = ref({ b_id: '', b_name: '', b_xh: 10 })
+
+const categoryParentName = computed(() => {
+ if (categoryParentId.value == null || categoryParentId.value === '') return '全部分类'
+ const parent = categories.value.find((row) => String(row.b_id) === String(categoryParentId.value))
+ return parent?.b_name || String(categoryParentId.value)
+})
+
+watch(categoryModalOpen, (open) => {
+ if (!open) return
+ if (editingCategory.value) {
+ categoryDraft.value = {
+ b_id: editingCategory.value.b_id ?? '',
+ b_name: editingCategory.value.b_name ?? '',
+ b_xh: Number(editingCategory.value.b_xh) || 0,
+ }
+ return
+ }
+ categoryDraft.value = {
+ b_id: '',
+ b_name: '',
+ b_xh: nextSiblingXh(categories.value, categoryParentId.value),
+ }
+})
+
+function setCategoryField(field, value) {
+ categoryDraft.value = { ...categoryDraft.value, [field]: value }
+}
+
+const categoryFormRef = ref(null)
+
+const categoryFormRules = computed(() => ({
+ b_id: [
+ { required: true, message: '请填写分类编码' },
+ { pattern: /^[A-Za-z0-9_-]+$/, message: '分类编码只能包含字母、数字、下划线或短横线' },
+ { max: 150, message: '分类编码长度不能超过 150' },
+ {
+ // b_id 是主键,重复即入库失败;这里提前拦下(修改自身时排除)
+ validator: (value) => {
+ const code = String(value ?? '').trim()
+ if (!code) return true
+ const selfId = String(editingCategory.value?.b_id ?? '')
+ const duplicated = categories.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 onCategoryOk() {
+ const errors = (await categoryFormRef.value?.validateAll()) ?? []
+ if (errors.length) {
+ Message.warning('请修正表单中标红的错误项')
+ return
+ }
+ void onCategorySubmit({
+ b_id: String(categoryDraft.value.b_id ?? '').trim(),
+ b_name: String(categoryDraft.value.b_name ?? '').trim(),
+ b_xh: Number(categoryDraft.value.b_xh) || 0,
+ })
+}
+
function onCategoryAdd({ parentId }) {
editingCategory.value = null
categoryParentId.value = parentId == null ? null : String(parentId)
@@ -201,13 +399,10 @@ function onCategoryEdit(node) {
categoryModalOpen.value = true
}
-/**
- * 弹窗确定:新增补 b_parent_id / b_depth / b_path 后 insert;修改只提交名称与排序号
- * (分类编码是主键,创建后不可改)。落库成功后重载整棵树,保持「categories 是唯一数据源」。
- */
+/** 新增补 b_depth / b_path 后 insert;修改只提交名称与排序号(编码是主键,创建后不可改) */
async function onCategorySubmit(payload) {
if (categoryModalSaving.value) return
- const isEdit = !!editingCategory.value
+ const isEdit = isCategoryEdit.value
categoryModalSaving.value = true
try {
if (isEdit) {
@@ -226,7 +421,6 @@ async function onCategorySubmit(payload) {
b_xh: payload.b_xh,
b_canuse: 1,
}
- // b_depth / b_path 是 b_parent_id 的派生字段;b_path 非空且无默认值,必须随行写入
const parentNode = categoryParentId.value
? findTreeNode(categoryTree.value, categoryParentId.value)
: null
@@ -244,31 +438,22 @@ async function onCategorySubmit(payload) {
}
}
-/* ================= 删除分类(两条守卫:有子分类 / 分类下有往来单位) ================= */
-const categoryDeleteTarget = ref(null)
-const categoryDeleting = ref(false)
+/* ================= 删除分类(有子分类 / 分类下有往来单位 两条守卫) ================= */
+/** 守卫查询进行中:阻止右键重复触发 */
+const categoryChecking = ref(false)
-/** 确认框显隐由「待删分类」派生:关闭即清空待删项,避免残留 */
-const categoryDeleteOpen = computed({
- get: () => !!categoryDeleteTarget.value,
- set: (open) => {
- if (!open) categoryDeleteTarget.value = null
- },
-})
-
-/** 删除入口:先做同步/异步守卫,全部通过才弹确认框 */
async function onCategoryDelete(node) {
const category = categoryById.value.get(String(node?.b_id ?? ''))
- if (!category || categoryDeleting.value) return
+ if (!category || categoryChecking.value) return
if (hasChildCategory(categories.value, category.b_id)) {
Message.warning('该分类下还有子分类,请先删除子分类')
return
}
- categoryDeleting.value = true
+ categoryChecking.value = true
+ let hasData = false
try {
- // 分类下还挂着往来单位时不允许删除。b_cate_id 是 varchar,条件值用 varchar 字面量
- // (不加 N 前缀),避免列被隐式转成 nvarchar 而用不上 ix_b_othercompany_cate
+ // b_cate_id 是 varchar:条件值不加 N 前缀,避免列被隐式转成 nvarchar 而用不上索引
const response = await pageDataApi({
viewName: MODULE_CODE,
searchColumns: ['b_id'],
@@ -276,37 +461,36 @@ async function onCategoryDelete(node) {
pageNo: 1,
pageSize: 1,
})
- const total = Number(response.data?.total ?? response.data?.rows?.length ?? 0)
- if (total > 0) {
- Message.warning('该分类下还有往来单位数据,不能删除')
- return
- }
+ hasData = Number(response.data?.total ?? response.data?.rows?.length ?? 0) > 0
} catch (error) {
Message.error('分类数据检查失败:' + (error?.message || error))
return
} finally {
- categoryDeleting.value = false
+ categoryChecking.value = false
+ }
+ if (hasData) {
+ Message.warning('该分类下还有往来单位数据,不能删除')
+ return
}
- categoryDeleteTarget.value = category
-}
-
-async function confirmCategoryDelete() {
- const category = categoryDeleteTarget.value
- if (!category || categoryDeleting.value) return
- categoryDeleting.value = true
- try {
- await saveObjectApi([
- { table: CATEGORY_TABLE, key_field: 'b_id', deletes: [{ b_id: category.b_id }] },
- ])
- Message.success('分类已删除')
- categoryDeleteTarget.value = null
- await loadCategories()
- } catch (error) {
- Message.error('删除失败:' + (error?.message || error))
- } finally {
- categoryDeleting.value = false
- }
+ Modal.confirm({
+ title: `确定删除分类「${category.b_name || category.b_id}」?`,
+ content: '此操作不可恢复。',
+ okText: '删除',
+ okButtonProps: { danger: true },
+ onOk: async () => {
+ try {
+ await saveObjectApi([
+ { table: CATEGORY_TABLE, key_field: 'b_id', deletes: [{ b_id: category.b_id }] },
+ ])
+ } catch (error) {
+ Message.error('删除失败:' + (error?.message || error))
+ throw error // 保持确认框打开,便于重试
+ }
+ Message.success('分类已删除')
+ await loadCategories()
+ },
+ })
}
/* ================= 拖拽排序(同级调序 / 跨级换父) ================= */
@@ -315,8 +499,7 @@ async function onCategoryDrop({ dragKey, dropKey, dropPosition }) {
const dropId = String(dropKey)
if (dragId === dropId) return
- // 在树的临时副本上算 diff:树只是 categories 的派生视图,
- // 就地改会与唯一数据源脱节,故一律「算差异 → 落库 → 重载」
+ // 树只是 categories 的派生视图,所以一贯「在副本上算差异 → 落库 → 重载」
const tree = buildCategoryTree(categories.value)
const dragPos = findNodePosition(tree, dragId)
const dropPos = findNodePosition(tree, dropId)
@@ -324,7 +507,7 @@ async function onCategoryDrop({ dragKey, dropKey, dropPosition }) {
const dragNode = dragPos.list[dragPos.index]
const dropNode = dropPos.list[dropPos.index]
- // 落点不能是被拖节点自身或它的后代:父子关系成环后树与 b_path 都会失真
+ // 落点不能是被拖节点自身或它的后代:成环后树与 b_path 都会失真
if (findTreeNode([dragNode], dropId)) {
Message.warning('不能把分类移动到它自己的下级')
return
@@ -338,14 +521,12 @@ async function onCategoryDrop({ dragKey, dropKey, dropPosition }) {
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)
@@ -356,10 +537,9 @@ async function onCategoryDrop({ dragKey, dropKey, dropPosition }) {
}
renumberSets.forEach((list) => renumberSiblings(list, updatedRows))
- // 父子关系变化后整树重建 b_depth / b_path,仅收集实际变化的行
rebuildTreePositions(tree, updatedRows)
- // b_parent_id 是权威关系,必须与本节点的派生字段同批落库:
- // renumberSiblings / rebuildTreePositions 只收集实际变化的派生字段,都不负责父级列本身
+ // 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 })
}
@@ -385,18 +565,53 @@ void loadCategories()
- void onCategoryDelete(node)"
- @refresh="loadCategories"
- @drop="(payload) => void onCategoryDrop(payload)"
- />
+
@@ -409,20 +624,15 @@ void loadCategories()
:row-selection="rowSelection"
>
-
+
-
- 确定删除选中的 {{ selectedRowKeys.length }} 条往来单位?此操作不可恢复。
-
-
- 取消
- 删除
-
-
-
-
-
-
-
-
-
- 确定删除分类「{{ categoryDeleteTarget?.b_name || categoryDeleteTarget?.b_id }}」?
-
-
-
- 取消
-
-
- 删除
-
-
+
@@ -509,9 +715,100 @@ void loadCategories()
opacity: 1;
}
-/* 右侧列表卡:不再套白底。FmsModuleListPage 内部已有 .fms-module-card
- * (白底 + 圆角 + 阴影),外层再铺白底会把内卡之间的灰缝染色、并让整块区域变成直角白板。
- * 左侧分类树卡片的样式随 CategoryTreePanel 一起维护。 */
+/* 灰底画布上的白卡,与右侧列表卡用灰缝分隔 */
+.oc-tree-panel {
+ position: relative;
+ 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;
+}
+
+.oc-tree-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex: none;
+}
+
+.oc-tree-search {
+ flex: 1;
+ min-width: 0;
+}
+
+.oc-tree-search__icon {
+ color: var(--fms-text-secondary);
+}
+
+.oc-tree-header__actions {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+.oc-tree-header__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;
+ }
+}
+
+.oc-tree-panel__scroll {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+}
+
+.oc-tree-panel__scroll :deep(.tree) {
+ min-width: 100%;
+}
+
+.oc-tree-node__name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ 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;
flex-direction: column;
@@ -520,10 +817,4 @@ void loadCategories()
min-width: 0;
min-height: 0;
}
-
-.oc-confirm-text {
- margin: 0;
- line-height: 1.7;
- color: var(--fms-text);
-}
diff --git a/code/fms/fms-vue/src/views/base/othercompany/utils.js b/code/fms/fms-vue/src/views/base/othercompany/utils.js
deleted file mode 100644
index 4333bc27..00000000
--- a/code/fms/fms-vue/src/views/base/othercompany/utils.js
+++ /dev/null
@@ -1,131 +0,0 @@
-/**
- * 往来单位分类树(othercompany)页面级纯函数。
- * 只放无 Vue 状态依赖的逻辑:建树、位置派生、排序号计算、兄弟重排。
- * 树本身的通用查找/过滤/插入/移除见 @/utils/tree,不在此重复。
- */
-import { buildTree, sortTree } from '@/utils/tree'
-
-/**
- * 「全部分类」虚拟根节点的选中键。
- * 必须与 FmsTree 的 rootKey 默认值一致,页面与树面板共用同一常量,避免两处硬编码。
- */
-export const ALL_CATEGORY_KEY = '__fms_tree_root__'
-
-/**
- * 由扁平分类行构建排序后的分类树。
- *
- * 注意:必须在浅拷贝上构建。buildTree 会就地往行对象上挂 children,
- * 若直接在 categories 的行上构建,同一批行被重复构建时会累积出重复子节点,
- * 也会把树结构污染回「唯一数据源」categories。
- *
- * @param {Array} categories 分类行数组(b_othercompany_bmfl)
- * @returns {Array} 分类树(节点为浅拷贝)
- */
-export function buildCategoryTree(categories) {
- const tree = buildTree((categories || []).map((row) => ({ ...row })))
- sortTree(tree)
- return tree
-}
-
-/**
- * 按父节点派生分类行的 b_depth / b_path(b_parent_id 是权威关系,二者为派生字段)。
- * 根节点 b_depth = 0,b_path 形如 /A/B/(节点编码前后均带 /)。
- * b_path 非空且无默认值,新增时必须随行写入。
- * @param {Object} row 分类行(就地修改;b_id 必须是最终分类编码)
- * @param {Object|null} parentNode 父节点;null 表示根节点
- */
-export function applyCategoryPosition(row, parentNode) {
- row.b_depth = parentNode ? Number(parentNode.b_depth ?? 0) + 1 : 0
- row.b_path = parentNode ? `${String(parentNode.b_path || '')}${row.b_id}/` : `/${row.b_id}/`
-}
-
-/**
- * 计算同级下一个排序号(同级最大 b_xh + 10,无同级时取 10)。
- * 新增分类时作为排序号默认值,避免新节点插到同级最前。
- * @param {Array} categories 分类行数组
- * @param {string|null} parentId 父节点编码;null 表示顶级
- * @returns {number} 排序号
- */
-export function nextSiblingXh(categories, parentId) {
- const normalizedParentId = parentId == null ? null : String(parentId)
- let maxXh = 0
- for (const row of categories || []) {
- 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
-}
-
-/**
- * 是否还有下级分类。有子分类的分类不允许删除。
- * @param {Array} categories 分类行数组
- * @param {string|number} id 分类编码
- * @returns {boolean}
- */
-export function hasChildCategory(categories, id) {
- const targetId = String(id)
- return (categories || []).some(
- (row) => row.b_parent_id != null && String(row.b_parent_id) === targetId,
- )
-}
-
-/**
- * 在树中查找节点所在的父数组及索引。
- * @param {Array} nodes 树节点数组
- * @param {string|number} id 目标节点 ID
- * @returns {{ list: Array, index: number }|null}
- */
-export function findNodePosition(nodes, id) {
- for (let i = 0; i < nodes.length; i++) {
- if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
- if (nodes[i].children) {
- const found = findNodePosition(nodes[i].children, id)
- if (found) return found
- }
- }
- return null
-}
-
-/** 向 updatedRows 合并一行变更(同一 b_id 累加字段,避免同节点产生多条 update 请求) */
-export function addRowUpdate(updatedRows, row) {
- const existing = updatedRows.find((item) => String(item.b_id) === String(row.b_id))
- if (existing) Object.assign(existing, row)
- else updatedRows.push({ ...row })
-}
-
-/**
- * 重排某组兄弟的 b_xh(10 起步、步长 10),并把变化的行收集到 updatedRows。
- * @param {Array} list 兄弟节点数组(就地修改 b_xh)
- * @param {Array} updatedRows 变化行收集器
- */
-export function renumberSiblings(list, updatedRows) {
- list.forEach((sibling, index) => {
- const nextXh = (index + 1) * 10
- if (Number(sibling.b_xh) !== nextXh) {
- sibling.b_xh = nextXh
- addRowUpdate(updatedRows, { b_id: sibling.b_id, b_xh: nextXh })
- }
- })
-}
-
-/**
- * 递归重建分类树的 b_depth / b_path(b_parent_id 为权威关系,二者为派生字段)。
- * 根节点 b_depth = 0、b_path = /编码/;子节点沿用父级路径拼接,与 applyCategoryPosition 一致。
- * 仅在值实际变化时收集,避免无谓更新。
- * @param {Array} nodes 树节点数组(就地修改)
- * @param {Array} updatedRows 变化行收集器
- * @param {string} [parentPath] 父节点路径(形如 /A/;根层传空串)
- * @param {number} [depth] 当前层级
- */
-export function rebuildTreePositions(nodes, updatedRows, parentPath = '', depth = 0) {
- for (const node of nodes) {
- const path = parentPath ? `${parentPath}${node.b_id}/` : `/${node.b_id}/`
- if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) {
- node.b_depth = depth
- node.b_path = path
- addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path })
- }
- if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1)
- }
-}
diff --git a/code/fms/fms-vue/src/views/base/otherdata/index.vue b/code/fms/fms-vue/src/views/base/otherdata/index.vue
index 0da3a7b9..cadb7fbc 100644
--- a/code/fms/fms-vue/src/views/base/otherdata/index.vue
+++ b/code/fms/fms-vue/src/views/base/otherdata/index.vue
@@ -1,18 +1,487 @@
-
+
+
+
+
+
+
+
+
+ (dirtyCount = count)"
+ >
+
+
+
+ 新增行
+
+
+
+ 保存{{ dirtyCount ? `(${dirtyCount})` : '' }}
+
+
+
+ 批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
+
+
+
+
+
+ 请选择数据模块
+ 从左侧分类树中选择一个数据模块以查看与维护其数据
+
+
+
+
+
+
-
diff --git a/code/fms/fms-vue/src/views/module/menu-management/MenuBasicPanel.vue b/code/fms/fms-vue/src/views/module/menu-management/MenuBasicPanel.vue
index ebc02bf6..ef96cc1f 100644
--- a/code/fms/fms-vue/src/views/module/menu-management/MenuBasicPanel.vue
+++ b/code/fms/fms-vue/src/views/module/menu-management/MenuBasicPanel.vue
@@ -9,7 +9,7 @@
*/
import { computed, ref } from 'vue'
import { Languages } from '@lucide/vue'
-import { Button, Form, FormItem, Input, Select } from '@/components/ui'
+import { Button, Form, FormItem, Input, Select, Tag } from '@/components/ui'
import IconPicker from '@/components/icon-picker/IconPicker.vue'
import { menuRouteOptions } from '@/router/routes'
import { MENU_TYPE_OPTIONS } from './utils'
@@ -17,20 +17,35 @@ import { MENU_TYPE_OPTIONS } from './utils'
const props = defineProps({
/** 已保存菜单不允许修改菜单类型(父级按未保存标记计算) */
typeLocked: { type: Boolean, default: false },
- /** 可绑定的业务模块下拉(父级加载一次后传入,避免每次选菜单重复请求) */
- moduleOptions: { type: Array, default: () => [] },
+ /** 模块清单(s_module 启用行,父级加载一次后传入,用于展示已绑模块名称) */
+ moduleRows: { type: Array, default: () => [] },
/** 页面加载 / 保存中:禁用表单 */
loading: { type: Boolean, default: false },
})
-const emit = defineEmits(['openI18n'])
+const emit = defineEmits(['openI18n', 'openModulePicker'])
/** 菜单主表行(s_menu),父级 v-model 绑定 maindata */
const maindata = defineModel({ type: Object, default: null })
+/**
+ * 已绑定模块编码(s_menu_module 草稿,父级持有并随菜单保存一并提交)。
+ * 交互上通过模块树多选:勾分类节点自动级联其下全部模块,落库只登记 data / virtual。
+ */
+const modules = defineModel('modules', { type: Array, default: () => [] })
+
const isPage = computed(() => maindata.value?.b_menu_type === 'page')
const isExternal = computed(() => maindata.value?.b_menu_type === 'external')
+/** 已绑模块 → 展示项(模块表缺该编码时回退显示编码) */
+const boundModules = computed(() => {
+ const nameMap = Object.fromEntries(props.moduleRows.map((m) => [String(m.b_id), m.b_name]))
+ return modules.value.map((id) => ({ id: String(id), name: nameMap[String(id)] || String(id) }))
+})
+/** 标签最多展示前几个,其余收成 +N,避免长清单撑破表单 */
+const MAX_VISIBLE_MODULES = 3
+const visibleModules = computed(() => boundModules.value.slice(0, MAX_VISIBLE_MODULES))
+
/**
* 路由地址候选:由静态路由表(@/router/routes)派生的页面路由,
* Select 的默认筛选即对 label / value 做包含匹配,等价于 like '%关键词%',
@@ -52,8 +67,8 @@ const formRules = computed(() => ({
b_name: [{ required: true, message: '请填写菜单名称' }],
b_menu_type: [{ required: true, message: '请选择菜单类型' }],
b_route: isPage.value || isExternal.value ? [{ required: true, message: '请填写路由地址' }] : [],
- // 绑定模块非必填:页面菜单可不绑定业务模块(仅用于路由 + 数据来源),
- // 留空时保存为空,后续需要再补选。
+ // 绑定模块非必填:页面菜单可不绑定模块(模块绑定存 s_menu_module 关系表),
+ // 未绑定不报错,后续需要再补选。
b_module_id: [],
}))
@@ -75,12 +90,12 @@ function onMenuTypeChange(menuType) {
if (!maindata.value) return
if (props.typeLocked) return
maindata.value.b_menu_type = menuType
- // 目录和外链不绑定业务模块;页面同时使用路由和模块。
+ // 目录和外链不绑定业务模块(清空绑定草稿,保存时关系行按 diff 删除);页面同时使用路由和模块。
if (menuType === 'directory') {
maindata.value.b_route = null
- maindata.value.b_module_id = null
+ modules.value = []
} else if (menuType === 'external') {
- maindata.value.b_module_id = null
+ modules.value = []
}
}
@@ -142,14 +157,19 @@ function onMenuTypeChange(menuType) {
setField('b_route', v)" />
-
+
-
diff --git a/code/fms/fms-vue/src/views/module/menu-management/MenuTreePanel.vue b/code/fms/fms-vue/src/views/module/menu-management/MenuTreePanel.vue
index 74e10a27..03943ff2 100644
--- a/code/fms/fms-vue/src/views/module/menu-management/MenuTreePanel.vue
+++ b/code/fms/fms-vue/src/views/module/menu-management/MenuTreePanel.vue
@@ -4,12 +4,12 @@
*
* 职责:搜索框与树展示,展开 / 选中 / 拖拽 / 右键菜单的 UI 交互,节点启用停用的操作入口。
* 数据契约:树数据、选中项、未保存标记均由父级唯一持有,本组件只读并通过事件上报意图:
- * select / add / delete / drop / refresh / toggleEnable。
+ * select / add / delete / drop / toggleEnable。
* 搜索关键字与展开键是纯 UI 状态,留在本组件;展开键需由父级在新建菜单后追加祖先路径,
* 故以 v-model:expanded-keys 暴露。
*/
import { computed, ref, watch } from 'vue'
-import { ExternalLink, FileText, Folder, Plus, RefreshCw, Search, Trash2 } from '@lucide/vue'
+import { ExternalLink, FileText, Folder, Plus, Search, Trash2 } from '@lucide/vue'
import { Button, Checkbox, Dropdown, Input } from '@/components/ui'
import FmsTree from '@/components/fms-tree/FmsTree.vue'
import { collectAllKeys, filterTreeByText } from '@/utils/tree'
@@ -27,15 +27,7 @@ const props = defineProps({
unsavedIds: { type: Set, default: () => new Set() },
})
-const emit = defineEmits([
- 'update:expandedKeys',
- 'select',
- 'add',
- 'delete',
- 'drop',
- 'refresh',
- 'toggleEnable',
-])
+const emit = defineEmits(['update:expandedKeys', 'select', 'add', 'delete', 'drop', 'toggleEnable'])
/** 搜索关键字(纯 UI 状态) */
const keyword = ref('')
@@ -94,15 +86,6 @@ function onToolbarAdd({ key }) {
@@ -941,6 +968,16 @@ loadModuleOptions()
+
+ (modulePickerOpen = v)"
+ @confirm="menuModules = $event"
+ />
+
String(row.b_field) === String(fieldKey))
}
@@ -181,14 +181,13 @@ const selectedFieldId = computed(() => {
return row?.b_field != null ? String(row.b_field) : null
})
-// 字段库点击:已加入 → 定位表格行;未加入 → 生成默认查询行加入
+// 字段库点击:未加入 → 生成默认查询行加入;已加入 → 再次点击即移除(加入 / 移除切换)
function onFieldSelect(field) {
if (!field || props.loading) return
const fieldKey = String(field.b_field)
const existing = findRowByFieldId(fieldKey)
if (existing) {
- activeKey.value = String(existing._rowKey)
- nextTick(() => tableRef.value?.scrollToRow?.(String(existing._rowKey)))
+ removeRow(existing)
return
}
const row = {
@@ -244,14 +243,16 @@ const columns = shallowRef([
:selected-field-id="selectedFieldId"
:loading="loading"
drag-mode="native"
- item-title="点击加入查询;已加入的点击定位;从右侧表格删除行即移除"
- added-title="已加入查询"
+ item-title="点击加入查询;已加入的再次点击移除"
+ added-title="已加入查询,点击移除"
@select="onFieldSelect"
/>
-
新增查询条件
+
新增查询条件
批量删除
diff --git a/code/fms/fms-vue/src/views/module/module-management/components/design-editor/FormDesignEditor.vue b/code/fms/fms-vue/src/views/module/module-management/components/design-editor/FormDesignEditor.vue
index dac68c3f..4467a758 100644
--- a/code/fms/fms-vue/src/views/module/module-management/components/design-editor/FormDesignEditor.vue
+++ b/code/fms/fms-vue/src/views/module/module-management/components/design-editor/FormDesignEditor.vue
@@ -688,12 +688,11 @@ function scrollToRowCard(rowId) {
function selectField(field) {
if (!field || props.loading) return
- // 已加入表单的字段:点击定位选中画布卡片(移除走 Delete / 卡片删除按钮,
- // 与拖拽已加入字段会被拒绝的语义保持一致,避免误删)
+ // 已加入表单的字段:再次点击即移除(字段库点击 = 加入 / 移除切换,
+ // 与卡片删除按钮 / Delete 键同一移除通道,自动回收栅格)
const existing = localEdit.value.find((r) => String(r.b_field) === String(field.b_field))
if (existing) {
- selectRow(existing)
- nextTick(() => scrollToRowCard(String(existing._rowKey)))
+ removeRow(existing)
return
}
// 未加入的字段:插入到表单并定位到画布卡片
@@ -1390,8 +1389,8 @@ onBeforeUnmount(() => {
onStart: onLibDragStart,
onEnd: onLibDragEnd,
}"
- item-title="点击加入表单;已加入的点击定位到画布卡片"
- added-title="已加入表单"
+ item-title="点击加入表单;已加入的再次点击移除"
+ added-title="已加入表单,点击移除"
@select="selectField"
/>
diff --git a/code/fms/fms-vue/src/views/module/module-management/components/design-editor/TableDesignEditor.vue b/code/fms/fms-vue/src/views/module/module-management/components/design-editor/TableDesignEditor.vue
index 2c9aa61e..e0856a51 100644
--- a/code/fms/fms-vue/src/views/module/module-management/components/design-editor/TableDesignEditor.vue
+++ b/code/fms/fms-vue/src/views/module/module-management/components/design-editor/TableDesignEditor.vue
@@ -345,15 +345,44 @@ const dataColumns = computed(() => {
]
if (isSubtable.value) {
cols.push(
- { dataIndex: 'b_visible', title: '显示', width: 70, align: 'center', editor: { type: 'checkbox' } },
- { dataIndex: 'b_required', title: '必填', width: 70, align: 'center', editor: { type: 'checkbox' } },
- { dataIndex: 'b_readonly', title: '只读', width: 70, align: 'center', editor: { type: 'checkbox' } },
- { dataIndex: 'b_disabled', title: '禁用', width: 70, align: 'center', editor: { type: 'checkbox' } },
+ {
+ dataIndex: 'b_visible',
+ title: '显示',
+ width: 70,
+ align: 'center',
+ editor: { type: 'checkbox' },
+ },
+ {
+ dataIndex: 'b_required',
+ title: '必填',
+ width: 70,
+ align: 'center',
+ editor: { type: 'checkbox' },
+ },
+ {
+ dataIndex: 'b_readonly',
+ title: '只读',
+ width: 70,
+ align: 'center',
+ editor: { type: 'checkbox' },
+ },
+ {
+ dataIndex: 'b_disabled',
+ title: '禁用',
+ width: 70,
+ align: 'center',
+ editor: { type: 'checkbox' },
+ },
{ dataIndex: 'b_width', title: '列宽(px)', width: 110, editor: {} },
)
} else {
cols.push(
- { dataIndex: 'b_width', title: '列宽', width: 90, editor: { type: 'input', inputType: 'number' } },
+ {
+ dataIndex: 'b_width',
+ title: '列宽',
+ width: 90,
+ editor: { type: 'input', inputType: 'number' },
+ },
{ dataIndex: 'b_i18n', title: '标题多语言', width: 220 },
)
}
@@ -380,8 +409,12 @@ const treeFeatures = computed(() => ({
:selected-field-id="selectedRow?.b_field"
:loading="loading"
drag-mode="native"
- :item-title="isSubtable ? '点击加入表格;已加入的点击定位;拖拽到下方分组树可加入对应分组' : '点击加入列表;已加入的点击定位;拖拽到下方分组树可加入对应分组'"
- :added-title="isSubtable ? '已加入表格' : '已加入列表'"
+ :item-title="
+ isSubtable
+ ? '点击加入表格;已加入的再次点击移除;拖拽到下方分组树可加入对应分组'
+ : '点击加入列表;已加入的再次点击移除;拖拽到下方分组树可加入对应分组'
+ "
+ :added-title="isSubtable ? '已加入表格,点击移除' : '已加入列表,点击移除'"
@select="selectField"
/>
@@ -426,7 +459,9 @@ const treeFeatures = computed(() => ({
删除
- ?
+ ?
操作说明
diff --git a/code/fms/fms-vue/src/views/module/module-management/components/design-editor/common/FieldLibrary.vue b/code/fms/fms-vue/src/views/module/module-management/components/design-editor/common/FieldLibrary.vue
index 435284a2..718e4d38 100644
--- a/code/fms/fms-vue/src/views/module/module-management/components/design-editor/common/FieldLibrary.vue
+++ b/code/fms/fms-vue/src/views/module/module-management/components/design-editor/common/FieldLibrary.vue
@@ -1,7 +1,7 @@