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

+20 -20
View File
@@ -369,7 +369,7 @@
"367": "Community 367",
"368": "Community 368",
"369": "Community 369",
"370": "canPreview",
"370": "loader.ts",
"371": "fetchData",
"372": "detail.vue",
"373": "Community 373",
@@ -377,12 +377,12 @@
"375": "Community 375",
"376": "index.vue",
"377": "Community 377",
"378": "Community 378",
"378": "index.vue",
"379": "Community 379",
"380": "Community 380",
"381": "Community 381",
"382": "Community 382",
"383": "index.vue",
"383": "ApproverSelector.vue",
"384": "Community 384",
"385": "Community 385",
"386": "navigation.ts",
@@ -400,7 +400,7 @@
"398": "Community 398",
"399": "Community 399",
"400": "Community 400",
"401": "index.vue",
"401": "fetchPriceLetterDetail",
"402": "Community 402",
"403": "Community 403",
"404": "Community 404",
@@ -447,29 +447,29 @@
"445": "Community 445",
"446": "Community 446",
"447": "crm-product-display.ts",
"448": "loadUserOptions",
"448": "theme.min.js",
"449": "Community 449",
"450": "Community 450",
"451": "Community 451",
"452": "modal.ts",
"453": "SettingActions.vue",
"454": "fetchData",
"455": "removeProductsNotAllowedByHazard",
"452": "handleSearch",
"453": "emit",
"454": "zh_CN.js",
"455": "zh_CN.js",
"456": "Community 456",
"457": "colors.ts",
"457": "index.vue",
"458": "Community 458",
"459": "Community 459",
"460": "Community 460",
"461": "handleConfirmApproval",
"461": "modal.ts",
"462": "Community 462",
"463": "db.ts",
"464": "canPreview",
"463": "error-handle.ts",
"464": "zh_CN.js",
"465": "index.vue",
"466": "SettingDrawer.vue",
"466": "v2.4.2.1 - 2025-06-16",
"467": "Community 467",
"468": "Community 468",
"469": "Community 469",
"470": "index.vue",
"470": "zh_CN.js",
"471": "Community 471",
"472": "Community 472",
"473": "Community 473",
@@ -477,13 +477,14 @@
"475": "Community 475",
"476": "Community 476",
"477": "Community 477",
"478": "zh_CN.js",
"478": "customerPool.ts",
"479": "Community 479",
"480": "Community 480",
"481": "Community 481",
"482": "zh_CN.js",
"483": "detail.vue",
"482": "detail.vue",
"483": "emit",
"484": "Community 484",
"485": "zh_CN.js",
"486": "Community 486",
"487": "Community 487",
"488": "Community 488",
@@ -491,6 +492,7 @@
"490": "Community 490",
"491": "Community 491",
"492": "Community 492",
"493": "price-letter-template.ts",
"494": "Community 494",
"496": "Community 496",
"498": "Community 498",
@@ -630,7 +632,6 @@
"670": "Community 670",
"671": "Community 671",
"672": "Community 672",
"674": "Community 674",
"678": "Community 678",
"680": "Community 680",
"681": "Community 681",
@@ -877,7 +878,6 @@
"1016": "TransferDialog.vue",
"1017": "BatchUpdateDateDialog.vue",
"1027": "IntoPoolDialog.vue",
"1036": "customerPool.ts",
"1042": "index.js",
"1043": "skin.shadowdom.js",
"1044": "content.inline.js",
+1 -1
View File
@@ -1 +1 @@
.
/Users/wuzilong/work/00code/shanxia2.0/web-admin
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+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 {