20260916172703
This commit is contained in:
1 parent
dce29f250e
commit
de05498552
25 files changed
+1141
-1315
No files matched your search
@@ -87,15 +87,20 @@ export function sqlLiteral(value, field, endOfRange = false) {
|
||||
}
|
||||
|
||||
let normalized = String(value)
|
||||
const isDate = field.b_type === 'date' || field.b_type === 'datetime'
|
||||
if (isDate && value && typeof value.format === 'function') {
|
||||
normalized = value.format('YYYY-MM-DD')
|
||||
} else if (isDate) {
|
||||
normalized = String(value).slice(0, 10)
|
||||
}
|
||||
|
||||
if (field.b_type === 'datetime') {
|
||||
normalized += endOfRange ? ' 23:59:59' : ' 00:00:00'
|
||||
if (field.b_type === 'date' || field.b_type === 'datetime') {
|
||||
const isDateTimeField = field.b_type === 'datetime'
|
||||
// dayjs 对象按真实时刻格式化;字符串沿用自身精度(10 位=到日,更长=带时分秒)
|
||||
const text =
|
||||
value && typeof value.format === 'function'
|
||||
? value.format(isDateTimeField ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD')
|
||||
: String(value)
|
||||
if (isDateTimeField && text.length > 10) {
|
||||
// 值自带时分秒(datetime 控件产出)时按原时刻比较,不夹到当天起 / 当天末
|
||||
normalized = text
|
||||
} else {
|
||||
normalized = text.slice(0, 10)
|
||||
if (isDateTimeField) normalized += endOfRange ? ' 23:59:59' : ' 00:00:00'
|
||||
}
|
||||
}
|
||||
|
||||
return `N'${escapeSqlText(normalized)}'`
|
||||
|
||||
@@ -19,6 +19,11 @@ const emit = defineEmits(['update:modelValue', 'enter'])
|
||||
const isRange = computed(() => isRangeQuery(props.item))
|
||||
const isNumeric = computed(() => isNumericField(props.item))
|
||||
const isDate = computed(() => isDateQuery(props.item))
|
||||
// 日期区间两端控件类型随字段业务类型走:datetime 出时间列,date 只到日
|
||||
const isDateTime = computed(() => props.item.field.b_type === 'datetime')
|
||||
// datetime 端点用秒级精度:分钟精度下「当天末」表达不出来,结束端默认时间还会被
|
||||
// normalizeDateForFormat 归零成 23:59:00,导致 23:59:01 之后的数据查不到
|
||||
const dateFormatProps = computed(() => (isDateTime.value ? { format: 'YYYY-MM-DD HH:mm:ss' } : {}))
|
||||
const isCheckbox = computed(() => props.item.field.b_type === 'checkbox')
|
||||
const isSelect = computed(
|
||||
() => props.item.config.b_component === 'select' || props.item.field.b_type === 'select',
|
||||
@@ -80,18 +85,26 @@ function isDateBeforeLimit(valueToCompare) {
|
||||
class="fms-query-control fms-query-control__range"
|
||||
:class="{ 'fms-query-control__range--compact': compact }"
|
||||
>
|
||||
<FmsField
|
||||
v-if="isDate"
|
||||
class="fms-query-control__date-range"
|
||||
type="range"
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:control-props="{
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
disabledDate: (date) => isDateAfterLimit(date) || isDateBeforeLimit(date),
|
||||
}"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
<!-- 日期区间:两端是两个独立控件,各自只受另一端约束;不接 enter —— Enter 已被日期面板用于开合 -->
|
||||
<template v-if="isDate">
|
||||
<FmsField
|
||||
v-model="startValue"
|
||||
:type="isDateTime ? 'datetime' : 'date'"
|
||||
:disabled="disabled"
|
||||
:control-props="{
|
||||
...dateFormatProps,
|
||||
timeDefault: 'start',
|
||||
disabledDate: isDateAfterLimit,
|
||||
}"
|
||||
/>
|
||||
<span class="fms-query-control__separator">-</span>
|
||||
<FmsField
|
||||
v-model="endValue"
|
||||
:type="isDateTime ? 'datetime' : 'date'"
|
||||
:disabled="disabled"
|
||||
:control-props="{ ...dateFormatProps, timeDefault: 'end', disabledDate: isDateBeforeLimit }"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<FmsField
|
||||
v-model="startValue"
|
||||
|
||||
@@ -156,4 +156,18 @@
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
flex-shrink: 0;
|
||||
animation: button-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes button-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 尊重系统“减少动态效果”偏好:静止展示加载图标,避免诱发晕动不适 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:deep(.button-spinner) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,12 @@ const props = defineProps({
|
||||
start: { type: [Date, null], default: null },
|
||||
end: { type: [Date, null], default: null },
|
||||
hoverValue: { type: [Date, null], default: null },
|
||||
/** 无值时的默认时间:start=当天起,end=当天末,空=当前时刻;精度由 format 决定 */
|
||||
timeDefault: {
|
||||
type: String,
|
||||
default: '',
|
||||
validator: (value) => ['', 'start', 'end'].includes(value),
|
||||
},
|
||||
pickerConfig: {
|
||||
type: Object,
|
||||
default: () => analyzeDateFormat(DEFAULT_DATE_FORMAT),
|
||||
@@ -136,7 +142,14 @@ const monthOptions = computed(() => {
|
||||
})
|
||||
|
||||
/* ---------- 时间状态 ---------- */
|
||||
const time = ref(props.modelValue ?? new Date())
|
||||
/** 单选模式下无值时的时间初值,与 startTime / endTime 的日边界约定一致 */
|
||||
function defaultTime() {
|
||||
if (props.timeDefault === 'start') return dayjs().startOf('day').toDate()
|
||||
if (props.timeDefault === 'end') return dayjs().endOf('day').toDate()
|
||||
return new Date()
|
||||
}
|
||||
|
||||
const time = ref(props.modelValue ?? defaultTime())
|
||||
const startTime = ref(props.start ?? dayjs().startOf('day').toDate())
|
||||
const endTime = ref(props.end ?? dayjs().endOf('day').toDate())
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { Calendar, ChevronDown, X } from '@lucide/vue'
|
||||
import { X } from '@lucide/vue'
|
||||
import Popover from '../popover/popover.vue'
|
||||
import CalendarPanel from './calendar-panel.vue'
|
||||
import {
|
||||
@@ -22,6 +22,12 @@ const props = defineProps({
|
||||
clearable: { type: Boolean, default: true },
|
||||
disabled: { type: Boolean, default: false },
|
||||
disabledDate: { type: Function, default: () => false },
|
||||
/** 无值时的默认时间:start=当天起,end=当天末,空=当前时刻;精度由 format 决定 */
|
||||
timeDefault: {
|
||||
type: String,
|
||||
default: '',
|
||||
validator: (value) => ['', 'start', 'end'].includes(value),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
@@ -143,9 +149,6 @@ watch(pickerConfig, () => {
|
||||
>
|
||||
<template #trigger>
|
||||
<div class="fms-date-trigger" @click="toggle">
|
||||
<span class="fms-date-prefix" aria-hidden="true">
|
||||
<Calendar :size="15" />
|
||||
</span>
|
||||
<input
|
||||
class="fms-date-input"
|
||||
type="text"
|
||||
@@ -167,7 +170,6 @@ watch(pickerConfig, () => {
|
||||
>
|
||||
<X :size="14" aria-hidden="true" />
|
||||
</button>
|
||||
<ChevronDown v-else :size="15" class="fms-date-arrow" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -177,6 +179,7 @@ watch(pickerConfig, () => {
|
||||
ref="panelRef"
|
||||
type="date"
|
||||
:model-value="draftValue"
|
||||
:time-default="timeDefault"
|
||||
:picker-config="pickerConfig"
|
||||
:disabled-date="disabledDate"
|
||||
@pick="handlePick"
|
||||
|
||||
@@ -3,21 +3,20 @@
|
||||
* 沿用 fms 主题 CSS 变量(见 src/theme/tokens.css)
|
||||
* ============================================================ */
|
||||
|
||||
/* 与 Input 一致:宽度完全交给容器,不设 min/max 宽度下限,
|
||||
窄容器(如查询区并排的两个日期控件)下可自由收缩 */
|
||||
.fms-date-picker,
|
||||
.fms-range-picker {
|
||||
display: block;
|
||||
width: 100%;
|
||||
/* 窄容器下保持日历可读(7 列日期格子约 31px),
|
||||
浮层跟随触发器宽度,视觉统一不受影响 */
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
/* ---------- 触发器(类 Input 外观) ---------- */
|
||||
.fms-date-trigger,
|
||||
.fms-range-trigger {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
/* min-height 而非 height:与 Input/Select 一致,内容不足时撑到控件高度,
|
||||
被放进更矮的容器(如表格编辑单元格)时可被 min-height:0 压缩 */
|
||||
min-height: var(--fms-control-height);
|
||||
@@ -49,12 +48,6 @@
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-primary) 18%, transparent);
|
||||
}
|
||||
|
||||
.fms-date-prefix {
|
||||
display: inline-flex;
|
||||
color: var(--fms-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fms-date-input,
|
||||
.fms-range-input {
|
||||
flex: 1;
|
||||
@@ -66,6 +59,7 @@
|
||||
font-size: var(--fms-font-size);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.fms-range-input {
|
||||
@@ -77,13 +71,27 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 清除按钮绝对定位在右侧,脱离文档流:
|
||||
触发器宽度恒定,文字不会因按钮显隐而左右跳动 */
|
||||
.fms-date-suffix {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: var(--fms-control-padding-x);
|
||||
bottom: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--fms-text-secondary);
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fms-date-suffix > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 尾部无常驻图标,清除按钮常态占位但透明,hover / 聚焦时淡入接管。
|
||||
* 用 opacity 而非 display:none 切换:既不消耗额外布局空间(绝对定位),
|
||||
* 又不会因显隐改变输入框宽度造成文字位移(对齐 antd 的处理方式)。 */
|
||||
.fms-date-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -94,6 +102,15 @@
|
||||
background: transparent;
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--fms-duration-base) var(--fms-ease-standard);
|
||||
}
|
||||
|
||||
.fms-date-trigger:is(:hover, :focus-within) .fms-date-clear,
|
||||
.fms-range-trigger:is(:hover, :focus-within) .fms-date-clear {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fms-date-clear:hover {
|
||||
@@ -111,11 +128,6 @@
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.fms-date-picker.is-open .fms-date-arrow,
|
||||
.fms-range-picker.is-open .fms-date-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 禁用态 */
|
||||
.fms-date-picker.is-disabled .fms-date-trigger,
|
||||
.fms-range-picker.is-disabled .fms-range-trigger {
|
||||
@@ -134,6 +146,9 @@
|
||||
.fms-calendar-panel {
|
||||
--fms-cal-h: 282px; /* 274px 日历内容 + 8px 底部留白,时间区使用同一总高度 */
|
||||
width: 100%;
|
||||
/* 日历可读下限(7 列日期格子约 31px)挂在面板自身:触发器可以随容器收缩,
|
||||
浮层取「触发器等宽与内容固有宽度的较大值」,因此面板宽度不被窄触发器拖垮 */
|
||||
min-width: 240px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
@@ -379,6 +394,8 @@
|
||||
height: var(--fms-cal-h);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 日历 7 列 × 31px 的固有宽度:带时间选择时由时间列把浮层撑宽,日历不会被压扁 */
|
||||
min-width: 217px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { Calendar, ChevronDown, X } from '@lucide/vue'
|
||||
import { X } from '@lucide/vue'
|
||||
import Popover from '../popover/popover.vue'
|
||||
import CalendarPanel from './calendar-panel.vue'
|
||||
import {
|
||||
@@ -199,9 +199,6 @@ watch(pickerConfig, () => {
|
||||
>
|
||||
<template #trigger>
|
||||
<div class="fms-range-trigger" @click="toggle">
|
||||
<span class="fms-date-prefix" aria-hidden="true">
|
||||
<Calendar :size="15" />
|
||||
</span>
|
||||
<input
|
||||
class="fms-range-input"
|
||||
type="text"
|
||||
@@ -235,7 +232,6 @@ watch(pickerConfig, () => {
|
||||
>
|
||||
<X :size="14" aria-hidden="true" />
|
||||
</button>
|
||||
<ChevronDown v-else :size="15" class="fms-date-arrow" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/* ============================================================
|
||||
* 公共样式层 —— 跨页面复用的样式骨架与部件
|
||||
*
|
||||
* 与 theme/tokens.css 的分工:
|
||||
* tokens.css 管「值」——颜色 / 尺寸 / 阴影 / 动效等 CSS 变量
|
||||
* common.scss 管「形」——多页面共用的布局骨架与部件样式
|
||||
*
|
||||
* 只收「≥2 处真实调用点」的公共类,命名一律 fms- 前缀;
|
||||
* 一次性样式留在各自页面的 scoped 块里,不进这里。
|
||||
* 由 styles/index.css 全局引入,页面直接写 class,无需 scoped / :deep()。
|
||||
* ============================================================ */
|
||||
|
||||
/* ========== 一、左右分栏页面骨架 ==========
|
||||
* 标准三层:.fms-page > .fms-page__body(放 Splitter)> .fms-page__main(右栏)。
|
||||
* 页面根靠 flex:1 在 DefaultLayout 的 .fms-content / .fms-page-wrap flex 链上撑满可视
|
||||
* 高度,不写 calc(100vh - xx);固定高度 + 内部滚动由各层自己的 overflow 承担。 */
|
||||
.fms-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 左右分栏容器:左栏(树面板)+ 右栏(主区),由 Splitter 承担分栏 */
|
||||
.fms-page__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 右栏主区:纵向堆叠「工具条 / 内容卡」,卡间 8px 灰缝(同 .fms-stack + gap) */
|
||||
.fms-page__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ========== 二、撑满与堆叠容器 ========== */
|
||||
|
||||
/* 撑满父级:宽高 100% + 允许收缩(flex 子项下不会把父级撑破)。
|
||||
* 刻意不含 overflow:hidden —— 子元素带 box-shadow 时父级裁切会把阴影切掉,
|
||||
* 需要裁切的场景叠加 .fms-stack 或就地写 overflow。 */
|
||||
.fms-fill {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 纵向堆叠:仅表达 flex 方向,撑满/裁切按需叠加 .fms-fill / .fms-stack */
|
||||
.fms-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 裁切溢出:页面根 / 分栏容器用。侧栏折叠等过渡瞬间内容宽度会短暂超出容器,
|
||||
* 不裁切会把滚动条闪出来;横向滚动应由内部容器(表格 / splitter)自己处理。 */
|
||||
.fms-clip {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 纵向堆叠 + 撑满 + 裁切:内容卡容器(内部自带滚动)的通用形态 */
|
||||
.fms-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ========== 三、Splitter 视觉定制 ==========
|
||||
* 面板不再留白,两张白卡紧贴;灰缝由分隔条自身的触发区宽度
|
||||
* (--fms-splitter-trigger-size)承担。常显线平时透明,hover / 拖拽 / 键盘聚焦时
|
||||
* 才显示整条细线 + 中央手柄。
|
||||
* 必须是独立修饰类 —— .fms-splitter 是 Splitter 组件自己的根类,
|
||||
* 直接写会波及弹窗 / 抽屉 / 设计器里的所有分割条。 */
|
||||
.fms-splitter-clean {
|
||||
> .fms-splitter-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fms-splitter-bar::before {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fms-splitter-bar:hover::before,
|
||||
.fms-splitter-bar-active::before,
|
||||
.fms-splitter-bar:focus-visible::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 四、左树面板(左右结构的左栏)==========
|
||||
* 灰底画布上的白卡:白底 + 圆角 + 阴影,与右侧内容卡用灰缝分隔。
|
||||
* 内部纵向排列:头部(搜索 + 动作)→ 滚动区。 */
|
||||
.fms-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;
|
||||
}
|
||||
|
||||
/* 头部条:搜索框撑满,动作按钮靠右且不参与压缩 */
|
||||
.fms-tree-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.fms-tree-panel__search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fms-tree-panel__search-icon {
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.fms-tree-panel__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 树滚动区 */
|
||||
.fms-tree-panel__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
|
||||
/* 树宽度跟随面板:内容更宽时横向滚动,而不是把卡片撑破 */
|
||||
.tree {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 树节点名称:撑满剩余空间(.tree-node-content 是 flex 容器,需 flex:1 才有空间可撑),
|
||||
* 超出单行省略。停用等状态色由使用方自己叠加。 */
|
||||
.fms-tree-node-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ========== 五、通用部件 ========== */
|
||||
|
||||
/* 白卡三件套:灰底画布上的内容面。卡间用 gap/margin 留灰缝,不用 border 画分隔线 */
|
||||
.fms-card {
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
}
|
||||
|
||||
/* 描边图标按钮:树面板动作区里的工具位,正方形、不随文字缩放。
|
||||
* 必须嵌在 .fms-tree-panel__actions 下写:它挂在组件库 Button(type="ghost")上,
|
||||
* 需要 (0,2,0) 才压得过 .button / .button-ghost 的 padding / border / 背景,
|
||||
* 以及 (0,3,0) 的 ghost hover —— 与 AppTopbar 里 `.fms-topbar .fms-icon-btn` 同一做法。 */
|
||||
.fms-tree-panel__actions .fms-tool-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--fms-control-height);
|
||||
height: var(--fms-control-height);
|
||||
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 var(--fms-duration-base) var(--fms-ease-standard),
|
||||
background var(--fms-duration-base) var(--fms-ease-standard),
|
||||
color var(--fms-duration-base) var(--fms-ease-standard);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--fms-secondary);
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片内工具条:白底 + 圆角、不投影(与内容卡同一平面,靠灰缝分隔) */
|
||||
.fms-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
padding: 8px 10px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
}
|
||||
|
||||
/* 状态占位:加载中 / 空数据的居中提示,无背景;
|
||||
* 需要卡片时叠加 .fms-card(如整块区域的空态)。 */
|
||||
.fms-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: var(--fms-font-size);
|
||||
|
||||
/* 状态图标退到文案之后 */
|
||||
> svg {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* 面板内区块标题 */
|
||||
.fms-pane-title {
|
||||
font-size: var(--fms-font-size);
|
||||
font-weight: 600;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@import '../theme/tokens.css';
|
||||
@import './common.scss';
|
||||
@import '../components/ui/grid/index.scss';
|
||||
@import '@fontsource/noto-sans-sc/400.css';
|
||||
@import '@fontsource/noto-sans-sc/500.css';
|
||||
|
||||
@@ -440,23 +440,23 @@ void loadCategories()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="oc-page">
|
||||
<Splitter class="oc-splitter">
|
||||
<div class="fms-fill fms-clip">
|
||||
<Splitter class="fms-splitter-clean">
|
||||
<SplitterPanel :default-size="248" :min="180" :max="420">
|
||||
<aside class="oc-tree-panel">
|
||||
<div class="oc-tree-header">
|
||||
<aside class="fms-tree-panel">
|
||||
<div class="fms-tree-panel__header">
|
||||
<Input
|
||||
v-model="categoryKeyword"
|
||||
placeholder="搜索分类"
|
||||
allow-clear
|
||||
class="oc-tree-search"
|
||||
class="fms-tree-panel__search"
|
||||
>
|
||||
<template #prefix><Search :size="14" class="oc-tree-search__icon" /></template>
|
||||
<template #prefix><Search :size="14" class="fms-tree-panel__search-icon" /></template>
|
||||
</Input>
|
||||
<div class="oc-tree-header__actions">
|
||||
<div class="fms-tree-panel__actions">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="oc-tree-header__icon-btn"
|
||||
class="fms-tool-btn"
|
||||
title="新增分类"
|
||||
aria-label="新增分类"
|
||||
@click="onCategoryToolbarAdd"
|
||||
@@ -466,7 +466,7 @@ void loadCategories()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oc-tree-panel__scroll">
|
||||
<div class="fms-tree-panel__scroll">
|
||||
<!-- 搜索过滤时禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
|
||||
<FmsTree
|
||||
v-model:expanded-keys="expandedCategoryKeys"
|
||||
@@ -482,7 +482,7 @@ void loadCategories()
|
||||
@drop="(payload) => void onCategoryDrop(payload)"
|
||||
>
|
||||
<template #title="{ title }">
|
||||
<span class="oc-tree-node__name">{{ title }}</span>
|
||||
<span class="fms-tree-node-name">{{ title }}</span>
|
||||
</template>
|
||||
</FmsTree>
|
||||
</div>
|
||||
@@ -490,7 +490,8 @@ void loadCategories()
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel>
|
||||
<main class="oc-list">
|
||||
<!-- 列表卡自带白底(.fms-module-card),外层不再铺白底,避免灰缝被染色 -->
|
||||
<main class="fms-fill fms-column">
|
||||
<FmsModuleListPage
|
||||
ref="listRef"
|
||||
:data-code="MODULE_CODE"
|
||||
@@ -562,126 +563,3 @@ void loadCategories()
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.oc-page,
|
||||
.oc-splitter {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.oc-page {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.oc-splitter :deep(> .fms-splitter-panel) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.oc-splitter :deep(.fms-splitter-bar::before) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.oc-splitter :deep(.fms-splitter-bar:hover::before),
|
||||
.oc-splitter :deep(.fms-splitter-bar-active::before),
|
||||
.oc-splitter :deep(.fms-splitter-bar:focus-visible::before) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 灰底画布上的白卡,与右侧列表卡用灰缝分隔 */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 列表卡自带白底(.fms-module-card),外层不再铺白底,避免灰缝被染色 */
|
||||
.oc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -263,22 +263,22 @@ void loadCategories()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="od-page">
|
||||
<Splitter class="od-splitter">
|
||||
<div class="fms-fill fms-clip">
|
||||
<Splitter class="fms-splitter-clean">
|
||||
<SplitterPanel :default-size="260" :min="200" :max="420">
|
||||
<aside class="od-tree-panel">
|
||||
<div class="od-tree-header">
|
||||
<aside class="fms-tree-panel">
|
||||
<div class="fms-tree-panel__header">
|
||||
<Input
|
||||
v-model="categoryKeyword"
|
||||
placeholder="搜索分类 / 数据"
|
||||
allow-clear
|
||||
class="od-tree-search"
|
||||
class="fms-tree-panel__search"
|
||||
>
|
||||
<template #prefix><Search :size="14" class="od-tree-search__icon" /></template>
|
||||
<template #prefix><Search :size="14" class="fms-tree-panel__search-icon" /></template>
|
||||
</Input>
|
||||
</div>
|
||||
|
||||
<div class="od-tree-panel__scroll">
|
||||
<div class="fms-tree-panel__scroll">
|
||||
<!-- 搜索过滤时树是浅拷贝视图,仅作导航展示,无编辑交互 -->
|
||||
<FmsTree
|
||||
v-model:expanded-keys="expandedCategoryKeys"
|
||||
@@ -290,7 +290,7 @@ void loadCategories()
|
||||
@select="onCategorySelect"
|
||||
>
|
||||
<template #title="{ title }">
|
||||
<span class="od-tree-node__name">{{ title }}</span>
|
||||
<span class="fms-tree-node-name">{{ title }}</span>
|
||||
</template>
|
||||
</FmsTree>
|
||||
</div>
|
||||
@@ -298,7 +298,8 @@ void loadCategories()
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel>
|
||||
<main class="od-list">
|
||||
<!-- 列表卡自带白底(.fms-module-card),外层不再铺白底,避免灰缝被染色 -->
|
||||
<main class="fms-fill fms-column">
|
||||
<FmsModuleListPage
|
||||
v-if="selectedModuleCode"
|
||||
ref="listRef"
|
||||
@@ -341,8 +342,8 @@ void loadCategories()
|
||||
</Button>
|
||||
</template>
|
||||
</FmsModuleListPage>
|
||||
<div v-else class="od-list-empty">
|
||||
<div class="od-list-empty__hint">
|
||||
<div v-else class="fms-state fms-card">
|
||||
<div class="od-empty-hint">
|
||||
<strong>请选择数据模块</strong>
|
||||
<span>从左侧分类树中选择一个数据模块以查看与维护其数据</span>
|
||||
</div>
|
||||
@@ -354,103 +355,10 @@ void loadCategories()
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.od-page,
|
||||
.od-splitter {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
/* 其余骨架(页面根 / Splitter / 树面板 / 空态卡)见 styles/common.scss */
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 列表卡自带白底(.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 {
|
||||
/* 空态提示文案:标题 + 说明两行居中 */
|
||||
.od-empty-hint {
|
||||
display: flex;
|
||||
max-width: 360px;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -80,25 +80,20 @@ function onToolbarAdd({ key }) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="mm-tree-panel">
|
||||
<div class="mm-tree-header">
|
||||
<Input v-model="keyword" allow-clear class="mm-tree-search">
|
||||
<template #prefix><Search :size="14" class="mm-tree-search__icon" /></template>
|
||||
<aside class="fms-tree-panel">
|
||||
<div class="fms-tree-panel__header">
|
||||
<Input v-model="keyword" allow-clear class="fms-tree-panel__search">
|
||||
<template #prefix><Search :size="14" class="fms-tree-panel__search-icon" /></template>
|
||||
</Input>
|
||||
<div class="mm-tree-header__actions">
|
||||
<div class="fms-tree-panel__actions">
|
||||
<Dropdown :items="addOptions" @click="onToolbarAdd">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-tree-header__icon-btn"
|
||||
title="新增菜单"
|
||||
aria-label="新增菜单"
|
||||
>
|
||||
<Button type="ghost" class="fms-tool-btn" title="新增菜单" aria-label="新增菜单">
|
||||
<Plus :size="14" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mm-tree-panel__scroll">
|
||||
<div class="fms-tree-panel__scroll">
|
||||
<!-- 过滤(搜索)状态下禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
|
||||
<FmsTree
|
||||
:tree-data="filteredTree"
|
||||
@@ -115,7 +110,7 @@ function onToolbarAdd({ key }) {
|
||||
<template #title="{ node, title }">
|
||||
<span class="mm-tree-node">
|
||||
<span
|
||||
class="mm-tree-node__name"
|
||||
class="fms-tree-node-name"
|
||||
:class="{ 'is-disabled': Number(node.b_canuse) !== 1 }"
|
||||
>
|
||||
{{ title }}
|
||||
@@ -134,77 +129,7 @@ function onToolbarAdd({ key }) {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ---- 左侧树面板 ---- */
|
||||
.mm-tree-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mm-tree-panel__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mm-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mm-tree-search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mm-tree-search__icon {
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.mm-tree-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mm-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;
|
||||
}
|
||||
}
|
||||
/* 面板骨架(白卡 / 头部 / 搜索框 / 滚动区 / 工具按钮)见 styles/common.scss */
|
||||
|
||||
/* ---- 树节点 ---- */
|
||||
.mm-tree-node {
|
||||
@@ -217,16 +142,8 @@ function onToolbarAdd({ key }) {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.mm-tree-node__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.is-disabled {
|
||||
color: var(--fms-text-disabled, #9ca3af);
|
||||
}
|
||||
.fms-tree-node-name.is-disabled {
|
||||
color: var(--fms-text-disabled, #9ca3af);
|
||||
}
|
||||
|
||||
/* 树节点启用勾选框:靠右、不随文字缩放 */
|
||||
|
||||
@@ -822,9 +822,9 @@ loadModuleOptions()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mm-page">
|
||||
<div class="mm-body">
|
||||
<Splitter class="mm-body-splitter">
|
||||
<div class="fms-page">
|
||||
<div class="fms-page__body">
|
||||
<Splitter class="fms-splitter-clean">
|
||||
<SplitterPanel :default-size="248" :min="180" :max="420">
|
||||
<MenuTreePanel
|
||||
v-model:expanded-keys="menuExpandedKeys"
|
||||
@@ -841,10 +841,10 @@ loadModuleOptions()
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel>
|
||||
<main class="mm-main">
|
||||
<main class="fms-page__main">
|
||||
<template v-if="selectedMenu">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="mm-main-toolbar">
|
||||
<div class="fms-toolbar">
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="configSaving"
|
||||
@@ -860,11 +860,11 @@ loadModuleOptions()
|
||||
|
||||
<!-- 主体:三块 Splitter 分割 -->
|
||||
<div v-if="maindata" class="mm-edit-body">
|
||||
<Splitter class="mm-edit-splitter">
|
||||
<Splitter class="fms-splitter-clean mm-edit-splitter">
|
||||
<SplitterPanel :default-size="360">
|
||||
<div class="mm-form-col__card">
|
||||
<div class="fms-card mm-form-col__card">
|
||||
<div class="mm-form-col__head">
|
||||
<div class="mm-pane__title">菜单配置</div>
|
||||
<div class="fms-pane-title">菜单配置</div>
|
||||
</div>
|
||||
<MenuBasicPanel
|
||||
ref="basicPanelRef"
|
||||
@@ -880,13 +880,13 @@ loadModuleOptions()
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
<div v-else-if="configLoading" class="mm-config-state">
|
||||
<div v-else-if="configLoading" class="fms-state">
|
||||
<Spin />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div v-else class="mm-config-state">暂无配置数据</div>
|
||||
<div v-else class="fms-state">暂无配置数据</div>
|
||||
</template>
|
||||
<div v-else class="mm-main__empty">
|
||||
<div v-else class="fms-state fms-card">
|
||||
<FolderTree :size="32" />
|
||||
<span>请选择左侧菜单节点</span>
|
||||
</div>
|
||||
@@ -954,87 +954,14 @@ loadModuleOptions()
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ============================================================
|
||||
* 菜单管理页面样式(仅页面布局)
|
||||
* 菜单管理页面样式(仅页面专有部分)
|
||||
* 通用骨架(页面三层 / Splitter / 白卡 / 工具条 / 状态占位)见 styles/common.scss
|
||||
* 面板内部样式随各面板组件(MenuTreePanel / MenuBasicPanel)一起维护
|
||||
* ============================================================ */
|
||||
|
||||
/* ---- 页面根 ---- */
|
||||
.mm-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- 主体左右布局 ---- */
|
||||
.mm-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- Splitter 面板 ----
|
||||
* 面板不再左右留白,两张白卡紧贴;灰缝由分隔条自身的触发区宽度
|
||||
* (--fms-splitter-trigger-size,默认 6px)承担。
|
||||
* 常显线平时透明,hover / 拖拽时显示整条 100% 高度的细线 + 手柄提示 */
|
||||
.mm-body-splitter :deep(> .fms-splitter-panel) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mm-body-splitter :deep(.fms-splitter-bar::before) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mm-body-splitter :deep(.fms-splitter-bar:hover::before),
|
||||
.mm-body-splitter :deep(.fms-splitter-bar-active::before),
|
||||
.mm-body-splitter :deep(.fms-splitter-bar:focus-visible::before) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---- 右侧主区 ---- */
|
||||
.mm-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mm-main__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 14px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
|
||||
svg {
|
||||
color: var(--fms-text-secondary);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 顶部操作栏 ---- */
|
||||
.mm-main-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 10px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
}
|
||||
|
||||
/* ---- 主体三块 Splitter ---- */
|
||||
/* ---- 主体三块 Splitter ----
|
||||
* 面板裁切与「常显线平时透明」已由 .fms-splitter-clean 统一处理,
|
||||
* 这里只补面板之间的 3px 灰缝,首尾面板不留边距。 */
|
||||
.mm-edit-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -1043,7 +970,6 @@ loadModuleOptions()
|
||||
}
|
||||
|
||||
.mm-edit-splitter :deep(> .fms-splitter-panel) {
|
||||
overflow: hidden;
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
@@ -1056,13 +982,10 @@ loadModuleOptions()
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* 左栏:表单卡片 */
|
||||
/* 左栏:表单卡片(.fms-card 提供白卡三件套,这里只写排布) */
|
||||
.mm-form-col__card {
|
||||
height: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
@@ -1075,25 +998,5 @@ loadModuleOptions()
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 区块标题 */
|
||||
.mm-pane__title {
|
||||
margin-bottom: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
.mm-config-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -76,25 +76,20 @@ function onToolbarAdd({ key }) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="mm-tree-panel">
|
||||
<div class="mm-tree-header">
|
||||
<Input v-model="keyword" placeholder="搜索模块" allow-clear class="mm-tree-search">
|
||||
<template #prefix><Search :size="14" class="mm-tree-search__icon" /></template>
|
||||
<aside class="fms-tree-panel">
|
||||
<div class="fms-tree-panel__header">
|
||||
<Input v-model="keyword" placeholder="搜索模块" allow-clear class="fms-tree-panel__search">
|
||||
<template #prefix><Search :size="14" class="fms-tree-panel__search-icon" /></template>
|
||||
</Input>
|
||||
<div class="mm-tree-header__actions">
|
||||
<div class="fms-tree-panel__actions">
|
||||
<Dropdown :items="MODULE_ADD_OPTIONS" @click="onToolbarAdd">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-tree-header__icon-btn"
|
||||
title="新建模块"
|
||||
aria-label="新建模块"
|
||||
>
|
||||
<Button type="ghost" class="fms-tool-btn" title="新建模块" aria-label="新建模块">
|
||||
<Plus :size="14" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mm-tree-panel__scroll">
|
||||
<div class="fms-tree-panel__scroll">
|
||||
<!-- 过滤(搜索)状态下禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
|
||||
<FmsTree
|
||||
:tree-data="filteredTree"
|
||||
@@ -111,7 +106,7 @@ function onToolbarAdd({ key }) {
|
||||
<template #title="{ node, title }">
|
||||
<span class="mm-tree-node">
|
||||
<span
|
||||
class="mm-tree-node__name"
|
||||
class="fms-tree-node-name"
|
||||
:class="{ 'is-disabled': Number(node.b_canuse) !== 1 }"
|
||||
>
|
||||
{{ title }}
|
||||
@@ -124,78 +119,7 @@ function onToolbarAdd({ key }) {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ---- 左侧树面板 ---- */
|
||||
.mm-tree-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
/* 灰底画布上的白卡:白底 + 圆角 + 阴影,与右侧内容卡用灰缝分隔 */
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mm-tree-panel__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mm-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mm-tree-search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mm-tree-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mm-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;
|
||||
}
|
||||
}
|
||||
|
||||
.mm-tree-search__icon {
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
/* 面板骨架(白卡 / 头部 / 搜索框 / 滚动区 / 工具按钮)见 styles/common.scss */
|
||||
|
||||
/* 树节点单行:名称(停用变灰)撑满剩余空间。
|
||||
.mm-tree-node 是 .tree-node-content(flex 容器)的子元素,
|
||||
@@ -210,15 +134,7 @@ function onToolbarAdd({ key }) {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.mm-tree-node__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.is-disabled {
|
||||
color: var(--fms-text-disabled, #9ca3af);
|
||||
}
|
||||
.fms-tree-node-name.is-disabled {
|
||||
color: var(--fms-text-disabled, #9ca3af);
|
||||
}
|
||||
</style>
|
||||
@@ -930,9 +930,9 @@ loadModuleTree()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mm-page">
|
||||
<div class="mm-body">
|
||||
<Splitter class="mm-body-splitter">
|
||||
<div class="fms-page">
|
||||
<div class="fms-page__body">
|
||||
<Splitter class="fms-splitter-clean">
|
||||
<SplitterPanel :default-size="248" :min="180" :max="420">
|
||||
<ModuleTreePanel
|
||||
v-model:expanded-keys="moduleExpandedKeys"
|
||||
@@ -948,9 +948,9 @@ loadModuleTree()
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel>
|
||||
<main class="mm-main">
|
||||
<main class="fms-page__main">
|
||||
<template v-if="selectedModule">
|
||||
<div class="mm-main-toolbar">
|
||||
<div class="fms-toolbar">
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="configSaving"
|
||||
@@ -961,7 +961,7 @@ loadModuleTree()
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mm-body__inner">
|
||||
<div class="fms-card fms-stack">
|
||||
<Tabs
|
||||
v-if="maindata"
|
||||
v-model="activeSection"
|
||||
@@ -976,7 +976,7 @@ loadModuleTree()
|
||||
</template>
|
||||
</Tabs>
|
||||
|
||||
<div v-if="maindata" class="mm-config-content">
|
||||
<div v-if="maindata" class="fms-stack">
|
||||
<div v-show="activeSection === 'basic'" class="mm-config-pane">
|
||||
<ModuleBasicPanel
|
||||
ref="basicPanelRef"
|
||||
@@ -1079,14 +1079,14 @@ loadModuleTree()
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="configLoading" class="mm-config-state">
|
||||
<div v-else-if="configLoading" class="fms-state mm-config-state">
|
||||
<Spin />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div v-else class="mm-config-state">暂无配置数据</div>
|
||||
<div v-else class="fms-state mm-config-state">暂无配置数据</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="mm-main__empty">
|
||||
<div v-else class="fms-state fms-card">
|
||||
<FolderTree :size="32" />
|
||||
<span>请选择左侧模块节点</span>
|
||||
</div>
|
||||
@@ -1131,85 +1131,6 @@ loadModuleTree()
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mm-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mm-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mm-body-splitter :deep(> .fms-splitter-panel) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mm-body-splitter :deep(.fms-splitter-bar::before) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mm-body-splitter :deep(.fms-splitter-bar:hover::before),
|
||||
.mm-body-splitter :deep(.fms-splitter-bar-active::before),
|
||||
.mm-body-splitter :deep(.fms-splitter-bar:focus-visible::before) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mm-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mm-main__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
|
||||
svg {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.mm-main-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
padding: 8px 10px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
}
|
||||
|
||||
.mm-body__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mm-config-nav.tabs {
|
||||
flex: none;
|
||||
}
|
||||
@@ -1227,15 +1148,6 @@ loadModuleTree()
|
||||
color: var(--fms-primary);
|
||||
}
|
||||
|
||||
.mm-config-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mm-config-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -1246,15 +1158,8 @@ loadModuleTree()
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
/* 配置区状态占位的兜底高度:整块空态撑满即可,这里窗口很矮时也不塌陷 */
|
||||
.mm-config-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -10,19 +10,7 @@ import { FmsModuleListPage } from '@/components/fms-module-list'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="log-page">
|
||||
<div class="fms-fill fms-clip fms-column">
|
||||
<FmsModuleListPage data-code="v_s_log_audit" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.log-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -10,19 +10,7 @@ import { FmsModuleListPage } from '@/components/fms-module-list'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="log-page">
|
||||
<div class="fms-fill fms-clip fms-column">
|
||||
<FmsModuleListPage data-code="v_s_log_login" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.log-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -12,19 +12,7 @@ import { FmsModuleListPage } from '@/components/fms-module-list'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="log-page">
|
||||
<div class="fms-fill fms-clip fms-column">
|
||||
<FmsModuleListPage data-code="v_s_log_technical" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.log-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,519 +0,0 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 用户管理 —— 左侧部门树面板(b_dept)。
|
||||
*
|
||||
* 只负责部门树的加载与维护(新增 / 修改 / 删除 / 搜索 / 拖拽调层级与顺序),
|
||||
* 选中结果通过 v-model 抛给编排层,由编排层转成右侧用户列表的固定查询条件。
|
||||
*
|
||||
* b_dept 遵循核心表结构的树规范:b_parent_id 是权威关系,b_depth / b_path 是派生字段;
|
||||
* 成环守卫与 b_xh / b_depth / b_path / b_parent_id 的重算全部由 @/utils/tree 的
|
||||
* computeTreeMove 承担,本组件只做「算差异 → 落库 → 重载」。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Pencil, Plus, Search, Trash2 } from '@lucide/vue'
|
||||
import { Button, Form, FormItem, Input, Message, Modal } from '@/components/ui'
|
||||
import FmsTree from '@/components/fms-tree/FmsTree.vue'
|
||||
import { loadDataApi, saveObjectApi } from '@/services/api'
|
||||
import {
|
||||
applyTreePosition,
|
||||
buildSortedTree,
|
||||
collectAllKeys,
|
||||
computeTreeMove,
|
||||
filterTreeByText,
|
||||
findTreeNode,
|
||||
} from '@/utils/tree'
|
||||
|
||||
const DEPT_TABLE = 'b_dept'
|
||||
|
||||
/** 选中的部门编码;空串表示「未选中」(FmsTree 用空选中键表达虚拟根) */
|
||||
const selectedKey = defineModel('selectedKey', { type: String, default: '' })
|
||||
|
||||
const emit = defineEmits(['loaded'])
|
||||
|
||||
/* ===================== 纯函数 ===================== */
|
||||
|
||||
/** 同级下一个排序号(最大 b_xh + 10,无同级时取 10) */
|
||||
function nextSiblingXh(rows, parentId) {
|
||||
const normalizedParentId = parentId == null ? null : String(parentId)
|
||||
let maxXh = 0
|
||||
for (const row of rows || []) {
|
||||
const rowParentId = row.b_parent_id == null ? null : String(row.b_parent_id)
|
||||
if (rowParentId !== normalizedParentId) continue
|
||||
maxXh = Math.max(maxXh, Number(row.b_xh) || 0)
|
||||
}
|
||||
return maxXh + 10
|
||||
}
|
||||
|
||||
/* ===================== 状态与加载 ===================== */
|
||||
const depts = ref([])
|
||||
const loading = ref(false)
|
||||
const expandedKeys = ref([])
|
||||
const keyword = ref('')
|
||||
|
||||
const deptById = computed(() => new Map(depts.value.map((item) => [String(item.b_id), item])))
|
||||
|
||||
/** 树是 depts 的派生视图(在浅拷贝上构建) */
|
||||
const deptTree = computed(() => buildSortedTree(depts.value))
|
||||
|
||||
/** 部门表只有名称,匹配「名称 + 编码」两列 */
|
||||
const filteredTree = computed(() =>
|
||||
filterTreeByText(deptTree.value, keyword.value, ['b_name', 'b_id']),
|
||||
)
|
||||
|
||||
// 搜索时展开全部(保证命中节点可见),清空时收起
|
||||
watch(keyword, (kw) => {
|
||||
expandedKeys.value = kw.trim() ? collectAllKeys(deptTree.value) : []
|
||||
})
|
||||
|
||||
async function loadDepts() {
|
||||
loading.value = true
|
||||
try {
|
||||
// 只加载启用部门(未启用的不展示)
|
||||
const response = await loadDataApi(DEPT_TABLE, 'b_canuse = 1', 'b_path ASC, b_xh ASC, b_id ASC')
|
||||
depts.value = response.data || []
|
||||
|
||||
// 选中的部门已被删除时清空选中
|
||||
const existingKeys = new Set(depts.value.map((item) => String(item.b_id)))
|
||||
if (selectedKey.value && !existingKeys.has(selectedKey.value)) {
|
||||
selectedKey.value = ''
|
||||
}
|
||||
|
||||
// 展开所有含子节点的部门,并保留用户已手动展开的
|
||||
const parentKeys = depts.value
|
||||
.map((item) => (item.b_parent_id == null ? '' : String(item.b_parent_id)))
|
||||
.filter(Boolean)
|
||||
expandedKeys.value = [...new Set([...expandedKeys.value, ...parentKeys])]
|
||||
|
||||
emit('loaded', depts.value)
|
||||
} catch (error) {
|
||||
Message.error('部门加载失败:' + (error?.message || error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** FmsTree 已把点中虚拟根归一化成空数组,这里只需处理业务部门 */
|
||||
function onSelect(keys) {
|
||||
selectedKey.value = keys.length ? String(keys[0]) : ''
|
||||
}
|
||||
|
||||
/* ===================== 右键菜单与工具栏 ===================== */
|
||||
|
||||
/** node 为 null 表示右键点在空白处,一并按虚拟根处理 */
|
||||
function isRootNode(node) {
|
||||
return !node || node.isFmsRoot
|
||||
}
|
||||
|
||||
/** 根节点只能新增顶级部门 */
|
||||
function buildContextMenu(node) {
|
||||
if (isRootNode(node)) {
|
||||
return [{ key: 'add', label: '新增部门', icon: Plus }]
|
||||
}
|
||||
return [
|
||||
{ key: 'add', label: '新增子部门', icon: Plus },
|
||||
{ divider: true },
|
||||
{ key: 'edit', label: '修改部门', icon: Pencil },
|
||||
{ key: 'delete', label: '删除', icon: Trash2, danger: true },
|
||||
]
|
||||
}
|
||||
|
||||
function onMenuClick({ item, node }) {
|
||||
if (item.key === 'add') {
|
||||
onAdd({ parentId: isRootNode(node) ? null : String(node.b_id) })
|
||||
} else if (item.key === 'edit') {
|
||||
onEdit(node)
|
||||
} else if (item.key === 'delete') {
|
||||
void onDelete(node)
|
||||
}
|
||||
}
|
||||
|
||||
/** 工具栏新增挂到当前选中部门下;未选中时建为顶级 */
|
||||
function onToolbarAdd() {
|
||||
const selected = String(selectedKey.value ?? '')
|
||||
onAdd({ parentId: selected || null })
|
||||
}
|
||||
|
||||
/* ===================== 新增 / 修改弹窗 ===================== */
|
||||
const modalOpen = ref(false)
|
||||
const modalSaving = ref(false)
|
||||
/** 修改时的部门行;新增时为 null */
|
||||
const editingDept = ref(null)
|
||||
/** 新增部门的上级编码;null 表示顶级 */
|
||||
const parentId = ref(null)
|
||||
|
||||
const isEdit = computed(() => !!editingDept.value)
|
||||
|
||||
/** 本地草稿:打开时快照,确定时提交 */
|
||||
const draft = ref({ b_id: '', b_name: '', b_xh: 10 })
|
||||
|
||||
const parentName = computed(() => {
|
||||
if (parentId.value == null || parentId.value === '') return '顶级部门'
|
||||
const parent = depts.value.find((row) => String(row.b_id) === String(parentId.value))
|
||||
return parent?.b_name || String(parentId.value)
|
||||
})
|
||||
|
||||
watch(modalOpen, (open) => {
|
||||
if (!open) return
|
||||
if (editingDept.value) {
|
||||
draft.value = {
|
||||
b_id: editingDept.value.b_id ?? '',
|
||||
b_name: editingDept.value.b_name ?? '',
|
||||
b_xh: Number(editingDept.value.b_xh) || 0,
|
||||
}
|
||||
return
|
||||
}
|
||||
draft.value = {
|
||||
b_id: '',
|
||||
b_name: '',
|
||||
b_xh: nextSiblingXh(depts.value, parentId.value),
|
||||
}
|
||||
})
|
||||
|
||||
function setField(field, value) {
|
||||
draft.value = { ...draft.value, [field]: value }
|
||||
}
|
||||
|
||||
const formRef = ref(null)
|
||||
|
||||
const formRules = computed(() => ({
|
||||
b_id: [
|
||||
{ required: true, message: '请填写部门编码' },
|
||||
{ pattern: /^[A-Za-z0-9_-]+$/, message: '部门编码只能包含字母、数字、下划线或短横线' },
|
||||
{ max: 50, message: '部门编码长度不能超过 50' },
|
||||
{
|
||||
// b_id 是主键,重复即入库失败;这里提前拦下(修改自身时排除)
|
||||
validator: (value) => {
|
||||
const code = String(value ?? '').trim()
|
||||
if (!code) return true
|
||||
const selfId = String(editingDept.value?.b_id ?? '')
|
||||
const duplicated = depts.value.some(
|
||||
(row) => String(row.b_id) === code && String(row.b_id) !== selfId,
|
||||
)
|
||||
return duplicated ? '部门编码已存在' : true
|
||||
},
|
||||
},
|
||||
],
|
||||
b_name: [
|
||||
{ required: true, message: '请填写部门名称' },
|
||||
{ max: 200, message: '部门名称长度不能超过 200' },
|
||||
],
|
||||
}))
|
||||
|
||||
async function onOk() {
|
||||
const errors = (await formRef.value?.validateAll()) ?? []
|
||||
if (errors.length) {
|
||||
Message.warning('请修正表单中标红的错误项')
|
||||
return
|
||||
}
|
||||
void onSubmit({
|
||||
b_id: String(draft.value.b_id ?? '').trim(),
|
||||
b_name: String(draft.value.b_name ?? '').trim(),
|
||||
b_xh: Number(draft.value.b_xh) || 0,
|
||||
})
|
||||
}
|
||||
|
||||
function onAdd({ parentId: targetParentId }) {
|
||||
editingDept.value = null
|
||||
parentId.value = targetParentId == null ? null : String(targetParentId)
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
function onEdit(node) {
|
||||
const dept = deptById.value.get(String(node?.b_id ?? ''))
|
||||
if (!dept) {
|
||||
Message.error('未找到需要修改的部门')
|
||||
return
|
||||
}
|
||||
editingDept.value = dept
|
||||
parentId.value = dept.b_parent_id == null ? null : String(dept.b_parent_id)
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
/** 新增补 b_depth / b_path 后 insert;修改只提交名称与排序号(编码是主键,创建后不可改) */
|
||||
async function onSubmit(payload) {
|
||||
if (modalSaving.value) return
|
||||
const isEditing = isEdit.value
|
||||
modalSaving.value = true
|
||||
try {
|
||||
if (isEditing) {
|
||||
await saveObjectApi([
|
||||
{
|
||||
table: DEPT_TABLE,
|
||||
key_field: 'b_id',
|
||||
updates: [{ b_id: payload.b_id, b_name: payload.b_name, b_xh: payload.b_xh }],
|
||||
},
|
||||
])
|
||||
} else {
|
||||
const row = {
|
||||
b_id: payload.b_id,
|
||||
b_parent_id: parentId.value,
|
||||
b_name: payload.b_name,
|
||||
b_xh: payload.b_xh,
|
||||
b_canuse: 1,
|
||||
}
|
||||
const parentNode = parentId.value ? findTreeNode(deptTree.value, parentId.value) : null
|
||||
applyTreePosition(row, parentNode)
|
||||
await saveObjectApi([{ table: DEPT_TABLE, key_field: 'b_id', inserts: [row] }])
|
||||
selectedKey.value = String(row.b_id)
|
||||
}
|
||||
modalOpen.value = false
|
||||
Message.success(isEditing ? '部门已修改' : '部门已添加')
|
||||
await loadDepts()
|
||||
} catch (error) {
|
||||
Message.error((isEditing ? '修改' : '添加') + '失败:' + (error?.message || error))
|
||||
} finally {
|
||||
modalSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 删除部门 ===================== */
|
||||
/** 守卫查询进行中:阻止右键重复触发 */
|
||||
const checking = ref(false)
|
||||
|
||||
async function onDelete(node) {
|
||||
const dept = deptById.value.get(String(node?.b_id ?? ''))
|
||||
if (!dept || checking.value) return
|
||||
|
||||
checking.value = true
|
||||
// 子部门与用户都查库,不能用本地 depts 判断:它只含启用部门(b_canuse = 1),
|
||||
// 会放过「停用的子部门」「停用部门下的用户」,删完就留下 b_parent_id / b_dept_id 悬空的孤儿行。
|
||||
const deptId = String(dept.b_id).replaceAll("'", "''")
|
||||
let childCount = 0
|
||||
let userCount = 0
|
||||
try {
|
||||
const childResponse = await loadDataApi('b_dept', `b_parent_id = '${deptId}'`, 'b_id ASC')
|
||||
childCount = (childResponse.data || []).length
|
||||
if (!childCount) {
|
||||
// 部门下有用户时不允许删除(b_user.b_dept_id 指向本部门)
|
||||
const userResponse = await loadDataApi('b_user', `b_dept_id = '${deptId}'`, 'b_id ASC')
|
||||
userCount = (userResponse.data || []).length
|
||||
}
|
||||
} catch (error) {
|
||||
Message.error('部门数据检查失败:' + (error?.message || error))
|
||||
return
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
if (childCount > 0) {
|
||||
Message.warning('该部门下还有子部门,请先删除子部门')
|
||||
return
|
||||
}
|
||||
if (userCount > 0) {
|
||||
Message.warning(`该部门下还有 ${userCount} 个用户,不能删除`)
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: `确定删除部门「${dept.b_name || dept.b_id}」?`,
|
||||
content: '此操作不可恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await saveObjectApi([
|
||||
{ table: DEPT_TABLE, key_field: 'b_id', deletes: [{ b_id: dept.b_id }] },
|
||||
])
|
||||
} catch (error) {
|
||||
Message.error('删除失败:' + (error?.message || error))
|
||||
throw error // 保持确认框打开,便于重试
|
||||
}
|
||||
Message.success('部门已删除')
|
||||
await loadDepts()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* ===================== 拖拽排序(同级调序 / 跨级换父) ===================== */
|
||||
async function onDrop({ dragKey, dropKey, dropPosition }) {
|
||||
// 树只是 depts 的派生视图,所以一贯「在副本上算差异 → 落库 → 重载」。
|
||||
// 成环守卫与 b_xh、b_depth、b_path、b_parent_id 的重算都在 computeTreeMove 内
|
||||
const result = computeTreeMove(buildSortedTree(depts.value), {
|
||||
dragKey,
|
||||
dropKey,
|
||||
dropPosition,
|
||||
messages: { cycle: '不能把部门移动到它自己的下级' },
|
||||
})
|
||||
if (!result.ok) {
|
||||
if (result.message) Message.warning(result.message)
|
||||
return
|
||||
}
|
||||
const { updatedRows } = result
|
||||
if (!updatedRows.length) return
|
||||
|
||||
try {
|
||||
await saveObjectApi(
|
||||
updatedRows.map((row) => ({ table: DEPT_TABLE, key_field: 'b_id', updates: [row] })),
|
||||
)
|
||||
Message.success('排序已更新')
|
||||
} catch (error) {
|
||||
Message.error('排序更新失败:' + (error?.message || error))
|
||||
}
|
||||
// 失败也重载:以库里为准,避免界面停在一次没有落库的排序上
|
||||
await loadDepts()
|
||||
}
|
||||
|
||||
void loadDepts()
|
||||
|
||||
defineExpose({ reload: loadDepts })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="dept-panel">
|
||||
<div class="dept-panel__header">
|
||||
<Input v-model="keyword" placeholder="搜索部门" allow-clear class="dept-panel__search">
|
||||
<template #prefix><Search :size="14" class="dept-panel__search-icon" /></template>
|
||||
</Input>
|
||||
<div class="dept-panel__actions">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="dept-panel__icon-btn"
|
||||
title="新增部门"
|
||||
aria-label="新增部门"
|
||||
@click="onToolbarAdd"
|
||||
>
|
||||
<Plus :size="14" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dept-panel__scroll">
|
||||
<!-- 搜索过滤时禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
|
||||
<FmsTree
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
show-root
|
||||
root-title="全部部门"
|
||||
:tree-data="filteredTree"
|
||||
:loading="loading"
|
||||
:selected-keys="selectedKey ? [String(selectedKey)] : []"
|
||||
:context-menu="buildContextMenu"
|
||||
:draggable="!keyword.trim()"
|
||||
@select="onSelect"
|
||||
@context-menu-click="onMenuClick"
|
||||
@drop="(payload) => void onDrop(payload)"
|
||||
>
|
||||
<template #title="{ title }">
|
||||
<span class="dept-panel__node-name">{{ title }}</span>
|
||||
</template>
|
||||
</FmsTree>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 修改:确定先校验再落库 -->
|
||||
<Modal
|
||||
v-model:open="modalOpen"
|
||||
:title="isEdit ? '修改部门' : '新增部门'"
|
||||
:width="520"
|
||||
:mask-closable="false"
|
||||
:confirm-loading="modalSaving"
|
||||
@ok="onOk"
|
||||
>
|
||||
<Form ref="formRef" :model="draft" :rules="formRules" layout="vertical" :gap="16">
|
||||
<FormItem label="上级部门" name="b_parent_id">
|
||||
<Input :model-value="parentName" disabled />
|
||||
</FormItem>
|
||||
<FormItem label="部门编码" name="b_id" required>
|
||||
<Input
|
||||
:model-value="draft.b_id"
|
||||
:disabled="isEdit"
|
||||
placeholder="主键即编码,保存后不可修改"
|
||||
@update:model-value="(v) => setField('b_id', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="部门名称" name="b_name" required>
|
||||
<Input :model-value="draft.b_name" @update:model-value="(v) => setField('b_name', v)" />
|
||||
</FormItem>
|
||||
<FormItem label="排序号" name="b_xh">
|
||||
<Input
|
||||
:model-value="draft.b_xh"
|
||||
type="number"
|
||||
@update:model-value="(v) => setField('b_xh', Number(v) || 0)"
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Modal>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dept-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dept-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.dept-panel__search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dept-panel__search-icon {
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.dept-panel__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dept-panel__icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
background: var(--fms-card);
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
background 150ms ease,
|
||||
color 150ms ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--fms-secondary);
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.dept-panel__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.dept-panel__scroll :deep(.tree) {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.dept-panel__node-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -3,22 +3,47 @@
|
||||
* 系统管理 —— 用户管理(b_user)。
|
||||
*
|
||||
* 左侧部门树(b_dept)+ 右侧用户列表,与 base/othercompany 的「左树右表」同构:
|
||||
* - 左树由 DeptTreePanel 自管(加载 / 增删改 / 拖拽),本页只持有「选中部门」;
|
||||
* - 左树在本页内维护(加载 / 搜索 / 增删改 / 拖拽),对外只有「选中部门」;
|
||||
* - 右表由 FmsModuleListPage 按 data-code 的模块配置驱动;
|
||||
* - 本页只做三件事:选中部门 → 固定查询条件(含全部下级)、
|
||||
* 新增用户时默认归入选中部门、用户的新增与编辑走 FmsModuleEditModal。
|
||||
* - 两边的衔接只有两条:选中部门 → 固定查询条件(含全部下级)、
|
||||
* 新增用户时默认归入选中部门;用户的增删改走 FmsModuleEditModal。
|
||||
*
|
||||
* b_dept 遵循核心表结构的树规范:b_parent_id 是权威关系,b_depth / b_path 是派生字段;
|
||||
* 成环守卫与 b_xh / b_depth / b_path / b_parent_id 的重算全部由 @/utils/tree 的
|
||||
* computeTreeMove 承担,这里只做「算差异 → 落库 → 重载」。
|
||||
*
|
||||
* 页面同时管两个领域,部门相关标识一律带 dept 前缀,避免与用户侧同名。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus, Trash2 } from '@lucide/vue'
|
||||
import { Button, Message, Modal, Splitter, SplitterPanel } from '@/components/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Pencil, Plus, Search, Trash2 } from '@lucide/vue'
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormItem,
|
||||
Input,
|
||||
Message,
|
||||
Modal,
|
||||
Splitter,
|
||||
SplitterPanel,
|
||||
} from '@/components/ui'
|
||||
import { FmsModuleListPage } from '@/components/fms-module-list'
|
||||
import { FmsModuleEditModal } from '@/components/fms-module-edit'
|
||||
import FmsTree from '@/components/fms-tree/FmsTree.vue'
|
||||
import { escapeSqlText } from '@/components/fms-module-common/queryUtils'
|
||||
import { loadDataApi, saveObjectApi } from '@/services/api'
|
||||
import { usePermissionStore } from '@/stores/permissions'
|
||||
import { collectDescendantIds } from '@/utils/tree'
|
||||
import DeptTreePanel from './DeptTreePanel.vue'
|
||||
import {
|
||||
applyTreePosition,
|
||||
buildSortedTree,
|
||||
collectAllKeys,
|
||||
collectDescendantIds,
|
||||
computeTreeMove,
|
||||
filterTreeByText,
|
||||
findTreeNode,
|
||||
} from '@/utils/tree'
|
||||
|
||||
const MODULE_CODE = 'v_b_user'
|
||||
const DEPT_TABLE = 'b_dept'
|
||||
|
||||
const permissionStore = usePermissionStore()
|
||||
const canCreateRow = computed(() => permissionStore.canPower(MODULE_CODE, 'create'))
|
||||
@@ -27,16 +52,329 @@ const canDeleteRow = computed(() => permissionStore.canPower(MODULE_CODE, 'delet
|
||||
const listRef = ref(null)
|
||||
|
||||
/* ================= 左侧部门树 ================= */
|
||||
/** 选中的部门编码;空串表示「全部部门」。树数据留在面板内,本页只需要它算子树范围 */
|
||||
/** 选中的部门编码;空串表示「未选中」(FmsTree 用空选中键表达虚拟根) */
|
||||
const selectedDeptKey = ref('')
|
||||
const depts = ref([])
|
||||
const deptLoading = ref(false)
|
||||
const deptExpandedKeys = ref([])
|
||||
const deptKeyword = ref('')
|
||||
|
||||
function onDeptLoaded(rows) {
|
||||
depts.value = rows
|
||||
const deptById = computed(() => new Map(depts.value.map((item) => [String(item.b_id), item])))
|
||||
|
||||
/** 树是 depts 的派生视图(在浅拷贝上构建) */
|
||||
const deptTree = computed(() => buildSortedTree(depts.value))
|
||||
|
||||
/** 部门表只有名称,匹配「名称 + 编码」两列 */
|
||||
const filteredDeptTree = computed(() =>
|
||||
filterTreeByText(deptTree.value, deptKeyword.value, ['b_name', '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
|
||||
}
|
||||
|
||||
/** 搜索时展开全部(保证命中节点可见),清空时收起 */
|
||||
watch(deptKeyword, (kw) => {
|
||||
deptExpandedKeys.value = kw.trim() ? collectAllKeys(deptTree.value) : []
|
||||
})
|
||||
|
||||
async function loadDepts() {
|
||||
deptLoading.value = true
|
||||
try {
|
||||
// 只加载启用部门(未启用的不展示)
|
||||
const response = await loadDataApi(DEPT_TABLE, 'b_canuse = 1', 'b_path ASC, b_xh ASC, b_id ASC')
|
||||
depts.value = response.data || []
|
||||
|
||||
// 选中的部门已被删除时清空选中
|
||||
const existingKeys = new Set(depts.value.map((item) => String(item.b_id)))
|
||||
if (selectedDeptKey.value && !existingKeys.has(selectedDeptKey.value)) {
|
||||
selectedDeptKey.value = ''
|
||||
}
|
||||
|
||||
// 展开所有含子节点的部门,并保留用户已手动展开的
|
||||
const parentKeys = depts.value
|
||||
.map((item) => (item.b_parent_id == null ? '' : String(item.b_parent_id)))
|
||||
.filter(Boolean)
|
||||
deptExpandedKeys.value = [...new Set([...deptExpandedKeys.value, ...parentKeys])]
|
||||
} catch (error) {
|
||||
Message.error('部门加载失败:' + (error?.message || error))
|
||||
} finally {
|
||||
deptLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** FmsTree 已把点中虚拟根归一化成空数组,这里只需处理业务部门 */
|
||||
function onDeptSelect(keys) {
|
||||
selectedDeptKey.value = keys.length ? String(keys[0]) : ''
|
||||
}
|
||||
|
||||
/* ---- 右键菜单与工具栏 ---- */
|
||||
|
||||
/** node 为 null 表示右键点在空白处,一并按虚拟根处理 */
|
||||
function isDeptRootNode(node) {
|
||||
return !node || node.isFmsRoot
|
||||
}
|
||||
|
||||
/** 根节点只能新增顶级部门 */
|
||||
function buildDeptContextMenu(node) {
|
||||
if (isDeptRootNode(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 onDeptMenuClick({ item, node }) {
|
||||
if (item.key === 'add') {
|
||||
addDept({ parentId: isDeptRootNode(node) ? null : String(node.b_id) })
|
||||
} else if (item.key === 'edit') {
|
||||
editDept(node)
|
||||
} else if (item.key === 'delete') {
|
||||
void deleteDept(node)
|
||||
}
|
||||
}
|
||||
|
||||
/** 工具栏新增挂到当前选中部门下;未选中时建为顶级 */
|
||||
function onDeptToolbarAdd() {
|
||||
const selected = String(selectedDeptKey.value ?? '')
|
||||
addDept({ parentId: selected || null })
|
||||
}
|
||||
|
||||
/* ---- 新增 / 修改弹窗 ---- */
|
||||
const deptModalOpen = ref(false)
|
||||
const deptModalSaving = ref(false)
|
||||
/** 修改时的部门行;新增时为 null */
|
||||
const editingDept = ref(null)
|
||||
/** 新增部门的上级编码;null 表示顶级 */
|
||||
const deptParentId = ref(null)
|
||||
|
||||
const isDeptEdit = computed(() => !!editingDept.value)
|
||||
|
||||
/** 本地草稿:打开时快照,确定时提交 */
|
||||
const deptDraft = ref({ b_id: '', b_name: '', b_xh: 10 })
|
||||
|
||||
const deptParentName = computed(() => {
|
||||
if (deptParentId.value == null || deptParentId.value === '') return '顶级部门'
|
||||
const parent = depts.value.find((row) => String(row.b_id) === String(deptParentId.value))
|
||||
return parent?.b_name || String(deptParentId.value)
|
||||
})
|
||||
|
||||
watch(deptModalOpen, (open) => {
|
||||
if (!open) return
|
||||
if (editingDept.value) {
|
||||
deptDraft.value = {
|
||||
b_id: editingDept.value.b_id ?? '',
|
||||
b_name: editingDept.value.b_name ?? '',
|
||||
b_xh: Number(editingDept.value.b_xh) || 0,
|
||||
}
|
||||
return
|
||||
}
|
||||
deptDraft.value = {
|
||||
b_id: '',
|
||||
b_name: '',
|
||||
b_xh: nextSiblingXh(depts.value, deptParentId.value),
|
||||
}
|
||||
})
|
||||
|
||||
function setDeptField(field, value) {
|
||||
deptDraft.value = { ...deptDraft.value, [field]: value }
|
||||
}
|
||||
|
||||
const deptFormRef = ref(null)
|
||||
|
||||
const deptFormRules = computed(() => ({
|
||||
b_id: [
|
||||
{ required: true, message: '请填写部门编码' },
|
||||
{ pattern: /^[A-Za-z0-9_-]+$/, message: '部门编码只能包含字母、数字、下划线或短横线' },
|
||||
{ max: 50, message: '部门编码长度不能超过 50' },
|
||||
{
|
||||
// b_id 是主键,重复即入库失败;这里提前拦下(修改自身时排除)
|
||||
validator: (value) => {
|
||||
const code = String(value ?? '').trim()
|
||||
if (!code) return true
|
||||
const selfId = String(editingDept.value?.b_id ?? '')
|
||||
const duplicated = depts.value.some(
|
||||
(row) => String(row.b_id) === code && String(row.b_id) !== selfId,
|
||||
)
|
||||
return duplicated ? '部门编码已存在' : true
|
||||
},
|
||||
},
|
||||
],
|
||||
b_name: [
|
||||
{ required: true, message: '请填写部门名称' },
|
||||
{ max: 200, message: '部门名称长度不能超过 200' },
|
||||
],
|
||||
}))
|
||||
|
||||
async function onDeptModalOk() {
|
||||
const errors = (await deptFormRef.value?.validateAll()) ?? []
|
||||
if (errors.length) {
|
||||
Message.warning('请修正表单中标红的错误项')
|
||||
return
|
||||
}
|
||||
void submitDept({
|
||||
b_id: String(deptDraft.value.b_id ?? '').trim(),
|
||||
b_name: String(deptDraft.value.b_name ?? '').trim(),
|
||||
b_xh: Number(deptDraft.value.b_xh) || 0,
|
||||
})
|
||||
}
|
||||
|
||||
function addDept({ parentId: targetParentId }) {
|
||||
editingDept.value = null
|
||||
deptParentId.value = targetParentId == null ? null : String(targetParentId)
|
||||
deptModalOpen.value = true
|
||||
}
|
||||
|
||||
function editDept(node) {
|
||||
const dept = deptById.value.get(String(node?.b_id ?? ''))
|
||||
if (!dept) {
|
||||
Message.error('未找到需要修改的部门')
|
||||
return
|
||||
}
|
||||
editingDept.value = dept
|
||||
deptParentId.value = dept.b_parent_id == null ? null : String(dept.b_parent_id)
|
||||
deptModalOpen.value = true
|
||||
}
|
||||
|
||||
/** 新增补 b_depth / b_path 后 insert;修改只提交名称与排序号(编码是主键,创建后不可改) */
|
||||
async function submitDept(payload) {
|
||||
if (deptModalSaving.value) return
|
||||
const isEditing = isDeptEdit.value
|
||||
deptModalSaving.value = true
|
||||
try {
|
||||
if (isEditing) {
|
||||
await saveObjectApi([
|
||||
{
|
||||
table: DEPT_TABLE,
|
||||
key_field: 'b_id',
|
||||
updates: [{ b_id: payload.b_id, b_name: payload.b_name, b_xh: payload.b_xh }],
|
||||
},
|
||||
])
|
||||
} else {
|
||||
const row = {
|
||||
b_id: payload.b_id,
|
||||
b_parent_id: deptParentId.value,
|
||||
b_name: payload.b_name,
|
||||
b_xh: payload.b_xh,
|
||||
b_canuse: 1,
|
||||
}
|
||||
const parentNode = deptParentId.value
|
||||
? findTreeNode(deptTree.value, deptParentId.value)
|
||||
: null
|
||||
applyTreePosition(row, parentNode)
|
||||
await saveObjectApi([{ table: DEPT_TABLE, key_field: 'b_id', inserts: [row] }])
|
||||
selectedDeptKey.value = String(row.b_id)
|
||||
}
|
||||
deptModalOpen.value = false
|
||||
Message.success(isEditing ? '部门已修改' : '部门已添加')
|
||||
await loadDepts()
|
||||
} catch (error) {
|
||||
Message.error((isEditing ? '修改' : '添加') + '失败:' + (error?.message || error))
|
||||
} finally {
|
||||
deptModalSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 删除部门 ---- */
|
||||
/** 守卫查询进行中:阻止右键重复触发 */
|
||||
const deptChecking = ref(false)
|
||||
|
||||
async function deleteDept(node) {
|
||||
const dept = deptById.value.get(String(node?.b_id ?? ''))
|
||||
if (!dept || deptChecking.value) return
|
||||
|
||||
deptChecking.value = true
|
||||
// 子部门与用户都查库,不能用本地 depts 判断:它只含启用部门(b_canuse = 1),
|
||||
// 会放过「停用的子部门」「停用部门下的用户」,删完就留下 b_parent_id / b_dept_id 悬空的孤儿行。
|
||||
const deptId = String(dept.b_id).replaceAll("'", "''")
|
||||
let childCount = 0
|
||||
let userCount = 0
|
||||
try {
|
||||
const childResponse = await loadDataApi('b_dept', `b_parent_id = '${deptId}'`, 'b_id ASC')
|
||||
childCount = (childResponse.data || []).length
|
||||
if (!childCount) {
|
||||
// 部门下有用户时不允许删除(b_user.b_dept_id 指向本部门)
|
||||
const userResponse = await loadDataApi('b_user', `b_dept_id = '${deptId}'`, 'b_id ASC')
|
||||
userCount = (userResponse.data || []).length
|
||||
}
|
||||
} catch (error) {
|
||||
Message.error('部门数据检查失败:' + (error?.message || error))
|
||||
return
|
||||
} finally {
|
||||
deptChecking.value = false
|
||||
}
|
||||
if (childCount > 0) {
|
||||
Message.warning('该部门下还有子部门,请先删除子部门')
|
||||
return
|
||||
}
|
||||
if (userCount > 0) {
|
||||
Message.warning(`该部门下还有 ${userCount} 个用户,不能删除`)
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: `确定删除部门「${dept.b_name || dept.b_id}」?`,
|
||||
content: '此操作不可恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await saveObjectApi([
|
||||
{ table: DEPT_TABLE, key_field: 'b_id', deletes: [{ b_id: dept.b_id }] },
|
||||
])
|
||||
} catch (error) {
|
||||
Message.error('删除失败:' + (error?.message || error))
|
||||
throw error // 保持确认框打开,便于重试
|
||||
}
|
||||
Message.success('部门已删除')
|
||||
await loadDepts()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* ---- 拖拽排序(同级调序 / 跨级换父) ---- */
|
||||
async function onDeptDrop({ dragKey, dropKey, dropPosition }) {
|
||||
// 树只是 depts 的派生视图,所以一贯「在副本上算差异 → 落库 → 重载」。
|
||||
// 成环守卫与 b_xh、b_depth、b_path、b_parent_id 的重算都在 computeTreeMove 内
|
||||
const result = computeTreeMove(buildSortedTree(depts.value), {
|
||||
dragKey,
|
||||
dropKey,
|
||||
dropPosition,
|
||||
messages: { cycle: '不能把部门移动到它自己的下级' },
|
||||
})
|
||||
if (!result.ok) {
|
||||
if (result.message) Message.warning(result.message)
|
||||
return
|
||||
}
|
||||
const { updatedRows } = result
|
||||
if (!updatedRows.length) return
|
||||
|
||||
try {
|
||||
await saveObjectApi(
|
||||
updatedRows.map((row) => ({ table: DEPT_TABLE, key_field: 'b_id', updates: [row] })),
|
||||
)
|
||||
Message.success('排序已更新')
|
||||
} catch (error) {
|
||||
Message.error('排序更新失败:' + (error?.message || error))
|
||||
}
|
||||
// 失败也重载:以库里为准,避免界面停在一次没有落库的排序上
|
||||
await loadDepts()
|
||||
}
|
||||
|
||||
/* ================= 选中部门 → 用户列表固定查询条件 ================= */
|
||||
/**
|
||||
* 选中部门 → 固定查询条件(含全部下级部门)。
|
||||
* b_dept_id 是 varchar:条件值不加 N 前缀,避免列被隐式转成 nvarchar 而用不上索引。
|
||||
*/
|
||||
const deptFilterCondition = computed(() => {
|
||||
@@ -163,17 +501,106 @@ function confirmBatchDelete() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
void loadDepts()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="su-page">
|
||||
<Splitter class="su-splitter">
|
||||
<div class="fms-fill fms-clip">
|
||||
<Splitter class="fms-splitter-clean">
|
||||
<SplitterPanel :default-size="248" :min="180" :max="420">
|
||||
<DeptTreePanel v-model:selected-key="selectedDeptKey" @loaded="onDeptLoaded" />
|
||||
<aside class="fms-tree-panel">
|
||||
<div class="fms-tree-panel__header">
|
||||
<Input
|
||||
v-model="deptKeyword"
|
||||
placeholder="搜索部门"
|
||||
allow-clear
|
||||
class="fms-tree-panel__search"
|
||||
>
|
||||
<template #prefix><Search :size="14" class="fms-tree-panel__search-icon" /></template>
|
||||
</Input>
|
||||
<div class="fms-tree-panel__actions">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="fms-tool-btn"
|
||||
title="新增部门"
|
||||
aria-label="新增部门"
|
||||
@click="onDeptToolbarAdd"
|
||||
>
|
||||
<Plus :size="14" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fms-tree-panel__scroll">
|
||||
<!-- 搜索过滤时禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
|
||||
<FmsTree
|
||||
v-model:expanded-keys="deptExpandedKeys"
|
||||
show-root
|
||||
root-title="全部部门"
|
||||
:tree-data="filteredDeptTree"
|
||||
:loading="deptLoading"
|
||||
:selected-keys="selectedDeptKey ? [String(selectedDeptKey)] : []"
|
||||
:context-menu="buildDeptContextMenu"
|
||||
:draggable="!deptKeyword.trim()"
|
||||
@select="onDeptSelect"
|
||||
@context-menu-click="onDeptMenuClick"
|
||||
@drop="(payload) => void onDeptDrop(payload)"
|
||||
>
|
||||
<template #title="{ title }">
|
||||
<span class="fms-tree-node-name">{{ title }}</span>
|
||||
</template>
|
||||
</FmsTree>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 修改:确定先校验再落库 -->
|
||||
<Modal
|
||||
v-model:open="deptModalOpen"
|
||||
:title="isDeptEdit ? '修改部门' : '新增部门'"
|
||||
:width="520"
|
||||
:mask-closable="false"
|
||||
:confirm-loading="deptModalSaving"
|
||||
@ok="onDeptModalOk"
|
||||
>
|
||||
<Form
|
||||
ref="deptFormRef"
|
||||
:model="deptDraft"
|
||||
:rules="deptFormRules"
|
||||
layout="vertical"
|
||||
:gap="16"
|
||||
>
|
||||
<FormItem label="上级部门" name="b_parent_id">
|
||||
<Input :model-value="deptParentName" disabled />
|
||||
</FormItem>
|
||||
<FormItem label="部门编码" name="b_id" required>
|
||||
<Input
|
||||
:model-value="deptDraft.b_id"
|
||||
:disabled="isDeptEdit"
|
||||
placeholder="主键即编码,保存后不可修改"
|
||||
@update:model-value="(v) => setDeptField('b_id', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="部门名称" name="b_name" required>
|
||||
<Input
|
||||
:model-value="deptDraft.b_name"
|
||||
@update:model-value="(v) => setDeptField('b_name', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="排序号" name="b_xh">
|
||||
<Input
|
||||
:model-value="deptDraft.b_xh"
|
||||
type="number"
|
||||
@update:model-value="(v) => setDeptField('b_xh', Number(v) || 0)"
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Modal>
|
||||
</aside>
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel>
|
||||
<main class="su-list">
|
||||
<!-- 列表卡自带白底(.fms-module-card),外层不再铺白底,避免灰缝被染色 -->
|
||||
<main class="fms-fill fms-column">
|
||||
<FmsModuleListPage
|
||||
ref="listRef"
|
||||
:data-code="MODULE_CODE"
|
||||
@@ -217,41 +644,3 @@ function confirmBatchDelete() {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.su-page,
|
||||
.su-splitter {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.su-page {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.su-splitter :deep(> .fms-splitter-panel) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.su-splitter :deep(.fms-splitter-bar::before) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.su-splitter :deep(.fms-splitter-bar:hover::before),
|
||||
.su-splitter :deep(.fms-splitter-bar-active::before),
|
||||
.su-splitter :deep(.fms-splitter-bar:focus-visible::before) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 列表卡自带白底(.fms-module-card),外层不再铺白底,避免灰缝被染色 */
|
||||
.su-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
||||
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'
|
||||
import FmsField from '@/components/fms-field/FmsField.vue'
|
||||
|
||||
/** 构造一个查询条件项(字段 + 配置),供工具栏布局用例复用 */
|
||||
const queryItem = (field) => ({
|
||||
@@ -11,6 +12,12 @@ const queryItem = (field) => ({
|
||||
config: { b_operator: 'like', b_component: 'input' },
|
||||
})
|
||||
|
||||
/** 构造一个区间条件项:字段类型决定两端控件的形态 */
|
||||
const rangeQueryItem = (bType) => ({
|
||||
field: { b_field: 'created', b_type: bType },
|
||||
config: { b_operator: 'between', b_component: bType },
|
||||
})
|
||||
|
||||
describe('fms-module-list components', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
@@ -98,4 +105,71 @@ describe('fms-module-list components', () => {
|
||||
expect(wrapper.find('.filter-row').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('日期区间拆成两个独立控件加分隔符,控件类型随字段类型走', () => {
|
||||
const dateRange = mount(FmsQueryControl, {
|
||||
props: { item: rangeQueryItem('date'), modelValue: ['', ''] },
|
||||
})
|
||||
const dateFields = dateRange.findAllComponents(FmsField)
|
||||
expect(dateFields.map((field) => field.props('type'))).toEqual(['date', 'date'])
|
||||
// date 只到日,不需要覆盖控件自身的 format
|
||||
expect(dateFields[0].props('controlProps').format).toBeUndefined()
|
||||
expect(dateRange.find('.fms-query-control__separator').exists()).toBe(true)
|
||||
dateRange.unmount()
|
||||
|
||||
const datetimeRange = mount(FmsQueryControl, {
|
||||
props: { item: rangeQueryItem('datetime'), modelValue: ['', ''] },
|
||||
})
|
||||
const datetimeFields = datetimeRange.findAllComponents(FmsField)
|
||||
expect(datetimeFields.map((field) => field.props('type'))).toEqual(['datetime', 'datetime'])
|
||||
// datetime 端点必须到秒:分钟精度下「当天末」表达不出来,结束端默认时间会被归零成 23:59
|
||||
expect(datetimeFields.map((field) => field.props('controlProps').format)).toEqual([
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
])
|
||||
datetimeRange.unmount()
|
||||
})
|
||||
|
||||
it('日期区间两端各自只受另一端约束,并带当天起 / 当天末的默认时间', () => {
|
||||
const wrapper = mount(FmsQueryControl, {
|
||||
props: { item: rangeQueryItem('date'), modelValue: ['2026-01-10', '2026-01-20'] },
|
||||
})
|
||||
const [start, end] = wrapper.findAllComponents(FmsField)
|
||||
const startProps = start.props('controlProps')
|
||||
const endProps = end.props('controlProps')
|
||||
|
||||
expect(startProps.timeDefault).toBe('start')
|
||||
expect(endProps.timeDefault).toBe('end')
|
||||
|
||||
// 开始端:只禁晚于结束端,往前扩区间不受限
|
||||
expect(startProps.disabledDate(new Date('2026-01-25'))).toBe(true)
|
||||
expect(startProps.disabledDate(new Date('2026-01-05'))).toBe(false)
|
||||
// 结束端:只禁早于开始端,往后扩区间不受限
|
||||
expect(endProps.disabledDate(new Date('2026-01-05'))).toBe(true)
|
||||
expect(endProps.disabledDate(new Date('2026-01-25'))).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('日期区间单端变更只写对应下标,另一端保持', () => {
|
||||
const wrapper = mount(FmsQueryControl, {
|
||||
props: { item: rangeQueryItem('date'), modelValue: ['2026-01-10', '2026-01-20'] },
|
||||
})
|
||||
const [start, end] = wrapper.findAllComponents(FmsField)
|
||||
|
||||
start.vm.$emit('update:modelValue', '2026-01-05')
|
||||
expect(wrapper.emitted('update:modelValue').at(-1)[0]).toEqual(['2026-01-05', '2026-01-20'])
|
||||
|
||||
end.vm.$emit('update:modelValue', '2026-01-31')
|
||||
expect(wrapper.emitted('update:modelValue').at(-1)[0]).toEqual(['2026-01-10', '2026-01-31'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('数值区间仍是两个输入框加分隔符', () => {
|
||||
const wrapper = mount(FmsQueryControl, {
|
||||
props: { item: rangeQueryItem('number'), modelValue: ['', ''] },
|
||||
})
|
||||
expect(wrapper.findAll('input')).toHaveLength(2)
|
||||
expect(wrapper.find('.fms-query-control__separator').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildConditionSql, defaultQueryValue } from '@/components/fms-module-common/queryUtils'
|
||||
import {
|
||||
buildConditionSql,
|
||||
defaultQueryValue,
|
||||
sqlLiteral,
|
||||
} from '@/components/fms-module-common/queryUtils'
|
||||
import { formatDateValue, parseOptions } from '@/components/fms-module-common/fieldUtils'
|
||||
import { setMessages, translate } from '@/stores/i18n'
|
||||
|
||||
@@ -54,6 +58,20 @@ describe('fms-module-list query utilities', () => {
|
||||
).toBe("[status] IN (N'a', N'b')")
|
||||
})
|
||||
|
||||
it('日期时间字面量:自带时分秒按原时刻比较,仅日精度才补当天起 / 当天末', () => {
|
||||
const datetimeField = { b_field: 'created', b_type: 'datetime' }
|
||||
const dateField = { b_field: 'created', b_type: 'date' }
|
||||
|
||||
// datetime 值自带时分秒 → 原样保留,结束端也不再被夹到当天末
|
||||
expect(sqlLiteral('2026-01-01 09:30:00', datetimeField)).toBe("N'2026-01-01 09:30:00'")
|
||||
expect(sqlLiteral('2026-01-31 18:00:00', datetimeField, true)).toBe("N'2026-01-31 18:00:00'")
|
||||
// 只有日精度 → 按端点补默认时间
|
||||
expect(sqlLiteral('2026-01-01', datetimeField)).toBe("N'2026-01-01 00:00:00'")
|
||||
expect(sqlLiteral('2026-01-31', datetimeField, true)).toBe("N'2026-01-31 23:59:59'")
|
||||
// date 字段始终截到日,不补时分秒
|
||||
expect(sqlLiteral('2026-01-01 09:30:00', dateField)).toBe("N'2026-01-01'")
|
||||
})
|
||||
|
||||
it('parses options and normalizes defaults', () => {
|
||||
const parsed = parseOptions({ b_options: '{"options":[{"key":"1","name":"启用"},"2"]}' })
|
||||
expect(parsed.options).toEqual([
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* 2. 新增用户时把选中部门补成默认值(只补新增、且表单未带该字段时);
|
||||
* 3. 未选中部门时不加任何固定条件(显示全部用户)。
|
||||
*
|
||||
* 树的增删改与拖拽已在 DeptTreePanel 内,由 fms-tree / utils 的既有用例覆盖,这里不重复。
|
||||
* 部门树的增删改与拖拽逻辑在本页内,但落库前的计算由 @/utils/tree 承担,
|
||||
* 已由 fms-tree / utils 的既有用例覆盖,这里不重复。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
@@ -94,8 +95,8 @@ const passthrough = {
|
||||
}
|
||||
|
||||
async function mountUser() {
|
||||
// DeptTreePanel 必须真实挂载:本页的「选中部门 → 查询条件」依赖它加载完部门后 emit('loaded'),
|
||||
// 若被默认打桩,emit 不会触发,页面拿到的 depts 恒为空。
|
||||
// 部门的加载在本页内完成(loadDepts 直接写 depts),测试只需让 loadDataApi 返回部门行,
|
||||
// 页面即可拿到 depts,不依赖任何子组件 emit。
|
||||
//
|
||||
// 布局容器必须按「Vue 实际推断出的组件名」打桩。
|
||||
// Splitter/SplitterPanel 两个 SFC 的文件名分别是 splitter.vue / panel.vue,
|
||||
@@ -105,7 +106,6 @@ async function mountUser() {
|
||||
stubs: {
|
||||
splitter: passthrough,
|
||||
panel: passthrough,
|
||||
DeptTreePanel: false,
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
Reference in new issue
Block a user