20260914223013
This commit is contained in:
1 parent
549c8bd2eb
commit
63514476d7
60 files changed
+6478
-2004
No files matched your search
@@ -50,6 +50,11 @@ const extraConditionCount = computed(
|
||||
extraQuickItems.value.filter((item) => hasConditionValue(filters[item.field.b_field])).length,
|
||||
)
|
||||
|
||||
/** 网格列数按实际可见条件数收缩(最多 4 列):只有一个条件时不应占满 4 列宽 */
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(${Math.min(Math.max(visibleQuickItems.value.length, 1), 4)}, minmax(0, 200px))`,
|
||||
}))
|
||||
|
||||
function initializeDrafts() {
|
||||
for (const key of Object.keys(filters)) delete filters[key]
|
||||
for (const item of props.queryItems) {
|
||||
@@ -77,7 +82,7 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true })
|
||||
<div class="fms-query-toolbar">
|
||||
<div class="query-card">
|
||||
<div class="query-layout">
|
||||
<div class="query-grid">
|
||||
<div class="query-grid" :style="gridStyle">
|
||||
<div v-for="item in visibleQuickItems" :key="item.field.b_field" class="query-field">
|
||||
<label class="query-field__label">{{ labelOf(item) }}</label>
|
||||
<FmsQueryControl
|
||||
@@ -139,7 +144,7 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true })
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
}
|
||||
/* 常用条件网格:固定一行 4 个、每列最大 264px;flex 不撑满(宽屏右侧留白)、窄屏可收缩 */
|
||||
/* 常用条件网格:一行最多 4 个、每列最大 200px;列数按实际条件数收缩(行内 style 控制),窄屏可收缩 */
|
||||
.query-grid {
|
||||
display: grid;
|
||||
flex: 0 1 auto;
|
||||
|
||||
@@ -90,6 +90,8 @@ const effectiveTreeData = computed(() => {
|
||||
[fn.value.key]: props.rootKey,
|
||||
isFmsRoot: true,
|
||||
isLeaf: false,
|
||||
// 虚拟根是整棵树的容器,不参与拖拽排序(仍可作为落点,把节点拖进根 = 移到顶层)
|
||||
draggable: false,
|
||||
[fn.value.children]: props.treeData,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import Modal from '../modal/modal.vue'
|
||||
import { Modal } from '../index.js'
|
||||
import Button from '../button/button.vue'
|
||||
import Message from '../message/message-manager.js'
|
||||
|
||||
@@ -26,6 +26,20 @@ function saveWithFeedback() {
|
||||
modalFeedbackOpen.value = false
|
||||
Message.success('保存成功')
|
||||
}
|
||||
|
||||
/** 命令式确认:onOk 返回 Promise 时按钮进入 loading,reject 则保持打开 */
|
||||
function openConfirm() {
|
||||
Modal.confirm({
|
||||
title: '确定删除这条记录?',
|
||||
content: '此操作不可恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
Message.success('已删除')
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -62,6 +76,11 @@ function saveWithFeedback() {
|
||||
<Button type="outline" @click="modalFeedbackOpen = true">保存并提示</Button>
|
||||
</div>
|
||||
|
||||
<p class="demo-label">命令式确认(Modal.confirm)</p>
|
||||
<div class="demo-panel">
|
||||
<Button type="outline" @click="openConfirm">删除确认</Button>
|
||||
</div>
|
||||
|
||||
<Modal v-model:open="modalBasicOpen" title="基础模态框" :width="440">
|
||||
<p>
|
||||
这是一个基础模态框。拖动标题栏可移动位置,拖动右下角可调整尺寸;右上角可切换全屏;点击遮罩、关闭按钮或按
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup>
|
||||
import Space from '../space/space.vue'
|
||||
import Button from '../button/button.vue'
|
||||
import Tag from '../tag/tag.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="showcase-section" aria-labelledby="space-title">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">Components</p>
|
||||
<h2 id="space-title">Space 间距</h2>
|
||||
<p>设置并排子元素之间的统一间距,避免它们紧贴在一起;支持方向、间距、对齐、换行与分隔符。</p>
|
||||
</div>
|
||||
|
||||
<p class="demo-label">基础用法(水平排列按钮)</p>
|
||||
<div class="demo-panel">
|
||||
<Space>
|
||||
<Button type="primary">主要</Button>
|
||||
<Button type="secondary">次要</Button>
|
||||
<Button type="outline">描边</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<p class="demo-label">间距大小 size:small / middle / large / 自定义数字</p>
|
||||
<div class="demo-panel space-demo-stack">
|
||||
<Space size="small">
|
||||
<span class="space-box">small</span>
|
||||
<span class="space-box">8px</span>
|
||||
<span class="space-box">间距</span>
|
||||
</Space>
|
||||
<Space size="middle">
|
||||
<span class="space-box">middle</span>
|
||||
<span class="space-box">16px</span>
|
||||
<span class="space-box">间距</span>
|
||||
</Space>
|
||||
<Space size="large">
|
||||
<span class="space-box">large</span>
|
||||
<span class="space-box">24px</span>
|
||||
<span class="space-box">间距</span>
|
||||
</Space>
|
||||
<Space :size="32">
|
||||
<span class="space-box">数字</span>
|
||||
<span class="space-box">32px</span>
|
||||
<span class="space-box">间距</span>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<p class="demo-label">垂直方向 vertical(size 数组 [水平, 垂直])</p>
|
||||
<div class="demo-panel">
|
||||
<Space vertical :size="[16, 12]">
|
||||
<span class="space-box space-box-wide">第一行</span>
|
||||
<span class="space-box space-box-wide">第二行</span>
|
||||
<span class="space-box space-box-wide">第三行</span>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<p class="demo-label">对齐 align:start / center / end / baseline(子项高度不一)</p>
|
||||
<div class="demo-panel space-demo-stack">
|
||||
<Space align="start" class="space-demo-row">
|
||||
<span class="space-box space-box-sm">start</span>
|
||||
<span class="space-box space-box-lg">高</span>
|
||||
<span class="space-box space-box-sm">对齐</span>
|
||||
</Space>
|
||||
<Space align="center" class="space-demo-row">
|
||||
<span class="space-box space-box-sm">center</span>
|
||||
<span class="space-box space-box-lg">高</span>
|
||||
<span class="space-box space-box-sm">居中</span>
|
||||
</Space>
|
||||
<Space align="end" class="space-demo-row">
|
||||
<span class="space-box space-box-sm">end</span>
|
||||
<span class="space-box space-box-lg">高</span>
|
||||
<span class="space-box space-box-sm">对齐</span>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<p class="demo-label">自动换行 wrap(容器宽度不足时折行)</p>
|
||||
<div class="demo-panel">
|
||||
<Space wrap :size="12" class="space-demo-wrap">
|
||||
<Tag v-for="n in 12" :key="n" variant="secondary">标签 {{ n }}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<p class="demo-label">分隔符 separator(插槽)</p>
|
||||
<div class="demo-panel space-demo-stack">
|
||||
<Space>
|
||||
<a class="space-link" href="#" @click.prevent>编辑</a>
|
||||
<a class="space-link" href="#" @click.prevent>删除</a>
|
||||
<a class="space-link" href="#" @click.prevent>更多</a>
|
||||
<template #separator><span class="space-demo-sep">|</span></template>
|
||||
</Space>
|
||||
<Space>
|
||||
<span>首页</span>
|
||||
<span>分类</span>
|
||||
<span>详情</span>
|
||||
<template #separator><span class="space-demo-sep">/</span></template>
|
||||
</Space>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 演示专用:给间距一个可见的承载块,方便肉眼比对不同 size 的差异 */
|
||||
.space-demo-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.space-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--fms-control-radius);
|
||||
background: var(--fms-primary);
|
||||
color: var(--fms-primary-text);
|
||||
font-size: var(--fms-font-size);
|
||||
}
|
||||
|
||||
.space-box-wide {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.space-box-sm {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.space-box-lg {
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.space-demo-row {
|
||||
height: 56px;
|
||||
padding: 4px 8px;
|
||||
border: 1px dashed var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
}
|
||||
|
||||
.space-demo-wrap {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.space-link {
|
||||
color: var(--fms-primary);
|
||||
font-size: var(--fms-font-size);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.space-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.space-demo-sep {
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,7 @@ import FormGroup from './form/FormGroup.vue'
|
||||
import FormItem from './form/form-item.vue'
|
||||
import Input from './input/input.vue'
|
||||
import Modal from './modal/modal.vue'
|
||||
import { confirm as modalConfirm } from './modal/modal-confirm.js'
|
||||
import Pagination from './pagination/pagination.vue'
|
||||
import Popover from './popover/popover.vue'
|
||||
import Radio from './radio/radio.vue'
|
||||
@@ -34,6 +35,9 @@ import Tree from './tree/tree.vue'
|
||||
|
||||
import { Message } from './message/message-manager'
|
||||
|
||||
/** 命令式确认对话框:Modal.confirm({ title, content, okText, onOk, ... }) */
|
||||
Modal.confirm = modalConfirm
|
||||
|
||||
export {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createVNode, render } from 'vue'
|
||||
import ModalConfirm from './modal-confirm.vue'
|
||||
|
||||
/**
|
||||
* 命令式确认对话框:自建容器挂到 body,关闭动画结束(Modal 的 closed 事件)
|
||||
* 后才卸载容器,保证退场动画完整播放。
|
||||
*
|
||||
* 用法:
|
||||
* Modal.confirm({
|
||||
* title: '确定删除?',
|
||||
* content: '此操作不可恢复',
|
||||
* okText: '删除',
|
||||
* okButtonProps: { danger: true },
|
||||
* onOk: async () => { ... }, // 返回 Promise 时按钮进入 loading;reject 保持打开
|
||||
* onCancel: () => {},
|
||||
* })
|
||||
*
|
||||
* @returns {{ destroy: () => void }} destroy 立即关闭并卸载(不等退场动画)
|
||||
*/
|
||||
export function confirm(options = {}) {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
let destroyed = false
|
||||
function destroy() {
|
||||
if (destroyed) return
|
||||
destroyed = true
|
||||
render(null, container)
|
||||
container.remove()
|
||||
}
|
||||
|
||||
render(createVNode(ModalConfirm, { ...options, onClosed: destroy }), container)
|
||||
|
||||
return { destroy }
|
||||
}
|
||||
|
||||
export default confirm
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 命令式确认对话框(由 Modal.confirm 动态挂载,业务不直接使用)。
|
||||
*
|
||||
* onOk 返回 Promise 时确定按钮进入 loading:resolve 后关闭,reject 保持打开,
|
||||
* 错误由调用方负责提示。
|
||||
*
|
||||
* 关闭来源与 onCancel 的关系:只有点「取消」按钮才算显式取消、触发 onCancel;
|
||||
* 点遮罩空白 / Esc / 右上角关闭都是「静默关闭」——只关窗不跑 onCancel,
|
||||
* 避免用户随手点空白就误触发取消逻辑。
|
||||
* 与 antdv 的 Modal.confirm 有意不同:这里允许点遮罩空白处关闭(antdv 默认禁止)。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import Modal from './modal.vue'
|
||||
import Button from '../button/button.vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: '' },
|
||||
content: { type: String, default: '' },
|
||||
okText: { type: String, default: '确定' },
|
||||
cancelText: { type: String, default: '取消' },
|
||||
/** 透传给确定按钮;danger: true 等价于 type: 'danger' */
|
||||
okButtonProps: { type: Object, default: () => ({}) },
|
||||
onOk: { type: Function, default: null },
|
||||
onCancel: { type: Function, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['closed'])
|
||||
|
||||
const visible = ref(true)
|
||||
const loading = ref(false)
|
||||
|
||||
const okProps = computed(() => {
|
||||
const { danger, ...rest } = props.okButtonProps
|
||||
return danger && !rest.type ? { ...rest, type: 'danger' } : rest
|
||||
})
|
||||
|
||||
async function handleOk() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await props.onOk?.()
|
||||
} catch {
|
||||
// 失败保持打开,等用户处理后重试或取消
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/** 显式点「取消」按钮:触发 onCancel 并关闭(唯一会跑 onCancel 的路径) */
|
||||
function handleCancel() {
|
||||
if (loading.value) return
|
||||
props.onCancel?.()
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 不绑 @cancel:遮罩 / Esc / 右上角 X 关闭时 Modal 会 emit('cancel'),
|
||||
这里故意不接,让这些路径「静默关闭」而不触发 onCancel;
|
||||
关窗本身由 v-model:open 完成(Modal 内部 setOpen(false) → update:open)。 -->
|
||||
<Modal
|
||||
v-model:open="visible"
|
||||
:title="title"
|
||||
:width="416"
|
||||
:confirm-loading="loading"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<p v-if="content" class="fms-modal-confirm-content">{{ content }}</p>
|
||||
<template #footer>
|
||||
<Button type="outline" :disabled="loading" @click="handleCancel">{{ cancelText }}</Button>
|
||||
<Button v-bind="okProps" :loading="loading" @click="handleOk">{{ okText }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fms-modal-confirm-content {
|
||||
margin: 0;
|
||||
line-height: 1.7;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
</style>
|
||||
@@ -40,7 +40,7 @@ const props = defineProps({
|
||||
confirmLoading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open', 'ok', 'cancel'])
|
||||
const emit = defineEmits(['update:open', 'ok', 'cancel', 'closed'])
|
||||
|
||||
/* ---------- 公共状态 ---------- */
|
||||
const innerOpen = ref(props.defaultOpen)
|
||||
@@ -137,54 +137,61 @@ function onPanelKeydown(event) {
|
||||
trapFocus(event, panelRef.value)
|
||||
}
|
||||
|
||||
/* ---------- 生命周期:打开/关闭时接管焦点与滚动 ---------- */
|
||||
watch(isOpen, (value) => {
|
||||
if (value) {
|
||||
// 记录触发元素,关闭后还原焦点
|
||||
triggerEl = document.activeElement
|
||||
lockScroll()
|
||||
scrollLocked = true
|
||||
nextTick(() => {
|
||||
if (!isOpen.value || !panelRef.value) return
|
||||
focusFirst(panelRef.value)
|
||||
panelRef.value.addEventListener('keydown', onPanelKeydown)
|
||||
// 标题栏作为拖拽手柄(面板与手柄此刻已渲染完成)
|
||||
dragHandle = bindDrag(panelRef.value, headerRef.value)
|
||||
// 四边缩放手柄:e/w 调宽度、s/n 调高度,均不超过视口(wrap 留出 32px 边距 / 124px 顶底)
|
||||
if (props.resizable) {
|
||||
resizeHandles = [
|
||||
{ el: resizeERef.value, direction: 'e' },
|
||||
{ el: resizeWRef.value, direction: 'w' },
|
||||
{ el: resizeSRef.value, direction: 's' },
|
||||
{ el: resizeNRef.value, direction: 'n' },
|
||||
]
|
||||
.filter((item) => item.el)
|
||||
.map(({ el, direction }) =>
|
||||
bindResize(panelRef.value, el, {
|
||||
direction,
|
||||
maxWidth: window.innerWidth - 32,
|
||||
maxHeight: window.innerHeight - 124,
|
||||
onResize: (size) => {
|
||||
resizeSize.value = size
|
||||
},
|
||||
}),
|
||||
)
|
||||
/* ---------- 生命周期:打开/关闭时接管焦点与滚动 ----------
|
||||
immediate:命令式挂载(Modal.confirm)时 open 初始即为 true,watch 不会因「变化」触发;
|
||||
加 immediate 让打开态初始化(滚动锁 / 焦点陷阱 / Esc / 拖拽)在挂载时执行一次,
|
||||
与受控用法(open 由 false→true)走同一段逻辑。 */
|
||||
watch(
|
||||
isOpen,
|
||||
(value) => {
|
||||
if (value) {
|
||||
// 记录触发元素,关闭后还原焦点
|
||||
triggerEl = document.activeElement
|
||||
lockScroll()
|
||||
scrollLocked = true
|
||||
nextTick(() => {
|
||||
if (!isOpen.value || !panelRef.value) return
|
||||
focusFirst(panelRef.value)
|
||||
panelRef.value.addEventListener('keydown', onPanelKeydown)
|
||||
// 标题栏作为拖拽手柄(面板与手柄此刻已渲染完成)
|
||||
dragHandle = bindDrag(panelRef.value, headerRef.value)
|
||||
// 四边缩放手柄:e/w 调宽度、s/n 调高度,均不超过视口(wrap 留出 32px 边距 / 124px 顶底)
|
||||
if (props.resizable) {
|
||||
resizeHandles = [
|
||||
{ el: resizeERef.value, direction: 'e' },
|
||||
{ el: resizeWRef.value, direction: 'w' },
|
||||
{ el: resizeSRef.value, direction: 's' },
|
||||
{ el: resizeNRef.value, direction: 'n' },
|
||||
]
|
||||
.filter((item) => item.el)
|
||||
.map(({ el, direction }) =>
|
||||
bindResize(panelRef.value, el, {
|
||||
direction,
|
||||
maxWidth: window.innerWidth - 32,
|
||||
maxHeight: window.innerHeight - 124,
|
||||
onResize: (size) => {
|
||||
resizeSize.value = size
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 这里只做与动画无关的拆卸(键盘监听、拖拽/缩放手柄)。
|
||||
// 解锁滚动、复位尺寸、还原焦点一律等退场动画结束再做(见 onAfterLeave)——
|
||||
// 否则动画还在播时背景就已经能滚动、焦点也提前跳回触发按钮,屏幕阅读器会跟丢。
|
||||
if (panelRef.value) panelRef.value.removeEventListener('keydown', onPanelKeydown)
|
||||
// 不清除 transform:面板保持拖拽位置播放消失动画(v-if 随后销毁 DOM,无残留)
|
||||
if (dragHandle) {
|
||||
dragHandle.destroy()
|
||||
dragHandle = null
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 这里只做与动画无关的拆卸(键盘监听、拖拽/缩放手柄)。
|
||||
// 解锁滚动、复位尺寸、还原焦点一律等退场动画结束再做(见 onAfterLeave)——
|
||||
// 否则动画还在播时背景就已经能滚动、焦点也提前跳回触发按钮,屏幕阅读器会跟丢。
|
||||
if (panelRef.value) panelRef.value.removeEventListener('keydown', onPanelKeydown)
|
||||
// 不清除 transform:面板保持拖拽位置播放消失动画(v-if 随后销毁 DOM,无残留)
|
||||
if (dragHandle) {
|
||||
dragHandle.destroy()
|
||||
dragHandle = null
|
||||
resizeHandles.forEach((h) => h.destroy())
|
||||
resizeHandles = []
|
||||
}
|
||||
resizeHandles.forEach((h) => h.destroy())
|
||||
resizeHandles = []
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** 退场动画结束后收尾:解锁滚动、复位状态、把焦点还给触发元素 */
|
||||
function onAfterLeave() {
|
||||
@@ -199,6 +206,7 @@ function onAfterLeave() {
|
||||
resizeSize.value = null
|
||||
if (triggerEl && typeof triggerEl.focus === 'function') triggerEl.focus()
|
||||
triggerEl = null
|
||||
emit('closed')
|
||||
}
|
||||
|
||||
// 全屏与拖拽互斥:进入全屏时停用拖拽并复位位置,退出后重新启用
|
||||
@@ -230,11 +238,13 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<!-- 遮罩与内容分两个 Transition:遮罩先铺(base 档),内容晚一拍落(slow 档 + 20ms 延迟);
|
||||
退场不错峰,两者同时走 —— 错峰退场会闪。after-leave 里做收尾(解锁滚动、还原焦点)。 -->
|
||||
<Transition name="fms-modal-mask">
|
||||
退场不错峰,两者同时走 —— 错峰退场会闪。after-leave 里做收尾(解锁滚动、还原焦点)。
|
||||
appear:命令式挂载(Modal.confirm)时 open 初始即为 true,元素首帧就在,
|
||||
默认 Transition 不播入场动画,加 appear 让初次渲染也走一次进入过渡。 -->
|
||||
<Transition name="fms-modal-mask" appear>
|
||||
<div v-if="isOpen && mask" class="fms-modal-mask" aria-hidden="true"></div>
|
||||
</Transition>
|
||||
<Transition name="fms-modal" @after-leave="onAfterLeave">
|
||||
<Transition name="fms-modal" appear @after-leave="onAfterLeave">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fms-modal-wrap"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/* Space 间距:内联弹性容器,子项之间用 gap 留白。
|
||||
具体间距值由组件按 size 内联注入 row-gap / column-gap,方向 / 对齐 / 换行由容器 class 控制,
|
||||
两者解耦——改预设不影响布局类,改布局类不影响间距。 */
|
||||
|
||||
.space {
|
||||
display: inline-flex;
|
||||
/* 兜底间距:组件总会内联注入 row-gap / column-gap,这里给默认值防止裸用时子项紧贴 */
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 垂直排列:主轴改为纵向(gap 的 row / column 语义随主轴自动对应上下 / 左右间距) */
|
||||
.space-vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 交叉轴对齐 */
|
||||
.space-align-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.space-align-center {
|
||||
align-items: center;
|
||||
}
|
||||
.space-align-end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.space-align-baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
/* 自动换行:仅水平方向生效(组件已按方向决定是否加此类) */
|
||||
.space-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 空子项不占位:条件渲染 / 分隔符留下的空包装直接隐藏,避免多出一段 gap */
|
||||
.space-item:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 分隔符:默认与文字同基线居中,颜色退到次级,不与主内容抢视觉 */
|
||||
.space-separator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<script>
|
||||
/**
|
||||
* Space 间距 —— 为并排的子元素设置统一间距,避免它们紧贴在一起。
|
||||
* 参考 antdv-next 的 Space:方向 / 间距大小(预设、数字、[水平, 垂直])/ 对齐 / 自动换行 / 分隔符。
|
||||
*
|
||||
* 与 Grid 的分工:Space 面向「一行内联元素的等距排列」(按钮组、标签行、操作区),
|
||||
* 会给每个子元素包一层 .space-item 以支持分隔符与空节点隐藏;整块页面布局请用 Grid。
|
||||
*
|
||||
* 为何用 render 函数而非 <script setup> 模板:
|
||||
* 分隔符需要「插在子元素之间」,必须先拿到并归一化默认插槽的 vnode 列表,再逐一包裹、
|
||||
* 交错插入分隔符 —— 这类对子节点的操纵用 render 函数最直接(antdv-next 的 Space 同样是 render 实现)。
|
||||
*/
|
||||
import { Comment, Fragment, Text, computed, defineComponent, h } from 'vue'
|
||||
|
||||
/** 预设间距(px):small / middle / large 三档,对齐 antdv 的 paddingXS / padding / paddingLG */
|
||||
const PRESET_GAP = { small: 8, middle: 16, large: 24 }
|
||||
|
||||
/** 把单个 size 值解析为 CSS 长度:预设名查表;纯数字补 px;其余(如 '12px' / '1rem')原样透传 */
|
||||
function toGap(value) {
|
||||
if (value == null) return `${PRESET_GAP.small}px`
|
||||
if (typeof value === 'number') return `${value}px`
|
||||
const text = String(value).trim()
|
||||
if (text in PRESET_GAP) return `${PRESET_GAP[text]}px`
|
||||
return /^\d+(\.\d+)?$/.test(text) ? `${text}px` : text
|
||||
}
|
||||
|
||||
/** 归一化默认插槽 vnode:展开 Fragment、过滤注释与纯空白文本,得到真正的子元素列表 */
|
||||
function normalizeChildren(nodes) {
|
||||
const result = []
|
||||
for (const node of nodes ?? []) {
|
||||
if (node == null || typeof node === 'boolean') continue
|
||||
if (Array.isArray(node)) {
|
||||
result.push(...normalizeChildren(node))
|
||||
continue
|
||||
}
|
||||
if (node.type === Comment) continue
|
||||
if (node.type === Fragment) {
|
||||
result.push(...normalizeChildren(node.children))
|
||||
continue
|
||||
}
|
||||
// 模板换行 / 缩进会产生纯空白文本节点,忽略;非空文本仍作为子元素保留
|
||||
if (node.type === Text && typeof node.children === 'string' && !node.children.trim()) continue
|
||||
result.push(node)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FmsSpace',
|
||||
props: {
|
||||
/** 间距大小:预设名 small / middle / large、数字(px)、或 [水平, 垂直] */
|
||||
size: { type: [String, Number, Array], default: 'small' },
|
||||
/** 排列方向;不传时由 vertical 决定 */
|
||||
orientation: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
validator: (value) => ['horizontal', 'vertical'].includes(value),
|
||||
},
|
||||
/** 垂直排列简写;与 orientation 同时配置时以 orientation 优先 */
|
||||
vertical: { type: Boolean, default: false },
|
||||
/** 交叉轴对齐:start / end / center / baseline。水平默认 center,垂直默认不设(stretch) */
|
||||
align: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
validator: (value) => ['start', 'end', 'center', 'baseline'].includes(value),
|
||||
},
|
||||
/** 是否自动换行,仅水平方向生效 */
|
||||
wrap: { type: Boolean, default: false },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const mergedOrientation = computed(
|
||||
() => props.orientation || (props.vertical ? 'vertical' : 'horizontal'),
|
||||
)
|
||||
|
||||
const mergedAlign = computed(() => {
|
||||
if (props.align) return props.align
|
||||
return mergedOrientation.value === 'horizontal' ? 'center' : undefined
|
||||
})
|
||||
|
||||
const containerClass = computed(() =>
|
||||
[
|
||||
'space',
|
||||
`space-${mergedOrientation.value}`,
|
||||
mergedAlign.value ? `space-align-${mergedAlign.value}` : '',
|
||||
props.wrap && mergedOrientation.value === 'horizontal' ? 'space-wrap' : '',
|
||||
].filter(Boolean),
|
||||
)
|
||||
|
||||
// size 归一:数组按 [水平, 垂直] 分别设 column-gap / row-gap;单值同时作用于两轴
|
||||
const gapStyle = computed(() => {
|
||||
const [horizontal, vertical] = Array.isArray(props.size)
|
||||
? props.size
|
||||
: [props.size, props.size]
|
||||
return { columnGap: toGap(horizontal), rowGap: toGap(vertical) }
|
||||
})
|
||||
|
||||
return () => {
|
||||
const children = normalizeChildren(slots.default?.())
|
||||
// 无有效子元素时不渲染容器(与 antdv 一致),避免留下一个空 gap 的盒子
|
||||
if (!children.length) return null
|
||||
|
||||
const separators = normalizeChildren(slots.separator?.())
|
||||
const hasSeparator = separators.length > 0
|
||||
const nodes = []
|
||||
children.forEach((child, index) => {
|
||||
nodes.push(
|
||||
h('div', { class: 'space-item', key: child.key ?? `space-item-${index}` }, [child]),
|
||||
)
|
||||
// 分隔符只插在两个子元素之间,末尾不加
|
||||
if (hasSeparator && index < children.length - 1) {
|
||||
nodes.push(h('span', { class: 'space-separator', key: `space-sep-${index}` }, separators))
|
||||
}
|
||||
})
|
||||
|
||||
// 单一根节点:父级透传的 class / style / 事件由 Vue 自动合并到此 div
|
||||
return h(
|
||||
'div',
|
||||
{ class: containerClass.value, style: gapStyle.value, 'data-slot': 'space' },
|
||||
nodes,
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './index.scss';
|
||||
</style>
|
||||
@@ -74,6 +74,7 @@
|
||||
.tree-node-switcher:hover:not(.is-placeholder) {
|
||||
background: color-mix(in srgb, var(--fms-primary) 10%, transparent);
|
||||
}
|
||||
/* 叶子占位:纯对齐用,无交互反馈 */
|
||||
.tree-node-switcher.is-placeholder {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ const leaf = computed(() =>
|
||||
isLeafNode(props.node, fn.value, { lazy: ctx.props.lazy, loadedKeys: ctx.loadedSet.value }),
|
||||
)
|
||||
const disabled = computed(() => !!(ctx.props.disabled || props.node.disabled))
|
||||
/** 不可拖拽排序:节点禁用,或节点显式标记 draggable === false(如「全部分类」虚拟根,仍可选中/作为落点) */
|
||||
const dragDisabled = computed(() => disabled.value || props.node?.draggable === false)
|
||||
const disableCheckbox = computed(() => !!props.node.disableCheckbox)
|
||||
const loading = computed(
|
||||
() =>
|
||||
@@ -115,12 +117,12 @@ function onCheckChange() {
|
||||
// 节点一直挂着 is-dragging(opacity .4),表现就是「点了发灰、移开也灰」。
|
||||
const gripArmed = ref(false)
|
||||
const draggableAttr = computed(() =>
|
||||
ctx.props.draggable && !disabled.value && gripArmed.value ? 'true' : 'false',
|
||||
ctx.props.draggable && !dragDisabled.value && gripArmed.value ? 'true' : 'false',
|
||||
)
|
||||
|
||||
/** grip 按下:武装本行拖拽(同步写 DOM,dragstart 可能早于 Vue 的 patch) */
|
||||
function armDrag() {
|
||||
if (disabled.value || !ctx.props.draggable) return
|
||||
if (dragDisabled.value || !ctx.props.draggable) return
|
||||
gripArmed.value = true
|
||||
rowRef.value?.setAttribute('draggable', 'true')
|
||||
// 按下但没真拖(不会产生 dragend)时的兜底解除
|
||||
@@ -132,7 +134,7 @@ function disarmDrag() {
|
||||
rowRef.value?.setAttribute('draggable', 'false')
|
||||
}
|
||||
function onDragStart(e) {
|
||||
if (disabled.value || !gripArmed.value) {
|
||||
if (dragDisabled.value || !gripArmed.value) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
@@ -180,9 +182,9 @@ function onContextMenu(e) {
|
||||
@dragend="onDragEnd"
|
||||
@contextmenu.stop="onContextMenu"
|
||||
>
|
||||
<!-- 拖拽手柄:整行唯一可发起拖拽的区域(按下时才把本行置为 draggable) -->
|
||||
<!-- 拖拽手柄:整行唯一可发起拖拽的区域(按下时才把本行置为 draggable);不可拖拽的节点不渲染 -->
|
||||
<span
|
||||
v-if="ctx.props.draggable"
|
||||
v-if="ctx.props.draggable && !dragDisabled"
|
||||
class="tree-node-grip"
|
||||
title="拖拽调整顺序"
|
||||
@mousedown="armDrag"
|
||||
@@ -190,7 +192,9 @@ function onContextMenu(e) {
|
||||
<GripVertical :aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
<!-- 展开/收起箭头:叶子占位对齐;懒加载中显示转圈 -->
|
||||
<!-- 展开/收起箭头:非叶子渲染真实箭头,叶子渲染等宽透明占位。
|
||||
占位不是多占地方,而是对齐机制:没有它,叶子标题会左移一个箭头宽,
|
||||
比同层非叶子靠左、并与父级标题齐平(缩进不足),层级关系丢失。 -->
|
||||
<span
|
||||
v-if="!leaf"
|
||||
class="tree-node-switcher"
|
||||
|
||||
@@ -107,10 +107,10 @@ export const routes = [
|
||||
moduleId: 'v_b_othercompany',
|
||||
},
|
||||
},
|
||||
// 往来单位详情:新增与编辑都在此页完成(:id = 'new' 表示新建)。
|
||||
// 带动态段,因此不会出现在菜单管理的「路由地址」下拉里(见 collectMenuRoutes)。
|
||||
// 往来单位详情:新增与编辑都在此页完成(不带参数为新增,?id=xxx 为编辑)。
|
||||
// 静态路径,靠 EXCLUDED_PATHS 排除出菜单管理的「路由地址」下拉。
|
||||
{
|
||||
path: '/base/othercompany/detail/:id',
|
||||
path: '/base/othercompany/detail',
|
||||
name: 'base-othercompany-detail',
|
||||
component: () => import('@/views/base/othercompany/detail.vue'),
|
||||
meta: {
|
||||
@@ -119,6 +119,40 @@ export const routes = [
|
||||
moduleId: 'v_b_othercompany',
|
||||
},
|
||||
},
|
||||
// 基础数据:联系人 —— 元数据驱动列表,新增 / 编辑跳详情页(v_b_contact)
|
||||
{
|
||||
path: '/base/contact',
|
||||
name: 'base-contact',
|
||||
component: () => import('@/views/base/contact/index.vue'),
|
||||
meta: {
|
||||
title: '联系人',
|
||||
componentName: 'BaseContact',
|
||||
moduleId: 'v_b_contact',
|
||||
},
|
||||
},
|
||||
// 联系人详情:新增与编辑都在此页完成(不带参数为新增,?id=xxx 为编辑)。
|
||||
// 静态路径,靠 EXCLUDED_PATHS 排除出菜单管理的「路由地址」下拉。
|
||||
{
|
||||
path: '/base/contact/detail',
|
||||
name: 'base-contact-detail',
|
||||
component: () => import('@/views/base/contact/detail.vue'),
|
||||
meta: {
|
||||
title: '联系人详情',
|
||||
componentName: 'BaseContactDetail',
|
||||
moduleId: 'v_b_contact',
|
||||
},
|
||||
},
|
||||
// 基础数据:其他数据 —— 左分类树 + 右可编辑列表(根模块 base_otherdata)
|
||||
{
|
||||
path: '/base/otherdata',
|
||||
name: 'base-otherdata',
|
||||
component: () => import('@/views/base/otherdata/index.vue'),
|
||||
meta: {
|
||||
title: '其他数据',
|
||||
componentName: 'BaseOtherdata',
|
||||
moduleId: 'base_otherdata',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -127,8 +161,8 @@ export const routes = [
|
||||
},
|
||||
]
|
||||
|
||||
/** 不作为菜单入口的路由:独立布局、无需登录即可访问的页面 */
|
||||
const EXCLUDED_PATHS = new Set(['/login'])
|
||||
/** 不作为菜单入口的路由:独立布局页(/login)与详情页(静态路径,无法靠动态段规则排除) */
|
||||
const EXCLUDED_PATHS = new Set(['/login', '/base/othercompany/detail', '/base/contact/detail'])
|
||||
|
||||
/**
|
||||
* 收集可作为菜单入口的页面路由(递归展开布局容器与分组节点)。
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 联系人详情 —— 新增与编辑都在本页完成。
|
||||
*
|
||||
* 路由契约:`/base/contact/detail`
|
||||
* - 不带参数(`route.query.id` 为空)表示新建;
|
||||
* - `?id=xxx` 表示编辑 b_contact.b_id = xxx;
|
||||
* - 顶部工具栏的保存 / 重置 / 删除与表单均尚未实现。
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contact-detail-page" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contact-detail-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,124 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 联系人(b_contact)。
|
||||
* 页面结构、权限与编辑入口尚未实现,先占位以便路由与目录结构就位。
|
||||
* 联系人(b_contact)—— 元数据驱动的业务列表页。
|
||||
*
|
||||
* 与 fms-vue 内既有页面(base/othercompany)保持一致:
|
||||
* - 列表 / 查询 / 分页 / 栏位设置:FmsModuleListPage 按 data-code=`v_b_contact` 自渲染;
|
||||
* - 新增与编辑都在详情页(base-contact-detail)完成,本页只做跳转,不做行内编辑;
|
||||
* - 页面只保留业务规则:权限点驱动按钮显隐、行删除、批量删除。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Plus, Trash2 } from '@lucide/vue'
|
||||
import { Button, Message, Modal } from '@/components/ui'
|
||||
import { FmsModuleListPage } from '@/components/fms-module-list'
|
||||
import { usePermissionStore } from '@/stores/permissions'
|
||||
|
||||
const MODULE_CODE = 'v_b_contact'
|
||||
|
||||
const permissionStore = usePermissionStore()
|
||||
const canCreate = computed(() => permissionStore.canPower(MODULE_CODE, 'create'))
|
||||
const canDelete = computed(() => permissionStore.canPower(MODULE_CODE, 'delete'))
|
||||
|
||||
const router = useRouter()
|
||||
const listRef = ref(null)
|
||||
const selectedRowKeys = ref([])
|
||||
/** 单行删除进行中:阻止行操作按钮重复触发 */
|
||||
const rowSaving = ref(false)
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys
|
||||
},
|
||||
}))
|
||||
|
||||
/** 新增:不带参数跳详情页;编辑同页完成,用 ?id=xxx 传主键 */
|
||||
function onCreate() {
|
||||
router.push({ name: 'base-contact-detail' })
|
||||
}
|
||||
|
||||
/* ================= 行操作(删除) ================= */
|
||||
// 删除走 FmsTable 内置的 confirm 状态化弹窗(见 createCellRenderer.js),
|
||||
// 与 i18n-type 等页面一致;不再手工 Modal.confirm,减少一层控制流。
|
||||
const rowActions = computed(() => {
|
||||
const actions = []
|
||||
if (canDelete.value) {
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
type: 'delete',
|
||||
label: '删除',
|
||||
confirm: { title: '删除联系人', content: '确定删除该联系人吗?此操作不可恢复。' },
|
||||
disabled: () => rowSaving.value,
|
||||
onClick: (row) => removeRow(row),
|
||||
})
|
||||
}
|
||||
return actions
|
||||
})
|
||||
|
||||
async function removeRow(row) {
|
||||
if (rowSaving.value) return
|
||||
rowSaving.value = true
|
||||
try {
|
||||
// 走列表组件的删除通道:主键 / 保存表由 FmsModuleListPage 内部解析
|
||||
await listRef.value?.deleteSelected([row.b_id])
|
||||
selectedRowKeys.value = selectedRowKeys.value.filter((key) => key !== row.b_id)
|
||||
} catch (error) {
|
||||
Message.error('删除失败:' + (error?.message || error))
|
||||
} finally {
|
||||
rowSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 批量删除 ================= */
|
||||
function confirmBatchDelete() {
|
||||
const keys = [...selectedRowKeys.value]
|
||||
if (!keys.length) return
|
||||
Modal.confirm({
|
||||
title: `确定删除选中的 ${keys.length} 条联系人?`,
|
||||
content: '此操作不可恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await listRef.value?.deleteSelected(keys)
|
||||
} catch (error) {
|
||||
Message.error('删除失败:' + (error?.message || error))
|
||||
throw error // 保持确认框打开,便于重试
|
||||
}
|
||||
Message.success('删除成功')
|
||||
selectedRowKeys.value = []
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contact-page" />
|
||||
<div class="contact-page">
|
||||
<FmsModuleListPage
|
||||
ref="listRef"
|
||||
:data-code="MODULE_CODE"
|
||||
:row-selection="rowSelection"
|
||||
:row-actions="rowActions"
|
||||
:inline-action-limit="2"
|
||||
>
|
||||
<template #actions="{ loading }">
|
||||
<Button v-if="canCreate" type="primary" :disabled="loading || rowSaving" @click="onCreate">
|
||||
<Plus :size="14" />
|
||||
新增
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDelete"
|
||||
type="danger"
|
||||
:disabled="loading || rowSaving || !selectedRowKeys.length"
|
||||
@click="confirmBatchDelete"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
|
||||
</Button>
|
||||
</template>
|
||||
</FmsModuleListPage>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
<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>
|
||||
@@ -1,247 +0,0 @@
|
||||
<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>
|
||||
@@ -2,8 +2,9 @@
|
||||
/**
|
||||
* 往来单位(合作伙伴)详情 —— 新增与编辑都在本页完成。
|
||||
*
|
||||
* 路由契约:`/base/othercompany/detail/:id`
|
||||
* - `id = 'new'` 表示新建,其余为 b_othercompany.b_id;
|
||||
* 路由契约:`/base/othercompany/detail`
|
||||
* - 不带参数(`route.query.id` 为空)表示新建;
|
||||
* - `?id=xxx` 表示编辑 b_othercompany.b_id = xxx;
|
||||
* - 顶部工具栏的保存 / 重置 / 删除、主表与子表(银行账号等)均尚未实现。
|
||||
*/
|
||||
</script>
|
||||
|
||||
@@ -2,69 +2,160 @@
|
||||
/**
|
||||
* 往来单位(合作伙伴)—— 左侧分类树 + 右侧模块列表。
|
||||
*
|
||||
* 数据来源:
|
||||
* - 左树:b_othercompany_bmfl(邻接表树,b_parent_id 是权威关系,b_path 为派生路径)
|
||||
* - 右表:模块 v_b_othercompany(查询栏 / 列 / 分页全部由 FmsModuleListPage 按模块配置驱动)
|
||||
*
|
||||
* 页面负责三件事:
|
||||
* - 把左侧选中的分类转成列表的固定查询条件(含全部下级分类);
|
||||
* - 列表列 b_cate_id 显示分类名称而不是节点编码;
|
||||
* - 左侧分类树的维护:新增 / 修改 / 删除 / 刷新 / 拖拽调层级与顺序
|
||||
* (CategoryTreePanel + CategoryEditModal,写 b_othercompany_bmfl 后重载整棵树)。
|
||||
*
|
||||
* 分类行的 b_depth / b_path 是 b_parent_id 的派生字段,统一由 ./utils.js 派生;
|
||||
* 往来单位的新增与编辑在详情页(base-othercompany-detail)完成,本页只做跳转。
|
||||
* 左树是 b_othercompany_bmfl(b_parent_id 是权威关系,b_depth / b_path 为派生字段),
|
||||
* 右表由 FmsModuleListPage 按模块 v_b_othercompany 的配置驱动。页面负责三件事:
|
||||
* 选中分类 → 固定查询条件(含全部下级)、b_cate_id 列显示分类名称、分类树的维护
|
||||
* (新增 / 修改 / 删除 / 刷新 / 拖拽调层级与顺序)。
|
||||
* 新增与编辑往来单位在详情页(base-othercompany-detail)完成,本页只做跳转。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Plus, Trash2 } from '@lucide/vue'
|
||||
import { Button, Message, Modal, Splitter, SplitterPanel } from '@/components/ui'
|
||||
import { Pencil, Plus, Search, Trash2 } from '@lucide/vue'
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormItem,
|
||||
Input,
|
||||
Message,
|
||||
Modal,
|
||||
Spin,
|
||||
Splitter,
|
||||
SplitterPanel,
|
||||
} from '@/components/ui'
|
||||
import { FmsModuleListPage } from '@/components/fms-module-list'
|
||||
import { escapeSqlText } from '@/components/fms-module-common/queryUtils'
|
||||
import FmsTree from '@/components/fms-tree/FmsTree.vue'
|
||||
import { loadDataApi, pageDataApi, saveObjectApi } from '@/services/api'
|
||||
import { usePermissionStore } from '@/stores/permissions'
|
||||
import { findTreeNode } from '@/utils/tree'
|
||||
import CategoryTreePanel from './CategoryTreePanel.vue'
|
||||
import CategoryEditModal from './CategoryEditModal.vue'
|
||||
import {
|
||||
ALL_CATEGORY_KEY,
|
||||
addRowUpdate,
|
||||
applyCategoryPosition,
|
||||
buildCategoryTree,
|
||||
findNodePosition,
|
||||
hasChildCategory,
|
||||
rebuildTreePositions,
|
||||
renumberSiblings,
|
||||
} from './utils'
|
||||
import { buildTree, collectAllKeys, filterTreeByText, findTreeNode, sortTree } from '@/utils/tree'
|
||||
|
||||
/** 右侧列表使用的模块编码(s_module.b_id / s_field.b_module_id) */
|
||||
const MODULE_CODE = 'v_b_othercompany'
|
||||
/** 左侧分类树表 */
|
||||
const CATEGORY_TABLE = 'b_othercompany_bmfl'
|
||||
const MODULE_CODE = 'v_b_othercompany' // 右侧列表模块
|
||||
const CATEGORY_TABLE = 'b_othercompany_bmfl' // 左侧分类树表
|
||||
/** 必须与 FmsTree 的 rootKey 默认值一致 */
|
||||
const ALL_CATEGORY_KEY = '__fms_tree_root__'
|
||||
|
||||
/* ===================== 分类树纯函数 ===================== */
|
||||
|
||||
/**
|
||||
* 必须在浅拷贝上构建:buildTree 会就地往行上挂 children,
|
||||
* 直接构建会累积出重复子节点、并污染唯一数据源 categories。
|
||||
*/
|
||||
function buildCategoryTree(rows) {
|
||||
const tree = buildTree((rows || []).map((row) => ({ ...row })))
|
||||
sortTree(tree)
|
||||
return tree
|
||||
}
|
||||
|
||||
/**
|
||||
* 派生 b_depth / b_path(b_parent_id 是权威关系,二者为派生字段)。
|
||||
* b_path 非空且无默认值,新增时必须随行写入。
|
||||
*/
|
||||
function applyCategoryPosition(row, parentNode) {
|
||||
row.b_depth = parentNode ? Number(parentNode.b_depth ?? 0) + 1 : 0
|
||||
row.b_path = parentNode ? `${String(parentNode.b_path || '')}${row.b_id}/` : `/${row.b_id}/`
|
||||
}
|
||||
|
||||
/** 同级下一个排序号(最大 b_xh + 10,无同级时取 10) */
|
||||
function nextSiblingXh(rows, parentId) {
|
||||
const normalizedParentId = parentId == null ? null : String(parentId)
|
||||
let maxXh = 0
|
||||
for (const row of rows || []) {
|
||||
const rowParentId = row.b_parent_id == null ? null : String(row.b_parent_id)
|
||||
if (rowParentId !== normalizedParentId) continue
|
||||
maxXh = Math.max(maxXh, Number(row.b_xh) || 0)
|
||||
}
|
||||
return maxXh + 10
|
||||
}
|
||||
|
||||
/** 有子分类的分类不允许删除 */
|
||||
function hasChildCategory(rows, id) {
|
||||
const targetId = String(id)
|
||||
return (rows || []).some((row) => row.b_parent_id != null && String(row.b_parent_id) === targetId)
|
||||
}
|
||||
|
||||
/** 查找节点所在的父数组与索引 */
|
||||
function findNodePosition(nodes, id) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
|
||||
if (nodes[i].children) {
|
||||
const found = findNodePosition(nodes[i].children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 同一 b_id 的字段合并进一行,避免同节点产生多条 update */
|
||||
function addRowUpdate(updatedRows, row) {
|
||||
const existing = updatedRows.find((item) => String(item.b_id) === String(row.b_id))
|
||||
if (existing) Object.assign(existing, row)
|
||||
else updatedRows.push({ ...row })
|
||||
}
|
||||
|
||||
/** 重排兄弟 b_xh(10 起步、步长 10),只收集实际变化的行 */
|
||||
function renumberSiblings(list, updatedRows) {
|
||||
list.forEach((sibling, index) => {
|
||||
const nextXh = (index + 1) * 10
|
||||
if (Number(sibling.b_xh) !== nextXh) {
|
||||
sibling.b_xh = nextXh
|
||||
addRowUpdate(updatedRows, { b_id: sibling.b_id, b_xh: nextXh })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 整树重建 b_depth / b_path(b_parent_id 为权威关系),只收集实际变化的行 */
|
||||
function rebuildTreePositions(nodes, updatedRows, parentPath = '', depth = 0) {
|
||||
for (const node of nodes) {
|
||||
const path = parentPath ? `${parentPath}${node.b_id}/` : `/${node.b_id}/`
|
||||
if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) {
|
||||
node.b_depth = depth
|
||||
node.b_path = path
|
||||
addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path })
|
||||
}
|
||||
if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 权限 ================= */
|
||||
const permissionStore = usePermissionStore()
|
||||
const canCreateRow = computed(() => permissionStore.canPower(MODULE_CODE, 'create'))
|
||||
const canDeleteRow = computed(() => permissionStore.canPower(MODULE_CODE, 'delete'))
|
||||
|
||||
const listRef = ref(null)
|
||||
|
||||
/* ================= 分类树 ================= */
|
||||
/* ================= 分类树:状态与加载 ================= */
|
||||
const categories = ref([])
|
||||
const categoryLoading = ref(false)
|
||||
const selectedCategoryKey = ref(ALL_CATEGORY_KEY)
|
||||
const expandedCategoryKeys = ref([])
|
||||
const categoryKeyword = ref('')
|
||||
|
||||
const categoryById = computed(
|
||||
() => new Map(categories.value.map((item) => [String(item.b_id), item])),
|
||||
)
|
||||
|
||||
/** 邻接表 → 树;顺序由 b_xh 决定。树是 categories 的派生视图(在浅拷贝上构建) */
|
||||
/** 树是 categories 的派生视图(在浅拷贝上构建) */
|
||||
const categoryTree = computed(() => buildCategoryTree(categories.value))
|
||||
|
||||
/** 分类没有 b_code,匹配「名称 + 编码」两列 */
|
||||
const filteredCategoryTree = computed(() =>
|
||||
filterTreeByText(categoryTree.value, categoryKeyword.value, ['b_name', 'b_id']),
|
||||
)
|
||||
|
||||
// 搜索时展开全部(保证命中节点可见),清空时收起
|
||||
watch(categoryKeyword, (kw) => {
|
||||
expandedCategoryKeys.value = kw.trim() ? collectAllKeys(categoryTree.value) : []
|
||||
})
|
||||
|
||||
async function loadCategories() {
|
||||
categoryLoading.value = true
|
||||
try {
|
||||
const response = await loadDataApi(CATEGORY_TABLE, '', 'b_path ASC, b_xh ASC, b_id ASC')
|
||||
// 只加载启用分类(未启用的不展示)
|
||||
const response = await loadDataApi(
|
||||
CATEGORY_TABLE,
|
||||
'b_canuse = 1',
|
||||
'b_path ASC, b_xh ASC, b_id ASC',
|
||||
)
|
||||
categories.value = response.data || []
|
||||
|
||||
// 选中的分类已被删除时回到「全部」
|
||||
@@ -76,7 +167,7 @@ async function loadCategories() {
|
||||
selectedCategoryKey.value = ALL_CATEGORY_KEY
|
||||
}
|
||||
|
||||
// 展开所有含子节点的分类;保留用户已手动展开的节点
|
||||
// 展开所有含子节点的分类,并保留用户已手动展开的
|
||||
const parentKeys = categories.value
|
||||
.map((item) => (item.b_parent_id == null ? '' : String(item.b_parent_id)))
|
||||
.filter(Boolean)
|
||||
@@ -96,7 +187,7 @@ function onCategorySelect(keys, info) {
|
||||
if (keys.length) selectedCategoryKey.value = String(keys[0])
|
||||
}
|
||||
|
||||
/** 收集某个分类及其全部下级分类的节点编码 */
|
||||
/** 收集某个分类及其全部下级分类的编码 */
|
||||
function collectDescendantIds(rootId) {
|
||||
const childrenByParent = new Map()
|
||||
for (const item of categories.value) {
|
||||
@@ -118,11 +209,7 @@ function collectDescendantIds(rootId) {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类 → 列表固定查询条件。
|
||||
* b_cate_id 是 varchar,条件值用 varchar 字面量(不加 N 前缀),避免列被隐式转成
|
||||
* nvarchar 而用不上 ix_b_othercompany_cate。
|
||||
*/
|
||||
/** b_cate_id 是 varchar:条件值不加 N 前缀,避免列被隐式转成 nvarchar 而用不上索引 */
|
||||
const categoryFilterCondition = computed(() => {
|
||||
if (selectedCategoryKey.value === ALL_CATEGORY_KEY) return ''
|
||||
const ids = collectDescendantIds(selectedCategoryKey.value)
|
||||
@@ -131,7 +218,7 @@ const categoryFilterCondition = computed(() => {
|
||||
return `[b_cate_id] IN (${literals.join(', ')})`
|
||||
})
|
||||
|
||||
/* ================= 列扩展:分类编码显示为分类名称 ================= */
|
||||
/* ================= 列扩展:b_cate_id 显示分类名称 ================= */
|
||||
const columnExtensions = computed(() => ({
|
||||
b_cate_id: {
|
||||
formatter: ({ value }) => {
|
||||
@@ -141,12 +228,50 @@ const columnExtensions = computed(() => ({
|
||||
},
|
||||
}))
|
||||
|
||||
/* ================= 分类树:右键菜单与工具栏 ================= */
|
||||
|
||||
/** node 为 null 表示右键点在空白处,一并按虚拟根处理 */
|
||||
function isRootCategoryNode(node) {
|
||||
return !node || node.isFmsRoot
|
||||
}
|
||||
|
||||
/** 根节点只能新增顶级分类 */
|
||||
function buildCategoryContextMenu(node) {
|
||||
if (isRootCategoryNode(node)) {
|
||||
return [{ key: 'add', label: '新增分类', icon: Plus }]
|
||||
}
|
||||
return [
|
||||
{ key: 'add', label: '新增子分类', icon: Plus },
|
||||
{ divider: true },
|
||||
{ key: 'edit', label: '修改分类', icon: Pencil },
|
||||
{ key: 'delete', label: '删除', icon: Trash2, danger: true },
|
||||
]
|
||||
}
|
||||
|
||||
function onCategoryMenuClick({ item, node }) {
|
||||
if (item.key === 'add') {
|
||||
onCategoryAdd({ parentId: isRootCategoryNode(node) ? null : String(node.b_id) })
|
||||
} else if (item.key === 'edit') {
|
||||
onCategoryEdit(node)
|
||||
} else if (item.key === 'delete') {
|
||||
void onCategoryDelete(node)
|
||||
}
|
||||
}
|
||||
|
||||
/** 工具栏新增挂到当前选中分类下;选中「全部分类」或未选中时建为顶级 */
|
||||
function onCategoryToolbarAdd() {
|
||||
const selected = String(selectedCategoryKey.value ?? '')
|
||||
onCategoryAdd({
|
||||
parentId: !selected || selected === ALL_CATEGORY_KEY ? null : selected,
|
||||
})
|
||||
}
|
||||
|
||||
/* ================= 新增:跳转详情页 ================= */
|
||||
const router = useRouter()
|
||||
|
||||
/** 详情页按路由参数 :id 区分,'new' 表示新建 */
|
||||
/** 新增:不带参数跳详情页;编辑同页完成,用 ?id=xxx 传主键 */
|
||||
function onCreate() {
|
||||
router.push({ name: 'base-othercompany-detail', params: { id: 'new' } })
|
||||
router.push({ name: 'base-othercompany-detail' })
|
||||
}
|
||||
|
||||
/* ================= 行多选与批量删除 ================= */
|
||||
@@ -159,24 +284,26 @@ const rowSelection = computed(() => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const confirmOpen = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
async function confirmBatchDelete() {
|
||||
deleting.value = true
|
||||
try {
|
||||
await listRef.value?.deleteSelected(selectedRowKeys.value)
|
||||
Message.success('删除成功')
|
||||
selectedRowKeys.value = []
|
||||
confirmOpen.value = false
|
||||
} catch (error) {
|
||||
Message.error('删除失败:' + (error?.message || error))
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
function confirmBatchDelete() {
|
||||
Modal.confirm({
|
||||
title: `确定删除选中的 ${selectedRowKeys.value.length} 条往来单位?`,
|
||||
content: '此操作不可恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await listRef.value?.deleteSelected(selectedRowKeys.value)
|
||||
} catch (error) {
|
||||
Message.error('删除失败:' + (error?.message || error))
|
||||
throw error // 保持确认框打开,便于重试
|
||||
}
|
||||
Message.success('删除成功')
|
||||
selectedRowKeys.value = []
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* ================= 分类维护:新增 / 修改 ================= */
|
||||
/* ================= 分类维护:弹窗与表单 ================= */
|
||||
const categoryModalOpen = ref(false)
|
||||
const categoryModalSaving = ref(false)
|
||||
/** 修改时的分类行;新增时为 null */
|
||||
@@ -184,6 +311,77 @@ const editingCategory = ref(null)
|
||||
/** 新增分类的上级编码;null 表示顶级 */
|
||||
const categoryParentId = ref(null)
|
||||
|
||||
const isCategoryEdit = computed(() => !!editingCategory.value)
|
||||
|
||||
/** 本地草稿:打开时快照,确定时提交 */
|
||||
const categoryDraft = ref({ b_id: '', b_name: '', b_xh: 10 })
|
||||
|
||||
const categoryParentName = computed(() => {
|
||||
if (categoryParentId.value == null || categoryParentId.value === '') return '全部分类'
|
||||
const parent = categories.value.find((row) => String(row.b_id) === String(categoryParentId.value))
|
||||
return parent?.b_name || String(categoryParentId.value)
|
||||
})
|
||||
|
||||
watch(categoryModalOpen, (open) => {
|
||||
if (!open) return
|
||||
if (editingCategory.value) {
|
||||
categoryDraft.value = {
|
||||
b_id: editingCategory.value.b_id ?? '',
|
||||
b_name: editingCategory.value.b_name ?? '',
|
||||
b_xh: Number(editingCategory.value.b_xh) || 0,
|
||||
}
|
||||
return
|
||||
}
|
||||
categoryDraft.value = {
|
||||
b_id: '',
|
||||
b_name: '',
|
||||
b_xh: nextSiblingXh(categories.value, categoryParentId.value),
|
||||
}
|
||||
})
|
||||
|
||||
function setCategoryField(field, value) {
|
||||
categoryDraft.value = { ...categoryDraft.value, [field]: value }
|
||||
}
|
||||
|
||||
const categoryFormRef = ref(null)
|
||||
|
||||
const categoryFormRules = computed(() => ({
|
||||
b_id: [
|
||||
{ required: true, message: '请填写分类编码' },
|
||||
{ pattern: /^[A-Za-z0-9_-]+$/, message: '分类编码只能包含字母、数字、下划线或短横线' },
|
||||
{ max: 150, message: '分类编码长度不能超过 150' },
|
||||
{
|
||||
// b_id 是主键,重复即入库失败;这里提前拦下(修改自身时排除)
|
||||
validator: (value) => {
|
||||
const code = String(value ?? '').trim()
|
||||
if (!code) return true
|
||||
const selfId = String(editingCategory.value?.b_id ?? '')
|
||||
const duplicated = categories.value.some(
|
||||
(row) => String(row.b_id) === code && String(row.b_id) !== selfId,
|
||||
)
|
||||
return duplicated ? '分类编码已存在' : true
|
||||
},
|
||||
},
|
||||
],
|
||||
b_name: [
|
||||
{ required: true, message: '请填写分类名称' },
|
||||
{ max: 200, message: '分类名称长度不能超过 200' },
|
||||
],
|
||||
}))
|
||||
|
||||
async function onCategoryOk() {
|
||||
const errors = (await categoryFormRef.value?.validateAll()) ?? []
|
||||
if (errors.length) {
|
||||
Message.warning('请修正表单中标红的错误项')
|
||||
return
|
||||
}
|
||||
void onCategorySubmit({
|
||||
b_id: String(categoryDraft.value.b_id ?? '').trim(),
|
||||
b_name: String(categoryDraft.value.b_name ?? '').trim(),
|
||||
b_xh: Number(categoryDraft.value.b_xh) || 0,
|
||||
})
|
||||
}
|
||||
|
||||
function onCategoryAdd({ parentId }) {
|
||||
editingCategory.value = null
|
||||
categoryParentId.value = parentId == null ? null : String(parentId)
|
||||
@@ -201,13 +399,10 @@ function onCategoryEdit(node) {
|
||||
categoryModalOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗确定:新增补 b_parent_id / b_depth / b_path 后 insert;修改只提交名称与排序号
|
||||
* (分类编码是主键,创建后不可改)。落库成功后重载整棵树,保持「categories 是唯一数据源」。
|
||||
*/
|
||||
/** 新增补 b_depth / b_path 后 insert;修改只提交名称与排序号(编码是主键,创建后不可改) */
|
||||
async function onCategorySubmit(payload) {
|
||||
if (categoryModalSaving.value) return
|
||||
const isEdit = !!editingCategory.value
|
||||
const isEdit = isCategoryEdit.value
|
||||
categoryModalSaving.value = true
|
||||
try {
|
||||
if (isEdit) {
|
||||
@@ -226,7 +421,6 @@ async function onCategorySubmit(payload) {
|
||||
b_xh: payload.b_xh,
|
||||
b_canuse: 1,
|
||||
}
|
||||
// b_depth / b_path 是 b_parent_id 的派生字段;b_path 非空且无默认值,必须随行写入
|
||||
const parentNode = categoryParentId.value
|
||||
? findTreeNode(categoryTree.value, categoryParentId.value)
|
||||
: null
|
||||
@@ -244,31 +438,22 @@ async function onCategorySubmit(payload) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 删除分类(两条守卫:有子分类 / 分类下有往来单位) ================= */
|
||||
const categoryDeleteTarget = ref(null)
|
||||
const categoryDeleting = ref(false)
|
||||
/* ================= 删除分类(有子分类 / 分类下有往来单位 两条守卫) ================= */
|
||||
/** 守卫查询进行中:阻止右键重复触发 */
|
||||
const categoryChecking = ref(false)
|
||||
|
||||
/** 确认框显隐由「待删分类」派生:关闭即清空待删项,避免残留 */
|
||||
const categoryDeleteOpen = computed({
|
||||
get: () => !!categoryDeleteTarget.value,
|
||||
set: (open) => {
|
||||
if (!open) categoryDeleteTarget.value = null
|
||||
},
|
||||
})
|
||||
|
||||
/** 删除入口:先做同步/异步守卫,全部通过才弹确认框 */
|
||||
async function onCategoryDelete(node) {
|
||||
const category = categoryById.value.get(String(node?.b_id ?? ''))
|
||||
if (!category || categoryDeleting.value) return
|
||||
if (!category || categoryChecking.value) return
|
||||
if (hasChildCategory(categories.value, category.b_id)) {
|
||||
Message.warning('该分类下还有子分类,请先删除子分类')
|
||||
return
|
||||
}
|
||||
|
||||
categoryDeleting.value = true
|
||||
categoryChecking.value = true
|
||||
let hasData = false
|
||||
try {
|
||||
// 分类下还挂着往来单位时不允许删除。b_cate_id 是 varchar,条件值用 varchar 字面量
|
||||
// (不加 N 前缀),避免列被隐式转成 nvarchar 而用不上 ix_b_othercompany_cate
|
||||
// b_cate_id 是 varchar:条件值不加 N 前缀,避免列被隐式转成 nvarchar 而用不上索引
|
||||
const response = await pageDataApi({
|
||||
viewName: MODULE_CODE,
|
||||
searchColumns: ['b_id'],
|
||||
@@ -276,37 +461,36 @@ async function onCategoryDelete(node) {
|
||||
pageNo: 1,
|
||||
pageSize: 1,
|
||||
})
|
||||
const total = Number(response.data?.total ?? response.data?.rows?.length ?? 0)
|
||||
if (total > 0) {
|
||||
Message.warning('该分类下还有往来单位数据,不能删除')
|
||||
return
|
||||
}
|
||||
hasData = Number(response.data?.total ?? response.data?.rows?.length ?? 0) > 0
|
||||
} catch (error) {
|
||||
Message.error('分类数据检查失败:' + (error?.message || error))
|
||||
return
|
||||
} finally {
|
||||
categoryDeleting.value = false
|
||||
categoryChecking.value = false
|
||||
}
|
||||
if (hasData) {
|
||||
Message.warning('该分类下还有往来单位数据,不能删除')
|
||||
return
|
||||
}
|
||||
|
||||
categoryDeleteTarget.value = category
|
||||
}
|
||||
|
||||
async function confirmCategoryDelete() {
|
||||
const category = categoryDeleteTarget.value
|
||||
if (!category || categoryDeleting.value) return
|
||||
categoryDeleting.value = true
|
||||
try {
|
||||
await saveObjectApi([
|
||||
{ table: CATEGORY_TABLE, key_field: 'b_id', deletes: [{ b_id: category.b_id }] },
|
||||
])
|
||||
Message.success('分类已删除')
|
||||
categoryDeleteTarget.value = null
|
||||
await loadCategories()
|
||||
} catch (error) {
|
||||
Message.error('删除失败:' + (error?.message || error))
|
||||
} finally {
|
||||
categoryDeleting.value = false
|
||||
}
|
||||
Modal.confirm({
|
||||
title: `确定删除分类「${category.b_name || category.b_id}」?`,
|
||||
content: '此操作不可恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await saveObjectApi([
|
||||
{ table: CATEGORY_TABLE, key_field: 'b_id', deletes: [{ b_id: category.b_id }] },
|
||||
])
|
||||
} catch (error) {
|
||||
Message.error('删除失败:' + (error?.message || error))
|
||||
throw error // 保持确认框打开,便于重试
|
||||
}
|
||||
Message.success('分类已删除')
|
||||
await loadCategories()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* ================= 拖拽排序(同级调序 / 跨级换父) ================= */
|
||||
@@ -315,8 +499,7 @@ async function onCategoryDrop({ dragKey, dropKey, dropPosition }) {
|
||||
const dropId = String(dropKey)
|
||||
if (dragId === dropId) return
|
||||
|
||||
// 在树的临时副本上算 diff:树只是 categories 的派生视图,
|
||||
// 就地改会与唯一数据源脱节,故一律「算差异 → 落库 → 重载」
|
||||
// 树只是 categories 的派生视图,所以一贯「在副本上算差异 → 落库 → 重载」
|
||||
const tree = buildCategoryTree(categories.value)
|
||||
const dragPos = findNodePosition(tree, dragId)
|
||||
const dropPos = findNodePosition(tree, dropId)
|
||||
@@ -324,7 +507,7 @@ async function onCategoryDrop({ dragKey, dropKey, dropPosition }) {
|
||||
|
||||
const dragNode = dragPos.list[dragPos.index]
|
||||
const dropNode = dropPos.list[dropPos.index]
|
||||
// 落点不能是被拖节点自身或它的后代:父子关系成环后树与 b_path 都会失真
|
||||
// 落点不能是被拖节点自身或它的后代:成环后树与 b_path 都会失真
|
||||
if (findTreeNode([dragNode], dropId)) {
|
||||
Message.warning('不能把分类移动到它自己的下级')
|
||||
return
|
||||
@@ -338,14 +521,12 @@ async function onCategoryDrop({ dragKey, dropKey, dropPosition }) {
|
||||
const renumberSets = new Set()
|
||||
|
||||
if (dropPosition === 'inside') {
|
||||
// 成为落点节点的子节点
|
||||
moved.b_parent_id = dropNode.b_id
|
||||
if (!dropNode.children) dropNode.children = []
|
||||
dropNode.children.push(moved)
|
||||
renumberSets.add(dropNode.children)
|
||||
renumberSets.add(dragPos.list)
|
||||
} else {
|
||||
// 同级插入:落点节点前 / 后
|
||||
moved.b_parent_id = dropParentId
|
||||
const targetList = dropPos.list
|
||||
let targetIndex = dropPos.index + (dropPosition === 'after' ? 1 : 0)
|
||||
@@ -356,10 +537,9 @@ async function onCategoryDrop({ dragKey, dropKey, dropPosition }) {
|
||||
}
|
||||
|
||||
renumberSets.forEach((list) => renumberSiblings(list, updatedRows))
|
||||
// 父子关系变化后整树重建 b_depth / b_path,仅收集实际变化的行
|
||||
rebuildTreePositions(tree, updatedRows)
|
||||
// b_parent_id 是权威关系,必须与本节点的派生字段同批落库:
|
||||
// renumberSiblings / rebuildTreePositions 只收集实际变化的派生字段,都不负责父级列本身
|
||||
// b_parent_id 是权威关系,必须与派生字段同批落库:
|
||||
// 上面两个函数只收集变化的派生字段,都不负责父级列本身
|
||||
if (String(moved.b_parent_id ?? '') !== String(movedParentId ?? '')) {
|
||||
addRowUpdate(updatedRows, { b_id: moved.b_id, b_parent_id: moved.b_parent_id ?? null })
|
||||
}
|
||||
@@ -385,18 +565,53 @@ void loadCategories()
|
||||
<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)"
|
||||
/>
|
||||
<aside class="oc-tree-panel">
|
||||
<div class="oc-tree-header">
|
||||
<Input
|
||||
v-model="categoryKeyword"
|
||||
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="onCategoryToolbarAdd"
|
||||
>
|
||||
<Plus :size="14" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oc-tree-panel__scroll">
|
||||
<!-- 搜索过滤时禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
|
||||
<FmsTree
|
||||
v-model:expanded-keys="expandedCategoryKeys"
|
||||
show-root
|
||||
root-title="全部分类"
|
||||
:tree-data="filteredCategoryTree"
|
||||
:selected-keys="selectedCategoryKey ? [String(selectedCategoryKey)] : []"
|
||||
:context-menu="buildCategoryContextMenu"
|
||||
:draggable="!categoryKeyword.trim()"
|
||||
@select="onCategorySelect"
|
||||
@context-menu-click="onCategoryMenuClick"
|
||||
@drop="(payload) => void onCategoryDrop(payload)"
|
||||
>
|
||||
<template #title="{ title }">
|
||||
<span class="oc-tree-node__name">{{ title }}</span>
|
||||
</template>
|
||||
</FmsTree>
|
||||
</div>
|
||||
|
||||
<div v-if="categoryLoading" class="oc-tree-panel__loading">
|
||||
<Spin />
|
||||
</div>
|
||||
</aside>
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel>
|
||||
@@ -409,20 +624,15 @@ void loadCategories()
|
||||
:row-selection="rowSelection"
|
||||
>
|
||||
<template #actions="{ loading }">
|
||||
<Button
|
||||
v-if="canCreateRow"
|
||||
type="primary"
|
||||
:disabled="loading || deleting"
|
||||
@click="onCreate"
|
||||
>
|
||||
<Button v-if="canCreateRow" type="primary" :disabled="loading" @click="onCreate">
|
||||
<Plus :size="14" />
|
||||
新增
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDeleteRow"
|
||||
type="danger"
|
||||
:disabled="loading || deleting || !selectedRowKeys.length"
|
||||
@click="confirmOpen = true"
|
||||
:disabled="loading || !selectedRowKeys.length"
|
||||
@click="confirmBatchDelete"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
|
||||
@@ -433,51 +643,47 @@ void loadCategories()
|
||||
</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"
|
||||
:title="isCategoryEdit ? '修改分类' : '新增分类'"
|
||||
:width="520"
|
||||
:mask-closable="false"
|
||||
:confirm-loading="categoryModalSaving"
|
||||
@ok="onCategoryOk"
|
||||
>
|
||||
<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>
|
||||
<Form
|
||||
ref="categoryFormRef"
|
||||
:model="categoryDraft"
|
||||
:rules="categoryFormRules"
|
||||
layout="vertical"
|
||||
:gap="16"
|
||||
>
|
||||
<FormItem label="上级分类" name="b_parent_id">
|
||||
<Input :model-value="categoryParentName" disabled />
|
||||
</FormItem>
|
||||
<FormItem label="分类编码" name="b_id" required>
|
||||
<Input
|
||||
:model-value="categoryDraft.b_id"
|
||||
:disabled="isCategoryEdit"
|
||||
placeholder="主键即编码,保存后不可修改"
|
||||
@update:model-value="(v) => setCategoryField('b_id', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="分类名称" name="b_name" required>
|
||||
<Input
|
||||
:model-value="categoryDraft.b_name"
|
||||
@update:model-value="(v) => setCategoryField('b_name', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="排序号" name="b_xh">
|
||||
<Input
|
||||
:model-value="categoryDraft.b_xh"
|
||||
type="number"
|
||||
@update:model-value="(v) => setCategoryField('b_xh', Number(v) || 0)"
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -509,9 +715,100 @@ void loadCategories()
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 右侧列表卡:不再套白底。FmsModuleListPage 内部已有 .fms-module-card
|
||||
* (白底 + 圆角 + 阴影),外层再铺白底会把内卡之间的灰缝染色、并让整块区域变成直角白板。
|
||||
* 左侧分类树卡片的样式随 CategoryTreePanel 一起维护。 */
|
||||
/* 灰底画布上的白卡,与右侧列表卡用灰缝分隔 */
|
||||
.oc-tree-panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.oc-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.oc-tree-search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.oc-tree-search__icon {
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.oc-tree-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.oc-tree-header__icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
background: var(--fms-card);
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
background 150ms ease,
|
||||
color 150ms ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--fms-secondary);
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.oc-tree-panel__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.oc-tree-panel__scroll :deep(.tree) {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.oc-tree-node__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.oc-tree-panel__loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(255 255 255 / 60%);
|
||||
}
|
||||
|
||||
/* 列表卡自带白底(.fms-module-card),外层不再铺白底,避免灰缝被染色 */
|
||||
.oc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -520,10 +817,4 @@ void loadCategories()
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.oc-confirm-text {
|
||||
margin: 0;
|
||||
line-height: 1.7;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
</style>
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* 往来单位分类树(othercompany)页面级纯函数。
|
||||
* 只放无 Vue 状态依赖的逻辑:建树、位置派生、排序号计算、兄弟重排。
|
||||
* 树本身的通用查找/过滤/插入/移除见 @/utils/tree,不在此重复。
|
||||
*/
|
||||
import { buildTree, sortTree } from '@/utils/tree'
|
||||
|
||||
/**
|
||||
* 「全部分类」虚拟根节点的选中键。
|
||||
* 必须与 FmsTree 的 rootKey 默认值一致,页面与树面板共用同一常量,避免两处硬编码。
|
||||
*/
|
||||
export const ALL_CATEGORY_KEY = '__fms_tree_root__'
|
||||
|
||||
/**
|
||||
* 由扁平分类行构建排序后的分类树。
|
||||
*
|
||||
* 注意:必须在浅拷贝上构建。buildTree 会就地往行对象上挂 children,
|
||||
* 若直接在 categories 的行上构建,同一批行被重复构建时会累积出重复子节点,
|
||||
* 也会把树结构污染回「唯一数据源」categories。
|
||||
*
|
||||
* @param {Array} categories 分类行数组(b_othercompany_bmfl)
|
||||
* @returns {Array} 分类树(节点为浅拷贝)
|
||||
*/
|
||||
export function buildCategoryTree(categories) {
|
||||
const tree = buildTree((categories || []).map((row) => ({ ...row })))
|
||||
sortTree(tree)
|
||||
return tree
|
||||
}
|
||||
|
||||
/**
|
||||
* 按父节点派生分类行的 b_depth / b_path(b_parent_id 是权威关系,二者为派生字段)。
|
||||
* 根节点 b_depth = 0,b_path 形如 /A/B/(节点编码前后均带 /)。
|
||||
* b_path 非空且无默认值,新增时必须随行写入。
|
||||
* @param {Object} row 分类行(就地修改;b_id 必须是最终分类编码)
|
||||
* @param {Object|null} parentNode 父节点;null 表示根节点
|
||||
*/
|
||||
export function applyCategoryPosition(row, parentNode) {
|
||||
row.b_depth = parentNode ? Number(parentNode.b_depth ?? 0) + 1 : 0
|
||||
row.b_path = parentNode ? `${String(parentNode.b_path || '')}${row.b_id}/` : `/${row.b_id}/`
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算同级下一个排序号(同级最大 b_xh + 10,无同级时取 10)。
|
||||
* 新增分类时作为排序号默认值,避免新节点插到同级最前。
|
||||
* @param {Array} categories 分类行数组
|
||||
* @param {string|null} parentId 父节点编码;null 表示顶级
|
||||
* @returns {number} 排序号
|
||||
*/
|
||||
export function nextSiblingXh(categories, parentId) {
|
||||
const normalizedParentId = parentId == null ? null : String(parentId)
|
||||
let maxXh = 0
|
||||
for (const row of categories || []) {
|
||||
const rowParentId = row.b_parent_id == null ? null : String(row.b_parent_id)
|
||||
if (rowParentId !== normalizedParentId) continue
|
||||
maxXh = Math.max(maxXh, Number(row.b_xh) || 0)
|
||||
}
|
||||
return maxXh + 10
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否还有下级分类。有子分类的分类不允许删除。
|
||||
* @param {Array} categories 分类行数组
|
||||
* @param {string|number} id 分类编码
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasChildCategory(categories, id) {
|
||||
const targetId = String(id)
|
||||
return (categories || []).some(
|
||||
(row) => row.b_parent_id != null && String(row.b_parent_id) === targetId,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 在树中查找节点所在的父数组及索引。
|
||||
* @param {Array} nodes 树节点数组
|
||||
* @param {string|number} id 目标节点 ID
|
||||
* @returns {{ list: Array, index: number }|null}
|
||||
*/
|
||||
export function findNodePosition(nodes, id) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
|
||||
if (nodes[i].children) {
|
||||
const found = findNodePosition(nodes[i].children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 向 updatedRows 合并一行变更(同一 b_id 累加字段,避免同节点产生多条 update 请求) */
|
||||
export function addRowUpdate(updatedRows, row) {
|
||||
const existing = updatedRows.find((item) => String(item.b_id) === String(row.b_id))
|
||||
if (existing) Object.assign(existing, row)
|
||||
else updatedRows.push({ ...row })
|
||||
}
|
||||
|
||||
/**
|
||||
* 重排某组兄弟的 b_xh(10 起步、步长 10),并把变化的行收集到 updatedRows。
|
||||
* @param {Array} list 兄弟节点数组(就地修改 b_xh)
|
||||
* @param {Array} updatedRows 变化行收集器
|
||||
*/
|
||||
export function renumberSiblings(list, updatedRows) {
|
||||
list.forEach((sibling, index) => {
|
||||
const nextXh = (index + 1) * 10
|
||||
if (Number(sibling.b_xh) !== nextXh) {
|
||||
sibling.b_xh = nextXh
|
||||
addRowUpdate(updatedRows, { b_id: sibling.b_id, b_xh: nextXh })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归重建分类树的 b_depth / b_path(b_parent_id 为权威关系,二者为派生字段)。
|
||||
* 根节点 b_depth = 0、b_path = /编码/;子节点沿用父级路径拼接,与 applyCategoryPosition 一致。
|
||||
* 仅在值实际变化时收集,避免无谓更新。
|
||||
* @param {Array} nodes 树节点数组(就地修改)
|
||||
* @param {Array} updatedRows 变化行收集器
|
||||
* @param {string} [parentPath] 父节点路径(形如 /A/;根层传空串)
|
||||
* @param {number} [depth] 当前层级
|
||||
*/
|
||||
export function rebuildTreePositions(nodes, updatedRows, parentPath = '', depth = 0) {
|
||||
for (const node of nodes) {
|
||||
const path = parentPath ? `${parentPath}${node.b_id}/` : `/${node.b_id}/`
|
||||
if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) {
|
||||
node.b_depth = depth
|
||||
node.b_path = path
|
||||
addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path })
|
||||
}
|
||||
if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,487 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 其他数据(旧「其他数据」子系统的入口)。
|
||||
* 页面结构、权限与编辑入口尚未实现,先占位以便路由与目录结构就位。
|
||||
* 其他数据 —— 左侧分类树 + 右侧可编辑列表。
|
||||
*
|
||||
* 数据模型(s_module 元数据表驱动):
|
||||
* - 根:`b_id = 'base_otherdata'`、`b_module_type = 'module'` 的模块(本页入口);
|
||||
* - 分类:根之下所有 `b_module_type = 'module'` 的子孙(用于组织数据模块的目录节点);
|
||||
* - 数据模块:分类下 `b_module_type = 'data'` 的子孙,右侧列表按其 b_id 加载。
|
||||
*
|
||||
* 分类树是纯导航视图:分类结构(新增 / 修改 / 删除 / 拖拽排序)统一在「模块管理」中维护,
|
||||
* 本页不提供任何分类编辑入口。
|
||||
*
|
||||
* 与旧版(fms-vue-old1)差异:
|
||||
* - 旧版 s_module 使用 `page / data_category / data` 三类型,新版归一为 `module / data / virtual`;
|
||||
* 映射:page → 根 module、data_category → 子 module、data → data。
|
||||
* - 旧版字段名 `b_viewtable / b_savetable / b_keyfield`,新版为 `b_view_table / b_save_table / b_key_field`;
|
||||
* 这些字段由 FmsModuleListPage 内部消化,本页无需感知。
|
||||
*
|
||||
* 权限:
|
||||
* - 分类可见性:无分类维护权限时,只显示「至少含一个可访问数据模块」的分类分支;
|
||||
* - 数据模块可见:canAccess(moduleCode);
|
||||
* - 数据模块行操作:canPower(moduleCode, 'create|update|delete')。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Plus, Save, Search, Trash2 } from '@lucide/vue'
|
||||
import { Button, Message, Modal, Spin, Splitter, SplitterPanel, Input } from '@/components/ui'
|
||||
import { FmsModuleListPage } from '@/components/fms-module-list'
|
||||
import FmsTree from '@/components/fms-tree/FmsTree.vue'
|
||||
import { loadDataApi } from '@/services/api'
|
||||
import { usePermissionStore } from '@/stores/permissions'
|
||||
import { buildTree, collectAllKeys, filterTreeByText, sortTree } from '@/utils/tree'
|
||||
|
||||
/** 根模块编码:本页所有分类与数据模块都挂在此模块下 */
|
||||
const ROOT_MODULE_ID = 'base_otherdata'
|
||||
|
||||
/* ================= 权限 ================= */
|
||||
const permissionStore = usePermissionStore()
|
||||
/** 分类维护权限:仅用于可见性裁剪(有维护权限则能看到全部分类结构) */
|
||||
const canManageCategory = computed(
|
||||
() =>
|
||||
permissionStore.canPower(ROOT_MODULE_ID, 'create') ||
|
||||
permissionStore.canPower(ROOT_MODULE_ID, 'update') ||
|
||||
permissionStore.canPower(ROOT_MODULE_ID, 'delete'),
|
||||
)
|
||||
|
||||
/* ================= 模块树:状态与加载 ================= */
|
||||
const modules = ref([])
|
||||
const categoryLoading = ref(false)
|
||||
const selectedCategoryKey = ref('')
|
||||
const selectedModuleCode = ref('')
|
||||
const expandedCategoryKeys = ref([])
|
||||
const categoryKeyword = ref('')
|
||||
|
||||
const moduleById = computed(() => new Map(modules.value.map((item) => [String(item.b_id), item])))
|
||||
|
||||
/**
|
||||
* 树数据:把根之下的所有子孙按 b_parent_id 组织成树。
|
||||
* 必须在浅拷贝上构建:buildTree 会就地往行上挂 children,直接构建会污染 modules。
|
||||
*/
|
||||
const categoryTree = computed(() => {
|
||||
const rows = modules.value.map((row) => ({ ...row, children: undefined }))
|
||||
const tree = buildTree(rows)
|
||||
sortTree(tree)
|
||||
return tree
|
||||
})
|
||||
|
||||
/** 搜索过滤:命中名称 / 编码,保留命中节点的子树 */
|
||||
const filteredCategoryTree = computed(() =>
|
||||
filterTreeByText(categoryTree.value, categoryKeyword.value, ['b_name', 'b_id']),
|
||||
)
|
||||
|
||||
// 搜索时展开全部(保证命中节点可见),清空时回到默认展开集合
|
||||
watch(categoryKeyword, (kw) => {
|
||||
expandedCategoryKeys.value = kw.trim()
|
||||
? collectAllKeys(categoryTree.value)
|
||||
: defaultExpandedKeys()
|
||||
})
|
||||
|
||||
/** 默认展开:无(「全部分类」虚拟根由 FmsTree 默认展开,分组与数据模块保持收起) */
|
||||
function defaultExpandedKeys() {
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断模块是否为「数据模块」(可作为右侧列表数据源的叶子)。
|
||||
* 虚拟模块(virtual)不作为其他数据页的数据源。
|
||||
*/
|
||||
function isDataModule(item) {
|
||||
return item?.b_module_type === 'data'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断模块是否属于根模块的子孙(按 b_path 前缀匹配)。
|
||||
* b_path 形如 `/base_otherdata/odc_123/b_some_data/`,前缀匹配即覆盖所有层级。
|
||||
*/
|
||||
function isDescendantOfRoot(item, rootPath) {
|
||||
if (!rootPath) return false
|
||||
const path = String(item.b_path || '')
|
||||
return path.startsWith(rootPath) && String(item.b_id) !== ROOT_MODULE_ID
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载模块树。
|
||||
* 步骤:拉全量启用模块 → 定位根 → 按 b_path 前缀过滤子孙 → 按权限裁剪可见集合。
|
||||
*/
|
||||
async function loadCategories() {
|
||||
categoryLoading.value = true
|
||||
try {
|
||||
// 权限数据可能尚未加载(首屏直接进入本页时),先确保到位
|
||||
await permissionStore.load()
|
||||
const response = await loadDataApi('s_module', 'b_canuse = 1', 'b_xh ASC, b_id ASC')
|
||||
const all = response.data || []
|
||||
|
||||
const root = all.find(
|
||||
(item) => String(item.b_id) === ROOT_MODULE_ID && item.b_module_type === 'module',
|
||||
)
|
||||
const rootPath = String(root?.b_path || '')
|
||||
|
||||
// 根之下的所有子孙(module + data + virtual,virtual 在渲染时过滤)
|
||||
const scoped = rootPath ? all.filter((item) => isDescendantOfRoot(item, rootPath)) : []
|
||||
const scopedById = new Map(scoped.map((item) => [String(item.b_id), item]))
|
||||
|
||||
// 权限裁剪:无分类维护权限时,只保留「至少含一个可访问数据模块」的分类分支
|
||||
const visibleIds = new Set()
|
||||
for (const item of scoped) {
|
||||
if (!isDataModule(item) || !permissionStore.canAccess(String(item.b_id))) continue
|
||||
let current = item
|
||||
while (current) {
|
||||
const currentId = String(current.b_id)
|
||||
if (visibleIds.has(currentId)) break
|
||||
visibleIds.add(currentId)
|
||||
const parentId = current.b_parent_id == null ? '' : String(current.b_parent_id)
|
||||
current = parentId ? scopedById.get(parentId) : null
|
||||
}
|
||||
}
|
||||
|
||||
// 只展示启用项(未启用的不进树);无分类维护权限时再按可见性裁剪
|
||||
modules.value = scoped.filter(
|
||||
(item) =>
|
||||
item.b_module_type !== 'virtual' &&
|
||||
Number(item.b_canuse) === 1 &&
|
||||
(canManageCategory.value || visibleIds.has(String(item.b_id))),
|
||||
)
|
||||
|
||||
// 选中的分类已被删除 / 已不可见时清空选择
|
||||
const existingKeys = new Set(modules.value.map((item) => String(item.b_id)))
|
||||
if (selectedCategoryKey.value && !existingKeys.has(selectedCategoryKey.value)) {
|
||||
selectedCategoryKey.value = ''
|
||||
selectedModuleCode.value = ''
|
||||
} else {
|
||||
// 重新校验当前选中的数据模块是否仍可访问
|
||||
applySelectedModule(selectedCategoryKey.value)
|
||||
}
|
||||
|
||||
// 默认只展开根模块(保留用户已手动展开的分组)
|
||||
const defaults = defaultExpandedKeys()
|
||||
expandedCategoryKeys.value = [...new Set([...expandedCategoryKeys.value, ...defaults])]
|
||||
} catch (error) {
|
||||
Message.error('分类加载失败:' + (error?.message || error))
|
||||
} finally {
|
||||
categoryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据选中的树节点 key 更新右侧数据模块编码。
|
||||
* 分类节点(module)不切换数据源,只更新视觉选中;数据模块节点(data)切换数据源。
|
||||
*/
|
||||
function applySelectedModule(key) {
|
||||
selectedCategoryKey.value = key ? String(key) : ''
|
||||
if (!key) {
|
||||
selectedModuleCode.value = ''
|
||||
return
|
||||
}
|
||||
const node = moduleById.value.get(String(key))
|
||||
if (isDataModule(node) && permissionStore.canAccess(String(node.b_id))) {
|
||||
selectedModuleCode.value = String(node.b_id)
|
||||
} else {
|
||||
selectedModuleCode.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function onCategorySelect(keys, info) {
|
||||
// 点「全部分类」虚拟根:清空右侧数据源
|
||||
if (info?.node?.isFmsRoot) {
|
||||
applySelectedModule('')
|
||||
return
|
||||
}
|
||||
const key = String(info?.node?.b_id ?? keys[0] ?? '')
|
||||
if (!key) return
|
||||
|
||||
const node = moduleById.value.get(key)
|
||||
// 点击分类:切换展开状态(数据模块是叶子,无展开可言)
|
||||
if (node?.b_module_type === 'module') {
|
||||
const expanded = expandedCategoryKeys.value.map(String)
|
||||
const isExpanded = expanded.includes(key)
|
||||
expandedCategoryKeys.value = isExpanded ? expanded.filter((k) => k !== key) : [...expanded, key]
|
||||
}
|
||||
applySelectedModule(key)
|
||||
}
|
||||
|
||||
/* ================= 右侧数据列表 ================= */
|
||||
const listRef = ref(null)
|
||||
const selectedRowKeys = ref([])
|
||||
const dirtyCount = ref(0)
|
||||
const editableSaving = ref(false)
|
||||
|
||||
const canCreateRow = computed(() => permissionStore.canPower(selectedModuleCode.value, 'create'))
|
||||
const canUpdateRow = computed(() => permissionStore.canPower(selectedModuleCode.value, 'update'))
|
||||
const canDeleteRow = computed(() => permissionStore.canPower(selectedModuleCode.value, 'delete'))
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys
|
||||
},
|
||||
}))
|
||||
|
||||
// 切换数据模块时清空选中与脏计数,避免跨模块误操作
|
||||
watch(selectedModuleCode, () => {
|
||||
selectedRowKeys.value = []
|
||||
dirtyCount.value = 0
|
||||
})
|
||||
|
||||
async function addNewRow() {
|
||||
if (!canCreateRow.value) return
|
||||
await listRef.value?.addRow?.()
|
||||
}
|
||||
|
||||
async function saveEditableChanges() {
|
||||
if (!canUpdateRow.value || editableSaving.value) return
|
||||
if (dirtyCount.value <= 0) {
|
||||
Message.info('没有需要保存的修改')
|
||||
return
|
||||
}
|
||||
editableSaving.value = true
|
||||
try {
|
||||
await listRef.value?.saveChanges?.()
|
||||
dirtyCount.value = 0
|
||||
} finally {
|
||||
editableSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBatchDelete() {
|
||||
const keys = [...selectedRowKeys.value]
|
||||
if (!keys.length) return
|
||||
Modal.confirm({
|
||||
title: `确定删除选中的 ${keys.length} 条记录?`,
|
||||
content: '此操作不可恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
editableSaving.value = true
|
||||
try {
|
||||
await listRef.value?.deleteSelected(keys)
|
||||
} catch (error) {
|
||||
Message.error('删除失败:' + (error?.message || error))
|
||||
throw error
|
||||
} finally {
|
||||
editableSaving.value = false
|
||||
}
|
||||
Message.success('删除成功')
|
||||
selectedRowKeys.value = []
|
||||
dirtyCount.value = 0
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
void loadCategories()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="otherdata-page" />
|
||||
<div class="od-page">
|
||||
<Splitter class="od-splitter">
|
||||
<SplitterPanel :default-size="260" :min="200" :max="420">
|
||||
<aside class="od-tree-panel">
|
||||
<div class="od-tree-header">
|
||||
<Input
|
||||
v-model="categoryKeyword"
|
||||
placeholder="搜索分类 / 数据"
|
||||
allow-clear
|
||||
class="od-tree-search"
|
||||
>
|
||||
<template #prefix><Search :size="14" class="od-tree-search__icon" /></template>
|
||||
</Input>
|
||||
</div>
|
||||
|
||||
<div class="od-tree-panel__scroll">
|
||||
<!-- 搜索过滤时树是浅拷贝视图,仅作导航展示,无编辑交互 -->
|
||||
<FmsTree
|
||||
v-model:expanded-keys="expandedCategoryKeys"
|
||||
show-root
|
||||
root-title="全部分类"
|
||||
:tree-data="filteredCategoryTree"
|
||||
:selected-keys="selectedCategoryKey ? [String(selectedCategoryKey)] : []"
|
||||
@select="onCategorySelect"
|
||||
>
|
||||
<template #title="{ title }">
|
||||
<span class="od-tree-node__name">{{ title }}</span>
|
||||
</template>
|
||||
</FmsTree>
|
||||
</div>
|
||||
|
||||
<div v-if="categoryLoading" class="od-tree-panel__loading">
|
||||
<Spin />
|
||||
</div>
|
||||
</aside>
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel>
|
||||
<main class="od-list">
|
||||
<FmsModuleListPage
|
||||
v-if="selectedModuleCode"
|
||||
ref="listRef"
|
||||
:key="selectedModuleCode"
|
||||
:data-code="selectedModuleCode"
|
||||
:editable="canUpdateRow"
|
||||
:row-draggable="canUpdateRow"
|
||||
order-field="b_xh"
|
||||
:row-selection="rowSelection"
|
||||
@dirty-change="({ count }) => (dirtyCount = count)"
|
||||
>
|
||||
<template #actions="{ loading }">
|
||||
<Button
|
||||
v-if="canCreateRow"
|
||||
type="primary"
|
||||
:disabled="loading || editableSaving"
|
||||
@click="addNewRow"
|
||||
>
|
||||
<Plus :size="14" />
|
||||
新增行
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canUpdateRow"
|
||||
type="primary"
|
||||
:loading="editableSaving"
|
||||
:disabled="loading || !dirtyCount"
|
||||
@click="saveEditableChanges"
|
||||
>
|
||||
<Save :size="14" />
|
||||
保存{{ dirtyCount ? `(${dirtyCount})` : '' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDeleteRow"
|
||||
type="danger"
|
||||
:disabled="loading || editableSaving || !selectedRowKeys.length"
|
||||
@click="confirmBatchDelete"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
|
||||
</Button>
|
||||
</template>
|
||||
</FmsModuleListPage>
|
||||
<div v-else class="od-list-empty">
|
||||
<div class="od-list-empty__hint">
|
||||
<strong>请选择数据模块</strong>
|
||||
<span>从左侧分类树中选择一个数据模块以查看与维护其数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.otherdata-page {
|
||||
<style scoped lang="scss">
|
||||
.od-page,
|
||||
.od-splitter {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.od-page {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.od-splitter :deep(> .fms-splitter-panel) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.od-splitter :deep(.fms-splitter-bar::before) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.od-splitter :deep(.fms-splitter-bar:hover::before),
|
||||
.od-splitter :deep(.fms-splitter-bar-active::before),
|
||||
.od-splitter :deep(.fms-splitter-bar:focus-visible::before) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 灰底画布上的白卡,与右侧列表卡用灰缝分隔(与 othercompany 同款布局) */
|
||||
.od-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;
|
||||
}
|
||||
|
||||
.od-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.od-tree-search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.od-tree-search__icon {
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.od-tree-panel__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.od-tree-panel__scroll :deep(.tree) {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.od-tree-node__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.od-tree-panel__loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(255 255 255 / 60%);
|
||||
}
|
||||
|
||||
/* 列表卡自带白底(.fms-module-card),外层不再铺白底,避免灰缝被染色 */
|
||||
.od-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.od-list-empty {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
place-items: center;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
}
|
||||
|
||||
.od-list-empty__hint {
|
||||
display: flex;
|
||||
max-width: 360px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
|
||||
strong {
|
||||
color: var(--fms-text);
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { Languages } from '@lucide/vue'
|
||||
import { Button, Form, FormItem, Input, Select } from '@/components/ui'
|
||||
import { Button, Form, FormItem, Input, Select, Tag } from '@/components/ui'
|
||||
import IconPicker from '@/components/icon-picker/IconPicker.vue'
|
||||
import { menuRouteOptions } from '@/router/routes'
|
||||
import { MENU_TYPE_OPTIONS } from './utils'
|
||||
@@ -17,20 +17,35 @@ import { MENU_TYPE_OPTIONS } from './utils'
|
||||
const props = defineProps({
|
||||
/** 已保存菜单不允许修改菜单类型(父级按未保存标记计算) */
|
||||
typeLocked: { type: Boolean, default: false },
|
||||
/** 可绑定的业务模块下拉(父级加载一次后传入,避免每次选菜单重复请求) */
|
||||
moduleOptions: { type: Array, default: () => [] },
|
||||
/** 模块清单(s_module 启用行,父级加载一次后传入,用于展示已绑模块名称) */
|
||||
moduleRows: { type: Array, default: () => [] },
|
||||
/** 页面加载 / 保存中:禁用表单 */
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['openI18n'])
|
||||
const emit = defineEmits(['openI18n', 'openModulePicker'])
|
||||
|
||||
/** 菜单主表行(s_menu),父级 v-model 绑定 maindata */
|
||||
const maindata = defineModel({ type: Object, default: null })
|
||||
|
||||
/**
|
||||
* 已绑定模块编码(s_menu_module 草稿,父级持有并随菜单保存一并提交)。
|
||||
* 交互上通过模块树多选:勾分类节点自动级联其下全部模块,落库只登记 data / virtual。
|
||||
*/
|
||||
const modules = defineModel('modules', { type: Array, default: () => [] })
|
||||
|
||||
const isPage = computed(() => maindata.value?.b_menu_type === 'page')
|
||||
const isExternal = computed(() => maindata.value?.b_menu_type === 'external')
|
||||
|
||||
/** 已绑模块 → 展示项(模块表缺该编码时回退显示编码) */
|
||||
const boundModules = computed(() => {
|
||||
const nameMap = Object.fromEntries(props.moduleRows.map((m) => [String(m.b_id), m.b_name]))
|
||||
return modules.value.map((id) => ({ id: String(id), name: nameMap[String(id)] || String(id) }))
|
||||
})
|
||||
/** 标签最多展示前几个,其余收成 +N,避免长清单撑破表单 */
|
||||
const MAX_VISIBLE_MODULES = 3
|
||||
const visibleModules = computed(() => boundModules.value.slice(0, MAX_VISIBLE_MODULES))
|
||||
|
||||
/**
|
||||
* 路由地址候选:由静态路由表(@/router/routes)派生的页面路由,
|
||||
* Select 的默认筛选即对 label / value 做包含匹配,等价于 like '%关键词%',
|
||||
@@ -52,8 +67,8 @@ const formRules = computed(() => ({
|
||||
b_name: [{ required: true, message: '请填写菜单名称' }],
|
||||
b_menu_type: [{ required: true, message: '请选择菜单类型' }],
|
||||
b_route: isPage.value || isExternal.value ? [{ required: true, message: '请填写路由地址' }] : [],
|
||||
// 绑定模块非必填:页面菜单可不绑定业务模块(仅用于路由 + 数据来源),
|
||||
// 留空时保存为空,后续需要再补选。
|
||||
// 绑定模块非必填:页面菜单可不绑定模块(模块绑定存 s_menu_module 关系表),
|
||||
// 未绑定不报错,后续需要再补选。
|
||||
b_module_id: [],
|
||||
}))
|
||||
|
||||
@@ -75,12 +90,12 @@ function onMenuTypeChange(menuType) {
|
||||
if (!maindata.value) return
|
||||
if (props.typeLocked) return
|
||||
maindata.value.b_menu_type = menuType
|
||||
// 目录和外链不绑定业务模块;页面同时使用路由和模块。
|
||||
// 目录和外链不绑定业务模块(清空绑定草稿,保存时关系行按 diff 删除);页面同时使用路由和模块。
|
||||
if (menuType === 'directory') {
|
||||
maindata.value.b_route = null
|
||||
maindata.value.b_module_id = null
|
||||
modules.value = []
|
||||
} else if (menuType === 'external') {
|
||||
maindata.value.b_module_id = null
|
||||
modules.value = []
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -142,14 +157,19 @@ function onMenuTypeChange(menuType) {
|
||||
<FormItem v-if="isExternal" label="外链地址" name="b_route" required>
|
||||
<Input :model-value="maindata.b_route" @update:model-value="(v) => setField('b_route', v)" />
|
||||
</FormItem>
|
||||
<!-- page: 绑定模块(非必填,可不绑定业务模块) -->
|
||||
<!-- page: 绑定模块(多选;点模块树分类节点自动级联勾选其下全部模块) -->
|
||||
<FormItem v-if="isPage" label="绑定模块" name="b_module_id">
|
||||
<Select
|
||||
:model-value="maindata.b_module_id"
|
||||
:options="moduleOptions"
|
||||
clearable
|
||||
@update:model-value="(v) => setField('b_module_id', v)"
|
||||
/>
|
||||
<div class="mm-module-field">
|
||||
<div class="mm-module-field__value">
|
||||
<Tag v-for="m in visibleModules" :key="m.id">{{ m.name }}</Tag>
|
||||
<span v-if="boundModules.length > visibleModules.length" class="mm-module-field__more">
|
||||
+{{ boundModules.length - visibleModules.length }}
|
||||
</span>
|
||||
</div>
|
||||
<Button type="outline" :disabled="loading" @click="emit('openModulePicker')">
|
||||
选择模块
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem label="图标" name="b_icon">
|
||||
<IconPicker
|
||||
@@ -184,4 +204,30 @@ function onMenuTypeChange(menuType) {
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* 绑定模块:只读展示框 + 选择按钮(多选走模块树弹层) */
|
||||
.mm-module-field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mm-module-field__value {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
}
|
||||
|
||||
.mm-module-field__more {
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -4,12 +4,12 @@
|
||||
*
|
||||
* 职责:搜索框与树展示,展开 / 选中 / 拖拽 / 右键菜单的 UI 交互,节点启用停用的操作入口。
|
||||
* 数据契约:树数据、选中项、未保存标记均由父级唯一持有,本组件只读并通过事件上报意图:
|
||||
* select / add / delete / drop / refresh / toggleEnable。
|
||||
* select / add / delete / drop / toggleEnable。
|
||||
* 搜索关键字与展开键是纯 UI 状态,留在本组件;展开键需由父级在新建菜单后追加祖先路径,
|
||||
* 故以 v-model:expanded-keys 暴露。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ExternalLink, FileText, Folder, Plus, RefreshCw, Search, Trash2 } from '@lucide/vue'
|
||||
import { ExternalLink, FileText, Folder, Plus, Search, Trash2 } from '@lucide/vue'
|
||||
import { Button, Checkbox, Dropdown, Input } from '@/components/ui'
|
||||
import FmsTree from '@/components/fms-tree/FmsTree.vue'
|
||||
import { collectAllKeys, filterTreeByText } from '@/utils/tree'
|
||||
@@ -27,15 +27,7 @@ const props = defineProps({
|
||||
unsavedIds: { type: Set, default: () => new Set() },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:expandedKeys',
|
||||
'select',
|
||||
'add',
|
||||
'delete',
|
||||
'drop',
|
||||
'refresh',
|
||||
'toggleEnable',
|
||||
])
|
||||
const emit = defineEmits(['update:expandedKeys', 'select', 'add', 'delete', 'drop', 'toggleEnable'])
|
||||
|
||||
/** 搜索关键字(纯 UI 状态) */
|
||||
const keyword = ref('')
|
||||
@@ -94,15 +86,6 @@ function onToolbarAdd({ key }) {
|
||||
<template #prefix><Search :size="14" class="mm-tree-search__icon" /></template>
|
||||
</Input>
|
||||
<div class="mm-tree-header__actions">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-tree-header__icon-btn"
|
||||
title="刷新菜单树"
|
||||
aria-label="刷新菜单树"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<RefreshCw :size="14" />
|
||||
</Button>
|
||||
<Dropdown :items="addOptions" @click="onToolbarAdd">
|
||||
<Button
|
||||
type="ghost"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 模块绑定选择弹层:按模块树勾选页面使用的模块。
|
||||
*
|
||||
* 交互:勾选分类节点(module)自动级联勾选其下全部模块 —— 由 Tree 的
|
||||
* checkable 非严格模式提供(点父级整棵子树开启 / 关闭,半选态自动计算)。
|
||||
* 落库只登记 data / virtual 模块编码:module 类型只是模块树上的分类,不参与权限,
|
||||
* 确定时统一过滤掉;勾选草稿(含分类节点)仅存在于弹层内部。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Modal } from '@/components/ui'
|
||||
import FmsTree from '@/components/fms-tree/FmsTree.vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
/** 模块树(module 分类 + data / virtual 数据模块) */
|
||||
treeData: { type: Array, default: () => [] },
|
||||
/** 已绑定模块编码(data / virtual) */
|
||||
checkedKeys: { type: Array, default: () => [] },
|
||||
/** 模块编码 → b_module_type;缺少映射的键按可绑定处理,避免误删历史绑定 */
|
||||
moduleTypes: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open', 'confirm'])
|
||||
|
||||
/** 弹层内的勾选草稿(含分类节点;关闭不落库,重新打开时按外部值重置) */
|
||||
const draftChecked = ref([])
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(value) => {
|
||||
if (value) draftChecked.value = [...props.checkedKeys]
|
||||
},
|
||||
)
|
||||
|
||||
/** 可绑定 = data / virtual(module 仅分类) */
|
||||
function isBindable(key) {
|
||||
const type = props.moduleTypes[String(key)]
|
||||
return type == null || type === 'data' || type === 'virtual'
|
||||
}
|
||||
|
||||
/** 初始展开:只展开第一层根节点,深层分类按需点开,避免一次铺开整棵树 */
|
||||
const rootKeys = computed(() => (props.treeData || []).map((node) => node.b_id))
|
||||
|
||||
function onOk() {
|
||||
emit(
|
||||
'confirm',
|
||||
draftChecked.value.filter(isBindable).map((key) => String(key)),
|
||||
)
|
||||
emit('update:open', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="open"
|
||||
title="绑定模块"
|
||||
:width="560"
|
||||
@update:open="(value) => emit('update:open', value)"
|
||||
@ok="onOk"
|
||||
>
|
||||
<div class="mp-tree">
|
||||
<FmsTree
|
||||
v-model:checked-keys="draftChecked"
|
||||
:tree-data="treeData"
|
||||
:default-expanded-keys="rootKeys"
|
||||
checkable
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mp-tree {
|
||||
max-height: 52vh;
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
}
|
||||
</style>
|
||||
@@ -34,6 +34,7 @@ import UnsavedChangesModal from '@/components/UnsavedChangesModal.vue'
|
||||
import I18nNameModal from '@/components/I18nNameModal.vue'
|
||||
import MenuTreePanel from './MenuTreePanel.vue'
|
||||
import MenuBasicPanel from './MenuBasicPanel.vue'
|
||||
import ModulePickerModal from './ModulePickerModal.vue'
|
||||
import {
|
||||
addRowUpdate,
|
||||
applyMenuPosition,
|
||||
@@ -51,6 +52,11 @@ function sqlText(value) {
|
||||
return `N'${String(value ?? '').replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
/** 编码数组的顺序无关签名(比较模块绑定集合是否变化) */
|
||||
function sortedKey(list) {
|
||||
return (list || []).map(String).toSorted().join(',')
|
||||
}
|
||||
|
||||
/* ================= 多语言配置(s_i18n_type 驱动) ================= */
|
||||
// 菜单多语言:默认语言(b_default=1)名称直接存 b_name;其余语言存 s_i18n(b_key=menu.编码)。
|
||||
// 语言清单来自数据库(stores/i18n),不再写死 zh-CN/en-US。
|
||||
@@ -105,6 +111,13 @@ const i18n_org = ref([])
|
||||
/** 多语言名称编辑弹窗 */
|
||||
const i18nModalVisible = ref(false)
|
||||
|
||||
/** 绑定模块草稿(s_menu_module 的模块编码数组,随菜单保存一并提交) */
|
||||
const menuModules = ref([])
|
||||
/** 绑定模块加载基线(保存时行级 diffRows 的原始集合) */
|
||||
const menuModules_org = ref([])
|
||||
/** 模块绑定选择弹层 */
|
||||
const modulePickerOpen = ref(false)
|
||||
|
||||
/** 菜单基础信息面板(保存前校验入口) */
|
||||
const basicPanelRef = ref(null)
|
||||
|
||||
@@ -154,37 +167,6 @@ async function loadMenuTree() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具栏「刷新」:loadMenuTree 会用库里数据整体重建树,
|
||||
* 未保存的新建菜单会凭空消失、当前草稿也会与新树脱节,故有未保存内容时先确认。
|
||||
*/
|
||||
function requestReloadTree() {
|
||||
const hasDraftNode = [...unsavedMenuIds].some((id) => findTreeNode(menuTreeData.value, id))
|
||||
// 选中未保存节点时,maindata 与 maindata_org 一致(未编辑态),hasUnsavedChanges 判不出来,需单独看
|
||||
const editingDraftNode =
|
||||
!!menuSelectedId.value && unsavedMenuIds.has(String(menuSelectedId.value))
|
||||
const dirty = loadedMenuId.value && hasUnsavedChanges()
|
||||
if (!hasDraftNode && !dirty) {
|
||||
void loadMenuTree()
|
||||
return
|
||||
}
|
||||
showConfirm({
|
||||
title: '刷新菜单树',
|
||||
content:
|
||||
'刷新会用数据库数据重建整棵树,当前未保存的修改与未保存的新建菜单都将丢失。确定继续吗?',
|
||||
okText: '刷新',
|
||||
onOk: async () => {
|
||||
// 选中项若为未保存节点,刷新后它必然不在树上:先清空选中,避免右侧残留悬空草稿。
|
||||
// 已保存节点则记录 id,树刷新后连同草稿一起重载,保持左右两侧一致。
|
||||
const reloadId =
|
||||
editingDraftNode || !menuSelectedId.value ? null : String(menuSelectedId.value)
|
||||
if (editingDraftNode) menuSelectedId.value = null
|
||||
await loadMenuTree()
|
||||
if (reloadId) await loadMenuConfig(reloadId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 工具栏「重载」:重载会放弃当前未保存的改动,先确认 */
|
||||
function requestReloadConfig() {
|
||||
const id = menuSelectedId.value
|
||||
@@ -342,7 +324,6 @@ async function addMenu(parentId, menuTypeValue = 'page') {
|
||||
b_i18n: null,
|
||||
b_menu_type: menuTypeValue,
|
||||
b_route: null,
|
||||
b_module_id: null,
|
||||
b_icon: null,
|
||||
b_xh: 10,
|
||||
b_canuse: 1,
|
||||
@@ -397,6 +378,8 @@ async function deleteMenu(id) {
|
||||
// s_i18n 无菜单归属列,按资源键删该菜单的全部语言行
|
||||
const reqs = [
|
||||
{ table: 's_i18n', key_field: 'b_key', deletes: [{ b_key: buildMenuI18nKey(menuId) }] },
|
||||
// 模块绑定关系随菜单删除(b_menu_id 为复合主键首列,按它批量删)
|
||||
{ table: 's_menu_module', key_field: 'b_menu_id', deletes: [{ b_menu_id: menuId }] },
|
||||
]
|
||||
reqs.push({ table: 's_menu', key_field: 'b_id', deletes: [{ b_id: menuId }] })
|
||||
await saveObjectApi(reqs)
|
||||
@@ -444,6 +427,15 @@ async function loadMenuConfig(menuId) {
|
||||
i18n_values.value = i18nInitValues
|
||||
i18n_values_org.value = { ...i18nInitValues }
|
||||
i18n_org.value = i18n_org_rows
|
||||
// 模块绑定(s_menu_module):行身份 (b_menu_id, b_module_id),只登记 data / virtual 模块
|
||||
const moduleRes = await loadDataApi(
|
||||
's_menu_module',
|
||||
`b_menu_id = ${sqlText(id)}`,
|
||||
'b_xh ASC, b_module_id ASC',
|
||||
)
|
||||
if (String(menuSelectedId.value) !== id) return
|
||||
menuModules_org.value = (moduleRes.data || []).map((row) => String(row.b_module_id))
|
||||
menuModules.value = [...menuModules_org.value]
|
||||
loadedMenuId.value = id
|
||||
} catch (e) {
|
||||
if (String(menuSelectedId.value) === String(menuId)) {
|
||||
@@ -466,6 +458,8 @@ watch(menuSelectedId, (id) => {
|
||||
configLoading.value = false
|
||||
loadedMenuId.value = null
|
||||
maindata.value = null
|
||||
menuModules.value = []
|
||||
menuModules_org.value = []
|
||||
return
|
||||
}
|
||||
// 未落库的新节点:临时 id 在库里查不到行,不能走 loadMenuConfig
|
||||
@@ -484,6 +478,8 @@ watch(menuSelectedId, (id) => {
|
||||
i18n_values.value = { [defaultLocale.value]: node.b_name || '' }
|
||||
i18n_values_org.value = { ...i18n_values.value }
|
||||
i18n_org.value = []
|
||||
menuModules.value = []
|
||||
menuModules_org.value = []
|
||||
loadedMenuId.value = String(id)
|
||||
return
|
||||
}
|
||||
@@ -507,12 +503,14 @@ function hasUnsavedChanges() {
|
||||
)
|
||||
)
|
||||
return true
|
||||
// 模块绑定变化视为未保存(顺序无关)
|
||||
if (sortedKey(menuModules.value) !== sortedKey(menuModules_org.value)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的「有未保存改动就先确认」闸门:无改动直接执行 action,有改动弹窗等待用户选择。
|
||||
* 所有会覆盖当前草稿的操作(切换节点 / 新建菜单 / 刷新树)都必须经此,
|
||||
* 所有会覆盖当前草稿的操作(切换节点 / 新建菜单)都必须经此,
|
||||
* 否则会出现「某条路径静默丢弃编辑」的不一致。
|
||||
*/
|
||||
function confirmIfUnsaved(action) {
|
||||
@@ -594,9 +592,9 @@ async function beforeSave() {
|
||||
)
|
||||
return false
|
||||
}
|
||||
// 绑定模块非必填:页面菜单可不绑定业务模块,留空即保存为空
|
||||
// 路由 / 模块按菜单类型收敛:目录、外链不绑模块(清空草稿,保存时按 diff 删除关系行)
|
||||
if (!typeRule.route && menuData.b_route) menuData.b_route = null
|
||||
if (!typeRule.module && menuData.b_module_id) menuData.b_module_id = null
|
||||
if (!typeRule.module && menuModules.value.length) menuModules.value = []
|
||||
|
||||
// 字符串业务键:菜单 b_id 即编码,创建后不可修改;保存时统一去除首尾空格
|
||||
const menuCode = String(menuData.b_id || '').trim()
|
||||
@@ -681,6 +679,32 @@ async function buildSaveData() {
|
||||
reqs.push(i18nReq)
|
||||
}
|
||||
|
||||
// 模块绑定(s_menu_module):行身份 (b_menu_id, b_module_id),只提交增删行;
|
||||
// b_xh 记录页面内模块顺序(按当前勾选顺序 10 起步),顺序变化会产生 update,可接受。
|
||||
const toModuleRows = (list) =>
|
||||
list.map((moduleId, index) => ({
|
||||
b_menu_id: menuId,
|
||||
b_module_id: String(moduleId),
|
||||
b_xh: (index + 1) * 10,
|
||||
b_canuse: 1,
|
||||
}))
|
||||
const moduleChanges = diffRows(
|
||||
toModuleRows(menuModules.value),
|
||||
toModuleRows(menuModules_org.value),
|
||||
{ keyFields: ['b_menu_id', 'b_module_id'] },
|
||||
)
|
||||
if (
|
||||
moduleChanges.inserts.length ||
|
||||
moduleChanges.updates.length ||
|
||||
moduleChanges.deletes.length
|
||||
) {
|
||||
const moduleReq = { table: 's_menu_module', key_field: 'b_menu_id,b_module_id' }
|
||||
if (moduleChanges.inserts.length) moduleReq.inserts = moduleChanges.inserts
|
||||
if (moduleChanges.updates.length) moduleReq.updates = moduleChanges.updates
|
||||
if (moduleChanges.deletes.length) moduleReq.deletes = moduleChanges.deletes
|
||||
reqs.push(moduleReq)
|
||||
}
|
||||
|
||||
return reqs.length ? reqs : null
|
||||
}
|
||||
|
||||
@@ -787,22 +811,24 @@ async function onToggleEnable({ node, checked }) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 加载模块列表(供页面类型下拉选择) ================= */
|
||||
const moduleOptions = ref([])
|
||||
/* ================= 加载模块清单(绑定模块选择器) ================= */
|
||||
/** 模块平铺行(s_module 启用行;已绑模块名称展示与类型过滤用) */
|
||||
const moduleRows = ref([])
|
||||
/** 模块树(module 分类 + data / virtual 数据模块;绑定弹层勾选用) */
|
||||
const moduleTree = ref([])
|
||||
/** 模块编码 → 模块类型(弹层确定时过滤掉 module 分类节点) */
|
||||
const moduleTypes = computed(() =>
|
||||
Object.fromEntries(moduleRows.value.map((m) => [String(m.b_id), String(m.b_module_type)])),
|
||||
)
|
||||
let moduleOptionsLoaded = false
|
||||
|
||||
async function loadModuleOptions() {
|
||||
if (moduleOptionsLoaded) return
|
||||
try {
|
||||
const res = await loadDataApi(
|
||||
's_module',
|
||||
"b_canuse = 1 AND b_module_type IN ('data', 'virtual')",
|
||||
'b_xh ASC, b_id ASC',
|
||||
)
|
||||
moduleOptions.value = (res.data || []).map((m) => ({
|
||||
label: `[${m.b_id}] ${m.b_name}`,
|
||||
value: String(m.b_id),
|
||||
}))
|
||||
const res = await loadDataApi('s_module', 'b_canuse = 1', 'b_path ASC, b_xh ASC, b_id ASC')
|
||||
const rows = res.data || []
|
||||
moduleRows.value = rows
|
||||
moduleTree.value = sortTree(buildTree(rows.map((row) => ({ ...row }))))
|
||||
moduleOptionsLoaded = true
|
||||
} catch (e) {
|
||||
// Silently fail; modules will be empty
|
||||
@@ -860,7 +886,6 @@ loadModuleOptions()
|
||||
@add="onMenuAdd"
|
||||
@delete="(id) => void deleteMenu(id)"
|
||||
@drop="(payload) => void onMenuDrop(payload)"
|
||||
@refresh="requestReloadTree"
|
||||
@toggle-enable="(payload) => void onToggleEnable(payload)"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
@@ -894,10 +919,12 @@ loadModuleOptions()
|
||||
<MenuBasicPanel
|
||||
ref="basicPanelRef"
|
||||
v-model="maindata"
|
||||
v-model:modules="menuModules"
|
||||
:type-locked="menuTypeLocked"
|
||||
:module-options="moduleOptions"
|
||||
:module-rows="moduleRows"
|
||||
:loading="busy"
|
||||
@open-i18n="i18nModalVisible = true"
|
||||
@open-module-picker="modulePickerOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
@@ -941,6 +968,16 @@ loadModuleOptions()
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 绑定模块选择(模块树多选:勾分类节点自动级联其下全部模块) -->
|
||||
<ModulePickerModal
|
||||
:open="modulePickerOpen"
|
||||
:tree-data="moduleTree"
|
||||
:checked-keys="menuModules"
|
||||
:module-types="moduleTypes"
|
||||
@update:open="(v) => (modulePickerOpen = v)"
|
||||
@confirm="menuModules = $event"
|
||||
/>
|
||||
|
||||
<!-- 多语言名称编辑(通用组件,与模块配置共用) -->
|
||||
<I18nNameModal
|
||||
:open="i18nModalVisible"
|
||||
|
||||
@@ -45,7 +45,6 @@ const MENU_ROW_FIELDS = [
|
||||
'b_i18n',
|
||||
'b_menu_type',
|
||||
'b_route',
|
||||
'b_module_id',
|
||||
'b_icon',
|
||||
'b_xh',
|
||||
'b_canuse',
|
||||
|
||||
@@ -170,7 +170,7 @@ function batchDelete() {
|
||||
}
|
||||
|
||||
/* ================= 字段库交互 ================= */
|
||||
// 已加入的字段(供 FieldLibrary ✓ 标记与点击定位)
|
||||
// 已加入的字段(供 FieldLibrary ✓ 标记与点击移除)
|
||||
function findRowByFieldId(fieldKey) {
|
||||
return queryConfig.value.find((row) => String(row.b_field) === String(fieldKey))
|
||||
}
|
||||
@@ -181,14 +181,13 @@ const selectedFieldId = computed(() => {
|
||||
return row?.b_field != null ? String(row.b_field) : null
|
||||
})
|
||||
|
||||
// 字段库点击:已加入 → 定位表格行;未加入 → 生成默认查询行加入
|
||||
// 字段库点击:未加入 → 生成默认查询行加入;已加入 → 再次点击即移除(加入 / 移除切换)
|
||||
function onFieldSelect(field) {
|
||||
if (!field || props.loading) return
|
||||
const fieldKey = String(field.b_field)
|
||||
const existing = findRowByFieldId(fieldKey)
|
||||
if (existing) {
|
||||
activeKey.value = String(existing._rowKey)
|
||||
nextTick(() => tableRef.value?.scrollToRow?.(String(existing._rowKey)))
|
||||
removeRow(existing)
|
||||
return
|
||||
}
|
||||
const row = {
|
||||
@@ -244,14 +243,16 @@ const columns = shallowRef([
|
||||
:selected-field-id="selectedFieldId"
|
||||
:loading="loading"
|
||||
drag-mode="native"
|
||||
item-title="点击加入查询;已加入的点击定位;从右侧表格删除行即移除"
|
||||
added-title="已加入查询"
|
||||
item-title="点击加入查询;已加入的再次点击移除"
|
||||
added-title="已加入查询,点击移除"
|
||||
@select="onFieldSelect"
|
||||
/>
|
||||
|
||||
<div class="mm-qdesign__main">
|
||||
<div class="mm-qdesign__toolbar">
|
||||
<Button type="primary" :disabled="loading" @click="addRow"><Plus :size="14" /> 新增查询条件</Button>
|
||||
<Button type="primary" :disabled="loading" @click="addRow"
|
||||
><Plus :size="14" /> 新增查询条件</Button
|
||||
>
|
||||
<Button type="danger" :disabled="loading || !selectedRowKeys.length" @click="batchDelete">
|
||||
<Trash2 :size="14" /> 批量删除
|
||||
</Button>
|
||||
|
||||
+5
-6
@@ -688,12 +688,11 @@ function scrollToRowCard(rowId) {
|
||||
|
||||
function selectField(field) {
|
||||
if (!field || props.loading) return
|
||||
// 已加入表单的字段:点击定位选中画布卡片(移除走 Delete / 卡片删除按钮,
|
||||
// 与拖拽已加入字段会被拒绝的语义保持一致,避免误删)
|
||||
// 已加入表单的字段:再次点击即移除(字段库点击 = 加入 / 移除切换,
|
||||
// 与卡片删除按钮 / Delete 键同一移除通道,自动回收栅格)
|
||||
const existing = localEdit.value.find((r) => String(r.b_field) === String(field.b_field))
|
||||
if (existing) {
|
||||
selectRow(existing)
|
||||
nextTick(() => scrollToRowCard(String(existing._rowKey)))
|
||||
removeRow(existing)
|
||||
return
|
||||
}
|
||||
// 未加入的字段:插入到表单并定位到画布卡片
|
||||
@@ -1390,8 +1389,8 @@ onBeforeUnmount(() => {
|
||||
onStart: onLibDragStart,
|
||||
onEnd: onLibDragEnd,
|
||||
}"
|
||||
item-title="点击加入表单;已加入的点击定位到画布卡片"
|
||||
added-title="已加入表单"
|
||||
item-title="点击加入表单;已加入的再次点击移除"
|
||||
added-title="已加入表单,点击移除"
|
||||
@select="selectField"
|
||||
/>
|
||||
|
||||
|
||||
+43
-8
@@ -345,15 +345,44 @@ const dataColumns = computed(() => {
|
||||
]
|
||||
if (isSubtable.value) {
|
||||
cols.push(
|
||||
{ dataIndex: 'b_visible', title: '显示', width: 70, align: 'center', editor: { type: 'checkbox' } },
|
||||
{ dataIndex: 'b_required', title: '必填', width: 70, align: 'center', editor: { type: 'checkbox' } },
|
||||
{ dataIndex: 'b_readonly', title: '只读', width: 70, align: 'center', editor: { type: 'checkbox' } },
|
||||
{ dataIndex: 'b_disabled', title: '禁用', width: 70, align: 'center', editor: { type: 'checkbox' } },
|
||||
{
|
||||
dataIndex: 'b_visible',
|
||||
title: '显示',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_required',
|
||||
title: '必填',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_readonly',
|
||||
title: '只读',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_disabled',
|
||||
title: '禁用',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
{ dataIndex: 'b_width', title: '列宽(px)', width: 110, editor: {} },
|
||||
)
|
||||
} else {
|
||||
cols.push(
|
||||
{ dataIndex: 'b_width', title: '列宽', width: 90, editor: { type: 'input', inputType: 'number' } },
|
||||
{
|
||||
dataIndex: 'b_width',
|
||||
title: '列宽',
|
||||
width: 90,
|
||||
editor: { type: 'input', inputType: 'number' },
|
||||
},
|
||||
{ dataIndex: 'b_i18n', title: '标题多语言', width: 220 },
|
||||
)
|
||||
}
|
||||
@@ -380,8 +409,12 @@ const treeFeatures = computed(() => ({
|
||||
:selected-field-id="selectedRow?.b_field"
|
||||
:loading="loading"
|
||||
drag-mode="native"
|
||||
:item-title="isSubtable ? '点击加入表格;已加入的点击定位;拖拽到下方分组树可加入对应分组' : '点击加入列表;已加入的点击定位;拖拽到下方分组树可加入对应分组'"
|
||||
:added-title="isSubtable ? '已加入表格' : '已加入列表'"
|
||||
:item-title="
|
||||
isSubtable
|
||||
? '点击加入表格;已加入的再次点击移除;拖拽到下方分组树可加入对应分组'
|
||||
: '点击加入列表;已加入的再次点击移除;拖拽到下方分组树可加入对应分组'
|
||||
"
|
||||
:added-title="isSubtable ? '已加入表格,点击移除' : '已加入列表,点击移除'"
|
||||
@select="selectField"
|
||||
/>
|
||||
|
||||
@@ -426,7 +459,9 @@ const treeFeatures = computed(() => ({
|
||||
<Trash2 :size="14" /> 删除
|
||||
</Button>
|
||||
<Popover trigger="hover" placement="bottom-end" :arrow="false" :offset="8" unwrapped>
|
||||
<template #trigger><span class="mm-edit__help-icon" aria-hidden="true">?</span></template>
|
||||
<template #trigger
|
||||
><span class="mm-edit__help-icon" aria-hidden="true">?</span></template
|
||||
>
|
||||
<template #content>
|
||||
<div class="mm-edit__help-title">操作说明</div>
|
||||
<ul class="mm-edit__help-list">
|
||||
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 字段库:展示模块全部字段(含未加入编辑器的字段),按名称 a-z 排序;
|
||||
* 搜索时按名称/编码过滤,命中才显示。点击加入/定位,拖出加入分组。
|
||||
* 字段库:展示模块全部字段(含未加入编辑器的字段),按字段定义顺序(b_xh)排列;
|
||||
* 搜索时按名称/编码过滤,命中才显示。点击加入 / 再次点击移除,拖出加入分组。
|
||||
*
|
||||
* 表格 / 表单两个编辑器共用(文案与选中态经 props 参数化):
|
||||
* - dragMode="native" —— 原生 HTML5 拖拽(表格面板:落点是下方分组结构树)
|
||||
@@ -30,15 +30,15 @@ const props = defineProps({
|
||||
*/
|
||||
sortable: { type: Object, default: null },
|
||||
placeholder: { type: String, default: '搜索字段名 / 编码' },
|
||||
itemTitle: { type: String, default: '点击加入;已加入的点击定位' },
|
||||
addedTitle: { type: String, default: '已加入' },
|
||||
itemTitle: { type: String, default: '点击加入;已加入的再次点击移除' },
|
||||
addedTitle: { type: String, default: '已加入,点击移除' },
|
||||
emptyText: { type: String, default: '无匹配字段' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
const libSearch = ref('')
|
||||
// 字段库:展示模块的全部字段(含未加入编辑器的字段),按名称 a-z 排序;
|
||||
// 字段库:展示模块的全部字段(含未加入编辑器的字段),按字段定义顺序(b_xh)排列;
|
||||
// 搜索时按名称/编码过滤,命中才显示
|
||||
const libraryFields = computed(() => filterLibraryFields(props.fields, libSearch.value))
|
||||
|
||||
|
||||
+10
-7
@@ -326,7 +326,7 @@ export function toggleFlag(row, key) {
|
||||
row[key] = Number(row[key]) === 1 ? 0 : 1
|
||||
}
|
||||
|
||||
/** 按关键字过滤字段(名称/编码),按名称排序 */
|
||||
/** 按关键字过滤字段(名称/编码),按字段定义顺序(b_xh,同序号回退 b_field)排列 */
|
||||
export function filterLibraryFields(fields, keyword) {
|
||||
const kw = String(keyword || '')
|
||||
.trim()
|
||||
@@ -342,9 +342,12 @@ export function filterLibraryFields(fields, keyword) {
|
||||
.toLowerCase()
|
||||
.includes(kw),
|
||||
)
|
||||
.toSorted((a, b) =>
|
||||
String(a.b_name || a.b_field || '').localeCompare(String(b.b_name || b.b_field || ''), 'zh'),
|
||||
)
|
||||
.toSorted((a, b) => {
|
||||
// 与字段定义(s_field)的排序一致:b_xh 升序,序号相同再按字段编码
|
||||
const xhDiff = (Number(a.b_xh) || 0) - (Number(b.b_xh) || 0)
|
||||
if (xhDiff !== 0) return xhDiff
|
||||
return String(a.b_field || '').localeCompare(String(b.b_field || ''))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -785,9 +788,9 @@ export function useStructureEditing({
|
||||
if (!field || loading()) return
|
||||
const existing = findRowByFieldId(String(field.b_field))
|
||||
if (existing) {
|
||||
selectRow(existing)
|
||||
nextTick(() => scrollToRowCell(String(existing.b_field)))
|
||||
return existing
|
||||
// 已加入的字段:再次点击即移除(字段库点击 = 加入 / 移除切换)
|
||||
removeRow(existing)
|
||||
return null
|
||||
}
|
||||
const band = selectedBand.value
|
||||
const gid = band ? String(band.gid) : null
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Modal.confirm 命令式确认对话框:开关时机与 onOk / onCancel 语义。
|
||||
* 覆盖「确定后关闭」「onOk reject 保持打开」「取消」「点遮罩静默关闭(不触发 onCancel)」
|
||||
* 「destroy 立即卸载」几种路径。
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { confirm } from '@/components/ui/modal/modal-confirm.js'
|
||||
|
||||
/** Modal 用 Teleport 挂到 body,只能从 document 上找面板 */
|
||||
const panel = () => document.querySelector('.fms-modal')
|
||||
/** footer 按钮顺序:[取消, 确定] */
|
||||
const footerButtons = () => [...document.querySelectorAll('.fms-modal-footer button')]
|
||||
|
||||
/**
|
||||
* 关窗要等 Transition 的两帧 rAF 与 after-leave,时序不确定:
|
||||
* 轮询到条件成立或超时为止。
|
||||
*/
|
||||
async function waitFor(predicate, timeout = 500) {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
if (predicate()) return true
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let active = null
|
||||
|
||||
afterEach(async () => {
|
||||
active?.destroy()
|
||||
active = null
|
||||
await waitFor(() => !panel())
|
||||
})
|
||||
|
||||
describe('Modal.confirm', () => {
|
||||
it('确定:onOk resolve 后对话框关闭', async () => {
|
||||
const onOk = vi.fn()
|
||||
active = confirm({ title: '确认', content: '删除后不可恢复', onOk })
|
||||
|
||||
expect(panel()).toBeTruthy()
|
||||
expect(document.body.textContent).toContain('删除后不可恢复')
|
||||
|
||||
footerButtons().at(-1).click()
|
||||
|
||||
expect(await waitFor(() => !panel())).toBe(true)
|
||||
expect(onOk).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('确定:onOk reject 时保持打开,等待重试或取消', async () => {
|
||||
const onOk = vi.fn(() => Promise.reject(new Error('删除失败')))
|
||||
active = confirm({ title: '确认', content: '内容', onOk })
|
||||
|
||||
footerButtons().at(-1).click()
|
||||
// 等一段确定的时间,确认对话框没有进入关闭流程
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
await flushPromises()
|
||||
|
||||
expect(onOk).toHaveBeenCalledTimes(1)
|
||||
expect(panel()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('取消:onCancel 被调用并关闭', async () => {
|
||||
const onCancel = vi.fn()
|
||||
active = confirm({ title: '确认', content: '内容', onCancel })
|
||||
|
||||
footerButtons()[0].click()
|
||||
|
||||
expect(await waitFor(() => !panel())).toBe(true)
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('点遮罩空白:关闭对话框但不触发 onCancel(静默关闭)', async () => {
|
||||
const onCancel = vi.fn()
|
||||
active = confirm({ title: '确认', content: '内容', onCancel })
|
||||
|
||||
const wrap = document.querySelector('.fms-modal-wrap')
|
||||
expect(wrap).toBeTruthy()
|
||||
// 点击 wrap 自身(面板外空白区域):target === currentTarget 才命中遮罩关闭分支。
|
||||
// 遮罩关闭走 Modal.requestClose → emit('cancel'),但 confirm 未绑 @cancel,
|
||||
// 故只由 v-model:open 关窗,不应触发调用方 onCancel。
|
||||
wrap.click()
|
||||
|
||||
expect(await waitFor(() => !panel())).toBe(true)
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('okButtonProps.danger 映射为危险态确定按钮', () => {
|
||||
active = confirm({ title: '确认', content: '内容', okButtonProps: { danger: true } })
|
||||
|
||||
expect(footerButtons().at(-1).className).toContain('button-danger')
|
||||
})
|
||||
|
||||
it('destroy 立即卸载容器与遮罩', async () => {
|
||||
active = confirm({ title: '确认', content: '内容' })
|
||||
expect(panel()).toBeTruthy()
|
||||
|
||||
active.destroy()
|
||||
active = null
|
||||
await waitFor(() => !panel())
|
||||
|
||||
expect(panel()).toBeFalsy()
|
||||
expect(document.querySelector('.fms-modal-wrap')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Space 间距组件:容器方向 / 间距 gap / 对齐 / 换行 / 分隔符 / 空子节点。
|
||||
* 只断言渲染出的 class、内联 gap 与包裹结构,不涉及真实布局尺寸。
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Space from '@/components/ui/space/space.vue'
|
||||
|
||||
/** 便捷挂载:默认塞两个子元素 */
|
||||
function mountSpace(props = {}, slots = {}) {
|
||||
return mount(Space, {
|
||||
props,
|
||||
slots: { default: '<span>A</span><span>B</span>', ...slots },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Space 组件', () => {
|
||||
it('默认水平:容器带方向类,每个子元素包一层 .space-item', () => {
|
||||
const wrapper = mountSpace()
|
||||
expect(wrapper.classes()).toContain('space')
|
||||
expect(wrapper.classes()).toContain('space-horizontal')
|
||||
expect(wrapper.findAll('.space-item')).toHaveLength(2)
|
||||
expect(wrapper.attributes('data-slot')).toBe('space')
|
||||
})
|
||||
|
||||
it('水平默认对齐 center;显式 align 覆盖', () => {
|
||||
expect(mountSpace().classes()).toContain('space-align-center')
|
||||
expect(mountSpace({ align: 'start' }).classes()).toContain('space-align-start')
|
||||
expect(mountSpace({ align: 'baseline' }).classes()).toContain('space-align-baseline')
|
||||
})
|
||||
|
||||
it('size 预设映射为对应 px 的 gap', () => {
|
||||
const style = mountSpace({ size: 'middle' }).attributes('style')
|
||||
expect(style).toContain('column-gap: 16px')
|
||||
expect(style).toContain('row-gap: 16px')
|
||||
})
|
||||
|
||||
it('size 数字按 px 处理', () => {
|
||||
const style = mountSpace({ size: 20 }).attributes('style')
|
||||
expect(style).toContain('column-gap: 20px')
|
||||
expect(style).toContain('row-gap: 20px')
|
||||
})
|
||||
|
||||
it('size 数组 [水平, 垂直] 分别设 column-gap / row-gap', () => {
|
||||
const style = mountSpace({ size: [12, 24] }).attributes('style')
|
||||
expect(style).toContain('column-gap: 12px')
|
||||
expect(style).toContain('row-gap: 24px')
|
||||
})
|
||||
|
||||
it('vertical 简写切换为纵向且不设默认对齐', () => {
|
||||
const wrapper = mountSpace({ vertical: true })
|
||||
expect(wrapper.classes()).toContain('space-vertical')
|
||||
expect(wrapper.classes()).not.toContain('space-align-center')
|
||||
})
|
||||
|
||||
it('orientation 优先于 vertical', () => {
|
||||
const wrapper = mountSpace({ orientation: 'horizontal', vertical: true })
|
||||
expect(wrapper.classes()).toContain('space-horizontal')
|
||||
expect(wrapper.classes()).not.toContain('space-vertical')
|
||||
})
|
||||
|
||||
it('wrap 仅水平方向生效', () => {
|
||||
expect(mountSpace({ wrap: true }).classes()).toContain('space-wrap')
|
||||
expect(mountSpace({ wrap: true, vertical: true }).classes()).not.toContain('space-wrap')
|
||||
})
|
||||
|
||||
it('separator 插槽:分隔符数量 = 子元素数 - 1,末尾不加', () => {
|
||||
const wrapper = mountSpace(
|
||||
{},
|
||||
{ default: '<span>A</span><span>B</span><span>C</span>', separator: '|' },
|
||||
)
|
||||
expect(wrapper.findAll('.space-item')).toHaveLength(3)
|
||||
expect(wrapper.findAll('.space-separator')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('|')
|
||||
})
|
||||
|
||||
it('无 separator 插槽时不渲染分隔符', () => {
|
||||
expect(mountSpace().findAll('.space-separator')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('无有效子元素时不渲染容器', () => {
|
||||
const wrapper = mount(Space, { slots: { default: '' } })
|
||||
expect(wrapper.find('.space').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('过滤子元素之间的空白文本节点', () => {
|
||||
const wrapper = mount(Space, {
|
||||
slots: { default: '<span>A</span>\n <span>B</span>\n' },
|
||||
})
|
||||
expect(wrapper.findAll('.space-item')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,12 @@ import FmsQueryControl from '@/components/fms-module-list/FmsQueryControl.vue'
|
||||
import FmsQueryToolbar from '@/components/fms-module-list/FmsQueryToolbar.vue'
|
||||
import FmsAdvancedQueryPanel from '@/components/fms-module-list/FmsAdvancedQueryPanel.vue'
|
||||
|
||||
/** 构造一个查询条件项(字段 + 配置),供工具栏布局用例复用 */
|
||||
const queryItem = (field) => ({
|
||||
field: { b_field: field, b_type: 'input' },
|
||||
config: { b_operator: 'like', b_component: 'input' },
|
||||
})
|
||||
|
||||
describe('fms-module-list components', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
@@ -27,6 +33,33 @@ describe('fms-module-list components', () => {
|
||||
expect(wrapper.html()).not.toContain('<a-')
|
||||
})
|
||||
|
||||
it('查询条件网格列数按条件数收缩(只有一个条件时不占 4 列宽)', () => {
|
||||
const one = mount(FmsQueryToolbar, {
|
||||
props: {
|
||||
queryItems: [queryItem('b_name')],
|
||||
queryLayout: { area: { b_name: 'quick' }, order: { b_name: 10 } },
|
||||
},
|
||||
})
|
||||
expect(one.find('.query-grid').element.style.gridTemplateColumns).toBe(
|
||||
'repeat(1, minmax(0, 200px))',
|
||||
)
|
||||
one.unmount()
|
||||
|
||||
const four = mount(FmsQueryToolbar, {
|
||||
props: {
|
||||
queryItems: [queryItem('a'), queryItem('b'), queryItem('c'), queryItem('d')],
|
||||
queryLayout: {
|
||||
area: { a: 'quick', b: 'quick', c: 'quick', d: 'quick' },
|
||||
order: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(four.find('.query-grid').element.style.gridTemplateColumns).toBe(
|
||||
'repeat(4, minmax(0, 200px))',
|
||||
)
|
||||
four.unmount()
|
||||
})
|
||||
|
||||
it('高级查询行间连接符表达行与行的连接:≥2 项才出现,点击切换后一项的 join', async () => {
|
||||
const state = {
|
||||
nodes: [
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
/**
|
||||
* 往来单位分类树页面级纯函数(views/base/othercompany/utils.js)。
|
||||
* 往来单位分类树页面级纯函数。
|
||||
*
|
||||
* 页面单文件自包含后(分类树逻辑内联在 views/base/othercompany/index.vue),
|
||||
* 纯函数不再单独导出,测试通过挂载页面组件访问 <script setup> 的内部绑定。
|
||||
*
|
||||
* 重点是 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'
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import Othercompany from '@/views/base/othercompany/index.vue'
|
||||
import { mountPage } from '../helpers/app.js'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
|
||||
pageDataApi: vi.fn(() =>
|
||||
Promise.resolve({ data: { rows: [], total: 0 }, code: 0, success: true }),
|
||||
),
|
||||
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
|
||||
}))
|
||||
|
||||
function category(over = {}) {
|
||||
return {
|
||||
@@ -30,6 +33,15 @@ function category(over = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面实例(分类树纯函数与状态都在它的 setup 里) */
|
||||
let vm
|
||||
|
||||
beforeEach(async () => {
|
||||
const { wrapper } = await mountPage(Othercompany)
|
||||
await flushPromises()
|
||||
vm = wrapper.vm
|
||||
})
|
||||
|
||||
describe('othercompany 分类树工具:buildCategoryTree', () => {
|
||||
it('按 b_parent_id 建树并按 b_xh 排序', () => {
|
||||
const rows = [
|
||||
@@ -38,7 +50,7 @@ describe('othercompany 分类树工具:buildCategoryTree', () => {
|
||||
category({ b_id: 'a1', b_parent_id: 'a', b_depth: 1, b_path: '/a/a1/', b_name: 'A1' }),
|
||||
]
|
||||
|
||||
const tree = buildCategoryTree(rows)
|
||||
const tree = vm.buildCategoryTree(rows)
|
||||
|
||||
expect(tree.map((node) => node.b_id)).toEqual(['a', 'b'])
|
||||
expect(tree[0].children.map((node) => node.b_id)).toEqual(['a1'])
|
||||
@@ -50,11 +62,11 @@ describe('othercompany 分类树工具:buildCategoryTree', () => {
|
||||
category({ b_id: 'a1', b_parent_id: 'a', b_depth: 1, b_path: '/a/a1/' }),
|
||||
]
|
||||
|
||||
buildCategoryTree(rows)
|
||||
vm.buildCategoryTree(rows)
|
||||
// buildTree 的 children 挂在浅拷贝上,原始行保持干净
|
||||
expect(rows[0].children).toBeUndefined()
|
||||
|
||||
const tree = buildCategoryTree(rows)
|
||||
const tree = vm.buildCategoryTree(rows)
|
||||
expect(tree[0].children).toHaveLength(1)
|
||||
expect(tree[0].children[0].b_id).toBe('a1')
|
||||
})
|
||||
@@ -63,13 +75,13 @@ describe('othercompany 分类树工具:buildCategoryTree', () => {
|
||||
describe('othercompany 分类树工具:位置派生', () => {
|
||||
it('根节点的 b_depth 为 0、b_path 为 /编码/', () => {
|
||||
const row = { b_id: 'a' }
|
||||
applyCategoryPosition(row, null)
|
||||
vm.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/' })
|
||||
vm.applyCategoryPosition(row, { b_depth: 2, b_path: '/a/b/' })
|
||||
expect(row).toMatchObject({ b_depth: 3, b_path: '/a/b/a1/' })
|
||||
})
|
||||
})
|
||||
@@ -82,10 +94,10 @@ describe('othercompany 分类树工具:排序号与子分类判定', () => {
|
||||
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)
|
||||
expect(vm.nextSiblingXh(rows, null)).toBe(40)
|
||||
expect(vm.nextSiblingXh(rows, 'a')).toBe(100)
|
||||
expect(vm.nextSiblingXh(rows, 'not-exist')).toBe(10)
|
||||
expect(vm.nextSiblingXh([], null)).toBe(10)
|
||||
})
|
||||
|
||||
it('hasChildCategory 只看直接子分类', () => {
|
||||
@@ -95,9 +107,9 @@ describe('othercompany 分类树工具:排序号与子分类判定', () => {
|
||||
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)
|
||||
expect(vm.hasChildCategory(rows, 'a')).toBe(true)
|
||||
expect(vm.hasChildCategory(rows, 'a1')).toBe(true)
|
||||
expect(vm.hasChildCategory(rows, 'a1x')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -107,7 +119,7 @@ describe('othercompany 分类树工具:重排与重建位置', () => {
|
||||
const b = { b_id: 'b', b_xh: 10 }
|
||||
const updated = []
|
||||
|
||||
renumberSiblings([a, b], updated)
|
||||
vm.renumberSiblings([a, b], updated)
|
||||
|
||||
expect([a.b_xh, b.b_xh]).toEqual([10, 20])
|
||||
expect(updated).toEqual([
|
||||
@@ -127,7 +139,7 @@ describe('othercompany 分类树工具:重排与重建位置', () => {
|
||||
]
|
||||
const updated = []
|
||||
|
||||
rebuildTreePositions(tree, updated)
|
||||
vm.rebuildTreePositions(tree, updated)
|
||||
|
||||
expect(tree[0].children[0]).toMatchObject({ b_depth: 1, b_path: '/a/a1/' })
|
||||
// 根节点本来就是 0 / '/a/',不该产生一条无意义的 update
|
||||
@@ -136,9 +148,9 @@ describe('othercompany 分类树工具:重排与重建位置', () => {
|
||||
|
||||
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 })
|
||||
vm.addRowUpdate(updated, { b_id: 'a', b_xh: 20 })
|
||||
vm.addRowUpdate(updated, { b_id: 'a', b_parent_id: 'p' })
|
||||
vm.addRowUpdate(updated, { b_id: 'b', b_xh: 10 })
|
||||
|
||||
expect(updated).toEqual([
|
||||
{ b_id: 'a', b_xh: 20, b_parent_id: 'p' },
|
||||
@@ -149,16 +161,16 @@ describe('othercompany 分类树工具:重排与重建位置', () => {
|
||||
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(vm.findNodePosition(tree, 'a')).toMatchObject({ index: 0 })
|
||||
const found = vm.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)
|
||||
expect(vm.findNodePosition(tree, 'not-exist')).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('othercompany 分类树工具:虚拟根键', () => {
|
||||
it('与 FmsTree 的 rootKey 约定保持一致', () => {
|
||||
expect(ALL_CATEGORY_KEY).toBe('__fms_tree_root__')
|
||||
expect(vm.ALL_CATEGORY_KEY).toBe('__fms_tree_root__')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 回归用例:菜单管理 - 绑定模块(s_menu_module)的加载与保存。
|
||||
*
|
||||
* s_menu 表没有模块列:绑定关系存 s_menu_module,行身份 = (b_menu_id, b_module_id),
|
||||
* 只登记 data / virtual 模块(module 只是模块树上的分类,不参与权限)。
|
||||
* 保存时与加载基线做行级 diff,只提交增删行;目录 / 外链菜单不绑定模块。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import MenuManagement from '@/views/module/menu-management/index.vue'
|
||||
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 directoryMenu = {
|
||||
b_id: 'base',
|
||||
b_parent_id: null,
|
||||
b_depth: 0,
|
||||
b_path: '/base/',
|
||||
b_name: '基础资料',
|
||||
b_i18n: 'menu.base',
|
||||
b_menu_type: 'directory',
|
||||
b_route: null,
|
||||
b_icon: null,
|
||||
b_xh: 10,
|
||||
b_canuse: 1,
|
||||
}
|
||||
|
||||
const pageMenu = {
|
||||
b_id: 'otherdata',
|
||||
b_parent_id: 'base',
|
||||
b_depth: 1,
|
||||
b_path: '/base/otherdata/',
|
||||
b_name: '其他数据',
|
||||
b_i18n: 'menu.otherdata',
|
||||
b_menu_type: 'page',
|
||||
b_route: '/base/otherdata',
|
||||
b_icon: null,
|
||||
b_xh: 10,
|
||||
b_canuse: 1,
|
||||
}
|
||||
|
||||
/** 模块清单:base / base_otherdata 为分类节点,b_port / b_money 为数据模块 */
|
||||
const modules = [
|
||||
{
|
||||
b_id: 'base',
|
||||
b_parent_id: null,
|
||||
b_depth: 0,
|
||||
b_path: '/base/',
|
||||
b_module_type: 'module',
|
||||
b_name: '基础资料',
|
||||
b_canuse: 1,
|
||||
b_xh: 10,
|
||||
},
|
||||
{
|
||||
b_id: 'base_otherdata',
|
||||
b_parent_id: 'base',
|
||||
b_depth: 1,
|
||||
b_path: '/base/base_otherdata/',
|
||||
b_module_type: 'module',
|
||||
b_name: '其他数据',
|
||||
b_canuse: 1,
|
||||
b_xh: 30,
|
||||
},
|
||||
{
|
||||
b_id: 'b_port',
|
||||
b_parent_id: 'base_otherdata',
|
||||
b_depth: 2,
|
||||
b_path: '/base/base_otherdata/b_port/',
|
||||
b_module_type: 'data',
|
||||
b_name: '港口',
|
||||
b_canuse: 1,
|
||||
b_xh: 10,
|
||||
},
|
||||
{
|
||||
b_id: 'b_money',
|
||||
b_parent_id: 'base_otherdata',
|
||||
b_depth: 2,
|
||||
b_path: '/base/base_otherdata/b_money/',
|
||||
b_module_type: 'data',
|
||||
b_name: '币种',
|
||||
b_canuse: 1,
|
||||
b_xh: 20,
|
||||
},
|
||||
]
|
||||
|
||||
/** 库中 s_menu_module 的现有行(每个用例前重置) */
|
||||
let boundRows = []
|
||||
|
||||
/** 从查询条件里取 N'xxx' 字面量(mock 按 id 过滤用;'1 = 1' 等条件返回 null) */
|
||||
function literalOf(condition) {
|
||||
return /N'([^']*)'/.exec(String(condition || ''))?.[1] ?? null
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
boundRows = [{ b_menu_id: 'otherdata', b_module_id: 'b_port', b_xh: 10, b_canuse: 1 }]
|
||||
loadDataApi.mockReset()
|
||||
saveObjectApi.mockClear()
|
||||
loadDataApi.mockImplementation((view, condition) => {
|
||||
if (view === 's_menu') {
|
||||
const id = literalOf(condition)
|
||||
const rows = [directoryMenu, pageMenu].filter((row) => !id || row.b_id === id)
|
||||
return Promise.resolve({ data: rows })
|
||||
}
|
||||
if (view === 's_module') return Promise.resolve({ data: modules })
|
||||
if (view === 's_menu_module') {
|
||||
const id = literalOf(condition)
|
||||
return Promise.resolve({
|
||||
data: boundRows.filter((row) => !id || row.b_menu_id === id),
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
})
|
||||
|
||||
/** 取出最近一次保存请求里的指定表请求 */
|
||||
function lastRequestOf(table) {
|
||||
const requests = saveObjectApi.mock.calls.at(-1)[0]
|
||||
return requests.find((request) => request.table === table)
|
||||
}
|
||||
|
||||
describe('菜单管理:绑定模块(s_menu_module)', () => {
|
||||
it('选中菜单时按 b_menu_id 加载绑定模块', async () => {
|
||||
const { wrapper } = await mountPage(MenuManagement)
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.menuSelectedId = 'otherdata'
|
||||
await flushPromises()
|
||||
|
||||
expect(loadDataApi).toHaveBeenCalledWith(
|
||||
's_menu_module',
|
||||
"b_menu_id = N'otherdata'",
|
||||
'b_xh ASC, b_module_id ASC',
|
||||
)
|
||||
expect(wrapper.vm.menuModules).toEqual(['b_port'])
|
||||
})
|
||||
|
||||
it('新增绑定:只提交 s_menu_module 的 insert 行', async () => {
|
||||
const { wrapper } = await mountPage(MenuManagement)
|
||||
await flushPromises()
|
||||
wrapper.vm.menuSelectedId = 'otherdata'
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.menuModules = ['b_port', 'b_money']
|
||||
await wrapper.vm.handleSave()
|
||||
|
||||
const request = lastRequestOf('s_menu_module')
|
||||
expect(request.key_field).toBe('b_menu_id,b_module_id')
|
||||
expect(request.inserts).toEqual([
|
||||
{ b_menu_id: 'otherdata', b_module_id: 'b_money', b_xh: 20, b_canuse: 1 },
|
||||
])
|
||||
expect(request.deletes).toBeUndefined()
|
||||
})
|
||||
|
||||
it('移除绑定:只提交 s_menu_module 的 delete 主键行', async () => {
|
||||
const { wrapper } = await mountPage(MenuManagement)
|
||||
await flushPromises()
|
||||
wrapper.vm.menuSelectedId = 'otherdata'
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.menuModules = []
|
||||
await wrapper.vm.handleSave()
|
||||
|
||||
const request = lastRequestOf('s_menu_module')
|
||||
expect(request.deletes).toEqual([{ b_menu_id: 'otherdata', b_module_id: 'b_port' }])
|
||||
expect(request.inserts).toBeUndefined()
|
||||
})
|
||||
|
||||
it('目录菜单不绑定模块:保存时清空并删除既有关系行', async () => {
|
||||
boundRows = [{ b_menu_id: 'base', b_module_id: 'b_port', b_xh: 10, b_canuse: 1 }]
|
||||
const { wrapper } = await mountPage(MenuManagement)
|
||||
await flushPromises()
|
||||
wrapper.vm.menuSelectedId = 'base'
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.menuModules).toEqual(['b_port'])
|
||||
|
||||
await wrapper.vm.handleSave()
|
||||
|
||||
const request = lastRequestOf('s_menu_module')
|
||||
expect(request.deletes).toEqual([{ b_menu_id: 'base', b_module_id: 'b_port' }])
|
||||
})
|
||||
})
|
||||
@@ -10,9 +10,8 @@
|
||||
* 3) 分类编码 b_id 是主键,修改时只提交名称与排序号,不能改编码。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { flushPromises } 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', () => ({
|
||||
@@ -28,8 +27,14 @@ vi.mock('@/components/ui/message/message-manager.js', () => {
|
||||
return { Message: m, default: m }
|
||||
})
|
||||
|
||||
// Modal.confirm 是命令式对话框:mock 掉后由用例手动触发 onOk,模拟用户点「确定」
|
||||
vi.mock('@/components/ui/modal/modal-confirm.js', () => ({
|
||||
confirm: vi.fn(),
|
||||
}))
|
||||
|
||||
import { loadDataApi, pageDataApi, saveObjectApi } from '@/services/api'
|
||||
import { Message } from '@/components/ui/message/message-manager.js'
|
||||
import { confirm as modalConfirm } from '@/components/ui/modal/modal-confirm.js'
|
||||
|
||||
const ROOT1 = {
|
||||
b_id: 'root1',
|
||||
@@ -70,6 +75,7 @@ beforeEach(() => {
|
||||
Message.success.mockClear()
|
||||
Message.error.mockClear()
|
||||
Message.warning.mockClear()
|
||||
modalConfirm.mockReset()
|
||||
loadDataApi.mockImplementation((view) => {
|
||||
if (view === 'b_othercompany_bmfl')
|
||||
return Promise.resolve({ data: CATEGORIES.map((row) => ({ ...row })) })
|
||||
@@ -157,8 +163,8 @@ describe('往来单位:删除分类的两条守卫', () => {
|
||||
|
||||
expect(Message.warning).toHaveBeenCalledWith('该分类下还有子分类,请先删除子分类')
|
||||
expect(pageDataApi).not.toHaveBeenCalled()
|
||||
expect(modalConfirm).not.toHaveBeenCalled()
|
||||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||||
expect(wrapper.vm.categoryDeleteTarget).toBe(null)
|
||||
})
|
||||
|
||||
it('分类下还有往来单位时拦下,不发删除请求', async () => {
|
||||
@@ -168,18 +174,20 @@ describe('往来单位:删除分类的两条守卫', () => {
|
||||
await wrapper.vm.onCategoryDelete({ b_id: 'child1' })
|
||||
|
||||
expect(Message.warning).toHaveBeenCalledWith('该分类下还有往来单位数据,不能删除')
|
||||
expect(modalConfirm).not.toHaveBeenCalled()
|
||||
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(modalConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.vm.confirmCategoryDelete()
|
||||
// 模拟用户点「删除」
|
||||
await modalConfirm.mock.calls[0][0].onOk()
|
||||
await flushPromises()
|
||||
|
||||
const request = lastRequestOf('b_othercompany_bmfl')
|
||||
@@ -221,36 +229,30 @@ describe('往来单位:拖拽分类', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('往来单位分类树面板:搜索', () => {
|
||||
describe('往来单位分类树:搜索', () => {
|
||||
it('按名称与编码过滤,并把命中的层级展开', async () => {
|
||||
// 面板收到的是已经建好的树(页面的 categoryTree)。这里整树真实渲染:
|
||||
// Input / Button 与保留字同名,shallowMount 打桩会触发 Vue 保留名告警。
|
||||
const treeData = [{ ...ROOT1, children: [{ ...CHILD1 }] }, { ...ROOT2 }]
|
||||
const wrapper = mount(CategoryTreePanel, { props: { treeData } })
|
||||
const wrapper = await mountOthercompany()
|
||||
|
||||
expect(wrapper.vm.filteredTree).toHaveLength(2)
|
||||
expect(wrapper.emitted('update:expandedKeys')).toBeUndefined()
|
||||
// 初始:整树两棵根(root1 带子节点 child1、root2),含子节点的分类保持展开
|
||||
expect(wrapper.vm.filteredCategoryTree.map((node) => node.b_id)).toEqual(['root1', 'root2'])
|
||||
expect(wrapper.vm.expandedCategoryKeys).toEqual(['root1'])
|
||||
|
||||
// 命中子分类的编码:保留其祖先,保证命中节点可见
|
||||
wrapper.vm.keyword = 'child1'
|
||||
wrapper.vm.categoryKeyword = '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.vm.filteredCategoryTree.map((node) => node.b_id)).toEqual(['root1'])
|
||||
expect(wrapper.vm.filteredCategoryTree[0].children.map((node) => node.b_id)).toEqual(['child1'])
|
||||
// 搜索中展开全部,保证命中节点可见
|
||||
expect(wrapper.emitted('update:expandedKeys')?.at(-1)?.[0]).toEqual([
|
||||
'root1',
|
||||
'child1',
|
||||
'root2',
|
||||
])
|
||||
expect(wrapper.vm.expandedCategoryKeys).toEqual(['root1', 'child1', 'root2'])
|
||||
|
||||
// 命中根分类的名称
|
||||
wrapper.vm.keyword = '另一个'
|
||||
wrapper.vm.categoryKeyword = '另一个'
|
||||
await flushPromises()
|
||||
expect(wrapper.vm.filteredTree.map((node) => node.b_id)).toEqual(['root2'])
|
||||
expect(wrapper.vm.filteredCategoryTree.map((node) => node.b_id)).toEqual(['root2'])
|
||||
|
||||
// 清空搜索后收起
|
||||
wrapper.vm.keyword = ''
|
||||
wrapper.vm.categoryKeyword = ''
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('update:expandedKeys')?.at(-1)?.[0]).toEqual([])
|
||||
expect(wrapper.vm.expandedCategoryKeys).toEqual([])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user