20260914172820

This commit is contained in:
oneao committed 2026-09-14 17:28:20 +08:00
1 parent c31329d387
commit 549c8bd2eb
38 files changed
+3430 -502

No files matched your search

@@ -14,11 +14,11 @@
* 一律转发到内部控件;
* - v-model:默认转发;selectId 额外支持 v-model:label(id + 显示名 双绑)。
*
* 内置 type:input | textarea | number | money | date | datetime | checkbox | select | selectId
* 内置 type:input | textarea | number | money | date | datetime | range | checkbox | select | selectId
* 未识别的 type 兜底为 Input。
*/
import { computed, useAttrs } from 'vue'
import { Checkbox, DatePicker, Input, Select } from '@/components/ui'
import { Checkbox, DatePicker, Input, RangePicker, Select } from '@/components/ui'
import FmsSelect from '@/components/fms-select/FmsSelect.vue'
defineOptions({ inheritAttrs: false })
@@ -53,6 +53,7 @@ const BUILTIN_COMPONENTS = {
checkbox: Checkbox,
date: DatePicker,
datetime: DatePicker,
range: RangePicker,
}
const resolvedComponent = computed(
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue'
import { Tree, Spin } from '@/components/ui'
import { Spin } from '@/components/ui'
import FmsTree from '@/components/fms-tree/FmsTree.vue'
const props = defineProps({
treeData: { type: Array, default: () => [] },
@@ -22,22 +23,19 @@ function onSelect(keys, info) {
}
emit('select', keys.length ? String(keys[0]) : '')
}
function onExpand(keys) {
emit('update:expandedKeys', keys)
}
</script>
<template>
<aside class="fc-panel">
<div class="fc-panel__tree">
<Tree
<FmsTree
show-root
:tree-data="treeData"
:selected-keys="selectedKeys"
:expanded-keys="expandedKeys"
:block-node="true"
@select="onSelect"
@update:expanded-keys="onExpand"
@update:expanded-keys="(keys) => emit('update:expandedKeys', keys)"
/>
<div v-if="loading" class="fc-panel__loading">
<Spin />
@@ -58,20 +58,17 @@ watch(
// ---------- 分类 ----------
const {
treeData: categoryTreeData,
categories: categoryTreeData,
loading: categoryLoading,
selectedKey: categorySelectedKey,
loadCategories,
selectCategory,
} = useFileCategory()
const activeCateId = computed(() => {
const key = categorySelectedKey.value
if (!key || key === '__fms_tree_root__') return ''
return key
})
const activeCateId = computed(() => categorySelectedKey.value || '')
const categoryExpandedKeys = ref(['__fms_tree_root__'])
// 「全部分类」虚拟根由 FmsTree 固定展开,这里只记业务分类的展开键
const categoryExpandedKeys = ref([])
function onCategorySelect(key) {
selectCategory(key)
@@ -122,7 +119,7 @@ onMounted(() => {
loadCategories()
if (props.initialCateId) {
selectCategory(props.initialCateId)
categoryExpandedKeys.value = ['__fms_tree_root__', String(props.initialCateId)]
categoryExpandedKeys.value = [String(props.initialCateId)]
}
})
@@ -1,10 +1,9 @@
import { ref, computed } from 'vue'
import { ref } from 'vue'
import { loadDataApi } from '@/services/api'
/**
* 文件分类逻辑 —— 加载 bf_files_cateid 扁平表并通过 Tree 展示。
* 返回的 treeData 已包含「全部分类」根节点(key 固定为 __fms_tree_root__),
* 供 FileCategoryPanel 直接渲染。
* 文件分类逻辑 —— 加载 bf_files_cateid 扁平表,供 FmsTree 展示(分类为平铺列表,无层级)。
* 「全部分类」虚拟根由 FmsTree 的 showRoot 注入,本组合式函数只负责取数与选中状态。
*/
export function useFileCategory() {
const categories = ref([])
@@ -22,10 +21,7 @@ export function useFileCategory() {
'b_canuse = 1',
'b_xh ASC, b_id ASC',
)
categories.value = (res.data || []).map((item) => ({
...item,
b_id: String(item.b_id),
}))
categories.value = res.data || []
} catch (e) {
error.value = e?.message || String(e)
categories.value = []
@@ -34,26 +30,6 @@ export function useFileCategory() {
}
}
/** 扁平分类转 Tree 所需 treeData(b_id → key, b_name → title),
并前置「全部分类」根节点(与旧版 FmsTree 行为一致) */
const treeData = computed(() => {
const children = categories.value.map((item) => ({
key: String(item.b_id),
// 字符串业务键:分类编码即 b_id(无独立 b_code 列)
title: item.b_name || String(item.b_id) || '',
b_id: String(item.b_id),
isLeaf: true,
}))
return [
{
key: '__fms_tree_root__',
title: '全部分类',
isFmsRoot: true,
children,
},
]
})
/** 按 key 查找分类原始数据 */
function findCategory(key) {
const id = String(key || '')
@@ -69,7 +45,6 @@ export function useFileCategory() {
loading,
error,
selectedKey,
treeData,
loadCategories,
findCategory,
selectCategory,
@@ -679,7 +679,7 @@ function resolveRowKey(row) {
// ── 可编辑表格:脏行追踪 / 保存 / 批量删除 ──
// 主键与系统审计字段(由后端维护)在可编辑表格中一律只读。
const SYSTEM_FIELDS = ['b_inputdatetime', 'b_inputuser_id', 'b_updatedatetime', 'b_updateuser_id']
const SYSTEM_FIELDS = ['b_created_at', 'b_created_by', 'b_updated_at', 'b_updated_by']
const readonlyFields = computed(() => {
const fields = new Set(SYSTEM_FIELDS)
if (keyField.value) fields.add(keyField.value)
@@ -1,6 +1,5 @@
<script setup>
import { computed } from 'vue'
import { RangePicker } from '@/components/ui'
import FmsField from '@/components/fms-field/FmsField.vue'
import {
isDateQuery,
@@ -81,14 +80,17 @@ function isDateBeforeLimit(valueToCompare) {
class="fms-query-control fms-query-control__range"
:class="{ 'fms-query-control__range--compact': compact }"
>
<RangePicker
<FmsField
v-if="isDate"
class="fms-query-control__date-range"
type="range"
:model-value="modelValue"
value-format="YYYY-MM-DD"
:disabled="disabled"
:disabled-date="(date) => isDateAfterLimit(date) || isDateBeforeLimit(date)"
@update:model-value="(next) => emit('update:modelValue', next)"
:control-props="{
valueFormat: 'YYYY-MM-DD',
disabledDate: (date) => isDateAfterLimit(date) || isDateBeforeLimit(date),
}"
@update:model-value="emit('update:modelValue', $event)"
/>
<template v-else>
<FmsField
@@ -116,31 +118,18 @@ function isDateBeforeLimit(valueToCompare) {
:disabled="disabled"
:control-props="{ options: checkboxOptions, filterable: false }"
/>
<select
v-else-if="isMultipleSelect"
class="fms-query-control fms-query-control__native-multiple"
multiple
:disabled="disabled"
:value="Array.isArray(modelValue) ? modelValue : []"
@change="
(event) =>
emit(
'update:modelValue',
[...event.target.selectedOptions].map((option) => option.value),
)
"
>
<option v-for="option in selectOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<FmsField
v-else-if="isSelect"
v-model="value"
class="fms-query-control"
type="select"
:disabled="disabled"
:control-props="{ options: selectOptions, clearable: true, filterable: !compact }"
:control-props="{
options: selectOptions,
clearable: true,
filterable: !compact,
multiple: isMultipleSelect,
}"
/>
<FmsField
v-else-if="isDate"
@@ -177,13 +166,4 @@ function isDateBeforeLimit(valueToCompare) {
color: var(--fms-text-secondary, #646a73);
text-align: center;
}
.fms-query-control__native-multiple {
width: 100%;
min-height: 34px;
padding: 5px 8px;
border: 1px solid var(--fms-border, #d9dce1);
border-radius: 4px;
background: var(--fms-surface, #fff);
color: var(--fms-text, #1f2329);
}
</style>
@@ -144,7 +144,7 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true })
display: grid;
flex: 0 1 auto;
min-width: 0;
grid-template-columns: repeat(4, minmax(0, 264px));
grid-template-columns: repeat(4, minmax(0, 200px));
gap: 8px 16px;
align-items: end;
}
@@ -0,0 +1,146 @@
<script setup>
/**
* FmsTree —— FMS 业务树(封装组件库 ui/Tree)。
*
* 渲染、交互、拖拽、右键菜单、键盘导航、无障碍全部由 ui/Tree 承担;本组件只做 FMS 业务约定适配:
* 1. 默认字段名 b_id / b_name / children,调用方不必重复书写 fieldNames;
* 2. 节点标题按 b_i18n 翻译(translate(b_i18n, b_name || b_code || b_id)),关闭 translateTitle 则直接取原值;
* 3. 可选「全部分类」虚拟根节点(showRoot),默认展开、可折叠,调用方无需感知 rootKey。
*
* 除下表声明的 props 外,其余 props 与事件(draggable / filter / disabled / contextMenu /
* selectedKeys / checkedKeys / lazy / v-model:selected-keys …)经 $attrs 原样透传给 ui/Tree,
* 事件名与 ui/Tree 保持一致。
*
* 注意:本组件始终向 ui/Tree 提供 #title 插槽(默认渲染翻译后的标题),因此 ui/Tree 内置的
* filter 关键词高亮不再生效(过滤本身照常生效);需要高亮时在 #title 插槽内自行实现。
*
* 用法:
* <FmsTree
* :tree-data="moduleTree"
* :expanded-keys="expandedKeys"
* :selected-keys="selectedId ? [selectedId] : []"
* :context-menu="buildContextMenu"
* draggable
* @select="onSelect"
* @context-menu-click="onMenuClick"
* >
* <template #title="{ node, title }">
* <span :class="{ 'is-disabled': Number(node.b_canuse) !== 1 }">{{ title }}</span>
* </template>
* </FmsTree>
*/
import { computed, ref } from 'vue'
import { Tree } from '@/components/ui'
import { translate } from '@/stores/i18n'
defineOptions({ inheritAttrs: false })
const props = defineProps({
/** 业务树数据(b_id / b_name / children) */
treeData: { type: Array, default: () => [] },
/** 字段名覆盖:与 FMS 默认 { key: 'b_id', title: 'b_name', children: 'children' } 浅合并 */
fieldNames: { type: Object, default: () => ({}) },
/** 节点标题是否按 b_i18n 翻译 */
translateTitle: { type: Boolean, default: true },
/** 是否注入「全部分类」虚拟根节点 */
showRoot: { type: Boolean, default: false },
/** 虚拟根节点 key */
rootKey: { type: [String, Number], default: '__fms_tree_root__' },
/** 虚拟根节点标题,空则取多语言键 common.allCategories */
rootTitle: { type: String, default: '' },
/**
* 展开键。undefined 表示非受控:无虚拟根时原样交给 ui/Tree(default-expand-all 等初始展开配置照常生效),
* 有虚拟根时由本组件用内部状态维护,并照常抛出 update:expandedKeys。
*/
expandedKeys: { type: Array, default: undefined },
})
const emit = defineEmits(['update:expandedKeys'])
/** showRoot 且调用方未受控时,本组件用内部状态持有展开键(始终不含 rootKey) */
const internalExpandedKeys = ref([])
const fn = computed(() => ({
key: 'b_id',
title: 'b_name',
children: 'children',
...props.fieldNames,
}))
/** 节点标题:b_i18n 有译文时取译文,否则按 b_name → b_code → b_id 回退 */
function resolveNodeTitle(node) {
if (node?.isFmsRoot) {
return props.rootTitle || translate('common.allCategories', '全部分类')
}
const fallback = node?.[fn.value.title] || node?.b_code || node?.[fn.value.key] || ''
return props.translateTitle ? translate(node?.b_i18n, fallback) : fallback
}
/**
* 实际渲染的树数据。
* 虚拟根只在外层包一层、不复制业务节点:节点仍是业务树里的同一个对象,
* 调用方对节点的改动(如启用勾选)能正常触发重渲染,按 b_id 反查父级/落点的逻辑也不会看到 rootKey。
* 根是分组节点、不是业务叶子:显式 isLeaf=false,业务树为空时也照样显示折叠箭头
* (否则空列表下根会被推断成叶子,界面上看不出这里还能展开)。
*/
const effectiveTreeData = computed(() => {
if (!props.showRoot) return props.treeData
return [
{
[fn.value.key]: props.rootKey,
isFmsRoot: true,
isLeaf: false,
[fn.value.children]: props.treeData,
},
]
})
/** 根被用户手动收起后置 true:不再补回 rootKey,使折叠箭头能正常工作 */
const rootCollapsed = ref(false)
const hasRootKey = (keys) => keys.some((key) => String(key) === String(props.rootKey))
/**
* 虚拟根默认展开(调用方的展开键里不需要出现 rootKey);
* 用户收起根之后不再强制补回,避免箭头点不动。
*/
const effectiveExpandedKeys = computed(() => {
const keys = props.expandedKeys
const withRoot = (list) =>
props.showRoot && !rootCollapsed.value && !hasRootKey(list) ? [props.rootKey, ...list] : list
if (keys === undefined) {
return props.showRoot ? withRoot(internalExpandedKeys.value) : undefined
}
return withRoot(keys)
})
function onExpandedKeysChange(keys) {
// 用户收起/展开根:以本次的展开键是否含 rootKey 为准
if (props.showRoot) rootCollapsed.value = !hasRootKey(keys)
// 非受控时自己留一份(去掉根 key,根的展开由 effectiveExpandedKeys 统一补回)
if (props.expandedKeys === undefined && props.showRoot) {
internalExpandedKeys.value = keys.filter((key) => String(key) !== String(props.rootKey))
}
emit('update:expandedKeys', keys)
}
</script>
<template>
<Tree
v-bind="$attrs"
:tree-data="effectiveTreeData"
:field-names="fn"
:expanded-keys="effectiveExpandedKeys"
@update:expanded-keys="onExpandedKeysChange"
>
<!-- 标题:默认渲染翻译后的文本;调用方可用 #title 覆盖,并从插槽参数取翻译结果 -->
<template #title="scope">
<slot name="title" :node="scope.node" :title="resolveNodeTitle(scope.node)">
{{ resolveNodeTitle(scope.node) }}
</slot>
</template>
<template v-if="$slots.empty" #empty>
<slot name="empty" />
</template>
</Tree>
</template>
@@ -3,6 +3,7 @@ import { onBeforeUnmount, ref } from 'vue'
import Select from '../select/select.vue'
const city = ref('beijing')
const multipleCities = ref(['beijing', 'guangzhou'])
const fruitSelect = ref('')
const clearableSelect = ref('shanghai')
const priority = ref('high')
@@ -86,7 +87,7 @@ onBeforeUnmount(() => {
<p class="eyebrow">Components</p>
<h2 id="select-title">Select 下拉选择</h2>
<p>
基础单选下拉,支持 options
单选与多选下拉,支持 options
数组与插槽两种数据源、筛选(默认开启,可自定义)、清除、禁用与键盘导航。
</p>
</div>
@@ -97,6 +98,12 @@ onBeforeUnmount(() => {
<Select v-model="fruitSelect" :options="fruitOptions" />
</div>
<p class="demo-label">多选(multiple)</p>
<div class="demo-panel demo-panel-grid">
<Select v-model="multipleCities" :options="selectOptions" multiple clearable />
<p class="select-demo-value">当前值:{{ multipleCities.join('、') || '未选择' }}</p>
</div>
<p class="demo-label">清除与禁用</p>
<div class="demo-panel demo-panel-grid">
<Select v-model="clearableSelect" :options="selectOptions" clearable />
@@ -160,4 +167,12 @@ onBeforeUnmount(() => {
.select-demo-option:hover {
background: var(--fms-secondary-hover);
}
.select-demo-value {
align-self: center;
margin: 0;
color: var(--fms-text-secondary);
font-size: 12px;
overflow-wrap: anywhere;
}
</style>
@@ -23,6 +23,7 @@
cursor: pointer;
position: relative;
z-index: 0;
box-shadow: var(--fms-shadow);
:deep(svg) {
width: 1em;
@@ -37,7 +38,9 @@
.select-trigger-open,
.select-trigger-focused {
border-color: var(--fms-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-primary) 20%, transparent);
box-shadow:
0 0 0 3px color-mix(in srgb, var(--fms-primary) 20%, transparent),
var(--fms-shadow);
/* 聚焦光环向外扩散 3px,提升层级避免被相邻表单项/后续兄弟元素遮挡 */
z-index: 1;
}
@@ -6,7 +6,7 @@ import Popover from '../popover/popover.vue'
/* ---------- props / emits ---------- */
const props = defineProps({
/** 双向绑定的选中值(类型取决于 valueKey 指向的字段,默认 string/number,也可为 object 等) */
modelValue: { type: [String, Number, Object, Boolean], default: undefined },
modelValue: { type: [String, Number, Object, Boolean, Array], default: undefined },
/** 选项数组;每项默认取 { label, value, disabled },可用 labelKey/valueKey 自定义字段名;
提供时渲染标准选项,未传则渲染默认插槽 */
options: { type: Array, default: undefined },
@@ -14,6 +14,8 @@ const props = defineProps({
disabled: { type: Boolean, default: false },
/** 有值时展示清除按钮 */
clearable: { type: Boolean, default: false },
/** 多选模式:modelValue 为数组,选中后下拉保持打开 */
multiple: { type: Boolean, default: false },
/** 是否开启筛选(默认开启;开启后直接在触发器输入框输入即可筛选) */
filterable: { type: Boolean, default: true },
/** 自定义筛选函数:(input, option) => boolean;不传则用默认的 label/value 包含匹配 */
@@ -63,7 +65,7 @@ const normalizedOptions = computed(() =>
return {
label: rawLabel == null ? '' : String(rawLabel),
value: rawValue,
selected: Object.is(rawValue, props.modelValue),
selected: isOptionSelected(rawValue),
disabled: Boolean(opt.disabled),
index,
raw: opt,
@@ -72,7 +74,7 @@ const normalizedOptions = computed(() =>
return {
label: String(opt),
value: opt,
selected: Object.is(opt, props.modelValue),
selected: isOptionSelected(opt),
disabled: false,
index,
raw: opt,
@@ -80,6 +82,12 @@ const normalizedOptions = computed(() =>
}),
)
function isOptionSelected(value) {
return props.multiple
? Array.isArray(props.modelValue) && props.modelValue.some((entry) => Object.is(entry, value))
: Object.is(value, props.modelValue)
}
// 可交互选项的原始索引列表(跳过 disabled),键盘导航只在这些索引间移动
const enabledIndices = computed(() =>
visibleOptions.value.reduce((acc, opt) => {
@@ -115,12 +123,19 @@ const visibleOptions = computed(() =>
)
const hasValue = computed(
() => props.modelValue !== undefined && props.modelValue !== null && props.modelValue !== '',
() =>
props.multiple
? Array.isArray(props.modelValue) && props.modelValue.length > 0
: props.modelValue !== undefined && props.modelValue !== null && props.modelValue !== '',
)
const selectedOption = computed(() => normalizedOptions.value.find((opt) => opt.selected))
const selectedOptions = computed(() => normalizedOptions.value.filter((opt) => opt.selected))
const selectedDisplayLabel = computed(() => {
if (props.multiple) {
return selectedOptions.value.map((option) => option.label).join('、')
}
if (selectedOption.value) return selectedOption.value.label
if (selectedCache.value && Object.is(selectedCache.value.value, props.modelValue)) {
return selectedCache.value.label
@@ -179,9 +194,7 @@ watch(open, (value) => {
nextTick(() => {
// 筛选词 watcher 完成后再定位选中项,避免清空旧筛选词时把高亮覆盖为第一项。
activeIndex.value =
enabledIndices.value.find((index) =>
Object.is(normalizedOptions.value[index].value, props.modelValue),
) ?? -1
enabledIndices.value.find((index) => normalizedOptions.value[index].selected) ?? -1
// 触发器输入框承接键入与键盘事件;聚焦下拉容器仅在无输入框场景(默认插槽)兜底
if (props.filterable) triggerInputRef.value?.focus()
else dropdownEl.value?.focus()
@@ -282,6 +295,10 @@ function onInput(event) {
// - 自由输入(allowFreeInput):仅在实际编辑后提交原始文本;选择下拉项前会结束编辑,避免覆盖选项值。
// - 非自由输入:若输入框文本精确匹配某选项 label 则选中该项;否则回退到上次选中值。
function commitFreeInputOrRevert() {
if (props.multiple) {
filterQuery.value = ''
return
}
if (props.allowFreeInput) {
if (editing.value && !Object.is(filterQuery.value, props.modelValue)) {
emit('update:modelValue', filterQuery.value)
@@ -312,6 +329,16 @@ function commitFreeInputOrRevert() {
function selectOption(option) {
if (props.disabled || option.disabled) return
editing.value = false
if (props.multiple) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const index = current.findIndex((value) => Object.is(value, option.value))
if (index >= 0) current.splice(index, 1)
else current.push(option.value)
emit('update:modelValue', current)
emit('change', current, option)
refocusTrigger()
return
}
selectedCache.value = { value: option.value, label: option.label }
emit('update:modelValue', option.value)
emit('change', option.value, option)
@@ -324,6 +351,15 @@ function selectOption(option) {
function selectValue(value) {
if (props.disabled) return
editing.value = false
if (props.multiple) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const index = current.findIndex((entry) => Object.is(entry, value))
if (index >= 0) current.splice(index, 1)
else current.push(value)
emit('update:modelValue', current)
emit('change', current, undefined)
return
}
emit('update:modelValue', value)
emit('change', value, undefined)
open.value = false
@@ -341,8 +377,9 @@ function clearValue() {
selectedCache.value = null
// 键盘触发清除时按钮会被卸载,先把焦点交还输入框,避免焦点丢失后浮层残留。
refocusTrigger()
emit('update:modelValue', undefined)
emit('change', undefined, undefined)
const emptyValue = props.multiple ? [] : undefined
emit('update:modelValue', emptyValue)
emit('change', emptyValue, undefined)
}
/* ---------- 键盘导航 ---------- */
@@ -517,6 +554,7 @@ function onTriggerKeydown(event) {
:id="listboxId"
class="select-dropdown"
role="listbox"
:aria-multiselectable="multiple || undefined"
tabindex="-1"
@focusout="onSelectFocusOut"
@pointerdown="onDropdownPointerDown"
@@ -285,7 +285,7 @@ provide('toggleSidebar', toggleSidebar)
/* 内容区:灰底画布内的滚动区(顶部由 .fms-tabs 的底边线分隔)。
* flex column:让页面根可用 flex:1 撑满可视高度(百分比高度链在滚动容器内不可靠)。
* overflow: auto hidden —— 只允许纵向滚动,禁止横向滚动条:
* overflow-x: hidden + overflow-y: auto —— 只允许纵向滚动,禁止横向滚动条:
* 侧栏折叠/展开过渡(flex-basis 180ms)瞬间内容宽度会短暂超出容器,
* 若允许 x 滚动会出现滚动条闪现;页面内容的横向滚动应由内部容器(表格/splitter)处理。
* 页面需要「固定高度 + 内部滚动」时走纯 flex 链:.fms-content / .fms-page-wrap 均已
@@ -298,7 +298,8 @@ provide('toggleSidebar', toggleSidebar)
min-height: 0;
display: flex;
flex-direction: column;
overflow: auto hidden;
overflow-x: hidden;
overflow-y: auto;
/* 上下 4px,左右 0 */
padding: 4px 0;
}
@@ -224,7 +224,6 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
* 背景用 --fms-surface(与侧栏同为框架面,比内容白卡沉一档) */
background: var(--fms-surface);
border-radius: 0 0 var(--fms-control-radius) var(--fms-control-radius);
box-shadow: var(--fms-frame-shadow);
}
.fms-topbar-left,
+23
View File
@@ -96,6 +96,29 @@ export const routes = [
],
},
// 业务页面后续在此追加,配置 meta.moduleId 后自动走权限校验
// 基础数据:往来单位(合作伙伴)—— 左分类树 + 右模块列表
{
path: '/base/othercompany',
name: 'base-othercompany',
component: () => import('@/views/base/othercompany/index.vue'),
meta: {
title: '往来单位',
componentName: 'BaseOthercompany',
moduleId: 'v_b_othercompany',
},
},
// 往来单位详情:新增与编辑都在此页完成(:id = 'new' 表示新建)。
// 带动态段,因此不会出现在菜单管理的「路由地址」下拉里(见 collectMenuRoutes)。
{
path: '/base/othercompany/detail/:id',
name: 'base-othercompany-detail',
component: () => import('@/views/base/othercompany/detail.vue'),
meta: {
title: '合作伙伴详情',
componentName: 'BaseOthercompanyDetail',
moduleId: 'v_b_othercompany',
},
},
],
},
{
+6 -9
View File
@@ -10,10 +10,10 @@
* 白卡无需描边即可被托起;画布带轻微冷蓝倾向,比中性灰更稳、更耐看。 */
--fms-background: #eef1f6;
--fms-card: #ffffff;
/* 框架面(侧栏 / 顶栏等大面积容器):比内容白卡沉一档。
* 三大块纯白(侧栏 + 顶栏 + 内容卡)拼在一起会刺眼、且互相"抢"层次,
* 让框架退后一步,白卡自然成为视觉主体。差 1~2% 即可,再大就显脏。 */
--fms-surface: #fbfcfd;
/* 框架面(侧栏 / 顶栏等大面积容器):与内容白卡同为纯白。
* 产品决定框架面不再"沉一档",侧栏/顶栏与内容卡统一纯白;层次由阴影与分隔线承担。
* 深色模式仍由 .dark 覆盖为深色框架面,保持"框架退后"的关系。 */
--fms-surface: #ffffff;
--fms-text: #1f2329;
--fms-secondary: #e9edf2;
--fms-secondary-hover: #f1f5f9;
@@ -98,11 +98,8 @@
--fms-form-label-width: 88px;
--fms-form-help-font-size: 12px;
/* 阴影:双层(近距定型 + 远距扩散),比单层 5% 更能把白卡从画布上托起。
* 卡片浮起后可省去大量 1px 外框线,用"面 + 影"代替"线"划分层次 */
--fms-shadow:
0 1px 2px rgba(30, 45, 64, 0.06),
0 4px 10px rgba(30, 45, 64, 0.04);
/* 阴影:控件使用的轻量单层阴影 */
--fms-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--fms-popover-padding: 8px;
--fms-popover-shadow: 0 3px 8px rgba(0, 0, 0, 0.12);
@@ -0,0 +1,18 @@
<script setup>
/**
* 联系人(b_contact)。
* 页面结构、权限与编辑入口尚未实现,先占位以便路由与目录结构就位。
*/
</script>
<template>
<div class="contact-page" />
</template>
<style scoped>
.contact-page {
width: 100%;
height: 100%;
overflow: hidden;
}
</style>
@@ -0,0 +1,147 @@
<script setup>
/**
* 新增 / 修改分类弹窗(b_othercompany_bmfl)。
*
* 数据契约:本组件只持有一份打开时快照的本地草稿;确定时把校验通过的
* { b_id, b_name, b_xh } 交给父级落库,父级再重载分类树。
* 上级分类由父级传入 parentId 决定(新增时点 + / 右键的落点),弹窗内只读展示。
* 分类编码是主键(创建后不可修改),因此修改态下禁用,并做全局唯一性校验。
*/
import { computed, ref, watch } from 'vue'
import { Button, Form, FormItem, Input, Message, Modal } from '@/components/ui'
import { nextSiblingXh } from './utils'
const props = defineProps({
/** 受控打开状态 */
open: { type: Boolean, default: false },
/** 修改时的分类行;新增时为 null */
editing: { type: Object, default: null },
/** 新增分类的上级编码;null 表示顶级 */
parentId: { type: [String, Number], default: null },
/** 全量分类行(编码全局查重 + 上级名称展示) */
categories: { type: Array, default: () => [] },
/** 保存中:禁用表单与按钮 */
saving: { type: Boolean, default: false },
})
const emit = defineEmits(['update:open', 'submit'])
const isEdit = computed(() => !!props.editing)
/** 本地草稿:打开时快照,确定时提交 */
const draft = ref({ b_id: '', b_name: '', b_xh: 10 })
/** 上级分类名称(顶级显示「全部分类」) */
const parentName = computed(() => {
if (props.parentId == null || props.parentId === '') return '全部分类'
const parent = props.categories.find((row) => String(row.b_id) === String(props.parentId))
return parent?.b_name || String(props.parentId)
})
watch(
() => props.open,
(open) => {
if (!open) return
if (props.editing) {
draft.value = {
b_id: props.editing.b_id ?? '',
b_name: props.editing.b_name ?? '',
b_xh: Number(props.editing.b_xh) || 0,
}
return
}
draft.value = {
b_id: '',
b_name: '',
b_xh: nextSiblingXh(props.categories, props.parentId),
}
},
)
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: 150, message: '分类编码长度不能超过 150' },
{
// b_id 是主键,重复即入库失败;这里提前拦下并给出明确提示(修改自身时排除)
validator: (value) => {
const code = String(value ?? '').trim()
if (!code) return true
const selfId = String(props.editing?.b_id ?? '')
const duplicated = props.categories.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
}
emit('submit', {
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 onCancel() {
emit('update:open', false)
}
</script>
<template>
<Modal
:open="open"
:title="isEdit ? '修改分类' : '新增分类'"
:width="520"
:mask-closable="false"
:confirm-loading="saving"
@cancel="onCancel"
>
<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>
<template #footer>
<Button type="outline" :disabled="saving" @click="onCancel">取消</Button>
<Button type="primary" :loading="saving" @click="onOk">确定</Button>
</template>
</Modal>
</template>
@@ -0,0 +1,247 @@
<script setup>
/**
* 往来单位分类树面板(othercompany 左侧)。
*
* 职责:搜索框、树展示,以及展开 / 选中 / 右键菜单 / 拖拽的 UI 交互。
* 数据契约:树数据、选中项、加载态由父级唯一持有,本组件只读并通过事件上报意图:
* select / add / edit / delete / refresh / drop。
* 树的加载与分类的落库(新增 / 修改 / 删除 / 拖拽排序)全部由父级(index.vue)决定。
* 搜索关键字是纯 UI 状态,留在本组件;展开键需由父级在加载后统一展开,故以
* v-model:expanded-keys 暴露。
*/
import { computed, ref, watch } from 'vue'
import { Pencil, Plus, RefreshCw, Search, Trash2 } from '@lucide/vue'
import { Button, Input, Spin } from '@/components/ui'
import FmsTree from '@/components/fms-tree/FmsTree.vue'
import { collectAllKeys, filterTreeByText } from '@/utils/tree'
import { ALL_CATEGORY_KEY } from './utils'
const props = defineProps({
/** 分类树(b_othercompany_bmfl),父级持有并负责加载 / 保存 */
treeData: { type: Array, required: true },
/** 树数据加载中 */
loading: { type: Boolean, default: false },
/** 当前选中的分类编码;「全部分类」时为虚拟根 key */
selectedKey: { type: [String, Number], default: '' },
/** 展开的树节点 key(v-model:父级加载分类后统一展开) */
expandedKeys: { type: Array, default: () => [] },
})
const emit = defineEmits([
'update:expandedKeys',
'select',
'add',
'edit',
'delete',
'refresh',
'drop',
])
/** 搜索关键字(纯 UI 状态) */
const keyword = ref('')
/** 分类没有 b_code,匹配「名称 + 编码」两列(编码即 b_id) */
const filteredTree = computed(() =>
filterTreeByText(props.treeData, keyword.value, ['b_name', 'b_id']),
)
// 搜索时展开全部(保证命中节点可见),清空时收起
watch(keyword, (kw) => {
emit('update:expandedKeys', kw.trim() ? collectAllKeys(props.treeData) : [])
})
/** 「全部分类」虚拟根(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 onContextMenuClick({ item, node }) {
const parentId = isRootNode(node) ? null : String(node.b_id)
if (item.key === 'add') emit('add', { parentId })
else if (item.key === 'edit') emit('edit', node)
else if (item.key === 'delete') emit('delete', node)
}
/** 工具栏新增:挂到当前选中分类下;选中「全部分类」或未选中时建为顶级分类 */
function onToolbarAdd() {
const selected = String(props.selectedKey ?? '')
emit('add', {
parentId: !selected || selected === ALL_CATEGORY_KEY ? null : selected,
})
}
</script>
<template>
<aside class="oc-tree-panel">
<div class="oc-tree-header">
<Input v-model="keyword" placeholder="搜索分类" allow-clear class="oc-tree-search">
<template #prefix><Search :size="14" class="oc-tree-search__icon" /></template>
</Input>
<div class="oc-tree-header__actions">
<Button
type="ghost"
class="oc-tree-header__icon-btn"
title="刷新分类"
aria-label="刷新分类"
@click="emit('refresh')"
>
<RefreshCw :size="14" />
</Button>
<Button
type="ghost"
class="oc-tree-header__icon-btn"
title="新增分类"
aria-label="新增分类"
@click="onToolbarAdd"
>
<Plus :size="14" />
</Button>
</div>
</div>
<div class="oc-tree-panel__scroll">
<!-- 过滤(搜索)状态下禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
<FmsTree
show-root
root-title="全部分类"
:tree-data="filteredTree"
:selected-keys="selectedKey ? [String(selectedKey)] : []"
:expanded-keys="expandedKeys"
:context-menu="buildContextMenu"
:draggable="!keyword.trim()"
@select="(keys, info) => emit('select', keys, info)"
@update:expanded-keys="(keys) => emit('update:expandedKeys', keys)"
@context-menu-click="onContextMenuClick"
@drop="(payload) => emit('drop', payload)"
>
<template #title="{ node, title }">
<span
class="oc-tree-node__name"
:class="{ 'is-disabled': !node.isFmsRoot && Number(node.b_canuse) !== 1 }"
>
{{ title }}
</span>
</template>
</FmsTree>
</div>
<div v-if="loading" class="oc-tree-panel__loading">
<Spin />
</div>
</aside>
</template>
<style lang="scss" scoped>
/* 灰底画布上的白卡:白底 + 圆角 + 阴影,与右侧列表卡用灰缝分隔 */
.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;
&.is-disabled {
color: var(--fms-text-disabled, #9ca3af);
}
}
.oc-tree-panel__loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgb(255 255 255 / 60%);
}
</style>
@@ -0,0 +1,21 @@
<script setup>
/**
* 往来单位(合作伙伴)详情 —— 新增与编辑都在本页完成。
*
* 路由契约:`/base/othercompany/detail/:id`
* - `id = 'new'` 表示新建,其余为 b_othercompany.b_id;
* - 顶部工具栏的保存 / 重置 / 删除、主表与子表(银行账号等)均尚未实现。
*/
</script>
<template>
<div class="oc-detail-page" />
</template>
<style scoped>
.oc-detail-page {
width: 100%;
height: 100%;
overflow: hidden;
}
</style>
@@ -0,0 +1,529 @@
<script setup>
/**
* 往来单位(合作伙伴)—— 左侧分类树 + 右侧模块列表。
*
* 数据来源:
* - 左树: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)完成,本页只做跳转。
*/
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Plus, Trash2 } from '@lucide/vue'
import { Button, Message, Modal, Splitter, SplitterPanel } from '@/components/ui'
import { FmsModuleListPage } from '@/components/fms-module-list'
import { escapeSqlText } from '@/components/fms-module-common/queryUtils'
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'
/** 右侧列表使用的模块编码(s_module.b_id / s_field.b_module_id) */
const MODULE_CODE = 'v_b_othercompany'
/** 左侧分类树表 */
const CATEGORY_TABLE = 'b_othercompany_bmfl'
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 categoryById = computed(
() => new Map(categories.value.map((item) => [String(item.b_id), item])),
)
/** 邻接表 → 树;顺序由 b_xh 决定。树是 categories 的派生视图(在浅拷贝上构建) */
const categoryTree = computed(() => buildCategoryTree(categories.value))
async function loadCategories() {
categoryLoading.value = true
try {
const response = await loadDataApi(CATEGORY_TABLE, '', 'b_path ASC, b_xh ASC, b_id ASC')
categories.value = response.data || []
// 选中的分类已被删除时回到「全部」
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
}
// 展开所有含子节点的分类;保留用户已手动展开的节点
const parentKeys = categories.value
.map((item) => (item.b_parent_id == null ? '' : String(item.b_parent_id)))
.filter(Boolean)
expandedCategoryKeys.value = [...new Set([...expandedCategoryKeys.value, ...parentKeys])]
} catch (error) {
Message.error('分类加载失败:' + (error?.message || error))
} finally {
categoryLoading.value = false
}
}
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
}
/**
* 分类 → 列表固定查询条件。
* b_cate_id 是 varchar,条件值用 varchar 字面量(不加 N 前缀),避免列被隐式转成
* nvarchar 而用不上 ix_b_othercompany_cate。
*/
const categoryFilterCondition = computed(() => {
if (selectedCategoryKey.value === ALL_CATEGORY_KEY) return ''
const ids = collectDescendantIds(selectedCategoryKey.value)
if (!ids.length) return '1 = 0'
const literals = ids.map((id) => `'${escapeSqlText(id)}'`)
return `[b_cate_id] IN (${literals.join(', ')})`
})
/* ================= 列扩展:分类编码显示为分类名称 ================= */
const columnExtensions = computed(() => ({
b_cate_id: {
formatter: ({ value }) => {
const category = categoryById.value.get(String(value ?? ''))
return category?.b_name || value || ''
},
},
}))
/* ================= 新增:跳转详情页 ================= */
const router = useRouter()
/** 详情页按路由参数 :id 区分,'new' 表示新建 */
function onCreate() {
router.push({ name: 'base-othercompany-detail', params: { id: 'new' } })
}
/* ================= 行多选与批量删除 ================= */
const selectedRowKeys = ref([])
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys
},
}))
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
}
}
/* ================= 分类维护:新增 / 修改 ================= */
const categoryModalOpen = ref(false)
const categoryModalSaving = ref(false)
/** 修改时的分类行;新增时为 null */
const editingCategory = ref(null)
/** 新增分类的上级编码;null 表示顶级 */
const categoryParentId = ref(null)
function onCategoryAdd({ parentId }) {
editingCategory.value = null
categoryParentId.value = parentId == null ? null : String(parentId)
categoryModalOpen.value = true
}
function onCategoryEdit(node) {
const category = categoryById.value.get(String(node?.b_id ?? ''))
if (!category) {
Message.error('未找到需要修改的分类')
return
}
editingCategory.value = category
categoryParentId.value = category.b_parent_id == null ? null : String(category.b_parent_id)
categoryModalOpen.value = true
}
/**
* 弹窗确定:新增补 b_parent_id / b_depth / b_path 后 insert;修改只提交名称与排序号
* (分类编码是主键,创建后不可改)。落库成功后重载整棵树,保持「categories 是唯一数据源」。
*/
async function onCategorySubmit(payload) {
if (categoryModalSaving.value) return
const isEdit = !!editingCategory.value
categoryModalSaving.value = true
try {
if (isEdit) {
await saveObjectApi([
{
table: CATEGORY_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: categoryParentId.value,
b_name: payload.b_name,
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
applyCategoryPosition(row, parentNode)
await saveObjectApi([{ table: CATEGORY_TABLE, key_field: 'b_id', inserts: [row] }])
selectedCategoryKey.value = String(row.b_id)
}
categoryModalOpen.value = false
Message.success(isEdit ? '分类已修改' : '分类已添加')
await loadCategories()
} catch (error) {
Message.error((isEdit ? '修改' : '添加') + '失败:' + (error?.message || error))
} finally {
categoryModalSaving.value = false
}
}
/* ================= 删除分类(两条守卫:有子分类 / 分类下有往来单位) ================= */
const categoryDeleteTarget = ref(null)
const categoryDeleting = 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 (hasChildCategory(categories.value, category.b_id)) {
Message.warning('该分类下还有子分类,请先删除子分类')
return
}
categoryDeleting.value = true
try {
// 分类下还挂着往来单位时不允许删除。b_cate_id 是 varchar,条件值用 varchar 字面量
// (不加 N 前缀),避免列被隐式转成 nvarchar 而用不上 ix_b_othercompany_cate
const response = await pageDataApi({
viewName: MODULE_CODE,
searchColumns: ['b_id'],
searchCondition: `[b_cate_id] = '${escapeSqlText(String(category.b_id))}'`,
pageNo: 1,
pageSize: 1,
})
const total = Number(response.data?.total ?? response.data?.rows?.length ?? 0)
if (total > 0) {
Message.warning('该分类下还有往来单位数据,不能删除')
return
}
} catch (error) {
Message.error('分类数据检查失败:' + (error?.message || error))
return
} finally {
categoryDeleting.value = false
}
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
}
}
/* ================= 拖拽排序(同级调序 / 跨级换父) ================= */
async function onCategoryDrop({ dragKey, dropKey, dropPosition }) {
const dragId = String(dragKey)
const dropId = String(dropKey)
if (dragId === dropId) return
// 在树的临时副本上算 diff:树只是 categories 的派生视图,
// 就地改会与唯一数据源脱节,故一律「算差异 → 落库 → 重载」
const tree = buildCategoryTree(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('不能把分类移动到它自己的下级')
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))
// 父子关系变化后整树重建 b_depth / b_path,仅收集实际变化的行
rebuildTreePositions(tree, 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 })
}
if (!updatedRows.length) return
try {
await saveObjectApi(
updatedRows.map((row) => ({ table: CATEGORY_TABLE, key_field: 'b_id', updates: [row] })),
)
Message.success('排序已更新')
} catch (error) {
Message.error('排序更新失败:' + (error?.message || error))
}
// 失败也重载:以库里为准,避免界面停在一次没有落库的排序上
await loadCategories()
}
void loadCategories()
</script>
<template>
<div class="oc-page">
<Splitter class="oc-splitter">
<SplitterPanel :default-size="248" :min="180" :max="420">
<CategoryTreePanel
v-model:expanded-keys="expandedCategoryKeys"
:tree-data="categoryTree"
:loading="categoryLoading"
:selected-key="selectedCategoryKey"
@select="onCategorySelect"
@add="onCategoryAdd"
@edit="onCategoryEdit"
@delete="(node) => void onCategoryDelete(node)"
@refresh="loadCategories"
@drop="(payload) => void onCategoryDrop(payload)"
/>
</SplitterPanel>
<SplitterPanel>
<main class="oc-list">
<FmsModuleListPage
ref="listRef"
:data-code="MODULE_CODE"
:fixed-search-condition="categoryFilterCondition"
:column-extensions="columnExtensions"
:row-selection="rowSelection"
>
<template #actions="{ loading }">
<Button
v-if="canCreateRow"
type="primary"
:disabled="loading || deleting"
@click="onCreate"
>
<Plus :size="14" />
新增
</Button>
<Button
v-if="canDeleteRow"
type="danger"
:disabled="loading || deleting || !selectedRowKeys.length"
@click="confirmOpen = true"
>
<Trash2 :size="14" />
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
</Button>
</template>
</FmsModuleListPage>
</main>
</SplitterPanel>
</Splitter>
<Modal
v-model:open="confirmOpen"
title="确认"
:confirm-loading="deleting"
:mask-closable="!deleting"
:keyboard="!deleting"
>
<p class="oc-confirm-text">
确定删除选中的 {{ selectedRowKeys.length }} 条往来单位?此操作不可恢复。
</p>
<template #footer>
<Button type="outline" :disabled="deleting" @click="confirmOpen = false">取消</Button>
<Button type="danger" :loading="deleting" @click="confirmBatchDelete">删除</Button>
</template>
</Modal>
<!-- 分类新增 / 修改 -->
<CategoryEditModal
v-model:open="categoryModalOpen"
:editing="editingCategory"
:parent-id="categoryParentId"
:categories="categories"
:saving="categoryModalSaving"
@submit="onCategorySubmit"
/>
<!-- 分类删除确认(守卫已在 onCategoryDelete 里过完) -->
<Modal
v-model:open="categoryDeleteOpen"
title="删除分类"
:confirm-loading="categoryDeleting"
:mask-closable="!categoryDeleting"
:keyboard="!categoryDeleting"
>
<p class="oc-confirm-text">
确定删除分类「{{ categoryDeleteTarget?.b_name || categoryDeleteTarget?.b_id }}」?
</p>
<template #footer>
<Button type="outline" :disabled="categoryDeleting" @click="categoryDeleteTarget = null">
取消
</Button>
<Button type="danger" :loading="categoryDeleting" @click="confirmCategoryDelete">
删除
</Button>
</template>
</Modal>
</div>
</template>
<style scoped lang="scss">
.oc-page,
.oc-splitter {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.oc-page {
overflow: hidden;
}
.oc-splitter :deep(> .fms-splitter-panel) {
overflow: hidden;
}
.oc-splitter :deep(.fms-splitter-bar::before) {
opacity: 0;
}
.oc-splitter :deep(.fms-splitter-bar:hover::before),
.oc-splitter :deep(.fms-splitter-bar-active::before),
.oc-splitter :deep(.fms-splitter-bar:focus-visible::before) {
opacity: 1;
}
/* 右侧列表卡:不再套白底。FmsModuleListPage 内部已有 .fms-module-card
* (白底 + 圆角 + 阴影),外层再铺白底会把内卡之间的灰缝染色、并让整块区域变成直角白板。
* 左侧分类树卡片的样式随 CategoryTreePanel 一起维护。 */
.oc-list {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.oc-confirm-text {
margin: 0;
line-height: 1.7;
color: var(--fms-text);
}
</style>
@@ -0,0 +1,131 @@
/**
* 往来单位分类树(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)
}
}
@@ -0,0 +1,18 @@
<script setup>
/**
* 其他数据(旧「其他数据」子系统的入口)。
* 页面结构、权限与编辑入口尚未实现,先占位以便路由与目录结构就位。
*/
</script>
<template>
<div class="otherdata-page" />
</template>
<style scoped>
.otherdata-page {
width: 100%;
height: 100%;
overflow: hidden;
}
</style>
@@ -95,6 +95,14 @@ function onMenuTypeChange(menuType) {
layout="vertical"
:gap="20"
>
<FormItem label="菜单编码" name="b_id" required>
<Input
:model-value="maindata.b_id"
:disabled="typeLocked"
placeholder="主键即编码,保存后不可修改"
@update:model-value="(v) => setField('b_id', v)"
/>
</FormItem>
<FormItem label="菜单名称" name="b_name" required>
<div class="mm-name-field">
<Input
@@ -112,14 +120,6 @@ function onMenuTypeChange(menuType) {
</Button>
</div>
</FormItem>
<FormItem label="菜单编码" name="b_id" required>
<Input
:model-value="maindata.b_id"
:disabled="typeLocked"
placeholder="主键即编码,保存后不可修改"
@update:model-value="(v) => setField('b_id', v)"
/>
</FormItem>
<FormItem label="菜单类型" name="b_menu_type" required>
<Select
:model-value="maindata.b_menu_type"
@@ -10,7 +10,8 @@
*/
import { computed, ref, watch } from 'vue'
import { ExternalLink, FileText, Folder, Plus, RefreshCw, Search, Trash2 } from '@lucide/vue'
import { Button, Checkbox, Dropdown, Input, Tree } from '@/components/ui'
import { Button, Checkbox, Dropdown, Input } from '@/components/ui'
import FmsTree from '@/components/fms-tree/FmsTree.vue'
import { collectAllKeys, filterTreeByText } from '@/utils/tree'
const props = defineProps({
@@ -117,10 +118,9 @@ function onToolbarAdd({ key }) {
<div class="mm-tree-panel__scroll">
<div v-if="loading" class="mm-tree-empty">加载中...</div>
<!-- 过滤(搜索)状态下禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
<Tree
<FmsTree
v-else
:tree-data="filteredTree"
:field-names="{ key: 'b_id', title: 'b_name', children: 'children' }"
:expanded-keys="expandedKeys"
@update:expanded-keys="(keys) => emit('update:expandedKeys', keys)"
:selected-keys="selectedId ? [selectedId] : []"
@@ -130,13 +130,13 @@ function onToolbarAdd({ key }) {
@context-menu-click="onContextMenuClick"
@drop="(payload) => emit('drop', payload)"
>
<template #title="{ node }">
<template #title="{ node, title }">
<span class="mm-tree-node">
<span
class="mm-tree-node__name"
:class="{ 'is-disabled': Number(node.b_canuse) !== 1 }"
>
{{ node.b_name || node.b_id || '(未命名)' }}
{{ title }}
</span>
<Checkbox
class="mm-tree-node__enable"
@@ -146,7 +146,7 @@ function onToolbarAdd({ key }) {
/>
</span>
</template>
</Tree>
</FmsTree>
</div>
</aside>
</template>
@@ -5,7 +5,8 @@
* 菜单类型:directory(目录)、page(页面)、external(外链)。
*
* 本文件是编排层,只保留三类职责:
* 1. 菜单树的加载 / 新增 / 删除 / 拖拽排序,以及树节点启用停用的落库;
* 1. 菜单树的加载 / 新增 / 删除 / 拖拽排序(含 b_depth / b_path 维护),
* 以及树节点启用停用的落库;
* 2. 菜单配置(主表 / 多语言)的加载与保存编排
* (校验 → diff → 分配真实主键 → 落库 → 重载);
* 3. 跨面板联动:菜单编码派生多语言键、未保存切换确认与统一确认弹窗。
@@ -33,7 +34,16 @@ import UnsavedChangesModal from '@/components/UnsavedChangesModal.vue'
import I18nNameModal from '@/components/I18nNameModal.vue'
import MenuTreePanel from './MenuTreePanel.vue'
import MenuBasicPanel from './MenuBasicPanel.vue'
import { findNodePosition, markDirectoryNonLeaf, menuRowOf, renumberSiblings } from './utils'
import {
addRowUpdate,
applyMenuPosition,
buildMenuI18nKey,
findNodePosition,
markDirectoryNonLeaf,
menuRowOf,
rebuildTreePositions,
renumberSiblings,
} from './utils'
import { useI18nStore } from '@/stores/i18n'
/** 生成安全 SQL 字符串字面量(search_condition 为后端拼 SQL,需要转义引号) */
@@ -220,16 +230,28 @@ async function onMenuDrop({ dragKey, dropKey, dropPosition }) {
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('不能把菜单移动到它自己的下级')
return
}
// 只有目录能承载子菜单:页面/外链是末端节点,放入会形成「页面挂子菜单」的非法层级。
// 必须在移除被拖节点之前拦截,否则中途 return 会让节点凭空消失。
if (dropPosition === 'inside' && dropNode.b_menu_type !== 'directory') {
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()
@@ -258,22 +280,31 @@ async function onMenuDrop({ dragKey, dropKey, dropPosition }) {
}
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 })
}
// 排序即时落库(下方 saveObjectApi),草稿与基线需一起同步:
// 只改 maindata 会让已落库的 b_xh / b_parent_id 被 hasUnsavedChanges 误判为未保存改动。
// 未保存草稿节点例外:它本就要等保存时才落库,应保持脏标记。
// 只改 maindata 会让已落库的 b_xh / b_parent_id / b_depth / b_path 被
// hasUnsavedChanges 误判为未保存改动。未保存草稿节点例外:
// 它本就要等保存时才落库,应保持脏标记。
if (maindata.value) {
const draftId = String(maindata.value.b_id)
const draftUnsaved = unsavedMenuIds.has(draftId)
const syncBaseline = !draftUnsaved && !!maindata_org.value
const draftRow = updatedRows.find((r) => String(r.b_id) === draftId)
if (draftRow) {
maindata.value.b_xh = draftRow.b_xh
if (syncBaseline) maindata_org.value.b_xh = draftRow.b_xh
}
if (draftId === dragId) {
maindata.value.b_parent_id = moved.b_parent_id
if (syncBaseline) maindata_org.value.b_parent_id = moved.b_parent_id
for (const [field, value] of Object.entries(draftRow)) {
if (field === 'b_id') continue
maindata.value[field] = value
if (syncBaseline) maindata_org.value[field] = value
}
}
}
@@ -303,6 +334,7 @@ async function addMenu(parentId, menuTypeValue = 'page') {
// 字符串业务键方案:持久化主键 = 菜单编码(基本信息面板录入)。
// 未落库节点先用本地临时负数 id 占位,仅用于树 key / 未保存判定,不写入数据库。
const newId = createTempId()
const parentNode = parentId ? findTreeNode(menuTreeData.value, String(parentId)) : null
const newMenu = {
b_id: newId,
b_parent_id: parentId ? String(parentId) : null,
@@ -315,6 +347,9 @@ async function addMenu(parentId, menuTypeValue = 'page') {
b_xh: 10,
b_canuse: 1,
}
// b_depth / b_path 是 b_parent_id 的派生字段(b_path 非空且无默认值,缺失会新增失败)。
// 此处先按父级填一次占位值;保存前会用最终菜单编码重算(临时 id 不落库)。
applyMenuPosition(newMenu, parentNode)
insertTreeNode(menuTreeData.value, newMenu, { sort: sortTree })
const ancestors = expandAncestors(menuTreeData.value, newId)
if (ancestors.length) {
@@ -359,7 +394,10 @@ async function deleteMenu(id) {
okType: 'danger',
onOk: async () => {
try {
const reqs = [{ table: 's_i18n', key_field: 'b_id', deletes: [{ b_module_id: menuId }] }]
// s_i18n 无菜单归属列,按资源键删该菜单的全部语言行
const reqs = [
{ table: 's_i18n', key_field: 'b_key', deletes: [{ b_key: buildMenuI18nKey(menuId) }] },
]
reqs.push({ table: 's_menu', key_field: 'b_id', deletes: [{ b_id: menuId }] })
await saveObjectApi(reqs)
collectUnsavedMenuIds(menu).forEach((k) => unsavedMenuIds.delete(k))
@@ -383,20 +421,20 @@ async function loadMenuConfig(menuId) {
const menu = res.data?.[0] || null
maindata_org.value = menu ? { ...menu } : null
maindata.value = cloneData(maindata_org.value)
// 多语言:默认语言复用 b_name,其余读 s_i18n(按 b_module_id 关联)
// 多语言:默认语言复用 b_name,其余读 s_i18n(s_i18n 主键为 b_key + b_locale,
// 菜单资源键统一 menu.编码,与保存时写入的键一致)
const i18nInitValues = { [defaultLocale.value]: menu?.b_name || '' }
const i18n_org_rows = []
if (menu) {
const i18nRes = await loadDataApi(
's_i18n',
`b_module_id = ${sqlText(id)}`,
'b_key ASC, b_locale ASC',
`b_key = ${sqlText(buildMenuI18nKey(id))}`,
'b_locale ASC',
)
for (const row of i18nRes.data || []) {
if (!row.b_locale || row.b_locale === defaultLocale.value) continue
i18nInitValues[row.b_locale] = row.b_value
i18n_org_rows.push({
b_id: row.b_id,
b_key: row.b_key,
b_locale: row.b_locale,
b_value: row.b_value,
@@ -571,8 +609,17 @@ async function beforeSave() {
return false
}
menuData.b_id = menuCode
// b_depth / b_path 由 b_parent_id 派生:新建草稿的临时 id 必须在落库前换成最终编码重算,
// 否则会把 /-1/ 这类临时路径写进库。父级不在树中(数据异常)时无法派生,保持原值不动。
const parentId = menuData.b_parent_id ? String(menuData.b_parent_id) : null
if (!parentId) {
applyMenuPosition(menuData, null)
} else {
const parentNode = findTreeNode(menuTreeData.value, parentId)
if (parentNode) applyMenuPosition(menuData, parentNode)
}
// 受管 i18n 键:菜单统一 menu. 前缀
menuData.b_i18n = `menu.${menuCode}`
menuData.b_i18n = buildMenuI18nKey(menuCode)
return true
}
@@ -587,7 +634,7 @@ async function buildSaveData() {
const isNew = unsavedMenuIds.has(treeId)
const menuId = String(menuData.b_id || '').trim()
if (!menuId) return null
const menuI18nKey = `menu.${menuId}`
const menuI18nKey = buildMenuI18nKey(menuId)
const reqs = []
// 主表:dataChanges diffRow 字段级对比(新建无基线 → 整行 insert)
@@ -602,35 +649,33 @@ async function buildSaveData() {
reqs.push(menuRequest)
}
// 多语言(s_i18n):行级 diff —— current(按当前值生成的语言行)与加载基线
// diffRows(i18n_org 保留每行 b_id),只提交有变化的语言行,不再整组重建
const originByLocale = new Map(i18n_org.value.map((r) => [String(r.b_locale), r]))
// 多语言(s_i18n):行级 diff —— current(按当前值生成的语言行)与加载基线,
// 行身份 = 主键 (b_key, b_locale),只提交有变化的语言行,不再整组重建
const currentI18nRows = []
for (const lang of nonDefaultLanguages.value) {
const value = String(i18n_values.value[lang.code] || '').trim()
if (!value) continue // 清空的语言直接不进 current → 基线行走 deletes
// 字符串业务键:s_i18n 主键 b_id = b_key + ':' + b_locale,行内已有值即可稳定定位
currentI18nRows.push({
b_id: `${menuI18nKey}:${lang.code}`,
b_key: menuI18nKey,
b_locale: lang.code,
b_value: value,
})
}
const previousI18nRows = i18n_org.value.map((r) => ({
b_id: r.b_id,
b_key: r.b_key,
b_locale: r.b_locale,
b_value: String(r.b_value ?? '').trim(),
}))
const i18nChanges = diffRows(currentI18nRows, previousI18nRows)
const i18nChanges = diffRows(currentI18nRows, previousI18nRows, {
keyFields: ['b_key', 'b_locale'],
})
if (i18nChanges.inserts.length || i18nChanges.updates.length || i18nChanges.deletes.length) {
const i18nReq = { table: 's_i18n', key_field: 'b_id' }
const i18nReq = { table: 's_i18n', key_field: 'b_key,b_locale' }
if (i18nChanges.inserts.length) {
i18nReq.inserts = i18nChanges.inserts.map((r) => ({ ...r, b_module_id: menuId, b_canuse: 1 }))
i18nReq.inserts = i18nChanges.inserts.map((r) => ({ ...r, b_canuse: 1 }))
}
if (i18nChanges.updates.length) {
i18nReq.updates = i18nChanges.updates.map((r) => ({ ...r, b_module_id: menuId, b_canuse: 1 }))
i18nReq.updates = i18nChanges.updates.map((r) => ({ ...r, b_canuse: 1 }))
}
if (i18nChanges.deletes.length) i18nReq.deletes = i18nChanges.deletes
reqs.push(i18nReq)
@@ -35,6 +35,22 @@ export function markDirectoryNonLeaf(nodes) {
return nodes
}
/** s_menu 主表列(树节点 → 主表草稿行时按此提取,剔除 children/isLeaf/icon 等树注入字段) */
const MENU_ROW_FIELDS = [
'b_id',
'b_parent_id',
'b_depth',
'b_path',
'b_name',
'b_i18n',
'b_menu_type',
'b_route',
'b_module_id',
'b_icon',
'b_xh',
'b_canuse',
]
/**
* 从树节点提取纯菜单行(剔除 children/isLeaf/icon 等树注入字段)。
* 未落库节点切回时,用它从树节点重建主表草稿。
@@ -42,19 +58,39 @@ export function markDirectoryNonLeaf(nodes) {
* @returns {Object} s_menu 行
*/
export function menuRowOf(node) {
const row = {}
// 字符串业务键:b_id 即菜单编码,不再有独立 b_code
return {
b_id: node.b_id,
b_parent_id: node.b_parent_id ? String(node.b_parent_id) : null,
b_name: node.b_name,
b_i18n: node.b_i18n ?? null,
b_menu_type: node.b_menu_type,
b_route: node.b_route ?? null,
b_module_id: node.b_module_id ?? null,
b_icon: node.b_icon ?? null,
b_xh: node.b_xh,
b_canuse: node.b_canuse,
}
for (const field of MENU_ROW_FIELDS) row[field] = node[field] ?? null
row.b_parent_id = node.b_parent_id ? String(node.b_parent_id) : null
// b_path 非空且无默认值:缺省时退化为空串占位,不能为 null
row.b_depth = Number(node.b_depth ?? 0)
row.b_path = node.b_path == null ? '' : String(node.b_path)
return row
}
/**
* 按父节点派生菜单行的 b_depth / b_path(b_parent_id 是权威关系,二者为派生字段)。
* 根节点 b_depth = 0,b_path 形如 /system/user/(节点编码前后均带 /)。
* b_path 非空且无默认值,新增时必须随行写入。
* @param {Object} row s_menu 行(就地修改,b_id 必须是最终菜单编码)
* @param {Object|null} parentNode 树中的父节点;null 表示根节点
*/
export function applyMenuPosition(row, parentNode) {
row.b_depth = parentNode ? Number(parentNode.b_depth ?? 0) + 1 : 0
row.b_path = parentNode ? `${String(parentNode.b_path || '')}${row.b_id}/` : `/${row.b_id}/`
}
/* ================= 多语言资源键(s_i18n,主键 b_key + b_locale) ================= */
/**
* 菜单多语言资源键:menu.{菜单编码}。
* 加载、保存、删除都以此键定位 s_i18n 行(s_i18n 无菜单/模块归属列)。
* @param {string} menuId 菜单编码
* @returns {string|null} 资源键;菜单编码为空时返回 null
*/
export function buildMenuI18nKey(menuId) {
const normalizedMenuId = String(menuId || '').trim()
return normalizedMenuId ? `menu.${normalizedMenuId}` : null
}
/**
@@ -74,17 +110,45 @@ export function findNodePosition(nodes, id) {
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 变化行收集器({ b_id, b_xh })
* @param {Array} updatedRows 变化行收集器
*/
export function renumberSiblings(list, updatedRows) {
list.forEach((s, i) => {
const nextXh = (i + 1) * 10
if (s.b_xh !== nextXh) {
s.b_xh = nextXh
updatedRows.push({ b_id: s.b_id, b_xh: nextXh })
addRowUpdate(updatedRows, { b_id: s.b_id, b_xh: nextXh })
}
})
}
/**
* 递归重建菜单树的 b_depth / b_path(b_parent_id 为权威关系,二者为派生字段)。
* 根节点 b_depth = 0,b_path 形如 /system/user/(节点编码前后均带 /)。
* 仅在值实际变化时收集,避免打开即脏;父子关系变化后调用即可整树收敛。
* @param {Array} nodes 树节点数组(就地修改)
* @param {Array} updatedRows 变化行收集器
* @param {string} [parentPath] 父节点路径
* @param {number} [depth] 当前层级
*/
export function rebuildTreePositions(nodes, updatedRows, parentPath = '', depth = 0) {
for (const node of nodes) {
const path = `${parentPath}${node.b_id}/`
if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) {
node.b_depth = depth
node.b_path = path
addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path })
}
if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1)
}
}
@@ -11,7 +11,8 @@
*/
import { computed, ref, watch } from 'vue'
import { Plus, Search, Trash2 } from '@lucide/vue'
import { Button, Dropdown, Input, Tree } from '@/components/ui'
import { Button, Dropdown, Input } from '@/components/ui'
import FmsTree from '@/components/fms-tree/FmsTree.vue'
import { collectAllKeys, filterTreeByText } from '@/utils/tree'
import { MODULE_ADD_OPTIONS } from './utils'
@@ -96,10 +97,9 @@ function onToolbarAdd({ key }) {
<div class="mm-tree-panel__scroll">
<div v-if="loading" class="mm-tree-empty">加载中...</div>
<!-- 过滤(搜索)状态下禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
<Tree
<FmsTree
v-else
:tree-data="filteredTree"
:field-names="{ key: 'b_id', title: 'b_name', children: 'children' }"
:expanded-keys="expandedKeys"
@update:expanded-keys="(keys) => emit('update:expandedKeys', keys)"
:selected-keys="selectedId ? [selectedId] : []"
@@ -109,17 +109,17 @@ function onToolbarAdd({ key }) {
@context-menu-click="onContextMenuClick"
@drop="(payload) => emit('drop', payload)"
>
<template #title="{ node }">
<template #title="{ node, title }">
<span class="mm-tree-node">
<span
class="mm-tree-node__name"
:class="{ 'is-disabled': Number(node.b_canuse) !== 1 }"
>
{{ node.b_name || node.b_id }}
{{ title }}
</span>
</span>
</template>
</Tree>
</FmsTree>
</div>
</aside>
</template>
@@ -0,0 +1,219 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { h, nextTick } from 'vue'
import FmsTree from '@/components/fms-tree/FmsTree.vue'
import { setMessages, clearMessages } from '@/stores/i18n'
const makeTreeData = () => [
{
b_id: '1',
b_name: '根节点',
b_canuse: 1,
children: [{ b_id: '2', b_name: '子节点' }],
},
]
const nodeContextMenu = () => [{ key: 'add', label: '新增子项' }]
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
beforeEach(() => {
clearMessages()
})
afterEach(() => {
clearMessages()
})
describe('FmsTree 业务约定', () => {
it('默认字段名按 b_id / b_name / children 渲染,无需传 fieldNames', () => {
const wrapper = mount(FmsTree, { props: { treeData: makeTreeData() } })
expect(wrapper.find('.tree-node-content').text()).toBe('根节点')
})
it('未传 fieldNames 时 b_name 缺失回退到 b_id', () => {
const wrapper = mount(FmsTree, { props: { treeData: [{ b_id: 'm1' }] } })
expect(wrapper.find('.tree-node-content').text()).toBe('m1')
})
it('fieldNames 可覆盖默认字段', () => {
const wrapper = mount(FmsTree, {
props: {
treeData: [{ code: 'a', label: '甲' }],
fieldNames: { key: 'code', title: 'label' },
},
})
expect(wrapper.find('.tree-node-content').text()).toBe('甲')
})
it('b_i18n 有译文时标题取译文', () => {
setMessages({ 'module.customer': '客户管理' })
const wrapper = mount(FmsTree, {
props: { treeData: [{ b_id: 'm1', b_name: 'Customer', b_i18n: 'module.customer' }] },
})
expect(wrapper.find('.tree-node-content').text()).toBe('客户管理')
})
it('translateTitle=false 时标题不做翻译', () => {
setMessages({ 'module.customer': '客户管理' })
const wrapper = mount(FmsTree, {
props: {
treeData: [{ b_id: 'm1', b_name: 'Customer', b_i18n: 'module.customer' }],
translateTitle: false,
},
})
expect(wrapper.find('.tree-node-content').text()).toBe('Customer')
})
})
describe('FmsTree #title 插槽', () => {
it('插槽透出翻译后的 title', () => {
const calls = []
mount(FmsTree, {
props: { treeData: makeTreeData() },
slots: {
title: (scope) => {
calls.push(scope)
return h('span', { class: 'node-name' }, scope.title)
},
},
})
expect(calls[0].title).toBe('根节点')
})
it('插槽拿到的 node 是业务树的活对象,改动能回写原数据并触发重渲染', async () => {
const data = makeTreeData()
const calls = []
const wrapper = mount(FmsTree, {
props: { treeData: data },
slots: {
title: (scope) => {
calls.push(scope)
return h('span', { class: 'node-used' }, String(scope.node.b_canuse))
},
},
})
expect(wrapper.find('.node-used').text()).toBe('1')
const node = calls[0].node
node.b_canuse = 0
node.b_name = '改名'
await nextTick()
expect(data[0].b_canuse).toBe(0)
expect(data[0].b_name).toBe('改名')
expect(wrapper.find('.node-used').text()).toBe('0')
})
})
describe('FmsTree 虚拟根(showRoot)', () => {
it('默认不注入根节点', () => {
const wrapper = mount(FmsTree, { props: { treeData: makeTreeData() } })
expect(wrapper.findAll('.tree-node')).toHaveLength(1)
expect(wrapper.find('.tree-node-content').text()).toBe('根节点')
})
it('showRoot 注入「全部分类」根,默认展开且带折叠箭头', () => {
const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), showRoot: true } })
const rows = wrapper.findAll('.tree-node')
expect(rows).toHaveLength(2)
expect(rows[0].text()).toContain('全部分类')
// 有子节点:根不是叶子,渲染真实折叠箭头(不是 is-placeholder 占位)
expect(rows[0].classes()).not.toContain('is-leaf')
expect(rows[0].find('.tree-node-switcher:not(.is-placeholder)').exists()).toBe(true)
// 调用方的 expandedKeys 为空,根的子节点依然可见
expect(rows[1].text()).toContain('根节点')
})
it('业务树为空时虚拟根仍显示折叠箭头(分组节点不是叶子)', () => {
const wrapper = mount(FmsTree, { props: { treeData: [], showRoot: true } })
const rows = wrapper.findAll('.tree-node')
expect(rows).toHaveLength(1)
expect(rows[0].classes()).not.toContain('is-leaf')
expect(rows[0].find('.tree-node-switcher:not(.is-placeholder)').exists()).toBe(true)
})
it('根节点以 rootKey 作为 key,业务节点不带根 key', () => {
const wrapper = mount(FmsTree, {
props: { treeData: makeTreeData(), showRoot: true, rootKey: 'ROOT' },
slots: { title: (scope) => h('span', { class: 'node-id' }, String(scope.node.b_id)) },
})
const ids = wrapper.findAll('.node-id').map((el) => el.text())
expect(ids).toEqual(['ROOT', '1'])
})
it('rootTitle 可自定义根标题', () => {
const wrapper = mount(FmsTree, {
props: { treeData: makeTreeData(), showRoot: true, rootTitle: '所有分类' },
})
expect(wrapper.find('.tree-node-content').text()).toBe('所有分类')
})
it('受控 expandedKeys 已包含根 key 时不再重复追加', () => {
const wrapper = mount(FmsTree, {
props: { treeData: makeTreeData(), showRoot: true, expandedKeys: ['__fms_tree_root__'] },
})
expect(wrapper.findAll('.tree-node')).toHaveLength(2)
})
it('非受控 + showRoot 时根可收起再展开,业务节点仍可自行展开', async () => {
const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), showRoot: true } })
expect(wrapper.findAll('.tree-node')).toHaveLength(2)
// 第一个真实展开按钮属于虚拟根:点击收起整棵树
await wrapper.find('.tree-node-switcher:not(.is-placeholder)').trigger('click')
await nextTick()
expect(wrapper.findAll('.tree-node')).toHaveLength(1)
expect(wrapper.emitted('update:expandedKeys')[0][0]).not.toContain('__fms_tree_root__')
// 再点一次展开,业务节点回来
await wrapper.find('.tree-node-switcher:not(.is-placeholder)').trigger('click')
await nextTick()
expect(wrapper.findAll('.tree-node')).toHaveLength(2)
expect(wrapper.find('.tree-node-content').text()).toBe('全部分类')
// 业务节点自己的展开按钮照常工作
await wrapper.findAll('.tree-node-switcher:not(.is-placeholder)')[1].trigger('click')
await nextTick()
expect(wrapper.findAll('.tree-node')).toHaveLength(3)
})
it('非受控且无虚拟根时展开键交给 ui/Tree,default-expand-all 照常生效', () => {
const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), defaultExpandAll: true } })
expect(wrapper.findAll('.tree-node')).toHaveLength(2)
})
})
describe('FmsTree 事件透传', () => {
it('select 事件原样透传给调用方', async () => {
const onSelect = vi.fn()
const wrapper = mount(FmsTree, { props: { treeData: makeTreeData() }, attrs: { onSelect } })
await wrapper.find('.tree-node-content').trigger('click')
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect.mock.calls[0][0]).toEqual(['1'])
expect(onSelect.mock.calls[0][1].node.b_id).toBe('1')
})
it('展开折叠抛出 update:expandedKeys', async () => {
const wrapper = mount(FmsTree, { props: { treeData: makeTreeData() } })
await wrapper.find('.tree-node-switcher').trigger('click')
expect(wrapper.emitted('update:expandedKeys')).toHaveLength(1)
expect(wrapper.emitted('update:expandedKeys')[0][0]).toEqual(['1'])
})
it('右键菜单点击事件透传给调用方', async () => {
const onContextMenuClick = vi.fn()
const wrapper = mount(FmsTree, {
props: { treeData: makeTreeData(), contextMenu: nodeContextMenu },
attrs: { onContextMenuClick },
})
await wrapper.find('.tree-node-content').trigger('contextmenu')
const menu = document.body.querySelector('.tree-context-menu')
expect(menu.textContent).toContain('新增子项')
menu.querySelector('.tree-context-menu__item').click()
await flush()
expect(onContextMenuClick).toHaveBeenCalledTimes(1)
expect(onContextMenuClick.mock.calls[0][0].item.key).toBe('add')
expect(onContextMenuClick.mock.calls[0][0].node.b_id).toBe('1')
})
})
@@ -37,6 +37,7 @@ describe('FmsField 类型 → 控件映射', () => {
['select', '.select-trigger'],
['date', '.fms-date-picker'],
['datetime', '.fms-date-picker'],
['range', '.fms-range-picker'],
])('type=%s 渲染对应控件(%s)', (type, selector) => {
const wrapper = mount(FmsField, { props: { type } })
expect(wrapper.find(selector).exists()).toBe(true)
@@ -72,6 +73,32 @@ describe('FmsField 值绑定', () => {
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([1])
wrapper.unmount()
})
it('select multiple:选项切换抛出数组并保持下拉打开', async () => {
const wrapper = mount(FmsField, {
props: {
type: 'select',
modelValue: ['a'],
controlProps: {
multiple: true,
options: [
{ label: '甲', value: 'a' },
{ label: '乙', value: 'b' },
],
},
},
attachTo: document.body,
})
await wrapper.find('.select-trigger').trigger('click')
await wrapper.vm.$nextTick()
const options = document.body.querySelectorAll('.select-option')
options[1].click()
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['a', 'b']])
expect(document.body.querySelector('.select-option')).not.toBeNull()
wrapper.unmount()
})
})
describe('FmsField 扩展口子', () => {
@@ -0,0 +1,164 @@
/**
* 往来单位分类树页面级纯函数(views/base/othercompany/utils.js)。
*
* 重点是 buildCategoryTree 不能污染原始行:buildTree 会就地往行对象上挂 children,
* 若直接在 categories 的行上构建,重复构建会累积出重复子节点。
*/
import { describe, expect, it } from 'vitest'
import {
ALL_CATEGORY_KEY,
addRowUpdate,
applyCategoryPosition,
buildCategoryTree,
findNodePosition,
hasChildCategory,
nextSiblingXh,
rebuildTreePositions,
renumberSiblings,
} from '@/views/base/othercompany/utils'
function category(over = {}) {
return {
b_id: 'a',
b_parent_id: null,
b_depth: 0,
b_path: '/a/',
b_name: 'A',
b_xh: 10,
b_canuse: 1,
...over,
}
}
describe('othercompany 分类树工具:buildCategoryTree', () => {
it('按 b_parent_id 建树并按 b_xh 排序', () => {
const rows = [
category({ b_id: 'b', b_xh: 20, b_name: 'B' }),
category({ b_id: 'a', b_xh: 10, b_name: 'A' }),
category({ b_id: 'a1', b_parent_id: 'a', b_depth: 1, b_path: '/a/a1/', b_name: 'A1' }),
]
const tree = buildCategoryTree(rows)
expect(tree.map((node) => node.b_id)).toEqual(['a', 'b'])
expect(tree[0].children.map((node) => node.b_id)).toEqual(['a1'])
})
it('不污染原始行,重复构建不会累积出重复子节点', () => {
const rows = [
category({ b_id: 'a' }),
category({ b_id: 'a1', b_parent_id: 'a', b_depth: 1, b_path: '/a/a1/' }),
]
buildCategoryTree(rows)
// buildTree 的 children 挂在浅拷贝上,原始行保持干净
expect(rows[0].children).toBeUndefined()
const tree = buildCategoryTree(rows)
expect(tree[0].children).toHaveLength(1)
expect(tree[0].children[0].b_id).toBe('a1')
})
})
describe('othercompany 分类树工具:位置派生', () => {
it('根节点的 b_depth 为 0、b_path 为 /编码/', () => {
const row = { b_id: 'a' }
applyCategoryPosition(row, null)
expect(row).toMatchObject({ b_depth: 0, b_path: '/a/' })
})
it('子节点继承父级路径并递增层级', () => {
const row = { b_id: 'a1' }
applyCategoryPosition(row, { b_depth: 2, b_path: '/a/b/' })
expect(row).toMatchObject({ b_depth: 3, b_path: '/a/b/a1/' })
})
})
describe('othercompany 分类树工具:排序号与子分类判定', () => {
it('同级最大 b_xh 加 10,无同级时取 10', () => {
const rows = [
category({ b_id: 'a', b_xh: 10 }),
category({ b_id: 'b', b_xh: 30 }),
category({ b_id: 'a1', b_parent_id: 'a', b_xh: 90 }),
]
expect(nextSiblingXh(rows, null)).toBe(40)
expect(nextSiblingXh(rows, 'a')).toBe(100)
expect(nextSiblingXh(rows, 'not-exist')).toBe(10)
expect(nextSiblingXh([], null)).toBe(10)
})
it('hasChildCategory 只看直接子分类', () => {
const rows = [
category({ b_id: 'a' }),
category({ b_id: 'a1', b_parent_id: 'a' }),
category({ b_id: 'a1x', b_parent_id: 'a1' }),
]
expect(hasChildCategory(rows, 'a')).toBe(true)
expect(hasChildCategory(rows, 'a1')).toBe(true)
expect(hasChildCategory(rows, 'a1x')).toBe(false)
})
})
describe('othercompany 分类树工具:重排与重建位置', () => {
it('renumberSiblings 按 10 步长重排并只收集变化的行', () => {
const a = { b_id: 'a', b_xh: 30 }
const b = { b_id: 'b', b_xh: 10 }
const updated = []
renumberSiblings([a, b], updated)
expect([a.b_xh, b.b_xh]).toEqual([10, 20])
expect(updated).toEqual([
{ b_id: 'a', b_xh: 10 },
{ b_id: 'b', b_xh: 20 },
])
})
it('rebuildTreePositions 整树收敛 b_depth / b_path,未变化的不重复收集', () => {
const tree = [
{
b_id: 'a',
b_depth: 0,
b_path: '/a/',
children: [{ b_id: 'a1', b_depth: 9, b_path: '/stale/', children: [] }],
},
]
const updated = []
rebuildTreePositions(tree, updated)
expect(tree[0].children[0]).toMatchObject({ b_depth: 1, b_path: '/a/a1/' })
// 根节点本来就是 0 / '/a/',不该产生一条无意义的 update
expect(updated).toEqual([{ b_id: 'a1', b_depth: 1, b_path: '/a/a1/' }])
})
it('addRowUpdate 按 b_id 合并字段,避免同节点多条 update', () => {
const updated = []
addRowUpdate(updated, { b_id: 'a', b_xh: 20 })
addRowUpdate(updated, { b_id: 'a', b_parent_id: 'p' })
addRowUpdate(updated, { b_id: 'b', b_xh: 10 })
expect(updated).toEqual([
{ b_id: 'a', b_xh: 20, b_parent_id: 'p' },
{ b_id: 'b', b_xh: 10 },
])
})
it('findNodePosition 返回所在父数组与索引', () => {
const tree = [{ b_id: 'a', children: [{ b_id: 'a1' }, { b_id: 'a2' }] }]
expect(findNodePosition(tree, 'a')).toMatchObject({ index: 0 })
const found = findNodePosition(tree, 'a2')
expect(found.index).toBe(1)
expect(found.list.map((node) => node.b_id)).toEqual(['a1', 'a2'])
expect(findNodePosition(tree, 'not-exist')).toBe(null)
})
})
describe('othercompany 分类树工具:虚拟根键', () => {
it('与 FmsTree 的 rootKey 约定保持一致', () => {
expect(ALL_CATEGORY_KEY).toBe('__fms_tree_root__')
})
})
@@ -0,0 +1,137 @@
/**
* 回归用例:菜单管理 - 保存时必须带上派生的树字段,并按 s_i18n 真实主键读写多语言。
*
* 1) s_menu.b_path 为 not null 且无默认值(b_depth / b_xh / b_canuse 才有默认值),
* 请求里缺 b_path 会直接失败:
* Cannot insert the value NULL into column 'b_path', table 'FMS.dbo.s_menu'
* 另外新增草稿的 b_id 是本地临时负数 id(仅用于树 key),保存时必须换成录入的
* 菜单编码后重算路径,否则会把 /-1/ 这类临时路径写进库。
*
* 2) s_i18n 主键为 (b_key, b_locale),表内没有 b_id / b_module_id。
* 菜单多语言行必须按 b_key = menu.{菜单编码} 定位,否则加载会报无效列名、
* 保存会报「主键字段不存在: s_i18n.b_id」。
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import MenuManagement from '@/views/module/menu-management/index.vue'
import { useI18nStore } from '@/stores/i18n'
import { mountPage } from '../helpers/app.js'
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(),
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
nextIdApi: vi.fn(() => Promise.resolve({ data: 9001, code: 0, success: true })),
describeApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
}))
vi.mock('@/components/ui/message/message-manager.js', () => {
const m = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }
return { Message: m, default: m }
})
import { loadDataApi, saveObjectApi } from '@/services/api'
const systemMenu = {
b_id: 'system',
b_parent_id: null,
b_depth: 0,
b_path: '/system/',
b_name: '系统管理',
b_i18n: 'menu.system',
b_menu_type: 'directory',
b_route: null,
b_icon: null,
b_xh: 10,
b_canuse: 1,
}
const languages = [
{ b_id: 'zh-CN', b_name: '简体中文', b_canuse: 1, b_default: 1, b_xh: 10 },
{ b_id: 'en-US', b_name: '英文', b_canuse: 1, b_default: 0, b_xh: 20 },
]
const i18nRows = [{ b_key: 'menu.system', b_locale: 'en-US', b_value: 'System', b_canuse: 1 }]
beforeEach(() => {
loadDataApi.mockReset()
saveObjectApi.mockClear()
loadDataApi.mockImplementation((view) => {
if (view === 's_menu') return Promise.resolve({ data: [systemMenu] })
if (view === 's_i18n') return Promise.resolve({ data: i18nRows })
if (view === 's_i18n_type') return Promise.resolve({ data: languages })
return Promise.resolve({ data: [] })
})
})
/** 取出最近一次保存请求里的指定表请求 */
function lastRequestOf(table) {
const requests = saveObjectApi.mock.calls.at(-1)[0]
return requests.find((request) => request.table === table)
}
describe('菜单管理:新增菜单的派生树字段', () => {
it('新增根菜单按最终菜单编码写入 b_path / b_depth', async () => {
const { wrapper } = await mountPage(MenuManagement)
await flushPromises()
await wrapper.vm.addMenu(null, 'page')
await flushPromises()
wrapper.vm.maindata.b_id = 'home'
wrapper.vm.maindata.b_name = '首页'
wrapper.vm.maindata.b_route = '/dashboard'
await wrapper.vm.handleSave()
const request = lastRequestOf('s_menu')
expect(request.inserts).toHaveLength(1)
expect(request.inserts[0].b_id).toBe('home')
expect(request.inserts[0].b_path).toBe('/home/')
expect(request.inserts[0].b_depth).toBe(0)
})
it('新增子菜单按父级路径拼出 b_path 并递增 b_depth', async () => {
const { wrapper } = await mountPage(MenuManagement)
await flushPromises()
await wrapper.vm.addMenu('system', 'page')
await flushPromises()
wrapper.vm.maindata.b_id = 'user'
wrapper.vm.maindata.b_name = '用户管理'
wrapper.vm.maindata.b_route = '/system/user'
await wrapper.vm.handleSave()
const request = lastRequestOf('s_menu')
expect(request.inserts[0].b_path).toBe('/system/user/')
expect(request.inserts[0].b_depth).toBe(1)
})
})
describe('菜单管理:多语言按 s_i18n 真实主键 (b_key, b_locale) 读写', () => {
it('加载按 b_key 精确匹配,保存以 (b_key, b_locale) 为键且不写不存在的列', async () => {
const { wrapper, pinia } = await mountPage(MenuManagement)
setActivePinia(pinia)
await useI18nStore().loadLanguages()
await flushPromises()
wrapper.vm.menuSelectedId = 'system'
await flushPromises()
expect(loadDataApi).toHaveBeenCalledWith('s_i18n', "b_key = N'menu.system'", 'b_locale ASC')
expect(wrapper.vm.i18n_values['en-US']).toBe('System')
wrapper.vm.setI18nValue('en-US', 'System 2')
await wrapper.vm.handleSave()
const request = lastRequestOf('s_i18n')
expect(request.key_field).toBe('b_key,b_locale')
expect(request.updates).toHaveLength(1)
expect(request.updates[0]).toMatchObject({
b_key: 'menu.system',
b_locale: 'en-US',
b_value: 'System 2',
b_canuse: 1,
})
expect(request.updates[0].b_id).toBeUndefined()
expect(request.updates[0].b_module_id).toBeUndefined()
})
})
@@ -5,6 +5,8 @@
* (表现为 "Component inside <Transition> renders non-element root node" +
* "Cannot set properties of null (setting '__vnode')")。
* 修复:watch(menuSelectedId) 对未落库节点不走 loadMenuConfig,从树节点重建草稿。
*
* 临时 id 现由 createTempId() 生成本地负数(不再取号),断言按实际值 -1 校验。
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
@@ -28,9 +30,11 @@ import { loadDataApi } from '@/services/api'
const savedMenu = {
b_id: '1',
b_code: 'home',
b_parent_id: null,
b_depth: 0,
b_path: '/1/',
b_name: '首页',
b_menu_type: 'page',
b_parent_id: null,
b_route: '/dashboard',
b_xh: 10,
b_canuse: 1,
@@ -40,8 +44,10 @@ beforeEach(() => {
loadDataApi.mockReset()
loadDataApi.mockImplementation((view, where) => {
if (view === 's_menu' && where === '1 = 1') return Promise.resolve({ data: [savedMenu] })
if (view === 's_menu' && where === 'b_id = 1') return Promise.resolve({ data: [savedMenu] })
if (view === 's_menu') return Promise.resolve({ data: [] }) // 含 b_id = 9001(临时 id):查无此行
// 加载单行用 sqlText 拼的 N'1' 字面量
if (view === 's_menu' && where === "b_id = N'1'")
return Promise.resolve({ data: [savedMenu] })
if (view === 's_menu') return Promise.resolve({ data: [] }) // 含本地临时 id(负数):查无此行
return Promise.resolve({ data: [] })
})
})
@@ -54,7 +60,7 @@ describe('菜单管理:未保存新节点来回切换', () => {
// 1. 新建节点(未保存)
await wrapper.vm.addMenu(null, 'page')
await flushPromises()
expect(String(wrapper.vm.menuSelectedId)).toBe('9001')
expect(String(wrapper.vm.menuSelectedId)).toBe('-1')
expect(wrapper.vm.maindata?.b_menu_type).toBe('page')
// 2. 切到旧节点(已保存)
@@ -64,14 +70,14 @@ describe('菜单管理:未保存新节点来回切换', () => {
// 3. 切回新节点:不应对临时 id 发起查库请求
loadDataApi.mockClear()
wrapper.vm.menuSelectedId = '9001'
wrapper.vm.menuSelectedId = '-1'
await flushPromises()
await flushPromises()
expect(loadDataApi).not.toHaveBeenCalled()
expect(wrapper.vm.maindata).not.toBeNull()
expect(String(wrapper.vm.maindata?.b_id)).toBe('9001')
expect(String(wrapper.vm.maindata?.b_id)).toBe('-1')
expect(wrapper.vm.maindata?.b_menu_type).toBe('page')
expect(String(wrapper.vm.loadedMenuId)).toBe('9001')
expect(String(wrapper.vm.loadedMenuId)).toBe('-1')
})
})
@@ -0,0 +1,256 @@
/**
* 回归用例:往来单位分类树(b_othercompany_bmfl)的新增 / 修改 / 删除 / 拖拽排序。
*
* 1) b_path 为 not null 且无默认值(b_depth / b_xh / b_canuse 才有默认值),新增分类时
* 必须按父级派生 b_depth / b_path 一起写入,否则插入直接失败;
* 且两个派生字段与 b_parent_id 必须同批落库,否则会留下「路径按新父级、父级列还是旧值」。
*
* 2) 删除分类有两条守卫:还有子分类、分类下还挂着往来单位。两条都必须拦在发请求之前。
*
* 3) 分类编码 b_id 是主键,修改时只提交名称与排序号,不能改编码。
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import Othercompany from '@/views/base/othercompany/index.vue'
import CategoryTreePanel from '@/views/base/othercompany/CategoryTreePanel.vue'
import { mountPage } from '../helpers/app.js'
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(),
pageDataApi: vi.fn(),
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
nextIdApi: vi.fn(() => Promise.resolve({ data: 9001, code: 0, success: true })),
describeApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
}))
vi.mock('@/components/ui/message/message-manager.js', () => {
const m = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }
return { Message: m, default: m }
})
import { loadDataApi, pageDataApi, saveObjectApi } from '@/services/api'
import { Message } from '@/components/ui/message/message-manager.js'
const ROOT1 = {
b_id: 'root1',
b_parent_id: null,
b_depth: 0,
b_path: '/root1/',
b_name: '一级分类',
b_xh: 10,
b_canuse: 1,
}
const ROOT2 = {
b_id: 'root2',
b_parent_id: null,
b_depth: 0,
b_path: '/root2/',
b_name: '另一个一级分类',
b_xh: 20,
b_canuse: 1,
}
const CHILD1 = {
b_id: 'child1',
b_parent_id: 'root1',
b_depth: 1,
b_path: '/root1/child1/',
b_name: '二级分类',
b_xh: 10,
b_canuse: 1,
}
const CATEGORIES = [ROOT1, ROOT2, CHILD1]
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
loadDataApi.mockReset()
pageDataApi.mockReset()
saveObjectApi.mockClear()
Message.success.mockClear()
Message.error.mockClear()
Message.warning.mockClear()
loadDataApi.mockImplementation((view) => {
if (view === 'b_othercompany_bmfl')
return Promise.resolve({ data: CATEGORIES.map((row) => ({ ...row })) })
return Promise.resolve({ data: [] })
})
pageDataApi.mockResolvedValue({ data: { rows: [], total: 0 } })
})
/** 取出最近一次保存请求里的指定表请求 */
function lastRequestOf(table) {
const requests = saveObjectApi.mock.calls.at(-1)?.[0] || []
return requests.find((request) => request.table === table)
}
/** 汇总最近一次保存请求里全部 updates 行 */
function lastUpdates() {
const requests = saveObjectApi.mock.calls.at(-1)?.[0] || []
return requests.flatMap((request) => request.updates || [])
}
async function mountOthercompany() {
const { wrapper } = await mountPage(Othercompany)
await flushPromises()
return wrapper
}
describe('往来单位:新增分类写派生树字段', () => {
it('新增顶级分类写入 b_parent_id=null 与根路径', async () => {
const wrapper = await mountOthercompany()
wrapper.vm.onCategoryAdd({ parentId: null })
await wrapper.vm.onCategorySubmit({ b_id: 'newcat', b_name: '新分类', b_xh: 30 })
await flushPromises()
const request = lastRequestOf('b_othercompany_bmfl')
expect(request.key_field).toBe('b_id')
expect(request.inserts).toHaveLength(1)
expect(request.inserts[0]).toMatchObject({
b_id: 'newcat',
b_parent_id: null,
b_depth: 0,
b_path: '/newcat/',
b_name: '新分类',
b_xh: 30,
b_canuse: 1,
})
})
it('新增子分类按父级路径拼出 b_path 并递增 b_depth', async () => {
const wrapper = await mountOthercompany()
wrapper.vm.onCategoryAdd({ parentId: 'root1' })
await wrapper.vm.onCategorySubmit({ b_id: 'child2', b_name: '三级分类', b_xh: 20 })
await flushPromises()
expect(lastRequestOf('b_othercompany_bmfl').inserts[0]).toMatchObject({
b_id: 'child2',
b_parent_id: 'root1',
b_depth: 1,
b_path: '/root1/child2/',
})
})
})
describe('往来单位:修改分类', () => {
it('只提交名称与排序号,分类编码保持为主键值', async () => {
const wrapper = await mountOthercompany()
wrapper.vm.onCategoryEdit({ b_id: 'child1' })
await wrapper.vm.onCategorySubmit({ b_id: 'child1', b_name: '改过的名字', b_xh: 99 })
await flushPromises()
const request = lastRequestOf('b_othercompany_bmfl')
expect(request.inserts).toBeUndefined()
expect(request.updates).toHaveLength(1)
expect(request.updates[0]).toEqual({ b_id: 'child1', b_name: '改过的名字', b_xh: 99 })
})
})
describe('往来单位:删除分类的两条守卫', () => {
it('还有子分类时直接拦下,不发任何请求', async () => {
const wrapper = await mountOthercompany()
await wrapper.vm.onCategoryDelete({ b_id: 'root1' })
expect(Message.warning).toHaveBeenCalledWith('该分类下还有子分类,请先删除子分类')
expect(pageDataApi).not.toHaveBeenCalled()
expect(saveObjectApi).not.toHaveBeenCalled()
expect(wrapper.vm.categoryDeleteTarget).toBe(null)
})
it('分类下还有往来单位时拦下,不发删除请求', async () => {
const wrapper = await mountOthercompany()
pageDataApi.mockResolvedValueOnce({ data: { rows: [{ b_id: 'OC001' }], total: 1 } })
await wrapper.vm.onCategoryDelete({ b_id: 'child1' })
expect(Message.warning).toHaveBeenCalledWith('该分类下还有往来单位数据,不能删除')
expect(saveObjectApi).not.toHaveBeenCalled()
expect(wrapper.vm.categoryDeleteTarget).toBe(null)
})
it('守卫通过后弹确认,确认才落库删除', async () => {
const wrapper = await mountOthercompany()
await wrapper.vm.onCategoryDelete({ b_id: 'child1' })
expect(wrapper.vm.categoryDeleteTarget?.b_id).toBe('child1')
expect(saveObjectApi).not.toHaveBeenCalled()
await wrapper.vm.confirmCategoryDelete()
await flushPromises()
const request = lastRequestOf('b_othercompany_bmfl')
expect(request.deletes).toEqual([{ b_id: 'child1' }])
})
})
describe('往来单位:拖拽分类', () => {
it('跨级拖入时同批落库 b_parent_id 与重建后的 b_depth / b_path', async () => {
const wrapper = await mountOthercompany()
await wrapper.vm.onCategoryDrop({
dragKey: 'child1',
dropKey: 'root2',
dropPosition: 'inside',
})
await flushPromises()
const moved = lastUpdates().find((row) => row.b_id === 'child1')
expect(moved).toMatchObject({
b_id: 'child1',
b_parent_id: 'root2',
b_depth: 1,
b_path: '/root2/child1/',
})
})
it('不能把分类拖到它自己的下级', async () => {
const wrapper = await mountOthercompany()
await wrapper.vm.onCategoryDrop({
dragKey: 'root1',
dropKey: 'child1',
dropPosition: 'inside',
})
expect(Message.warning).toHaveBeenCalledWith('不能把分类移动到它自己的下级')
expect(saveObjectApi).not.toHaveBeenCalled()
})
})
describe('往来单位分类树面板:搜索', () => {
it('按名称与编码过滤,并把命中的层级展开', async () => {
// 面板收到的是已经建好的树(页面的 categoryTree)。这里整树真实渲染:
// Input / Button 与保留字同名,shallowMount 打桩会触发 Vue 保留名告警。
const treeData = [{ ...ROOT1, children: [{ ...CHILD1 }] }, { ...ROOT2 }]
const wrapper = mount(CategoryTreePanel, { props: { treeData } })
expect(wrapper.vm.filteredTree).toHaveLength(2)
expect(wrapper.emitted('update:expandedKeys')).toBeUndefined()
// 命中子分类的编码:保留其祖先,保证命中节点可见
wrapper.vm.keyword = 'child1'
await flushPromises()
expect(wrapper.vm.filteredTree.map((node) => node.b_id)).toEqual(['root1'])
expect(wrapper.vm.filteredTree[0].children.map((node) => node.b_id)).toEqual(['child1'])
// 搜索中展开全部,保证命中节点可见
expect(wrapper.emitted('update:expandedKeys')?.at(-1)?.[0]).toEqual([
'root1',
'child1',
'root2',
])
// 命中根分类的名称
wrapper.vm.keyword = '另一个'
await flushPromises()
expect(wrapper.vm.filteredTree.map((node) => node.b_id)).toEqual(['root2'])
// 清空搜索后收起
wrapper.vm.keyword = ''
await flushPromises()
expect(wrapper.emitted('update:expandedKeys')?.at(-1)?.[0]).toEqual([])
})
})