This commit is contained in:
zlwu committed 2026-08-01 17:29:37 +08:00
1 parent 06956b742b
commit e59b2aa6cb
104 files changed
+653227 -7621

No files matched your search

+2 -1
View File
@@ -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>
+61 -97
View File
@@ -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)
}
+102 -1
View File
@@ -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}/`)
}
+30 -5
View File
@@ -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>
+8 -6
View File
@@ -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;
+50 -26
View File
@@ -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;
+294 -97
View File
@@ -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;
-4
View File
@@ -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 />
+9 -44
View File
@@ -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 {