20260914172820
This commit is contained in:
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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user