提交
This commit is contained in:
1 parent
06956b742b
commit
e59b2aa6cb
104 files changed
+653227
-7621
No files matched your search
@@ -283,7 +283,8 @@ export async function getQuoteApproverSetting() {
|
||||
data: {
|
||||
id: data.id,
|
||||
approverIds: (data.approverIds || []).map(String),
|
||||
status: data.status ?? '1',
|
||||
// 无 status 时默认启用;停用须显式为 '1'
|
||||
status: data.status === '1' ? '1' : '0',
|
||||
updateTime: data.updateTime
|
||||
} as QuoteApproverSettingVO
|
||||
}
|
||||
|
||||
@@ -264,11 +264,17 @@ function handleClose() {
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function defaultComment(type: typeof actionType.value) {
|
||||
if (type === 'approve') return '审批通过'
|
||||
if (type === 'reject') return '审批驳回'
|
||||
return ''
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
approvalType: 'sequential',
|
||||
approverIds: [],
|
||||
comment: '',
|
||||
comment: defaultComment(actionType.value),
|
||||
remark: ''
|
||||
}
|
||||
formRef.value?.clearValidate()
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterView v-if="isRefresh" v-slot="{ Component, route }" :style="contentStyle">
|
||||
<!-- 勿强制 minHeight:calc(100vh-…),否则门户布局会叠出多余纵向滚动条 -->
|
||||
<RouterView v-if="isRefresh" v-slot="{ Component, route }">
|
||||
<!-- 缓存路由动画 -->
|
||||
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
|
||||
<KeepAlive :max="keepAliveMax" :exclude="keepAliveExclude">
|
||||
@@ -58,7 +59,8 @@
|
||||
defineOptions({ name: 'ArtPageContent' })
|
||||
|
||||
const route = useRoute()
|
||||
const { containerMinHeight } = useAutoLayoutHeight()
|
||||
// 仅同步 --art-full-height;不给业务页强制 minHeight
|
||||
useAutoLayoutHeight()
|
||||
const { pageTransition, containerWidth, refresh, showWorkTab } = storeToRefs(useSettingStore())
|
||||
const { keepAliveExclude } = storeToRefs(useWorktabStore())
|
||||
|
||||
@@ -114,12 +116,6 @@
|
||||
}
|
||||
)
|
||||
|
||||
const contentStyle = computed(
|
||||
(): CSSProperties => ({
|
||||
minHeight: containerMinHeight.value
|
||||
})
|
||||
)
|
||||
|
||||
const reload = () => {
|
||||
isRefresh.value = false
|
||||
nextTick(() => {
|
||||
|
||||
@@ -1,71 +1,47 @@
|
||||
<!-- 一体化门户顶栏 -->
|
||||
<!-- 一体化门户顶栏:一级横排 + 大面板菜单(方案 A) -->
|
||||
<template>
|
||||
<header class="portal-header">
|
||||
<div class="portal-header-start">
|
||||
<div class="portal-header-brand" @click="toHome">
|
||||
<ArtLogo class="portal-header-logo" src="/logo-sx.svg" :size="28" />
|
||||
<p class="portal-header-title">{{ AppConfig.systemInfo.name }}</p>
|
||||
</div>
|
||||
<div class="portal-header-brand" @click="toHome">
|
||||
<ArtLogo class="portal-header-logo" src="/logo-sx.svg" :size="28" />
|
||||
<p class="portal-header-title">{{ AppConfig.systemInfo.name }}</p>
|
||||
</div>
|
||||
|
||||
<div class="portal-header-nav">
|
||||
<div class="portal-header-nav">
|
||||
<ArtPortalMegaMenu />
|
||||
</div>
|
||||
|
||||
<div class="portal-header-end">
|
||||
<div class="portal-header-actions">
|
||||
<ArtIconButton
|
||||
icon="fold"
|
||||
class="portal-header-icon-btn portal-header-fold-btn"
|
||||
:class="{ 'is-menu-collapsed': !menuOpen }"
|
||||
@click="toggleSidebar"
|
||||
v-if="shouldShowFullscreen"
|
||||
icon="fullscreen"
|
||||
:class="[
|
||||
'portal-header-icon-btn max-md:!hidden',
|
||||
!isFullscreen ? 'full-screen-btn' : 'exit-full-screen-btn'
|
||||
]"
|
||||
@click="toggleFullScreen"
|
||||
/>
|
||||
<ArtPortalModuleSelect />
|
||||
<ArtUserMenu />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="portal-header-center" aria-hidden="false">
|
||||
<p v-if="pageTitle" class="portal-header-page-title">{{ pageTitle }}</p>
|
||||
</div>
|
||||
|
||||
<div class="portal-header-actions">
|
||||
<ArtIconButton
|
||||
v-if="shouldShowFullscreen"
|
||||
icon="fullscreen"
|
||||
:class="[
|
||||
'portal-header-icon-btn max-md:!hidden',
|
||||
!isFullscreen ? 'full-screen-btn' : 'exit-full-screen-btn'
|
||||
]"
|
||||
@click="toggleFullScreen"
|
||||
/>
|
||||
<ArtUserMenu />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useFullscreen } from '@vueuse/core'
|
||||
import AppConfig from '@/config'
|
||||
import { useCommon } from '@/hooks/core/useCommon'
|
||||
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { formatMenuTitle } from '@/utils/router'
|
||||
import ArtUserMenu from '../art-header-bar/widget/ArtUserMenu.vue'
|
||||
import ArtPortalMegaMenu from '../art-portal-mega-menu/index.vue'
|
||||
|
||||
defineOptions({ name: 'ArtPortalHeader' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { homePath } = useCommon()
|
||||
const { shouldShowFullscreen } = useHeaderBar()
|
||||
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
|
||||
const settingStore = useSettingStore()
|
||||
const { menuOpen } = storeToRefs(settingStore)
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const matched = route.matched
|
||||
if (!matched.length) return ''
|
||||
const current = matched[matched.length - 1]
|
||||
const title = current.meta?.title
|
||||
return title ? formatMenuTitle(String(title)) : ''
|
||||
})
|
||||
|
||||
const toHome = (): void => {
|
||||
router.push(homePath.value)
|
||||
@@ -74,10 +50,6 @@
|
||||
const toggleFullScreen = (): void => {
|
||||
toggleFullscreen()
|
||||
}
|
||||
|
||||
const toggleSidebar = (): void => {
|
||||
settingStore.setMenuOpen(!menuOpen.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -85,42 +57,27 @@
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
/* 左侧无 padding:brand 区与侧栏同宽,右边框才能纵向对齐 */
|
||||
padding: 0 var(--mint-content-padding, 24px) 0 0;
|
||||
user-select: none;
|
||||
background: var(--mint-primary, #1ca851);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.portal-header-start {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.portal-header-brand {
|
||||
box-sizing: border-box;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
/* +1px:与侧栏 #app-sidebar 外边框(内容宽 + 1px border)纵向对齐 */
|
||||
width: calc(var(--app-sidebar-open-width, 270px) + 1px);
|
||||
min-width: calc(var(--app-sidebar-open-width, 270px) + 1px);
|
||||
max-width: calc(var(--app-sidebar-open-width, 270px) + 1px);
|
||||
max-width: none;
|
||||
padding-left: var(--mint-content-padding, 24px);
|
||||
padding-right: 16px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.portal-header-logo {
|
||||
@@ -128,11 +85,8 @@
|
||||
}
|
||||
|
||||
.portal-header-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
@@ -141,34 +95,27 @@
|
||||
}
|
||||
|
||||
.portal-header-nav {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-header-center {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
z-index: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
max-width: min(520px, calc(100% - 420px));
|
||||
pointer-events: none;
|
||||
width: max-content;
|
||||
max-width: min(720px, calc(100% - 520px));
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.portal-header-page-title {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
.portal-header-end {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.portal-header-actions {
|
||||
@@ -176,21 +123,6 @@
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
:deep(.portal-module-select-trigger) {
|
||||
background: transparent;
|
||||
|
||||
.portal-module-select-trigger__icon,
|
||||
.portal-module-select-trigger__label,
|
||||
.portal-module-select-trigger__arrow {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:hover:not(.is-disabled) {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.portal-header-icon-btn) {
|
||||
@@ -201,26 +133,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.portal-header-fold-btn :deep(.art-svg-icon) {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.portal-header-fold-btn.is-menu-collapsed :deep(.art-svg-icon) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
@media only screen and (width <= 800px) {
|
||||
.portal-header-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.portal-header-center {
|
||||
max-width: min(290px, calc(100% - 290px));
|
||||
}
|
||||
|
||||
.portal-header-page-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
<!-- 门户顶栏:一级横排 + 方案 A 大面板(左右分行二级/三级文字链) -->
|
||||
<template>
|
||||
<div class="portal-mega-menu" ref="rootRef">
|
||||
<nav class="portal-mega-l1" aria-label="一级模块">
|
||||
<button
|
||||
v-for="item in moduleList"
|
||||
:key="item.path"
|
||||
type="button"
|
||||
class="portal-mega-l1__item"
|
||||
:class="{
|
||||
'is-selected': isL1Selected(item)
|
||||
}"
|
||||
@click="toggleModule(item)"
|
||||
>
|
||||
<span>{{ item.formattedTitle }}</span>
|
||||
<span class="portal-mega-l1__caret">{{ openModulePath === item.path ? '▴' : '▾' }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="openModule"
|
||||
class="portal-mega-overlay"
|
||||
@click="closePanel"
|
||||
/>
|
||||
<div
|
||||
v-if="openModule"
|
||||
class="portal-mega-panel"
|
||||
role="dialog"
|
||||
aria-label="子菜单"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<div class="portal-mega-panel__body">
|
||||
<div class="portal-mega-split" :class="{ 'is-single': isSingleColumn }">
|
||||
<div class="portal-mega-pane">
|
||||
<div
|
||||
v-for="row in columns.left"
|
||||
:key="row.l2.path || row.l2.meta?.title"
|
||||
class="portal-mega-row"
|
||||
>
|
||||
<div class="portal-mega-row__l2">{{ formatMenuTitle(row.l2.meta.title) }}</div>
|
||||
<div class="portal-mega-row__l3">
|
||||
<button
|
||||
v-for="link in row.l3Links"
|
||||
:key="link.path || link.meta?.title"
|
||||
type="button"
|
||||
class="portal-mega-link"
|
||||
:class="{ 'is-active': isLinkActive(link) }"
|
||||
@click="handleLinkClick(link)"
|
||||
>
|
||||
{{ formatMenuTitle(link.meta.title) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!columns.left.length" class="portal-mega-empty">暂无菜单</p>
|
||||
</div>
|
||||
<div v-if="!isSingleColumn" class="portal-mega-pane portal-mega-pane--right">
|
||||
<div
|
||||
v-for="row in columns.right"
|
||||
:key="row.l2.path || row.l2.meta?.title"
|
||||
class="portal-mega-row"
|
||||
>
|
||||
<div class="portal-mega-row__l2">{{ formatMenuTitle(row.l2.meta.title) }}</div>
|
||||
<div class="portal-mega-row__l3">
|
||||
<button
|
||||
v-for="link in row.l3Links"
|
||||
:key="link.path || link.meta?.title"
|
||||
type="button"
|
||||
class="portal-mega-link"
|
||||
:class="{ 'is-active': isLinkActive(link) }"
|
||||
@click="handleLinkClick(link)"
|
||||
>
|
||||
{{ formatMenuTitle(link.meta.title) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { formatMenuTitle } from '@/utils/router'
|
||||
import { handleMenuJump } from '@/utils/navigation'
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import {
|
||||
buildMegaPanelColumns,
|
||||
filterPortalTopModules,
|
||||
isMegaPanelLinkActive,
|
||||
isPortalModuleActive,
|
||||
type PortalMegaPanelColumns
|
||||
} from '@/utils/router/portal-menu'
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
defineOptions({ name: 'ArtPortalMegaMenu' })
|
||||
|
||||
interface PortalModuleItem extends AppRouteRecord {
|
||||
isRouteActive: boolean
|
||||
formattedTitle: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const menuStore = useMenuStore()
|
||||
const { menuList } = storeToRefs(menuStore)
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const openModulePath = ref<string | null>(null)
|
||||
const panelStyle = ref<CSSProperties>({})
|
||||
|
||||
const currentRoutePath = computed(() => String(route.meta.activePath || route.path))
|
||||
|
||||
const moduleList = computed<PortalModuleItem[]>(() => {
|
||||
return filterPortalTopModules(menuList.value).map((item) => ({
|
||||
...item,
|
||||
isRouteActive: isPortalModuleActive(item, currentRoutePath.value),
|
||||
formattedTitle: formatMenuTitle(item.meta.title)
|
||||
}))
|
||||
})
|
||||
|
||||
const openModule = computed(() => {
|
||||
if (!openModulePath.value) return undefined
|
||||
return moduleList.value.find((item) => item.path === openModulePath.value)
|
||||
})
|
||||
|
||||
const columns = computed<PortalMegaPanelColumns>(() =>
|
||||
buildMegaPanelColumns(openModule.value)
|
||||
)
|
||||
|
||||
const isSingleColumn = computed(() => columns.value.right.length === 0)
|
||||
|
||||
/** 与顶栏中间槽上限一致;一级较少时保证两栏二级可读 */
|
||||
const PANEL_MIN_WIDTH = 720
|
||||
const PANEL_EDGE_PAD = 16
|
||||
|
||||
const syncPanelPosition = (): void => {
|
||||
const el = rootRef.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const viewportW = window.innerWidth
|
||||
const minWidth = Math.min(PANEL_MIN_WIDTH, Math.max(0, viewportW - PANEL_EDGE_PAD * 2))
|
||||
const width = Math.max(rect.width, minWidth)
|
||||
const centerX = rect.left + rect.width / 2
|
||||
let left = centerX - width / 2
|
||||
left = Math.max(PANEL_EDGE_PAD, Math.min(left, viewportW - width - PANEL_EDGE_PAD))
|
||||
panelStyle.value = {
|
||||
top: `${rect.bottom}px`,
|
||||
left: `${left}px`,
|
||||
width: `${width}px`
|
||||
}
|
||||
}
|
||||
|
||||
const closePanel = (): void => {
|
||||
openModulePath.value = null
|
||||
}
|
||||
|
||||
const toggleModule = async (item: PortalModuleItem): Promise<void> => {
|
||||
const path = String(item.path || '')
|
||||
if (!path) return
|
||||
openModulePath.value = openModulePath.value === path ? null : path
|
||||
if (openModulePath.value) {
|
||||
await nextTick()
|
||||
syncPanelPosition()
|
||||
}
|
||||
}
|
||||
|
||||
/** 顶栏同时只高亮一个一级:面板展开时跟展开项,否则跟当前路由 */
|
||||
const isL1Selected = (item: PortalModuleItem): boolean => {
|
||||
if (openModulePath.value) {
|
||||
return openModulePath.value === item.path
|
||||
}
|
||||
return item.isRouteActive
|
||||
}
|
||||
|
||||
const isLinkActive = (link: AppRouteRecord): boolean => {
|
||||
return isMegaPanelLinkActive(link, currentRoutePath.value)
|
||||
}
|
||||
|
||||
const handleLinkClick = (link: AppRouteRecord): void => {
|
||||
closePanel()
|
||||
handleMenuJump(link)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
closePanel()
|
||||
}
|
||||
)
|
||||
|
||||
watch(openModulePath, async (path) => {
|
||||
if (!path) return
|
||||
await nextTick()
|
||||
syncPanelPosition()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', syncPanelPosition)
|
||||
window.addEventListener('scroll', syncPanelPosition, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', syncPanelPosition)
|
||||
window.removeEventListener('scroll', syncPanelPosition, true)
|
||||
})
|
||||
|
||||
onClickOutside(rootRef, (event) => {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target?.closest?.('.portal-mega-panel')) {
|
||||
return
|
||||
}
|
||||
closePanel()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.portal-mega-menu {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.portal-mega-l1 {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 2px;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
max-height: 100%;
|
||||
/* 仅允许横向溢出;若只写 overflow-x:auto,overflow-y 会被算成 auto,易出现竖向滚动条 */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.portal-mega-l1__item {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
max-height: 56px;
|
||||
padding: 0 14px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 4px solid transparent;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-mega-l1__caret {
|
||||
font-size: 10px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.portal-mega-overlay {
|
||||
position: fixed;
|
||||
inset: 56px 0 0 0;
|
||||
z-index: 190;
|
||||
background: rgba(15, 23, 42, 0.25);
|
||||
}
|
||||
|
||||
.portal-mega-panel {
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
box-sizing: border-box;
|
||||
max-height: min(720px, calc(100vh - 72px));
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border: 1px solid var(--mint-border, #e2e8f0);
|
||||
border-top: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.portal-mega-panel__body {
|
||||
padding: 12px 16px 18px;
|
||||
}
|
||||
|
||||
.portal-mega-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 16px;
|
||||
align-items: stretch;
|
||||
|
||||
&.is-single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-mega-pane {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.portal-mega-pane--right {
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--mint-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.portal-mega-row {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr;
|
||||
gap: 8px 14px;
|
||||
align-items: start;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-mega-row__l2 {
|
||||
padding-top: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.portal-mega-row__l3 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.portal-mega-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 0;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
color: #0f172a;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-dark, #059669);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
font-weight: 600;
|
||||
color: var(--primary-dark, #059669);
|
||||
}
|
||||
}
|
||||
|
||||
.portal-mega-empty {
|
||||
margin: 12px 0;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@media only screen and (width <= 900px) {
|
||||
.portal-mega-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.portal-mega-pane--right {
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.portal-mega-row {
|
||||
grid-template-columns: 96px 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,29 +1,24 @@
|
||||
/**
|
||||
* useLayoutHeight - 页面布局高度管理
|
||||
*
|
||||
* 自动计算和管理页面内容区域的高度,确保内容区域能够正确填充剩余空间。
|
||||
* 监听头部元素高度变化,动态调整内容区域高度,避免出现滚动条或布局错乱。
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* 1. 动态高度计算 - 根据头部元素高度自动计算内容区域高度
|
||||
* 2. 响应式监听 - 自动监听元素尺寸变化并更新高度
|
||||
* 3. CSS 变量同步 - 自动更新 CSS 变量,方便全局使用
|
||||
* 4. 灵活配置 - 支持自定义间距、CSS 变量名等
|
||||
* 5. 自动查找模式 - 提供通过 ID 自动查找元素的便捷方式
|
||||
* 以实际内容滚动容器 `#app-content` 为高度来源,同步 `--art-full-height`。
|
||||
* 页面已在该容器内滚动,禁止再用 `calc(100vh - 顶栏)` 给业务页强制 minHeight,
|
||||
* 否则门户布局下(顶栏为 `#app-portal-header`)会量不到顶栏,叠出多余纵向滚动条与底部空白。
|
||||
*
|
||||
* @module useLayoutHeight
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted, type Ref } from 'vue'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
|
||||
/**
|
||||
* 页面容器高度配置
|
||||
*/
|
||||
interface LayoutHeightOptions {
|
||||
/** 额外的间距(默认 15px) */
|
||||
/**
|
||||
* @deprecated 已不再用于 100vh 扣减;保留参数以免调用方类型报错
|
||||
*/
|
||||
extraSpacing?: number
|
||||
/** 是否自动更新 CSS 变量(默认 true) */
|
||||
updateCssVar?: boolean
|
||||
@@ -31,26 +26,21 @@ interface LayoutHeightOptions {
|
||||
cssVarName?: string
|
||||
}
|
||||
|
||||
export function useLayoutHeight(options: LayoutHeightOptions = {}) {
|
||||
const { extraSpacing = 15, updateCssVar = true, cssVarName = '--art-full-height' } = options
|
||||
function syncFullHeightCssVar(
|
||||
availableHeight: Ref<number>,
|
||||
contentHeaderHeight: Ref<number>,
|
||||
options: LayoutHeightOptions
|
||||
) {
|
||||
const { updateCssVar = true, cssVarName = '--art-full-height' } = options
|
||||
|
||||
// 元素引用
|
||||
const headerRef = ref<HTMLElement>()
|
||||
const contentHeaderRef = ref<HTMLElement>()
|
||||
|
||||
// 使用 VueUse 自动监听元素尺寸变化
|
||||
const { height: headerHeight } = useElementSize(headerRef)
|
||||
const { height: contentHeaderHeight } = useElementSize(contentHeaderRef)
|
||||
|
||||
// 计算容器最小高度(响应式)
|
||||
const containerMinHeight = computed(() => {
|
||||
const totalHeight = headerHeight.value + contentHeaderHeight.value + extraSpacing
|
||||
return `calc(100vh - ${totalHeight}px)`
|
||||
const fullHeight = computed(() => {
|
||||
const available = Math.max(0, Math.round(availableHeight.value - contentHeaderHeight.value))
|
||||
return available > 0 ? `${available}px` : '100%'
|
||||
})
|
||||
|
||||
if (updateCssVar) {
|
||||
watch(
|
||||
containerMinHeight,
|
||||
fullHeight,
|
||||
(newHeight) => {
|
||||
requestAnimationFrame(() => {
|
||||
document.documentElement.style.setProperty(cssVarName, newHeight)
|
||||
@@ -60,89 +50,63 @@ export function useLayoutHeight(options: LayoutHeightOptions = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
return fullHeight
|
||||
}
|
||||
|
||||
function resolveHeaderEl(): HTMLElement | undefined {
|
||||
if (typeof document === 'undefined') return undefined
|
||||
return (
|
||||
document.getElementById('app-portal-header') ??
|
||||
document.getElementById('app-header') ??
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
export function useLayoutHeight(options: LayoutHeightOptions = {}) {
|
||||
const appContentRef = ref<HTMLElement>()
|
||||
const headerRef = ref<HTMLElement>()
|
||||
const contentHeaderRef = ref<HTMLElement>()
|
||||
|
||||
const { height: appContentHeight } = useElementSize(appContentRef)
|
||||
const { height: headerHeight } = useElementSize(headerRef)
|
||||
const { height: contentHeaderHeight } = useElementSize(contentHeaderRef)
|
||||
|
||||
/** @deprecated 请勿再用于业务页 minHeight;始终为空字符串 */
|
||||
const containerMinHeight = computed(() => '' as string)
|
||||
|
||||
syncFullHeightCssVar(appContentHeight, contentHeaderHeight, options)
|
||||
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
appContentRef.value = document.getElementById('app-content') ?? undefined
|
||||
contentHeaderRef.value = document.getElementById('app-content-header') ?? undefined
|
||||
if (!headerRef.value) {
|
||||
headerRef.value = resolveHeaderEl()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
/** 容器最小高度(响应式) */
|
||||
containerMinHeight,
|
||||
/** 头部元素引用 */
|
||||
headerRef,
|
||||
/** 内容头部元素引用 */
|
||||
contentHeaderRef,
|
||||
/** 头部高度(响应式) */
|
||||
headerHeight,
|
||||
/** 内容头部高度(响应式) */
|
||||
contentHeaderHeight
|
||||
contentHeaderHeight,
|
||||
appContentHeight
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ID 自动查找元素的布局高度管理
|
||||
* 适用于无法直接获取元素引用的场景
|
||||
* 通过 ID 自动查找内容区,同步全高 CSS 变量。
|
||||
* 默认不强制业务页 minHeight(由 ArtPageContent 决定)。
|
||||
*
|
||||
* @param headerIds 头部元素的 ID 数组
|
||||
* @param _headerIds 兼容旧签名,已忽略(改为测量 `#app-content`)
|
||||
* @param options 配置选项
|
||||
*
|
||||
* ```
|
||||
*/
|
||||
export function useAutoLayoutHeight(
|
||||
headerIds: string[] = ['app-header', 'app-content-header'],
|
||||
_headerIds: string[] = ['app-header', 'app-content-header'],
|
||||
options: LayoutHeightOptions = {}
|
||||
) {
|
||||
const { extraSpacing = 15, updateCssVar = true, cssVarName = '--art-full-height' } = options
|
||||
|
||||
// 创建元素引用
|
||||
const headerRef = ref<HTMLElement>()
|
||||
const contentHeaderRef = ref<HTMLElement>()
|
||||
|
||||
// 使用 VueUse 自动监听元素尺寸变化
|
||||
const { height: headerHeight } = useElementSize(headerRef)
|
||||
const { height: contentHeaderHeight } = useElementSize(contentHeaderRef)
|
||||
|
||||
// 计算容器最小高度(响应式)
|
||||
const containerMinHeight = computed(() => {
|
||||
const totalHeight = headerHeight.value + contentHeaderHeight.value + extraSpacing
|
||||
return `calc(100vh - ${totalHeight}px)`
|
||||
})
|
||||
|
||||
if (updateCssVar) {
|
||||
watch(
|
||||
containerMinHeight,
|
||||
(newHeight) => {
|
||||
requestAnimationFrame(() => {
|
||||
document.documentElement.style.setProperty(cssVarName, newHeight)
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
|
||||
// 在 DOM 挂载后查找元素
|
||||
onMounted(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
// 使用 nextTick 确保 DOM 完全渲染
|
||||
requestAnimationFrame(() => {
|
||||
const header = document.getElementById(headerIds[0])
|
||||
const contentHeader = document.getElementById(headerIds[1])
|
||||
|
||||
if (header) {
|
||||
headerRef.value = header
|
||||
}
|
||||
if (contentHeader) {
|
||||
contentHeaderRef.value = contentHeader
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
/** 容器最小高度(响应式) */
|
||||
containerMinHeight,
|
||||
/** 头部元素引用 */
|
||||
headerRef,
|
||||
/** 内容头部元素引用 */
|
||||
contentHeaderRef,
|
||||
/** 头部高度(响应式) */
|
||||
headerHeight,
|
||||
/** 内容头部高度(响应式) */
|
||||
contentHeaderHeight
|
||||
}
|
||||
return useLayoutHeight(options)
|
||||
}
|
||||
@@ -1,6 +1,22 @@
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
import { getFirstMenuPath, getFirstSecondLevelMenuPath, isIframe } from '@/utils/navigation/route'
|
||||
import { filterSidebarRoutes } from '@/utils/router/sidebar-menu-filter'
|
||||
import {
|
||||
filterSidebarRoutes,
|
||||
isSidebarNavigableRoute
|
||||
} from '@/utils/router/sidebar-menu-filter'
|
||||
|
||||
/** 大面板一行:二级标题 + 三级可点链接 */
|
||||
export interface PortalMegaPanelRow {
|
||||
/** 二级菜单(仅展示标题,不可点) */
|
||||
l2: AppRouteRecord
|
||||
/** 三级链接(含虚拟三级) */
|
||||
l3Links: AppRouteRecord[]
|
||||
}
|
||||
|
||||
export interface PortalMegaPanelColumns {
|
||||
left: PortalMegaPanelRow[]
|
||||
right: PortalMegaPanelRow[]
|
||||
}
|
||||
|
||||
/** 门户顶栏一级模块展示顺序(与路由注册顺序无关) */
|
||||
export const PORTAL_TOP_MODULE_ORDER = [
|
||||
@@ -179,3 +195,88 @@ export function getPortalDefaultPathForRoute(
|
||||
|
||||
return getFirstAccessiblePath(module)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析二级下的三级可点链接。
|
||||
* 无子节点且二级自身可导航时,生成与二级同名的虚拟三级(路由继承二级)。
|
||||
*/
|
||||
export function resolveMegaPanelL3Links(l2: AppRouteRecord): AppRouteRecord[] {
|
||||
const children = filterSidebarRoutes(l2.children || [])
|
||||
if (!children.length) {
|
||||
if (!isSidebarNavigableRoute(l2)) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
...l2,
|
||||
children: undefined,
|
||||
meta: {
|
||||
...l2.meta,
|
||||
title: l2.meta.title
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const links: AppRouteRecord[] = []
|
||||
for (const child of children) {
|
||||
if (isSidebarNavigableRoute(child)) {
|
||||
links.push(child)
|
||||
continue
|
||||
}
|
||||
const grandChildren = filterSidebarRoutes(child.children || [])
|
||||
for (const gc of grandChildren) {
|
||||
if (isSidebarNavigableRoute(gc)) {
|
||||
links.push(gc)
|
||||
}
|
||||
}
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
/** 将一级模块下的二三级整理为大面板左右两栏行数据 */
|
||||
export function buildMegaPanelColumns(
|
||||
l1Module: AppRouteRecord | undefined
|
||||
): PortalMegaPanelColumns {
|
||||
if (!l1Module) {
|
||||
return { left: [], right: [] }
|
||||
}
|
||||
|
||||
const l2List = filterSidebarRoutes(l1Module.children || [])
|
||||
const rows: PortalMegaPanelRow[] = []
|
||||
|
||||
for (const l2 of l2List) {
|
||||
const l3Links = resolveMegaPanelL3Links(l2)
|
||||
if (!l3Links.length) {
|
||||
continue
|
||||
}
|
||||
rows.push({ l2, l3Links })
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
return { left: [], right: [] }
|
||||
}
|
||||
|
||||
// 仅 1 个二级:单列展示,避免右侧空白
|
||||
if (rows.length === 1) {
|
||||
return { left: rows, right: [] }
|
||||
}
|
||||
|
||||
// 左右均分;超出建议可见行数时由面板整体纵向滚动
|
||||
// 例:4 → 2+2;7 → 4+3;10 → 5+5;12 → 6+6(不再 5+7)
|
||||
const leftCount = Math.ceil(rows.length / 2)
|
||||
return {
|
||||
left: rows.slice(0, leftCount),
|
||||
right: rows.slice(leftCount)
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断三级链接是否为当前路由 */
|
||||
export function isMegaPanelLinkActive(item: AppRouteRecord, routePath: string): boolean {
|
||||
const activePath = String(routePath)
|
||||
const itemPath = String(item.meta?.activePath || item.path || '')
|
||||
if (!itemPath) {
|
||||
return false
|
||||
}
|
||||
return activePath === itemPath || activePath.startsWith(`${itemPath}/`)
|
||||
}
|
||||
@@ -684,15 +684,40 @@ async function handleToolbarSubmit() {
|
||||
ElMessage.warning('请先勾选要提交的申请')
|
||||
return
|
||||
}
|
||||
|
||||
const validTargets = selectedRows.value.filter(isDraftEditable)
|
||||
const skippedCount = selectedRows.value.length - validTargets.length
|
||||
|
||||
if (!validTargets.length) {
|
||||
ElMessage.warning('所选申请均不是草稿或已驳回状态,无法提交审批')
|
||||
return
|
||||
}
|
||||
|
||||
if (skippedCount > 0) {
|
||||
ElMessage.warning(
|
||||
`已跳过 ${skippedCount} 条不可提交的申请(仅草稿或已驳回可提交),将继续提交其余 ${validTargets.length} 条`
|
||||
)
|
||||
}
|
||||
|
||||
loadingMap.submit = true
|
||||
try {
|
||||
for (const row of selectedRows.value) {
|
||||
await advanceApi.submit(row.id)
|
||||
const results = await Promise.allSettled(validTargets.map((row) => advanceApi.submit(row.id)))
|
||||
const successCount = results.filter((item) => item.status === 'fulfilled').length
|
||||
const failedCount = results.length - successCount
|
||||
const skipText = skippedCount > 0 ? `,跳过 ${skippedCount} 条` : ''
|
||||
|
||||
if (successCount === 0) {
|
||||
ElMessage.error(`提交失败${skipText}`)
|
||||
return
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
ElMessage.warning(`成功提交 ${successCount} 条,失败 ${failedCount} 条${skipText}`)
|
||||
} else if (successCount === 1 && skippedCount === 0) {
|
||||
ElMessage.success('提交成功')
|
||||
} else {
|
||||
ElMessage.success(`成功提交 ${successCount} 条${skipText}`)
|
||||
}
|
||||
ElMessage.success('提交成功')
|
||||
await refreshList()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '提交失败')
|
||||
} finally {
|
||||
loadingMap.submit = false
|
||||
}
|
||||
|
||||
@@ -420,6 +420,22 @@
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-alert
|
||||
v-if="approveRows.length > 1"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="batch-action-alert batch-action-alert--success"
|
||||
:title="`将${approveAction === 'approve' ? '审批通过' : '审批驳回'} ${approveRows.length} 条申请(使用同一审批意见)`"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="approveSkippedCount > 0"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="batch-action-alert batch-action-alert--danger"
|
||||
:title="`已跳过 ${approveSkippedCount} 条不可审批的申请`"
|
||||
/>
|
||||
<el-form label-position="top" class="bx-dialog-form">
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="审批时间">
|
||||
@@ -570,8 +586,32 @@ function formatNow() {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
const approveSkippedCount = ref(0)
|
||||
|
||||
function isApprovableRow(row?: any) {
|
||||
if (!row) return false
|
||||
return !row.status || row.status === 'pending'
|
||||
}
|
||||
|
||||
function openApproveDialog(rows: any[], action: 'approve' | 'reject') {
|
||||
approveRows.value = rows
|
||||
const validRows = (rows || []).filter(isApprovableRow)
|
||||
const skippedCount = (rows || []).length - validRows.length
|
||||
|
||||
if (!validRows.length) {
|
||||
ElMessage.warning(
|
||||
action === 'approve' ? '所选申请均不可审批通过' : '所选申请均不可驳回'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (skippedCount > 0) {
|
||||
ElMessage.warning(
|
||||
`已跳过 ${skippedCount} 条不可审批的申请,将继续处理其余 ${validRows.length} 条`
|
||||
)
|
||||
}
|
||||
|
||||
approveRows.value = validRows
|
||||
approveSkippedCount.value = skippedCount
|
||||
approveAction.value = action
|
||||
approvalOpinion.value = action === 'approve' ? '审批通过' : '审批驳回'
|
||||
approveTime.value = formatNow()
|
||||
@@ -742,22 +782,61 @@ const pendingSummaryMethod = amountSummary
|
||||
const approvedSummaryMethod = amountSummary
|
||||
|
||||
async function handleConfirmApprove() {
|
||||
if (!approveRows.value.length) {
|
||||
ElMessage.warning('没有可审批的申请')
|
||||
return
|
||||
}
|
||||
|
||||
const key = approveAction.value === 'approve' ? 'approvePass' : 'approveReject'
|
||||
loadingMap[key] = true
|
||||
const apiFn = approveAction.value === 'approve' ? advanceApi.approve : advanceApi.reject
|
||||
const msg = approveAction.value === 'approve' ? '审批通过成功' : '驳回成功'
|
||||
const actionLabel = approveAction.value === 'approve' ? '审批通过' : '审批驳回'
|
||||
|
||||
try {
|
||||
for (const row of approveRows.value) {
|
||||
await apiFn(row.id, { approvalOpinion: approvalOpinion.value })
|
||||
const results = await Promise.allSettled(
|
||||
approveRows.value.map((row) => apiFn(row.id, { approvalOpinion: approvalOpinion.value }))
|
||||
)
|
||||
const successCount = results.filter((item) => item.status === 'fulfilled').length
|
||||
const failedCount = results.length - successCount
|
||||
const skipText = approveSkippedCount.value > 0 ? `,跳过 ${approveSkippedCount.value} 条` : ''
|
||||
|
||||
if (successCount === 0) {
|
||||
ElMessage.error(`${actionLabel}失败${skipText}`)
|
||||
return
|
||||
}
|
||||
ElMessage.success(approveRows.value.length > 1 ? `批量${msg}` : msg)
|
||||
if (failedCount > 0) {
|
||||
ElMessage.warning(`成功${actionLabel} ${successCount} 条,失败 ${failedCount} 条${skipText}`)
|
||||
} else if (successCount === 1 && approveSkippedCount.value === 0) {
|
||||
ElMessage.success(`${actionLabel}成功`)
|
||||
} else {
|
||||
ElMessage.success(`成功${actionLabel} ${successCount} 条${skipText}`)
|
||||
}
|
||||
|
||||
dialogs.approve = false
|
||||
await refreshPending()
|
||||
await refreshMenuBadges(menuStore.menuList)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '操作失败')
|
||||
} finally {
|
||||
loadingMap[key] = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.batch-action-alert {
|
||||
margin-bottom: 12px;
|
||||
|
||||
&--success {
|
||||
:deep(.el-alert__title),
|
||||
:deep(.el-alert__icon) {
|
||||
color: var(--primary, #10b981);
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
:deep(.el-alert__title),
|
||||
:deep(.el-alert__icon) {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1107,13 +1107,22 @@ async function handleToolbarSubmit() {
|
||||
ElMessage.warning('请先勾选要提交的报销单')
|
||||
return
|
||||
}
|
||||
if (!selectedRows.value.every(isDraftEditable)) {
|
||||
ElMessage.warning('仅草稿或已驳回的报销单可提交')
|
||||
|
||||
const validTargets = selectedRows.value.filter(isDraftEditable)
|
||||
const skippedCount = selectedRows.value.length - validTargets.length
|
||||
|
||||
if (!validTargets.length) {
|
||||
ElMessage.warning('所选报销单均不是草稿或已驳回状态,无法提交审批')
|
||||
return
|
||||
}
|
||||
|
||||
if (skippedCount > 0) {
|
||||
ElMessage.warning(`已跳过 ${skippedCount} 条不可提交的报销单(仅草稿或已驳回可提交),将继续提交其余 ${validTargets.length} 条`)
|
||||
}
|
||||
|
||||
// 按申请人分别校验发票余额(代申请时用申请人额度)
|
||||
const needByApplicant = new Map<string, number>()
|
||||
for (const row of selectedRows.value) {
|
||||
for (const row of validTargets) {
|
||||
const aid = String(row.applicantId || currentUserId())
|
||||
needByApplicant.set(aid, (needByApplicant.get(aid) || 0) + Number(row.amount || 0))
|
||||
}
|
||||
@@ -1126,15 +1135,26 @@ async function handleToolbarSubmit() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
loadingMap.submit = true
|
||||
try {
|
||||
for (const row of selectedRows.value) {
|
||||
await reimburseApi.submit(row.id)
|
||||
const results = await Promise.allSettled(validTargets.map((row) => reimburseApi.submit(row.id)))
|
||||
const successCount = results.filter((item) => item.status === 'fulfilled').length
|
||||
const failedCount = results.length - successCount
|
||||
const skipText = skippedCount > 0 ? `,跳过 ${skippedCount} 条` : ''
|
||||
|
||||
if (successCount === 0) {
|
||||
ElMessage.error(`提交失败${skipText}`)
|
||||
return
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
ElMessage.warning(`成功提交 ${successCount} 条,失败 ${failedCount} 条${skipText}`)
|
||||
} else if (successCount === 1 && skippedCount === 0) {
|
||||
ElMessage.success('提交成功')
|
||||
} else {
|
||||
ElMessage.success(`成功提交 ${successCount} 条${skipText}`)
|
||||
}
|
||||
ElMessage.success('提交成功')
|
||||
await refreshList()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '提交失败')
|
||||
} finally {
|
||||
loadingMap.submit = false
|
||||
}
|
||||
|
||||
@@ -551,6 +551,22 @@
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-alert
|
||||
v-if="approveRows.length > 1"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="batch-action-alert batch-action-alert--success"
|
||||
:title="`将${approveAction === 'approve' ? '审批通过' : '审批驳回'} ${approveRows.length} 条报销单(使用同一审批意见)`"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="approveSkippedCount > 0"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="batch-action-alert batch-action-alert--danger"
|
||||
:title="`已跳过 ${approveSkippedCount} 条不可审批的报销单`"
|
||||
/>
|
||||
<el-form label-position="top" class="bx-dialog-form">
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="审批时间">
|
||||
@@ -741,8 +757,35 @@ function formatNow() {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
const approveSkippedCount = ref(0)
|
||||
|
||||
function isApprovableRow(row?: any) {
|
||||
if (!row) return false
|
||||
// 待审批列表中的单据;若状态已变则不可再审
|
||||
return !row.status || row.status === 'pending'
|
||||
}
|
||||
|
||||
function openApproveDialog(rows: any[], action: 'approve' | 'reject') {
|
||||
approveRows.value = rows
|
||||
const validRows = (rows || []).filter(isApprovableRow)
|
||||
const skippedCount = (rows || []).length - validRows.length
|
||||
|
||||
if (!validRows.length) {
|
||||
ElMessage.warning(
|
||||
action === 'approve'
|
||||
? '所选报销单均不可审批通过'
|
||||
: '所选报销单均不可驳回'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (skippedCount > 0) {
|
||||
ElMessage.warning(
|
||||
`已跳过 ${skippedCount} 条不可审批的报销单,将继续处理其余 ${validRows.length} 条`
|
||||
)
|
||||
}
|
||||
|
||||
approveRows.value = validRows
|
||||
approveSkippedCount.value = skippedCount
|
||||
approveAction.value = action
|
||||
approvalOpinion.value = action === 'approve' ? '审批通过' : '审批驳回'
|
||||
approveTime.value = formatNow()
|
||||
@@ -943,20 +986,39 @@ async function openView(row: any) {
|
||||
}
|
||||
|
||||
async function handleConfirmApprove() {
|
||||
if (!approveRows.value.length) {
|
||||
ElMessage.warning('没有可审批的报销单')
|
||||
return
|
||||
}
|
||||
|
||||
const key = approveAction.value === 'approve' ? 'approvePass' : 'approveReject'
|
||||
loadingMap[key] = true
|
||||
const apiFn = approveAction.value === 'approve' ? reimburseApi.approve : reimburseApi.reject
|
||||
const msg = approveAction.value === 'approve' ? '审批通过成功' : '驳回成功'
|
||||
const actionLabel = approveAction.value === 'approve' ? '审批通过' : '审批驳回'
|
||||
|
||||
try {
|
||||
for (const row of approveRows.value) {
|
||||
await apiFn(row.id, { approvalOpinion: approvalOpinion.value })
|
||||
const results = await Promise.allSettled(
|
||||
approveRows.value.map((row) => apiFn(row.id, { approvalOpinion: approvalOpinion.value }))
|
||||
)
|
||||
const successCount = results.filter((item) => item.status === 'fulfilled').length
|
||||
const failedCount = results.length - successCount
|
||||
const skipText = approveSkippedCount.value > 0 ? `,跳过 ${approveSkippedCount.value} 条` : ''
|
||||
|
||||
if (successCount === 0) {
|
||||
ElMessage.error(`${actionLabel}失败${skipText}`)
|
||||
return
|
||||
}
|
||||
ElMessage.success(approveRows.value.length > 1 ? `批量${msg}` : msg)
|
||||
if (failedCount > 0) {
|
||||
ElMessage.warning(`成功${actionLabel} ${successCount} 条,失败 ${failedCount} 条${skipText}`)
|
||||
} else if (successCount === 1 && approveSkippedCount.value === 0) {
|
||||
ElMessage.success(`${actionLabel}成功`)
|
||||
} else {
|
||||
ElMessage.success(`成功${actionLabel} ${successCount} 条${skipText}`)
|
||||
}
|
||||
|
||||
dialogs.approve = false
|
||||
await refreshPending()
|
||||
await refreshMenuBadges(menuStore.menuList)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '操作失败')
|
||||
} finally {
|
||||
loadingMap[key] = false
|
||||
}
|
||||
@@ -987,3 +1049,23 @@ function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) {
|
||||
return sums
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.batch-action-alert {
|
||||
margin-bottom: 12px;
|
||||
|
||||
&--success {
|
||||
:deep(.el-alert__title),
|
||||
:deep(.el-alert__icon) {
|
||||
color: var(--primary, #10b981);
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
:deep(.el-alert__title),
|
||||
:deep(.el-alert__icon) {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -496,7 +496,7 @@ async function handleApprove(row: any) {
|
||||
loadingMap[key] = true
|
||||
try {
|
||||
const api = row.bizType === '备用金' ? advanceApi : reimburseApi
|
||||
await api.approve(row.id, { approvalOpinion: '' })
|
||||
await api.approve(row.id, { approvalOpinion: '审批通过' })
|
||||
ElMessage.success('审批通过')
|
||||
await loadData()
|
||||
await refreshSidebarBadges()
|
||||
@@ -509,7 +509,7 @@ async function handleApprove(row: any) {
|
||||
|
||||
function openRejectDialog(row: any) {
|
||||
currentRow.value = row
|
||||
approveOpinion.value = ''
|
||||
approveOpinion.value = '审批驳回'
|
||||
dialogs.approve = true
|
||||
}
|
||||
|
||||
@@ -518,7 +518,8 @@ async function handleDialogApprove() {
|
||||
loadingMap.approvePass = true
|
||||
try {
|
||||
const api = currentRow.value.bizType === '备用金' ? advanceApi : reimburseApi
|
||||
await api.approve(currentRow.value.id, { approvalOpinion: approveOpinion.value })
|
||||
const opinion = approveOpinion.value.trim() || '审批通过'
|
||||
await api.approve(currentRow.value.id, { approvalOpinion: opinion })
|
||||
ElMessage.success('审批通过')
|
||||
dialogs.approve = false
|
||||
await loadData()
|
||||
@@ -535,7 +536,8 @@ async function handleDialogReject() {
|
||||
loadingMap.approveReject = true
|
||||
try {
|
||||
const api = currentRow.value.bizType === '备用金' ? advanceApi : reimburseApi
|
||||
await api.reject(currentRow.value.id, { approvalOpinion: approveOpinion.value })
|
||||
const opinion = approveOpinion.value.trim() || '审批驳回'
|
||||
await api.reject(currentRow.value.id, { approvalOpinion: opinion })
|
||||
ElMessage.success('已驳回')
|
||||
dialogs.approve = false
|
||||
await loadData()
|
||||
@@ -562,7 +564,7 @@ async function handleBatchPass() {
|
||||
try {
|
||||
for (const row of selectedRows.value) {
|
||||
const api = row.bizType === '备用金' ? advanceApi : reimburseApi
|
||||
await api.approve(row.id, { approvalOpinion: '' })
|
||||
await api.approve(row.id, { approvalOpinion: '审批通过' })
|
||||
}
|
||||
ElMessage.success('批量审批通过成功')
|
||||
await loadData()
|
||||
@@ -583,7 +585,7 @@ async function handleBatchReject() {
|
||||
try {
|
||||
for (const row of selectedRows.value) {
|
||||
const api = row.bizType === '备用金' ? advanceApi : reimburseApi
|
||||
await api.reject(row.id, { approvalOpinion: '' })
|
||||
await api.reject(row.id, { approvalOpinion: '审批驳回' })
|
||||
}
|
||||
ElMessage.success('批量驳回成功')
|
||||
await loadData()
|
||||
|
||||
@@ -271,7 +271,7 @@
|
||||
<script setup lang="ts" name="ContractApproval">
|
||||
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
|
||||
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
@@ -479,11 +479,29 @@
|
||||
})
|
||||
}
|
||||
|
||||
const DEFAULT_OPINIONS = ['审批通过', '审批驳回'] as const
|
||||
|
||||
const defaultOpinion = (result: string) => (result === 'rejected' ? '审批驳回' : '审批通过')
|
||||
|
||||
watch(
|
||||
() => approvalForm.value.approvalResult,
|
||||
(result) => {
|
||||
if (
|
||||
!approvalForm.value.approvalOpinion.trim() ||
|
||||
DEFAULT_OPINIONS.includes(
|
||||
approvalForm.value.approvalOpinion as (typeof DEFAULT_OPINIONS)[number]
|
||||
)
|
||||
) {
|
||||
approvalForm.value.approvalOpinion = defaultOpinion(result)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleApprove = (row: ApprovalVO) => {
|
||||
approvalForm.value = {
|
||||
id: row.id,
|
||||
approvalResult: 'approved',
|
||||
approvalOpinion: ''
|
||||
approvalOpinion: defaultOpinion('approved')
|
||||
}
|
||||
approvalDialog.visible = true
|
||||
}
|
||||
|
||||
@@ -10,6 +10,22 @@
|
||||
modal-class="order-module-dialog-mask"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-alert
|
||||
v-if="isBatchMode && batchCount > 0"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="batch-action-alert batch-action-alert--success"
|
||||
:title="batchInfoTitle"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="skippedCount > 0"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="batch-action-alert batch-action-alert--danger"
|
||||
:title="`已跳过 ${skippedCount} 条不可处理的合同`"
|
||||
/>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
@@ -35,14 +51,6 @@
|
||||
<template #label>
|
||||
选择审批人<span class="form-label-tip">支持搜索用户姓名</span>
|
||||
</template>
|
||||
<el-alert
|
||||
v-if="submitPrefillTip"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="已按「合同审批人设置」预填审批人,可修改后提交"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
<el-select
|
||||
v-model="form.approverIds"
|
||||
multiple
|
||||
@@ -158,6 +166,7 @@ const dialogVisible = ref(false)
|
||||
const actionType = ref<'submit' | 'approve' | 'reject' | 'withdraw'>('submit')
|
||||
const contractId = ref<string | number>()
|
||||
const contractIds = ref<(string | number)[]>()
|
||||
const skippedCount = ref(0)
|
||||
|
||||
const form = ref({
|
||||
approvalType: 'sequential',
|
||||
@@ -170,9 +179,11 @@ const form = ref({
|
||||
const approverList = ref<ApproverUser[]>([])
|
||||
const submitLoading = ref(false)
|
||||
const userLoading = ref(false)
|
||||
const submitPrefillTip = ref(false)
|
||||
const formRef = ref<ElFormInstance>()
|
||||
|
||||
const isBatchMode = computed(() => !!(contractIds.value && contractIds.value.length > 0))
|
||||
const batchCount = computed(() => contractIds.value?.length || 0)
|
||||
|
||||
const rules = computed(() => {
|
||||
const baseRules: any = {}
|
||||
if (actionType.value === 'submit') {
|
||||
@@ -195,12 +206,25 @@ const dialogTitle = computed(() => {
|
||||
withdraw: '撤回审批'
|
||||
}
|
||||
let title = titleMap[actionType.value]
|
||||
if (contractIds.value && contractIds.value.length > 0) {
|
||||
title += `(${contractIds.value.length}条)`
|
||||
if (isBatchMode.value) {
|
||||
title += `(${batchCount.value}条)`
|
||||
}
|
||||
return title
|
||||
})
|
||||
|
||||
const batchInfoTitle = computed(() => {
|
||||
if (actionType.value === 'submit') {
|
||||
return `将提交 ${batchCount.value} 条草稿合同(使用同一组审批人与说明)`
|
||||
}
|
||||
if (actionType.value === 'approve') {
|
||||
return `将审批通过 ${batchCount.value} 条合同(使用同一审批意见)`
|
||||
}
|
||||
if (actionType.value === 'reject') {
|
||||
return `将审批驳回 ${batchCount.value} 条合同(使用同一审批意见)`
|
||||
}
|
||||
return `将处理 ${batchCount.value} 条合同`
|
||||
})
|
||||
|
||||
const confirmButtonText = computed(() => {
|
||||
const textMap = {
|
||||
submit: '提交',
|
||||
@@ -214,6 +238,7 @@ const confirmButtonText = computed(() => {
|
||||
const open = async (id: string | number, type: 'submit' | 'approve' | 'reject' | 'withdraw' = 'submit') => {
|
||||
contractId.value = id
|
||||
contractIds.value = undefined
|
||||
skippedCount.value = 0
|
||||
actionType.value = type
|
||||
dialogVisible.value = true
|
||||
resetForm()
|
||||
@@ -225,10 +250,12 @@ const open = async (id: string | number, type: 'submit' | 'approve' | 'reject' |
|
||||
|
||||
const openBatch = async (
|
||||
idList: (string | number)[],
|
||||
type: 'submit' | 'approve' | 'reject' | 'withdraw' = 'submit'
|
||||
type: 'submit' | 'approve' | 'reject' | 'withdraw' = 'submit',
|
||||
options?: { skippedCount?: number }
|
||||
) => {
|
||||
contractIds.value = idList
|
||||
contractId.value = undefined
|
||||
skippedCount.value = options?.skippedCount || 0
|
||||
actionType.value = type
|
||||
dialogVisible.value = true
|
||||
resetForm()
|
||||
@@ -238,6 +265,33 @@ const openBatch = async (
|
||||
}
|
||||
}
|
||||
|
||||
const showBatchResultMessage = (options: {
|
||||
actionLabel: string
|
||||
successCount: number
|
||||
failedCount: number
|
||||
skippedCount?: number
|
||||
}) => {
|
||||
const { actionLabel, successCount, failedCount, skippedCount: skip = 0 } = options
|
||||
const skipText = skip > 0 ? `,跳过 ${skip} 条` : ''
|
||||
if (successCount === 0 && failedCount === 0) {
|
||||
$modal.msgWarning(`没有可${actionLabel}的合同${skipText}`)
|
||||
return
|
||||
}
|
||||
if (successCount === 0) {
|
||||
$modal.msgError(`${actionLabel}失败${skipText}`)
|
||||
return
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
$modal.msgWarning(`成功${actionLabel} ${successCount} 条,失败 ${failedCount} 条${skipText}`)
|
||||
return
|
||||
}
|
||||
if (successCount === 1 && skip === 0) {
|
||||
$modal.msgSuccess(`${actionLabel}成功`)
|
||||
return
|
||||
}
|
||||
$modal.msgSuccess(`成功${actionLabel} ${successCount} 条${skipText}`)
|
||||
}
|
||||
|
||||
const loadApproverList = async (nickName?: string) => {
|
||||
userLoading.value = true
|
||||
try {
|
||||
@@ -278,9 +332,7 @@ async function resolvePrefillApproverIds(userList: ApproverUser[]) {
|
||||
}
|
||||
|
||||
async function applyPrefillApprovers() {
|
||||
const prefillIds = await resolvePrefillApproverIds(approverList.value)
|
||||
form.value.approverIds = prefillIds
|
||||
submitPrefillTip.value = prefillIds.length > 0
|
||||
form.value.approverIds = await resolvePrefillApproverIds(approverList.value)
|
||||
}
|
||||
|
||||
const handleSearchUser = (query: string) => {
|
||||
@@ -298,34 +350,73 @@ const getApproverName = (approverId: string | number): string => {
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await formRef.value?.validate()
|
||||
const isBatch = !!(contractIds.value && contractIds.value.length > 0)
|
||||
const isBatch = isBatchMode.value
|
||||
if (!isBatch && (contractId.value == null || contractId.value === '')) {
|
||||
$modal.msgError('合同 ID 缺失,请关闭后重新打开提交审批')
|
||||
$modal.msgError('合同 ID 缺失,请关闭后重新打开')
|
||||
return
|
||||
}
|
||||
submitLoading.value = true
|
||||
try {
|
||||
let shouldClose = true
|
||||
|
||||
if (actionType.value === 'submit') {
|
||||
if (isBatch) {
|
||||
const count = await batchSubmitContract(contractIds.value!, form.value.approverIds, form.value.remark)
|
||||
$modal.msgSuccess(`提交成功,成功 ${count ?? contractIds.value!.length} 条`)
|
||||
showBatchResultMessage({
|
||||
actionLabel: '提交',
|
||||
successCount: count ?? contractIds.value!.length,
|
||||
failedCount: 0,
|
||||
skippedCount: skippedCount.value
|
||||
})
|
||||
} else {
|
||||
await submitContract(contractId.value!, form.value.approverIds, form.value.remark)
|
||||
$modal.msgSuccess('提交成功')
|
||||
}
|
||||
} else if (actionType.value === 'approve') {
|
||||
await approveContract(contractId.value!, form.value.comment)
|
||||
$modal.msgSuccess('审批通过')
|
||||
if (isBatch) {
|
||||
const results = await Promise.allSettled(
|
||||
contractIds.value!.map((id) => approveContract(id, form.value.comment))
|
||||
)
|
||||
const successCount = results.filter((item) => item.status === 'fulfilled').length
|
||||
const failedCount = results.length - successCount
|
||||
showBatchResultMessage({
|
||||
actionLabel: '审批通过',
|
||||
successCount,
|
||||
failedCount,
|
||||
skippedCount: skippedCount.value
|
||||
})
|
||||
shouldClose = successCount > 0
|
||||
} else {
|
||||
await approveContract(contractId.value!, form.value.comment)
|
||||
$modal.msgSuccess('审批通过')
|
||||
}
|
||||
} else if (actionType.value === 'reject') {
|
||||
await rejectContract(contractId.value!, form.value.comment)
|
||||
$modal.msgSuccess('已驳回')
|
||||
if (isBatch) {
|
||||
const results = await Promise.allSettled(
|
||||
contractIds.value!.map((id) => rejectContract(id, form.value.comment))
|
||||
)
|
||||
const successCount = results.filter((item) => item.status === 'fulfilled').length
|
||||
const failedCount = results.length - successCount
|
||||
showBatchResultMessage({
|
||||
actionLabel: '审批驳回',
|
||||
successCount,
|
||||
failedCount,
|
||||
skippedCount: skippedCount.value
|
||||
})
|
||||
shouldClose = successCount > 0
|
||||
} else {
|
||||
await rejectContract(contractId.value!, form.value.comment)
|
||||
$modal.msgSuccess('已驳回')
|
||||
}
|
||||
} else if (actionType.value === 'withdraw') {
|
||||
await withdrawContract(contractId.value!, form.value.reason)
|
||||
$modal.msgSuccess('已撤回')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
emit('success')
|
||||
if (shouldClose) {
|
||||
dialogVisible.value = false
|
||||
emit('success')
|
||||
}
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
@@ -350,7 +441,6 @@ const resetForm = () => {
|
||||
remark: '',
|
||||
reason: ''
|
||||
}
|
||||
submitPrefillTip.value = false
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
@@ -361,6 +451,24 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.batch-action-alert {
|
||||
margin-bottom: 12px;
|
||||
|
||||
&--success {
|
||||
:deep(.el-alert__title),
|
||||
:deep(.el-alert__icon) {
|
||||
color: var(--primary, #10b981);
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
:deep(.el-alert__title),
|
||||
:deep(.el-alert__icon) {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-tip-block {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
|
||||
@@ -141,16 +141,16 @@
|
||||
<el-button
|
||||
v-if="activeView === 'submitted'"
|
||||
class="btn-ghost"
|
||||
:disabled="single"
|
||||
@click="handleApprove(contractList[contractList.findIndex((item) => item.id === ids[0])])"
|
||||
:disabled="!canApproveBatch"
|
||||
@click="handleApprove()"
|
||||
>
|
||||
<el-icon><Select /></el-icon>审批通过
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="activeView === 'submitted'"
|
||||
class="btn-ghost"
|
||||
:disabled="single"
|
||||
@click="handleReject(contractList[contractList.findIndex((item) => item.id === ids[0])])"
|
||||
:disabled="!canApproveBatch"
|
||||
@click="handleReject()"
|
||||
>
|
||||
<el-icon><Close /></el-icon>审批驳回
|
||||
</el-button>
|
||||
@@ -1111,12 +1111,13 @@ const handleSubmitBatch = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (draftContracts.length < selectedContracts.length) {
|
||||
$modal.msgWarning(`选中了 ${selectedContracts.length} 条合同,其中 ${draftContracts.length} 条为草稿状态,只有草稿状态的合同可以提交审批`);
|
||||
const skippedCount = selectedContracts.length - draftContracts.length;
|
||||
if (skippedCount > 0) {
|
||||
$modal.msgWarning(`已跳过 ${skippedCount} 条非草稿合同,仅处理草稿状态单据`);
|
||||
}
|
||||
|
||||
const draftIds = draftContracts.map((item) => item.id);
|
||||
approvalDialogRef.value?.openBatch(draftIds, 'submit');
|
||||
approvalDialogRef.value?.openBatch(draftIds, 'submit', { skippedCount });
|
||||
};
|
||||
|
||||
/** 判断是否可以提交审批(至少有一条草稿状态的合同) */
|
||||
@@ -1126,32 +1127,55 @@ const canSubmit = computed(() => {
|
||||
return selectedContracts.some((item) => item.status === 'draft');
|
||||
});
|
||||
|
||||
/** 审批通过 */
|
||||
const handleApprove = (row?: ContractVO) => {
|
||||
if (!row && ids.value.length !== 1) {
|
||||
$modal.msgWarning('请选择一条数据进行审批');
|
||||
/** 判断是否可以审批(至少有一条当前用户可审批的合同) */
|
||||
const canApproveBatch = computed(() => {
|
||||
if (!selectedRows.value.length) return false;
|
||||
return selectedRows.value.some((item) => item.canApprove);
|
||||
});
|
||||
|
||||
/** 筛选可审批合同并打开审批弹窗(支持单条与批量) */
|
||||
const openApprovalForTargets = (
|
||||
row: ContractVO | undefined,
|
||||
type: 'approve' | 'reject'
|
||||
) => {
|
||||
const targets = row ? [row] : selectedRows.value;
|
||||
if (!targets.length) {
|
||||
$modal.msgWarning('请选择要审批的合同');
|
||||
return;
|
||||
}
|
||||
const targetRow = row || contractList.value[contractList.value.findIndex((item) => item.id === ids.value[0])];
|
||||
if (!targetRow) {
|
||||
$modal.msgWarning('未找到选中的数据');
|
||||
|
||||
const validTargets = targets.filter((item) => item.canApprove);
|
||||
const skippedCount = targets.length - validTargets.length;
|
||||
|
||||
if (!validTargets.length) {
|
||||
$modal.msgWarning(
|
||||
targets.length === 1
|
||||
? '您没有该合同的审批权限,或已审批过该合同'
|
||||
: '所选合同均不可审批(无权、已审或不在待审批状态)'
|
||||
);
|
||||
return;
|
||||
}
|
||||
approvalDialogRef.value?.open(targetRow.id, 'approve');
|
||||
|
||||
if (skippedCount > 0) {
|
||||
$modal.msgWarning(`已跳过 ${skippedCount} 条不可审批的合同,仅处理可审批单据`);
|
||||
}
|
||||
|
||||
const validIds = validTargets.map((item) => item.id);
|
||||
if (validIds.length === 1 && skippedCount === 0) {
|
||||
approvalDialogRef.value?.open(validIds[0], type);
|
||||
} else {
|
||||
approvalDialogRef.value?.openBatch(validIds, type, { skippedCount });
|
||||
}
|
||||
};
|
||||
|
||||
/** 审批驳回 */
|
||||
/** 审批通过(支持单条与批量) */
|
||||
const handleApprove = (row?: ContractVO) => {
|
||||
openApprovalForTargets(row, 'approve');
|
||||
};
|
||||
|
||||
/** 审批驳回(支持单条与批量) */
|
||||
const handleReject = (row?: ContractVO) => {
|
||||
if (!row && ids.value.length !== 1) {
|
||||
$modal.msgWarning('请选择一条数据进行审批');
|
||||
return;
|
||||
}
|
||||
const targetRow = row || contractList.value[contractList.value.findIndex((item) => item.id === ids.value[0])];
|
||||
if (!targetRow) {
|
||||
$modal.msgWarning('未找到选中的数据');
|
||||
return;
|
||||
}
|
||||
approvalDialogRef.value?.open(targetRow.id, 'reject');
|
||||
openApprovalForTargets(row, 'reject');
|
||||
};
|
||||
|
||||
/** 上传合同扫描件 */
|
||||
|
||||
@@ -81,11 +81,13 @@
|
||||
try {
|
||||
const res = await fetchUserOptions()
|
||||
const options = (res as any)?.data ?? res ?? []
|
||||
userList.value = (Array.isArray(options) ? options : []).map((item: any) => ({
|
||||
userId: item.value ?? item.userId ?? item.id,
|
||||
nickName: item.nickName ?? item.label ?? item.userName ?? '',
|
||||
userName: item.userNo ?? item.user_no ?? item.userName ?? ''
|
||||
}))
|
||||
userList.value = (Array.isArray(options) ? options : [])
|
||||
.map((item: any) => ({
|
||||
userId: String(item.value ?? item.userId ?? item.id ?? ''),
|
||||
nickName: item.nickName ?? item.label ?? item.userName ?? '',
|
||||
userName: item.userNo ?? item.user_no ?? item.userName ?? ''
|
||||
}))
|
||||
.filter((u) => u.userId)
|
||||
} catch (error) {
|
||||
console.error('加载用户列表失败:', error)
|
||||
userList.value = []
|
||||
|
||||
@@ -123,13 +123,29 @@ watch(dialogVisible, (val) => {
|
||||
emit('update:visible', val)
|
||||
})
|
||||
|
||||
const DEFAULT_OPINIONS = ['审批通过', '审批驳回'] as const
|
||||
|
||||
const form = ref({
|
||||
quoteId: undefined as number | undefined,
|
||||
result: 'approved',
|
||||
opinion: '',
|
||||
opinion: '审批通过',
|
||||
nextApproverUserId: undefined as string | number | undefined
|
||||
})
|
||||
|
||||
const defaultOpinion = (result: string) => (result === 'rejected' ? '审批驳回' : '审批通过')
|
||||
|
||||
watch(
|
||||
() => form.value.result,
|
||||
(result) => {
|
||||
if (
|
||||
!form.value.opinion.trim() ||
|
||||
DEFAULT_OPINIONS.includes(form.value.opinion as (typeof DEFAULT_OPINIONS)[number])
|
||||
) {
|
||||
form.value.opinion = defaultOpinion(result)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const rules = computed<FormRules>(() => {
|
||||
if (props.approvalType === 'approve') {
|
||||
return {
|
||||
@@ -165,7 +181,7 @@ const resetForm = () => {
|
||||
form.value = {
|
||||
quoteId: props.quoteInfo.id as number | undefined,
|
||||
result: 'approved',
|
||||
opinion: '',
|
||||
opinion: defaultOpinion('approved'),
|
||||
nextApproverUserId: undefined
|
||||
}
|
||||
formRef.value?.clearValidate()
|
||||
|
||||
@@ -140,12 +140,14 @@ const loadUsers = async (nickName?: string) => {
|
||||
try {
|
||||
const res = await fetchUserOptions()
|
||||
const options = (res as any)?.data ?? res ?? []
|
||||
const mapped = (Array.isArray(options) ? options : []).map((item: any) => ({
|
||||
userId: item.value ?? item.userId ?? item.id,
|
||||
nickName: item.nickName ?? item.label ?? item.userName ?? '',
|
||||
userName: item.userNo ?? item.user_no ?? item.userName ?? '',
|
||||
deptName: item.deptName ?? ''
|
||||
}))
|
||||
const mapped = (Array.isArray(options) ? options : [])
|
||||
.map((item: any) => ({
|
||||
userId: String(item.value ?? item.userId ?? item.id ?? ''),
|
||||
nickName: item.nickName ?? item.label ?? item.userName ?? '',
|
||||
userName: item.userNo ?? item.user_no ?? item.userName ?? '',
|
||||
deptName: item.deptName ?? ''
|
||||
}))
|
||||
.filter((user) => user.userId)
|
||||
userList.value = nickName
|
||||
? mapped.filter((user) => user.nickName.includes(nickName) || user.userName.includes(nickName))
|
||||
: mapped
|
||||
|
||||
@@ -463,14 +463,6 @@
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<el-alert
|
||||
v-if="submitPrefillTip"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="已按「报价单审批人设置」预填审批人,可修改后提交"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item prop="approverIds" class="form-col-span-2">
|
||||
<template #label>
|
||||
@@ -697,7 +689,6 @@ const submitForm = ref({
|
||||
const submitRules = ref({
|
||||
approverIds: [{ required: true, message: '请选择审批人', trigger: 'change' }]
|
||||
});
|
||||
const submitPrefillTip = ref(false);
|
||||
const submitFormRef = ref<FormInstance>();
|
||||
|
||||
// 用户列表(负责人 + 审批人候选)
|
||||
@@ -1097,11 +1088,11 @@ const loadUserList = async () => {
|
||||
const res = await fetchUserOptions();
|
||||
const options = (res as any)?.data ?? res ?? [];
|
||||
userList.value = (Array.isArray(options) ? options : []).map((item: any) => ({
|
||||
userId: item.value ?? item.userId ?? item.id,
|
||||
userId: String(item.value ?? item.userId ?? item.id ?? ''),
|
||||
nickName: item.nickName ?? item.label ?? item.userName ?? '',
|
||||
userName: item.userNo ?? item.user_no ?? item.userName ?? '',
|
||||
deptName: item.deptName ?? ''
|
||||
}));
|
||||
})).filter((u) => u.userId);
|
||||
} catch (error) {
|
||||
console.error('加载用户列表失败:', error);
|
||||
userList.value = [];
|
||||
@@ -1719,7 +1710,6 @@ const handleSubmit = async () => {
|
||||
approverIds: prefillIds,
|
||||
remark: ''
|
||||
};
|
||||
submitPrefillTip.value = prefillIds.length > 0;
|
||||
|
||||
// 打开审批对话框
|
||||
submitDialog.value.visible = true;
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
<el-button
|
||||
v-if="activeView !== 'submitted' && activeView !== 'accepted'"
|
||||
class="btn-ghost"
|
||||
:disabled="single"
|
||||
:disabled="multiple"
|
||||
@click="handleSubmit()"
|
||||
>
|
||||
<el-icon><Document /></el-icon>提交审批
|
||||
@@ -123,7 +123,7 @@
|
||||
<el-button
|
||||
v-if="activeView === 'submitted'"
|
||||
class="btn-ghost"
|
||||
:disabled="single"
|
||||
:disabled="multiple"
|
||||
@click="handleApproval()"
|
||||
>
|
||||
<el-icon><DocumentChecked /></el-icon>审批
|
||||
@@ -406,6 +406,22 @@
|
||||
modal-class="order-module-dialog-mask"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-alert
|
||||
v-if="submitTargetIds.length > 1"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="batch-action-alert batch-action-alert--success"
|
||||
:title="`将提交 ${submitTargetIds.length} 条草稿报价单(使用同一组审批人与备注)`"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="submitSkippedCount > 0"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="batch-action-alert batch-action-alert--danger"
|
||||
:title="`已跳过 ${submitSkippedCount} 条非草稿报价单`"
|
||||
/>
|
||||
<el-form
|
||||
ref="submitFormRef"
|
||||
:model="submitForm"
|
||||
@@ -415,14 +431,6 @@
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<el-alert
|
||||
v-if="submitPrefillTip"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="已按「报价单审批人设置」预填审批人,可修改后提交"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item prop="approverIds" class="form-col-span-2">
|
||||
<template #label>
|
||||
@@ -567,7 +575,40 @@
|
||||
modal-class="order-module-dialog-mask"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<section v-if="currentApprovalQuote" class="approval-dialog-section">
|
||||
<el-alert
|
||||
v-if="batchApprovalTargets.length > 1"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="batch-action-alert"
|
||||
:title="`将审批 ${batchApprovalTargets.length} 条报价单(使用同一审批结果与意见)`"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="approvalSkippedCount > 0"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="batch-action-alert"
|
||||
:title="`已跳过 ${approvalSkippedCount} 条无权或不在待审批状态的报价单`"
|
||||
/>
|
||||
<section
|
||||
v-if="batchApprovalTargets.length > 1"
|
||||
class="approval-dialog-section"
|
||||
>
|
||||
<h3 class="approval-section-title">待审批报价单</h3>
|
||||
<div class="batch-approval-quote-list">
|
||||
<el-tag
|
||||
v-for="item in batchApprovalTargets"
|
||||
:key="String(item.quote.id)"
|
||||
size="small"
|
||||
class="batch-approval-quote-tag"
|
||||
>
|
||||
{{ item.quote.quoteNo || item.quote.id }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="currentApprovalQuote" class="approval-dialog-section">
|
||||
<h3 class="approval-section-title">报价单信息</h3>
|
||||
<el-descriptions :column="2" class="approval-kv-descriptions">
|
||||
<el-descriptions-item label="报价单号">{{ currentApprovalQuote.quoteNo }}</el-descriptions-item>
|
||||
@@ -588,7 +629,7 @@
|
||||
</el-descriptions>
|
||||
</section>
|
||||
|
||||
<section v-if="approvalList.length > 0" class="approval-dialog-section">
|
||||
<section v-if="batchApprovalTargets.length <= 1 && approvalList.length > 0" class="approval-dialog-section">
|
||||
<div class="approval-section-head">
|
||||
<h3 class="approval-section-title">审批流程</h3>
|
||||
<el-tag size="small" type="info">
|
||||
@@ -637,8 +678,7 @@
|
||||
ref="approvalFormRef"
|
||||
:model="approvalForm"
|
||||
:rules="approvalRules"
|
||||
label-position="right"
|
||||
label-width="100px"
|
||||
label-position="top"
|
||||
class="crm-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
@@ -730,7 +770,7 @@ import DictTag from '@/components/DictTag/index.vue'
|
||||
import { $modal } from '@/utils/modal'
|
||||
import { formatMoneyDisplay } from '@/utils/money-format'
|
||||
import { formatBrandForPrint } from '@/utils/crm-product-brand'
|
||||
import { ref, reactive, onMounted, nextTick, getCurrentInstance, toRefs, computed, type ComponentInternalInstance } from 'vue';
|
||||
import { ref, reactive, onMounted, nextTick, getCurrentInstance, toRefs, computed, watch, type ComponentInternalInstance } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import type { FormInstance } from 'element-plus';
|
||||
@@ -1035,16 +1075,15 @@ const submitDialog = reactive({
|
||||
visible: false,
|
||||
loading: false
|
||||
});
|
||||
const submitTargetIds = ref<(string | number)[]>([]);
|
||||
const submitSkippedCount = ref(0);
|
||||
const submitForm = ref({
|
||||
id: undefined as string | number | undefined,
|
||||
approverIds: [] as (string | number)[],
|
||||
remark: ''
|
||||
});
|
||||
const submitRules = ref({
|
||||
approverIds: [{ required: true, message: '请选择审批人', trigger: 'change' }]
|
||||
});
|
||||
const submitPrefillTip = ref(false);
|
||||
|
||||
// 用户列表(审批人候选)
|
||||
const userList = ref<UserVO[]>([]);
|
||||
|
||||
@@ -1095,7 +1134,7 @@ const approvalDialog = reactive({
|
||||
const approvalForm = ref<ApprovalForm>({
|
||||
id: undefined as string | number | undefined,
|
||||
approvalResult: 'approved',
|
||||
approvalOpinion: '',
|
||||
approvalOpinion: '审批通过',
|
||||
remark: ''
|
||||
});
|
||||
const approvalRules = ref({
|
||||
@@ -1113,8 +1152,53 @@ const approvalRules = ref({
|
||||
}
|
||||
]
|
||||
});
|
||||
const QUOTE_DEFAULT_OPINIONS = ['审批通过', '审批驳回'] as const
|
||||
watch(
|
||||
() => approvalForm.value.approvalResult,
|
||||
(result) => {
|
||||
if (
|
||||
!approvalForm.value.approvalOpinion.trim() ||
|
||||
QUOTE_DEFAULT_OPINIONS.includes(
|
||||
approvalForm.value.approvalOpinion as (typeof QUOTE_DEFAULT_OPINIONS)[number]
|
||||
)
|
||||
) {
|
||||
approvalForm.value.approvalOpinion = result === 'rejected' ? '审批驳回' : '审批通过'
|
||||
}
|
||||
}
|
||||
)
|
||||
const approvalList = ref<QuoteApprovalVO[]>([]);
|
||||
const currentApprovalQuote = ref<QuoteVO | null>(null);
|
||||
type BatchApprovalTarget = { quote: QuoteVO; approvalId: string | number };
|
||||
const batchApprovalTargets = ref<BatchApprovalTarget[]>([]);
|
||||
const approvalSkippedCount = ref(0);
|
||||
|
||||
/** 批量操作结果提示 */
|
||||
const showBatchResultMessage = (options: {
|
||||
actionLabel: string
|
||||
successCount: number
|
||||
failedCount: number
|
||||
skippedCount?: number
|
||||
}) => {
|
||||
const { actionLabel, successCount, failedCount, skippedCount = 0 } = options
|
||||
const skipText = skippedCount > 0 ? `,跳过 ${skippedCount} 条` : ''
|
||||
if (successCount === 0 && failedCount === 0) {
|
||||
$modal.msgWarning(`没有可${actionLabel}的报价单${skipText}`)
|
||||
return
|
||||
}
|
||||
if (successCount === 0) {
|
||||
$modal.msgError(`${actionLabel}失败${skipText}`)
|
||||
return
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
$modal.msgWarning(`成功${actionLabel} ${successCount} 条,失败 ${failedCount} 条${skipText}`)
|
||||
return
|
||||
}
|
||||
if (successCount === 1 && skippedCount === 0) {
|
||||
$modal.msgSuccess(`${actionLabel}成功`)
|
||||
return
|
||||
}
|
||||
$modal.msgSuccess(`成功${actionLabel} ${successCount} 条${skipText}`)
|
||||
}
|
||||
|
||||
const queryFormRef = ref<FormInstance>();
|
||||
const submitFormRef = ref<FormInstance>();
|
||||
@@ -1301,12 +1385,12 @@ const handleView = (row?: QuoteVO) => {
|
||||
const handleQuoteDetailSubmit = async (quote: QuoteVO) => {
|
||||
await loadUserList();
|
||||
const prefillIds = await resolvePrefillApproverIds(userList.value);
|
||||
submitTargetIds.value = [quote.id];
|
||||
submitSkippedCount.value = 0;
|
||||
submitForm.value = {
|
||||
id: quote.id,
|
||||
approverIds: prefillIds,
|
||||
remark: ''
|
||||
};
|
||||
submitPrefillTip.value = prefillIds.length > 0;
|
||||
submitDialog.visible = true;
|
||||
};
|
||||
|
||||
@@ -1386,11 +1470,12 @@ const loadUserList = async () => {
|
||||
const res = await fetchUserOptions();
|
||||
const options = (res as any)?.data ?? res ?? [];
|
||||
userList.value = (Array.isArray(options) ? options : []).map((item: any) => ({
|
||||
userId: item.value ?? item.userId ?? item.id,
|
||||
// 与设置/预填统一为字符串,避免 el-select 因 number/string 类型不一致无法回显
|
||||
userId: String(item.value ?? item.userId ?? item.id ?? ''),
|
||||
nickName: item.nickName ?? item.label ?? item.userName ?? '',
|
||||
userName: item.userNo ?? item.user_no ?? item.userName ?? '',
|
||||
deptName: item.deptName ?? ''
|
||||
}));
|
||||
})).filter((u) => u.userId);
|
||||
} catch (error) {
|
||||
console.error('加载用户列表失败:', error);
|
||||
userList.value = [];
|
||||
@@ -1408,46 +1493,71 @@ async function resolvePrefillApproverIds(userList: { userId: string | number }[]
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交审批 */
|
||||
/** 提交审批(支持单条与批量) */
|
||||
const handleSubmit = async (row?: QuoteVO) => {
|
||||
if (!row && ids.value.length !== 1) {
|
||||
$modal.msgWarning('请选择一条数据进行提交审批');
|
||||
const targets = row ? [row] : selectedRows.value;
|
||||
if (!targets.length) {
|
||||
$modal.msgWarning('请选择要提交审批的数据');
|
||||
return;
|
||||
}
|
||||
const id = row?.id || ids.value[0];
|
||||
const quote = row || quoteList.value[quoteList.value.findIndex((item) => item.id === id)];
|
||||
if (quote && quote.status !== 'draft') {
|
||||
$modal.msgWarning('只能提交草稿状态的报价单');
|
||||
|
||||
const validTargets = targets.filter((item) => item.status === 'draft');
|
||||
const skippedCount = targets.length - validTargets.length;
|
||||
|
||||
if (!validTargets.length) {
|
||||
$modal.msgWarning('所选报价单均不是草稿状态,无法提交审批');
|
||||
return;
|
||||
}
|
||||
|
||||
if (skippedCount > 0) {
|
||||
$modal.msgWarning(`已跳过 ${skippedCount} 条非草稿报价单,仅处理草稿状态单据`);
|
||||
}
|
||||
|
||||
await loadUserList();
|
||||
const prefillIds = await resolvePrefillApproverIds(userList.value);
|
||||
submitTargetIds.value = validTargets.map((item) => item.id);
|
||||
submitSkippedCount.value = skippedCount;
|
||||
submitForm.value = {
|
||||
id: id,
|
||||
approverIds: prefillIds,
|
||||
remark: ''
|
||||
};
|
||||
submitPrefillTip.value = prefillIds.length > 0;
|
||||
submitDialog.visible = true;
|
||||
};
|
||||
|
||||
/** 确认提交审批 */
|
||||
const confirmSubmit = async () => {
|
||||
await submitFormRef.value?.validate();
|
||||
if (!submitTargetIds.value.length) {
|
||||
$modal.msgWarning('没有可提交审批的报价单');
|
||||
return;
|
||||
}
|
||||
|
||||
submitDialog.loading = true;
|
||||
try {
|
||||
// 直接使用原始ID,不转换类型(避免大整数精度丢失)
|
||||
// 后端会正确处理字符串或数字类型的ID
|
||||
await submitQuote({
|
||||
id: submitForm.value.id!,
|
||||
approverIds: submitForm.value.approverIds,
|
||||
remark: submitForm.value.remark
|
||||
const results = await Promise.allSettled(
|
||||
submitTargetIds.value.map((id) =>
|
||||
submitQuote({
|
||||
id,
|
||||
approverIds: submitForm.value.approverIds,
|
||||
remark: submitForm.value.remark
|
||||
})
|
||||
)
|
||||
);
|
||||
const successCount = results.filter((item) => item.status === 'fulfilled').length;
|
||||
const failedCount = results.length - successCount;
|
||||
showBatchResultMessage({
|
||||
actionLabel: '提交',
|
||||
successCount,
|
||||
failedCount,
|
||||
skippedCount: submitSkippedCount.value
|
||||
});
|
||||
$modal.msgSuccess('提交成功');
|
||||
submitDialog.visible = false;
|
||||
await getList();
|
||||
|
||||
await quoteDetailRef.value?.refresh();
|
||||
if (successCount > 0) {
|
||||
submitDialog.visible = false;
|
||||
await getList();
|
||||
await quoteDetailRef.value?.refresh();
|
||||
}
|
||||
} finally {
|
||||
submitDialog.loading = false;
|
||||
}
|
||||
@@ -1551,73 +1661,104 @@ const handleToContract = async (row: QuoteVO) => {
|
||||
await getList();
|
||||
};
|
||||
|
||||
/** 审批报价单 */
|
||||
/** 审批报价单(支持单条与批量) */
|
||||
const handleApproval = async (row?: QuoteVO) => {
|
||||
if (!row && ids.value.length !== 1) {
|
||||
$modal.msgWarning('请选择一条数据进行审批');
|
||||
const targets = row ? [row] : selectedRows.value;
|
||||
if (!targets.length) {
|
||||
$modal.msgWarning('请选择要审批的数据');
|
||||
return;
|
||||
}
|
||||
const id = row?.id || ids.value[0];
|
||||
const quote = row || quoteList.value[quoteList.value.findIndex((item) => item.id === id)];
|
||||
if (!quote.canApprove || activeView.value !== 'submitted') {
|
||||
$modal.msgWarning('只能审批待审批的报价单');
|
||||
if (activeView.value !== 'submitted') {
|
||||
$modal.msgWarning('请在「待审批」视图中进行审批');
|
||||
return;
|
||||
}
|
||||
// 保存当前审批的报价单
|
||||
currentApprovalQuote.value = quote;
|
||||
|
||||
// 重置表单
|
||||
const currentUserId = resolveCurrentUserId();
|
||||
if (!currentUserId) {
|
||||
$modal.msgError('无法获取当前用户信息,请重新登录');
|
||||
return;
|
||||
}
|
||||
|
||||
// 重置表单(默认审批意见,可直接确认)
|
||||
approvalForm.value = {
|
||||
id: undefined,
|
||||
id: undefined as unknown as string | number,
|
||||
approvalResult: 'approved',
|
||||
approvalOpinion: '',
|
||||
approvalOpinion: '审批通过',
|
||||
remark: ''
|
||||
};
|
||||
|
||||
// 清空审批记录列表
|
||||
approvalList.value = [];
|
||||
batchApprovalTargets.value = [];
|
||||
currentApprovalQuote.value = null;
|
||||
|
||||
const candidateQuotes = targets.filter((item) => item.canApprove);
|
||||
let skippedCount = targets.length - candidateQuotes.length;
|
||||
const validTargets: BatchApprovalTarget[] = [];
|
||||
let singleApprovalList: QuoteApprovalVO[] = [];
|
||||
|
||||
// 加载审批记录
|
||||
try {
|
||||
const res = await listApproval(id);
|
||||
approvalList.value = res.data || [];
|
||||
|
||||
// 获取当前用户ID
|
||||
const currentUserId = resolveCurrentUserId();
|
||||
if (!currentUserId) {
|
||||
$modal.msgError('无法获取当前用户信息,请重新登录');
|
||||
return;
|
||||
}
|
||||
|
||||
// 找到当前用户的待审批记录
|
||||
const pendingApproval = approvalList.value.find(
|
||||
(item) => String(item.approverId) === String(currentUserId) && item.approvalResult === 'pending'
|
||||
const loadResults = await Promise.allSettled(
|
||||
candidateQuotes.map(async (quote) => {
|
||||
const res = await listApproval(quote.id);
|
||||
return { quote, list: res.data || [] };
|
||||
})
|
||||
);
|
||||
|
||||
if (!pendingApproval) {
|
||||
// 检查用户是否已经审批过
|
||||
const approvedRecord = approvalList.value.find(
|
||||
(item) => String(item.approverId) === String(currentUserId)
|
||||
for (const result of loadResults) {
|
||||
if (result.status !== 'fulfilled') {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
const { quote, list } = result.value;
|
||||
const pendingApproval = list.find(
|
||||
(item) =>
|
||||
String(item.approverId) === String(currentUserId) && item.approvalResult === 'pending'
|
||||
);
|
||||
if (approvedRecord) {
|
||||
const resultText = approvedRecord.approvalResult === 'approved' ? '已通过' : '已拒绝';
|
||||
$modal.msgWarning(`您已经审批过该报价单(${resultText}),不能重复审批`);
|
||||
} else {
|
||||
$modal.msgWarning('您没有该报价单的审批权限');
|
||||
if (!pendingApproval) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
validTargets.push({ quote, approvalId: pendingApproval.id });
|
||||
if (candidateQuotes.length === 1) {
|
||||
singleApprovalList = list;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置审批记录ID
|
||||
approvalForm.value.id = pendingApproval.id;
|
||||
|
||||
// 打开审批对话框
|
||||
approvalDialog.visible = true;
|
||||
} catch (error: any) {
|
||||
console.error('加载审批记录失败:', error);
|
||||
$modal.msgError(error?.msg || '加载审批记录失败,请稍后重试');
|
||||
approvalList.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validTargets.length) {
|
||||
if (targets.length === 1) {
|
||||
$modal.msgWarning('您没有该报价单的审批权限,或已审批过该报价单');
|
||||
} else {
|
||||
$modal.msgWarning('所选报价单均不可审批(无权、已审或不在待审批状态)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (skippedCount > 0) {
|
||||
$modal.msgWarning(`已跳过 ${skippedCount} 条不可审批的报价单,仅处理可审批单据`);
|
||||
}
|
||||
|
||||
batchApprovalTargets.value = validTargets;
|
||||
approvalSkippedCount.value = skippedCount;
|
||||
if (validTargets.length === 1) {
|
||||
const only = validTargets[0];
|
||||
currentApprovalQuote.value = only.quote;
|
||||
approvalForm.value.id = only.approvalId;
|
||||
if (singleApprovalList.length) {
|
||||
approvalList.value = singleApprovalList;
|
||||
} else {
|
||||
try {
|
||||
const res = await listApproval(only.quote.id);
|
||||
approvalList.value = res.data || [];
|
||||
} catch {
|
||||
approvalList.value = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
approvalDialog.visible = true;
|
||||
};
|
||||
|
||||
/** 确认审批 */
|
||||
@@ -1630,12 +1771,22 @@ const confirmApproval = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!batchApprovalTargets.value.length) {
|
||||
$modal.msgWarning('没有可审批的报价单');
|
||||
return;
|
||||
}
|
||||
|
||||
// 二次确认
|
||||
const resultText = approvalForm.value.approvalResult === 'approved' ? '通过' : '拒绝';
|
||||
const targetCount = batchApprovalTargets.value.length;
|
||||
const confirmMsg =
|
||||
approvalForm.value.approvalResult === 'rejected'
|
||||
? `确认要拒绝该报价单吗?拒绝后报价单将无法再次提交审批。\n拒绝理由:${approvalForm.value.approvalOpinion}`
|
||||
: `确认要通过该报价单吗?`;
|
||||
? targetCount === 1
|
||||
? `确认要拒绝该报价单吗?拒绝后报价单将无法再次提交审批。\n拒绝理由:${approvalForm.value.approvalOpinion}`
|
||||
: `确认要拒绝选中的 ${targetCount} 条报价单吗?拒绝后将无法再次提交审批。\n拒绝理由:${approvalForm.value.approvalOpinion}`
|
||||
: targetCount === 1
|
||||
? '确认要通过该报价单吗?'
|
||||
: `确认要通过选中的 ${targetCount} 条报价单吗?`;
|
||||
|
||||
try {
|
||||
await $modal.confirm(confirmMsg);
|
||||
@@ -1645,17 +1796,35 @@ const confirmApproval = async () => {
|
||||
|
||||
approvalDialog.loading = true;
|
||||
try {
|
||||
await approveQuote(approvalForm.value);
|
||||
const results = await Promise.allSettled(
|
||||
batchApprovalTargets.value.map((item) =>
|
||||
approveQuote({
|
||||
id: item.approvalId,
|
||||
approvalResult: approvalForm.value.approvalResult,
|
||||
approvalOpinion: approvalForm.value.approvalOpinion,
|
||||
remark: approvalForm.value.remark
|
||||
})
|
||||
)
|
||||
);
|
||||
const successCount = results.filter((item) => item.status === 'fulfilled').length;
|
||||
const failedCount = results.length - successCount;
|
||||
|
||||
// 显示成功消息
|
||||
const quoteNo = currentApprovalQuote.value?.quoteNo || '';
|
||||
$modal.msgSuccess(`报价单【${quoteNo}】审批${resultText}成功`);
|
||||
if (targetCount === 1 && successCount === 1 && approvalSkippedCount.value === 0) {
|
||||
const quoteNo = currentApprovalQuote.value?.quoteNo || batchApprovalTargets.value[0]?.quote.quoteNo || '';
|
||||
$modal.msgSuccess(`报价单【${quoteNo}】审批${resultText}成功`);
|
||||
} else {
|
||||
showBatchResultMessage({
|
||||
actionLabel: `审批${resultText}`,
|
||||
successCount,
|
||||
failedCount,
|
||||
skippedCount: approvalSkippedCount.value
|
||||
});
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
approvalDialog.visible = false;
|
||||
|
||||
// 刷新列表
|
||||
await getList();
|
||||
if (successCount > 0) {
|
||||
approvalDialog.visible = false;
|
||||
await getList();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('审批失败:', error);
|
||||
$modal.msgError(error?.msg || `审批${resultText}失败,请稍后重试`);
|
||||
@@ -2739,6 +2908,34 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.batch-action-alert {
|
||||
margin-bottom: 12px;
|
||||
|
||||
&--success {
|
||||
:deep(.el-alert__title),
|
||||
:deep(.el-alert__icon) {
|
||||
color: var(--primary, #10b981);
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
:deep(.el-alert__title),
|
||||
:deep(.el-alert__icon) {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.batch-approval-quote-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.batch-approval-quote-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.approval-kv-descriptions {
|
||||
:deep(.el-descriptions__label) {
|
||||
font-weight: 500;
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
</div>
|
||||
|
||||
<div id="app-portal-body">
|
||||
<aside id="app-sidebar">
|
||||
<ArtSidebarMenu />
|
||||
</aside>
|
||||
|
||||
<main id="app-workspace">
|
||||
<div id="app-content">
|
||||
<ArtPageContent />
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
/* 锁死视口高度,避免「顶栏 + calc(100vh-56)」叠出外层纵向滚动条 */
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--mint-bg-page);
|
||||
|
||||
#app-portal-header {
|
||||
@@ -14,25 +17,17 @@
|
||||
#app-portal-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
flex-direction: column;
|
||||
/* 由 flex 吃掉顶栏剩余高度,勿再写 calc(100vh - 56px) */
|
||||
min-height: 0;
|
||||
height: calc(100vh - 56px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app-sidebar {
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
/* 边框画在 aside 上:内部 .menu-left 与侧栏同宽时会盖住 .layout-sidebar 的 border */
|
||||
border-right: 1px solid var(--mint-border, #d9e8e2);
|
||||
background: var(--mint-surface, #fff);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#app-workspace {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
@@ -68,48 +63,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 页面主标题已移至门户顶栏居中展示 */
|
||||
:deep(
|
||||
.page-container .panel-top:not(.panel-top-with-actions):not(.user-dept-aside__head):not(
|
||||
.storage-aside__head
|
||||
):not(.storage-content-header),
|
||||
.console-container .panel-top
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.panel-top.panel-top-with-actions .title-bar-heading) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.panel-top.panel-top-with-actions:not(:has(.panel-top-main))) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (width <= 1180px) {
|
||||
.app-layout-portal {
|
||||
#app-portal-body {
|
||||
height: calc(100dvh - 56px);
|
||||
}
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (width <= 800px) {
|
||||
.app-layout-portal {
|
||||
#app-portal-body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#app-sidebar {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
z-index: 300;
|
||||
height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
#app-workspace {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -102,13 +102,19 @@
|
||||
const submitting = ref(false)
|
||||
const form = reactive({
|
||||
approved: true,
|
||||
commentText: ''
|
||||
commentText: '审批通过'
|
||||
})
|
||||
|
||||
const DEFAULT_OPINIONS = ['审批通过', '审批驳回'] as const
|
||||
|
||||
const bizLoading = ref(false)
|
||||
const formFields = ref<Array<{ label: string; value: string }>>([])
|
||||
const instanceData = ref<Record<string, any> | null>(null)
|
||||
|
||||
function defaultOpinion(approved: boolean) {
|
||||
return approved ? '审批通过' : '审批驳回'
|
||||
}
|
||||
|
||||
async function loadFormData() {
|
||||
if (!props.instanceId) return
|
||||
bizLoading.value = true
|
||||
@@ -130,12 +136,21 @@
|
||||
(val) => {
|
||||
if (val) {
|
||||
form.approved = true
|
||||
form.commentText = ''
|
||||
form.commentText = defaultOpinion(true)
|
||||
loadFormData()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => form.approved,
|
||||
(approved) => {
|
||||
if (!form.commentText.trim() || DEFAULT_OPINIONS.includes(form.commentText as (typeof DEFAULT_OPINIONS)[number])) {
|
||||
form.commentText = defaultOpinion(approved)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function handleSubmit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user