This commit is contained in:
zlwu committed 2026-07-05 10:07:24 +08:00
1 parent 33c038b8d9
commit 54b27a3f0e
253 files changed
+30060 -14298

No files matched your search

+12
View File
@@ -22,6 +22,8 @@
import { toggleTransition } from './utils/ui/animation'
import { checkStorageCompatibility } from './utils/storage'
import { initializeTheme } from './hooks/core/useTheme'
import { refreshFilterCollapse, setupFilterCollapseObserver } from './utils/ui/filter-collapse'
import { setupListTableRowSelect } from './utils/ui/list-table-row-select'
const userStore = useUserStore()
const { language } = storeToRefs(userStore)
@@ -33,6 +35,8 @@
let buttonIconObserver: MutationObserver | null = null
let panelTitleObserver: MutationObserver | null = null
let filterCollapseObserver: MutationObserver | null = null
let teardownListTableRowSelect: (() => void) | null = null
const iconClassByText: Array<[RegExp, string]> = [
[/(新增|新建|创建|添加)/, 'auto-icon-plus'],
@@ -147,6 +151,7 @@
systemUpgrade()
refreshButtonAutoIcons()
refreshPanelTitleIcons()
refreshFilterCollapse()
buttonIconObserver = new MutationObserver(() => {
refreshButtonAutoIcons()
@@ -163,6 +168,9 @@
childList: true,
subtree: true
})
filterCollapseObserver = setupFilterCollapseObserver()
teardownListTableRowSelect = setupListTableRowSelect()
})
onBeforeUnmount(() => {
@@ -170,5 +178,9 @@
buttonIconObserver = null
panelTitleObserver?.disconnect()
panelTitleObserver = null
filterCollapseObserver?.disconnect()
filterCollapseObserver = null
teardownListTableRowSelect?.()
teardownListTableRowSelect = null
})
</script>
+7
View File
@@ -394,7 +394,11 @@ export interface Plan {
notifyMessage?: string
followUserId?: string
followUserName?: string
hazardCertReady?: number
hazardCertReadyBy?: string
hazardCertReadyTime?: string
remark?: string
createBy?: string
createTime: string
products?: PlanProduct[]
}
@@ -522,6 +526,8 @@ export const planApi = {
id: string,
products: Array<{ id: string; purchaseCertUrl?: string; transportCertUrl?: string }>
) => request.patch<Plan>(`/transport/plan/${id}/product-certs`, { products }),
setHazardCertReady: (id: string, ready: boolean) =>
request.patch<Plan>(`/transport/plan/${id}/hazard-cert-ready`, { ready }),
cancel: (id: string) => request.post<Plan>(`/transport/plan/${id}/cancel`),
getArrangeInfo: (id: string) => request.get<PlanArrangeInfo>(`/transport/plan/${id}/arrange-info`),
archive: (id: string, data?: PlanArchiveBody) =>
@@ -769,6 +775,7 @@ export interface FreightSettlement {
transports?: Transport[]
plans?: SettlementPlanSummary[]
approvalFlow?: SettlementApprovalFlowItem[]
approvalHistory?: SettlementApprovalFlowItem[]
vouchers?: RemittanceVoucher[]
}
+13 -6
View File
@@ -1,21 +1,28 @@
import { publicPlGet, publicPlPost, publicPlUpload } from '@/utils/http/public-pl'
import type { PublicTransportProduct } from '@/api/pl/public-transport'
export interface PublicTransportSitePage {
batchNo?: string
businessType: string
date?: string
partnerCompany: string
product: string
specification?: string
quantity: number
vehicleNo: string
planNo?: string
status?: string
remark?: string
pickupAddress?: string
pickupLng?: number | null
pickupLat?: number | null
pickupTime?: string
pickupNotes?: string
destinationName?: string
destinationAddress?: string
destinationLng?: number | null
destinationLat?: number | null
deliveryTime?: string
destinationNotes?: string
products: PublicTransportProduct[]
siteFeedbackRemark?: string
siteFeedbackImages: string[]
siteFeedbackTime?: string
readOnly: boolean
voided: boolean
}
+13
View File
@@ -6,6 +6,13 @@ export interface PublicTransportProduct {
packageSpec?: string
unit?: string
quantity?: number
remark?: string
}
export interface PublicTransportPersonnel {
personnelName: string
phoneNo?: string
role?: string
}
export interface PublicTransportPage {
@@ -15,12 +22,18 @@ export interface PublicTransportPage {
pickupAddress?: string
pickupLng?: number | null
pickupLat?: number | null
pickupTime?: string
pickupNotes?: string
destinationName?: string
destinationAddress?: string
destinationLng?: number | null
destinationLat?: number | null
deliveryTime?: string
destinationNotes?: string
licensePlate?: string
driverName?: string
driverPhone?: string
otherPersonnel?: PublicTransportPersonnel[]
products: PublicTransportProduct[]
feedbackRemark?: string
feedbackImages: string[]
+180 -26
View File
@@ -125,7 +125,7 @@ body {
// 盒子默认边框
.page-content {
border: 1px solid var(--mint-border) !important;
border-radius: var(--mint-radius) !important;
border-radius: 0 !important;
box-shadow: var(--mint-shadow) !important;
}
@@ -359,8 +359,8 @@ body {
.panel {
background: var(--mint-surface);
border: 1px solid var(--mint-border);
border-radius: var(--mint-radius);
border: none;
border-radius: 0;
box-shadow: var(--mint-shadow);
/* 不要用 overflow:hidden:会导致 el-table 右侧固定列(fixed="right")无法正确粘贴/层级错乱 */
overflow: visible;
@@ -941,7 +941,11 @@ body {
.filter-block {
padding: 12px 16px 10px;
background: var(--mint-surface);
border-bottom: 1px solid var(--mint-border);
border-bottom: none;
&.is-filter-collapsed {
display: none !important;
}
}
.filter-hint {
@@ -1066,7 +1070,7 @@ body {
.toolbar {
padding: 0;
background: var(--mint-table-head);
border-bottom: 1px solid var(--mint-border);
border-bottom: none;
}
.list-toolbar-row,
@@ -1077,6 +1081,7 @@ body {
align-items: center;
flex-wrap: wrap;
justify-content: flex-start;
background: var(--mint-table-head);
}
/* 列表工具栏按钮间距统一由 gap 控制,清除 Element 默认相邻按钮 margin */
@@ -1093,15 +1098,93 @@ body {
.toolbar-left {
display: inline-flex;
align-items: center;
gap: 0;
min-height: 36px;
padding: 0 10px 0 0;
margin-right: 4px;
border-right: 1px solid var(--mint-border);
border-right: none;
font-size: 12px;
color: var(--mint-text-muted);
font-weight: 600;
}
.btn-list-filter-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin-right: 8px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--mint-text-muted);
cursor: pointer;
flex-shrink: 0;
transition:
background-color 0.2s ease,
color 0.2s ease;
&::before {
content: '';
width: 16px;
height: 16px;
background-color: currentColor;
opacity: 0.95;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolygon points='22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3'/%3E%3C/svg%3E")
center / contain no-repeat;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolygon points='22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3'/%3E%3C/svg%3E")
center / contain no-repeat;
}
&:hover {
color: var(--mint-primary-dark);
background: var(--mint-primary-bg);
}
&[aria-expanded='true'] {
color: var(--mint-primary-dark);
background: var(--mint-primary-bg);
}
&:focus-visible {
outline: 2px solid var(--mint-primary);
outline-offset: 1px;
}
}
.quick-search-group {
display: inline-flex;
align-items: center;
flex-shrink: 0;
margin-right: 8px;
gap: 6px;
.quick-search-input {
width: 280px;
margin-right: 0;
}
.btn-list-filter-toggle {
margin-right: 0;
}
}
.quick-search-input:not(.quick-search-group .quick-search-input) {
width: 280px;
}
.quick-search-input .quick-search-icon {
cursor: pointer;
color: var(--mint-text-muted);
transition: color 0.2s ease;
&:hover {
color: var(--mint-primary-dark);
}
}
.sel-num {
color: var(--mint-primary-dark);
font-weight: 700;
@@ -1113,7 +1196,7 @@ body {
}
.list-wrap {
border-radius: 0 0 var(--mint-radius) var(--mint-radius);
border-radius: 0;
overflow: visible;
}
@@ -1131,9 +1214,9 @@ body {
gap: 8px 12px;
margin-top: 0;
padding: 10px 16px 12px;
border-top: 1px solid var(--mint-border);
border-top: none;
background: var(--mint-surface);
border-radius: 0 0 var(--mint-radius) var(--mint-radius);
border-radius: 0;
}
/* 分页条左侧说明文案(BX range / 分类 total-count 等)与右侧控件区分 */
@@ -1147,14 +1230,14 @@ body {
justify-content: flex-end;
margin-top: 0;
padding: 10px 16px 12px;
border-top: 1px solid var(--mint-border);
border-top: none;
background: var(--mint-surface);
}
.search-bar {
background: var(--mint-surface);
border: 1px solid var(--mint-border);
border-radius: var(--mint-radius);
border-radius: 0;
box-shadow: var(--mint-shadow);
}
@@ -1162,7 +1245,7 @@ body {
margin-bottom: 14px;
padding: 14px 16px;
border: 1px solid var(--mint-border);
border-radius: var(--mint-radius);
border-radius: 0;
background: var(--mint-primary-bg);
}
@@ -1179,7 +1262,7 @@ body {
}
.detail-section {
border-radius: var(--mint-radius);
border-radius: 0;
overflow: hidden;
margin-bottom: 14px;
}
@@ -1243,6 +1326,14 @@ body {
grid-column: 1 / -1;
}
/* 弹窗表单:栅格块与后续表单项/栅格块之间保留行间距(与模块弹窗 form-grid row-gap 一致) */
.el-dialog.dialog-form-layout .el-form {
[class*='form-grid-'] + .el-form-item,
[class*='form-grid-'] + [class*='form-grid-'] {
margin-top: 16px;
}
}
/* 表单栅格内 el-input-number 默认约 150px,须拉满与 el-input / el-select 同宽 */
.el-dialog.dialog-form-layout .el-form-item .el-input-number,
.page-container .el-form-item .el-input-number,
@@ -1288,11 +1379,11 @@ body {
}
}
/* 标准 CRUD 弹窗:相对视口上下各保留至少 50px 可视留白,正文区纵向滚动(不占满视口高度) */
/* 标准 CRUD 弹窗:相对视口上下各 20px 留白,正文区纵向滚动 */
.el-dialog.dialog-form-layout {
display: flex;
flex-direction: column;
max-height: calc(100vh - 100px);
max-height: var(--dialog-max-height, calc(100vh - 40px));
overflow: hidden;
}
@@ -1300,6 +1391,14 @@ body {
flex-shrink: 0;
}
.el-dialog.dialog-form-layout .el-dialog__headerbtn {
display: flex;
align-items: center;
justify-content: center;
top: 4px;
right: 4px;
}
.el-dialog.dialog-form-layout .el-dialog__body {
flex: 1;
min-height: 0;
@@ -1321,13 +1420,18 @@ body {
backdrop-filter: none !important;
.el-overlay-dialog {
margin-top: 16px;
margin-top: 0;
margin-bottom: auto;
padding-top: var(--dialog-viewport-inset, 20px);
box-sizing: border-box;
}
.el-dialog.dialog-form-layout,
.el-dialog.order-module-dialog {
max-width: calc(100vw - 48px);
max-height: calc(
100vh - var(--dialog-viewport-inset, 20px) * 2
);
}
}
@@ -1365,6 +1469,12 @@ body {
background: var(--mint-surface);
}
.app-layout-portal #app-sidebar .layout-sidebar {
height: 100%;
border-right: 1px solid var(--mint-border) !important;
background: var(--mint-surface);
}
#app-sidebar .menu-left .header {
height: 56px;
line-height: 56px;
@@ -1383,6 +1493,14 @@ body {
/* 弹窗 / 抽屉内键值展示:上下布局、深色正文(对齐 zkxw Token 链) */
:is(.el-dialog, .system-view-dialog, .el-drawer) {
.el-form-item__label {
font-size: 13px !important;
}
.view-kv-table th {
font-size: 13px !important;
}
:is(.view-kv, [class*='-view-kv']:not([class*='-grid']), .pl-plan-kv) {
display: flex !important;
flex-direction: column !important;
@@ -1393,7 +1511,7 @@ body {
dt {
margin: 0;
font-size: 13px;
font-size: 13px !important;
font-weight: 400 !important;
color: var(--mint-text-muted) !important;
line-height: 1.4;
@@ -1409,7 +1527,7 @@ body {
}
.view-field-label {
font-size: 13px;
font-size: 13px !important;
font-weight: 400 !important;
color: var(--mint-text-muted) !important;
line-height: 1.4;
@@ -1427,8 +1545,39 @@ body {
}
}
/* CRM 新增/编辑弹窗:label 左、控件右,垂直居中,label 可换行 */
.crm-dialog-form {
.el-form-item {
align-items: center;
}
/* 栅格内纵向间距由 gap 承担,避免与 margin 叠加 */
[class*='form-grid-'] > .el-form-item {
margin-bottom: 0;
}
/* 左右布局单行更矮,适当加大栅格行间距 */
[class*='form-grid-'] {
row-gap: 16px;
}
.el-form-item__label {
white-space: normal;
word-break: break-word;
line-height: 1.45;
height: auto !important;
padding-right: 12px;
align-self: center;
}
.el-form-item__content {
min-width: 0;
}
}
.el-dialog.dialog-form-layout {
.el-form-item__label {
font-size: 13px !important;
color: var(--mint-text) !important;
font-weight: 400 !important;
}
@@ -1451,10 +1600,14 @@ body {
/* 弹窗 / 查看态内嵌列表面板(CRM 详情 Tab、表单弹窗、选择器等) */
:is(.el-dialog, .system-view-dialog) {
.list-body.list-wrap {
border-top: 1px solid var(--mint-border);
}
.dialog-list-panel,
.customer-tab-list-panel {
> .list-toolbar:first-child {
border-radius: var(--mint-radius) var(--mint-radius) 0 0;
border-radius: 0;
overflow: hidden;
}
@@ -1488,19 +1641,14 @@ body {
flex-wrap: wrap;
}
.list-body {
border-top: 1px solid var(--mint-border);
}
> .list-body.list-wrap {
overflow: hidden;
border-radius: 0 0 var(--mint-radius) var(--mint-radius);
border-radius: 0;
}
> .list-body.list-wrap.dialog-list-body-only,
> .list-body.list-wrap.customer-tab-list-body-only {
border-top: none;
border-radius: var(--mint-radius);
border-radius: 0;
}
.pagination.list-pager-bar {
@@ -1544,6 +1692,12 @@ body {
white-space: nowrap;
}
}
/* 区块标题 / 筛选行后紧跟、未包 list-body 的裸 list-table:表头顶部分隔线 */
:is(.dialog-section-title, .dlg-view-section-title) + .list-table.el-table thead th.el-table__cell,
:is(.dialog-section-title, .dlg-view-section-title) + .inline-row + .list-table.el-table thead th.el-table__cell {
border-top: 1px solid var(--mint-border) !important;
}
}
/* 筛选区 label:13px 次要色(对齐 zkxw filter-form 实践;filter-hint 仍为 11px) */
+85 -4
View File
@@ -58,6 +58,11 @@
padding: 0 0 4px !important;
}
// 弹窗/抽屉内表单 label(含 label-right):统一 13px,避免 EP 默认 14px
:is(.el-dialog, .el-drawer) .el-form-item__label {
font-size: 13px !important;
}
// 日期选择器
.el-date-range-picker {
--el-datepicker-inrange-bg-color: var(--art-gray-200) !important;
@@ -149,12 +154,17 @@ html.dark .el-card {
border-radius: var(--mint-radius) !important;
overflow: hidden;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.12) !important;
display: flex;
flex-direction: column;
max-height: var(--dialog-max-height, calc(100vh - 40px));
margin: 0;
}
.el-dialog__header {
padding: 16px 22px 14px !important;
border-bottom: 1px solid var(--mint-border);
background: linear-gradient(135deg, var(--mint-primary-bg) 0%, var(--mint-surface) 60%);
flex-shrink: 0;
.el-dialog__title {
font-size: 16px;
@@ -163,15 +173,24 @@ html.dark .el-card {
}
}
.el-dialog__headerbtn {
top: 4px;
right: 4px;
}
.el-dialog__body {
padding: 18px 22px !important;
position: relative; // 为了兼容 el-pagination 样式,需要设置 relative,不然会影响 el-pagination 的样式,比如 el-pagination__jump--small 会被影响,导致 el-pagination__jump--small 按钮无法点击,详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.el-dialog__footer {
padding: 12px 22px !important;
border-top: 1px solid var(--mint-border);
background: var(--mint-table-head);
flex-shrink: 0;
}
.el-dialog.el-dialog-border {
@@ -372,8 +391,8 @@ html.dark .el-card {
// 修改 el-message-box 样式
.el-message-box__headerbtn .el-message-box__close,
.el-dialog__headerbtn .el-dialog__close {
top: 7px;
right: 7px;
top: 0;
right: 0;
width: 30px;
height: 30px;
border-radius: 5px;
@@ -747,6 +766,56 @@ body .el-table thead {
font-size: 14px;
}
// 主列表(含多选列):行可单击切换选中
.page-container .list-table.el-table:has(.el-table-column--selection) tbody tr.el-table__row,
.console-container .list-table.el-table:has(.el-table-column--selection) tbody tr.el-table__row {
cursor: pointer;
}
// 主列表:无顶/底外框;左右外缘 + 列间竖线 + 行间横线
.page-container .list-table.el-table,
.console-container .list-table.el-table,
.el-dialog .list-table.el-table,
.system-view-dialog .list-table.el-table {
border: none !important;
&::before,
&::after {
display: none !important;
}
.el-table__inner-wrapper::before,
.el-table__inner-wrapper::after {
display: none !important;
}
.el-table__border-left-patch,
.el-table__border-right-patch,
.el-table__border-bottom-patch {
display: none !important;
}
th.el-table__cell,
td.el-table__cell {
border-right: 1px solid var(--mint-border) !important;
border-bottom: 1px solid var(--mint-border) !important;
border-left: none !important;
border-top: none !important;
}
th.el-table__cell:first-child,
td.el-table__cell:first-child {
border-left: 1px solid var(--mint-border) !important;
}
.el-table__fixed-right {
th.el-table__cell:last-child,
td.el-table__cell:last-child {
border-right: 1px solid var(--mint-border) !important;
}
}
}
.page-container .el-table th.el-table__cell,
.console-container .el-table th.el-table__cell {
color: var(--mint-text-muted);
@@ -1196,12 +1265,24 @@ textarea::placeholder {
color: #147a3c !important;
}
// 列表分割线统一:每个分区只保留一条线
// 工具栏与列表之间:保留 .list-body 顶部线,去掉 .list-toolbar 底部线
// 列表分割线统一:工具栏与列表之间保留 list-body 顶部分隔线
.page-container .list-toolbar,
.console-container .list-toolbar,
.el-dialog .list-toolbar {
border-bottom: none !important;
background: var(--mint-table-head) !important;
}
.page-container .list-toolbar-row,
.console-container .list-toolbar-row,
.el-dialog .list-toolbar-row {
background: var(--mint-table-head);
}
.page-container .list-body,
.console-container .list-body,
.el-dialog .list-body {
border-top: 1px solid var(--mint-border);
}
// 列表与分页之间:保留列表底部线,去掉分页顶部线;分页控件右对齐(§7.9)
@@ -48,4 +48,8 @@
--danger-bg: #fef2f2;
--info: #64748b;
--info-bg: #f8fafc;
/* 弹窗相对视口上下留白(各 20px,总 40px) */
--dialog-viewport-inset: 20px;
--dialog-max-height: calc(100vh - var(--dialog-viewport-inset) * 2);
}
+9
View File
@@ -33,3 +33,12 @@
// 地磅(db)模块 Fresh Mint 公共样式
@use '../../styles/db-module.scss';
// 运输(pl)模块样式
@use '../../styles/pl-module.scss';
// 工作流(wf)模块样式
@use '../../styles/wf-module.scss';
// 系统管理(sys)模块样式
@use '../../styles/sys-module.scss';
@@ -1,5 +1,11 @@
<template>
<div class="attachment-upload">
<div
class="attachment-upload"
:class="{
'attachment-upload--compact': compact,
'attachment-upload--readonly': compact && disabled
}"
>
<div class="attachment-list">
<el-upload
v-if="!disabled"
@@ -11,7 +17,7 @@
@change="handleAttachmentChange"
>
<div class="upload-card">
<el-icon :size="28" color="#1ca851"><Plus /></el-icon>
<el-icon :size="previewIconSize" color="#1ca851"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
</div>
</el-upload>
@@ -23,18 +29,18 @@
>
<div
class="attachment-preview"
:class="{ 'is-clickable': canPreview(file) }"
@click="canPreview(file) ? handlePreviewAttachment(file) : undefined"
:class="{ 'is-clickable': Boolean(file.fileUrl) }"
@click="file.fileUrl ? handleAttachmentClick(file) : undefined"
>
<template v-if="isImageFile(file) && file.fileUrl">
<img :src="resolveFileUrl(file.fileUrl)" :alt="file.fileName" />
</template>
<template v-else-if="isImageFile(file) && !file.fileUrl">
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
<el-icon :size="previewIconSize" color="#94a3b8"><Loading /></el-icon>
</template>
<template v-else-if="isVideoFile(file)">
<div class="attachment-preview__icon attachment-preview__icon--video">
<el-icon :size="28"><VideoPlay /></el-icon>
<el-icon :size="previewIconSize"><VideoPlay /></el-icon>
</div>
</template>
<template v-else>
@@ -42,13 +48,22 @@
class="attachment-preview__icon"
:class="isPdfFile(file) ? 'attachment-preview__icon--pdf' : 'attachment-preview__icon--doc'"
>
<el-icon :size="28"><Document /></el-icon>
<el-icon :size="previewIconSize"><Document /></el-icon>
<span v-if="getFileExtension(file.fileName)" class="attachment-preview__ext">
{{ getFileExtension(file.fileName) }}
</span>
</div>
</template>
</div>
<button
v-if="compact && !disabled"
type="button"
class="attachment-compact-delete"
title="删除"
@click.stop="removeAttachment(index)"
>
<el-icon :size="12"><Delete /></el-icon>
</button>
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div v-if="!disabled" class="attachment-actions">
@@ -71,7 +86,7 @@
</div>
</div>
</div>
<span v-if="!disabled" class="upload-hint">{{ hint }}</span>
<span v-if="!disabled && !compact" class="upload-hint">{{ hint }}</span>
<el-dialog
v-model="previewVisible"
@@ -148,6 +163,8 @@ const props = withDefaults(
accept?: string
hint?: string
disabled?: boolean
/** 紧凑模式:小缩略图,只读时隐藏文件名与操作栏 */
compact?: boolean
}>(),
{
modelValue: '',
@@ -155,7 +172,8 @@ const props = withDefaults(
limit: 15,
accept: 'image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar',
hint: '支持图片、视频、PDF、Word、Excel等文件',
disabled: false
disabled: false,
compact: false
}
)
@@ -170,6 +188,11 @@ const previewFile = ref<AttachmentItem | null>(null)
const previewPdfSrc = ref('')
const previewLoading = ref(false)
const previewIconSize = computed(() => {
if (!props.compact) return 28
return props.disabled ? 14 : 20
})
function resolveFileUrl(url: string) {
const trimmed = String(url || '').trim()
if (!trimmed) return ''
@@ -325,6 +348,18 @@ function handlePreviewAttachment(file: AttachmentItem) {
})
}
function handleAttachmentClick(file: AttachmentItem) {
if (!file.fileUrl) return
if (canPreview(file)) {
handlePreviewAttachment(file)
return
}
const url = resolveFileUrl(file.fileUrl)
if (url) {
window.open(url, '_blank', 'noopener,noreferrer')
}
}
function handlePreviewClosed() {
previewFile.value = null
previewPdfSrc.value = ''
@@ -532,4 +567,108 @@ defineExpose({ hasUploading })
:deep(.el-upload) {
display: block;
}
.attachment-upload--compact {
.attachment-list {
gap: 6px;
}
.upload-card-text {
display: none;
}
.attachment-preview__ext {
display: none;
}
.attachment-footer {
display: none;
}
}
/* 表格编辑:中等缩略图,支持预览 + 角标删除 */
.attachment-upload--compact:not(.attachment-upload--readonly) {
.upload-card {
width: 88px;
height: 66px;
gap: 0;
border-radius: 8px;
}
.attachment-item {
position: relative;
width: 88px;
border-radius: 8px;
&:hover {
box-shadow: 0 2px 6px rgba(28, 168, 81, 0.12);
}
}
.attachment-preview {
height: 66px;
}
.attachment-preview__icon {
width: 36px;
min-height: 36px;
border-radius: 8px;
}
.attachment-compact-delete {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 999px;
background: rgba(15, 23, 42, 0.72);
color: #fff;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: #ef4444;
}
}
}
/* 只读紧凑:更小缩略图(查看弹窗等) */
.attachment-upload--compact.attachment-upload--readonly {
.attachment-list {
gap: 4px;
}
.upload-card {
width: 40px;
height: 40px;
gap: 0;
border-radius: 6px;
}
.attachment-item {
width: 40px;
border-radius: 6px;
&:hover {
box-shadow: 0 1px 4px rgba(28, 168, 81, 0.12);
}
}
.attachment-preview {
height: 40px;
}
.attachment-preview__icon {
width: 24px;
min-height: 24px;
border-radius: 6px;
}
}
</style>
@@ -0,0 +1,33 @@
<!-- 列表工具栏多功能检索框(漏斗切换由 filter-collapse 注入到 .quick-search-group) -->
<template>
<el-input
:model-value="modelValue"
class="quick-search-input"
:placeholder="placeholder"
clearable
@update:model-value="emit('update:modelValue', $event ?? '')"
@keyup.enter="emit('search')"
@clear="emit('clear')"
>
<template #suffix>
<el-icon class="quick-search-icon" @click="emit('search')"><Search /></el-icon>
</template>
</el-input>
</template>
<script setup lang="ts">
import { Search } from '@element-plus/icons-vue'
defineOptions({ name: 'ListQuickSearchInput' })
defineProps<{
modelValue: string
placeholder: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
search: []
clear: []
}>()
</script>
@@ -1,90 +1,84 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<el-dialog
v-model="innerVisible"
<SystemViewDialog
v-model:visible="innerVisible"
:title="carrier?.carrierName || '承运商详情'"
width="1080px"
align-center
destroy-on-close
class="dialog-form-layout carrier-view-dialog"
:icon="OfficeBuilding"
:meta="heroMeta"
:sections="viewSections"
:loading="loading"
width="1400px"
dialog-class="pl-carrier-view-dialog"
modal-class="order-module-dialog-mask"
append-to-body
style="backdrop-filter: none"
@closed="handleClosed"
@update:visible="handleVisibleChange"
>
<div class="carrier-view-body" v-loading="loading">
<div v-if="$slots['hero-actions']" class="carrier-view-hero-actions">
<slot name="hero-actions" />
</div>
<section class="carrier-view-top">
<h3 class="carrier-view-block-title">承运商信息</h3>
<ViewKvTable v-if="carrier" :fields="carrierViewFields" mode="triple" />
<template #hero-actions>
<slot name="hero-actions" />
</template>
<h3 class="carrier-view-block-title carrier-view-attachment-title">资质附件</h3>
<div v-if="carrier" class="carrier-view-attachments form-grid-2">
<div class="carrier-view-attachment-item">
<div class="carrier-view-attachment-label">营业执照</div>
<AttachmentUploadField
:model-value="carrier.businessLicenseUrls || ''"
disabled
folder="pl/carrier/license"
/>
</div>
<div class="carrier-view-attachment-item">
<div class="carrier-view-attachment-label">道路经营许可证</div>
<AttachmentUploadField
:model-value="carrier.roadTransportLicenseUrls || ''"
disabled
folder="pl/carrier/permit"
/>
</div>
<div class="carrier-view-attachment-item form-col-span-2">
<div class="carrier-view-attachment-label">运输协议</div>
<AttachmentUploadField
:model-value="carrier.transportAgreementUrls || ''"
disabled
folder="pl/carrier/agreement"
/>
</div>
<template #carrierAttachments>
<div v-if="carrier" class="carrier-view-attachments form-grid-3">
<div class="carrier-view-attachment-item">
<div class="carrier-view-attachment-label">营业执照</div>
<AttachmentUploadField
:model-value="carrier.businessLicenseUrls || ''"
disabled
folder="pl/carrier/license"
/>
</div>
<div class="carrier-view-attachment-item">
<div class="carrier-view-attachment-label">运输协议</div>
<AttachmentUploadField
:model-value="carrier.transportAgreementUrls || ''"
disabled
folder="pl/carrier/agreement"
/>
</div>
<div class="carrier-view-attachment-item">
<div class="carrier-view-attachment-label">道路经营许可证</div>
<AttachmentUploadField
:model-value="carrier.roadTransportLicenseUrls || ''"
disabled
folder="pl/carrier/permit"
/>
</div>
</section>
<section class="carrier-view-bottom">
<h3 class="carrier-view-block-title">关联车辆</h3>
<p v-if="!vehicleBundles.length && !loading" class="carrier-view-empty">暂无关联车辆</p>
<el-tabs v-else v-model="activeTab" class="carrier-vehicle-tabs">
<el-tab-pane
v-for="bundle in vehicleBundles"
:key="bundle.detail.id"
:label="bundle.detail.licensePlate"
:name="bundle.detail.id"
>
<PlVehicleViewPanel
:detail="bundle.detail"
:personnel-list="bundle.personnelList"
/>
</el-tab-pane>
</el-tabs>
</section>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="innerVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</div>
</template>
</el-dialog>
<template #carrierVehicles>
<p v-if="!vehicleBundles.length && !loading" class="carrier-view-empty">暂无关联车辆</p>
<el-tabs v-else v-model="activeTab" class="carrier-vehicle-tabs">
<el-tab-pane
v-for="bundle in vehicleBundles"
:key="bundle.detail.id"
:label="bundle.detail.licensePlate"
:name="bundle.detail.id"
lazy
>
<PlVehicleViewPanel
v-if="activeTab === bundle.detail.id"
:key="`${viewSessionKey}-${bundle.detail.id}`"
:detail="bundle.detail"
:personnel-list="bundle.personnelList"
table-mode="quint"
cert-layout="table"
/>
</el-tab-pane>
</el-tabs>
</template>
</SystemViewDialog>
</template>
<script setup lang="ts">
import { Close } from '@element-plus/icons-vue'
import { OfficeBuilding } from '@element-plus/icons-vue'
import { carrierApi, vehicleApi, type Carrier } from '@/api/pl'
import { pickBxList } from '@/utils/http/bx'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlVehicleViewPanel from '@/components/business/pl-vehicle-view-panel/index.vue'
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
import SystemViewDialog, {
type ViewSection
} from '@/components/business/system-view-dialog/index.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import {
loadVehicleViewBundles,
@@ -116,44 +110,130 @@ const loading = ref(false)
const carrier = ref<Carrier | null>(null)
const vehicleBundles = ref<VehicleViewBundle[]>([])
const activeTab = ref('')
const viewSessionKey = ref(0)
const carrierViewFields = computed<ViewField[]>(() => {
const heroMeta = computed(() => {
const c = carrier.value
if (!c) return ''
const parts = [
c.contactName || undefined,
c.contactPhone || undefined,
getPlCarrierStatusLabel(c.status),
getPlCarrierAuditStatusLabel(c.auditStatus)
].filter(Boolean)
return parts.join(' · ')
})
const carrierInfoRow1Fields = computed<ViewField[]>(() => {
const c = carrier.value
if (!c) return []
return [
{ label: '承运商名称', value: c.carrierName },
{ label: '联系人', value: c.contactName || '—' },
{ label: '联系电话', value: c.contactPhone || '—' },
{ label: '承运商名称', value: c.carrierName, long: false },
{ label: '联系人', value: c.contactName || '—', long: false },
{ label: '联系电话', value: c.contactPhone || '—', long: false },
{
label: '合作状态',
value: getPlCarrierStatusLabel(c.status),
tagType: getPlCarrierStatusTagType(c.status)
},
tagType: getPlCarrierStatusTagType(c.status),
long: false
}
]
})
const carrierInfoRow2Fields = computed<ViewField[]>(() => {
const c = carrier.value
if (!c) return []
return [
{
label: '审核状态',
value: getPlCarrierAuditStatusLabel(c.auditStatus),
tagType: getPlCarrierAuditStatusTagType(c.auditStatus)
tagType: getPlCarrierAuditStatusTagType(c.auditStatus),
long: false
},
{
label: '审核时间',
value: c.auditTime ? formatDateTime(c.auditTime) : '—'
value: c.auditTime ? formatDateTime(c.auditTime) : '—',
long: false
},
{ label: '审核备注', value: c.auditRemark || '—', long: false, fullSpan: false }
]
})
const carrierInfoRow3Fields = computed<ViewField[]>(() => {
const c = carrier.value
if (!c) return []
return [
{ label: '创建时间', value: formatDateTime(c.createTime), long: false },
{
label: '协议有效期',
value: c.transportAgreementExpired
? '协议已过期'
: c.transportAgreementExpireDate || '—',
tagType: c.transportAgreementExpired ? 'danger' : undefined
tagType: c.transportAgreementExpired ? 'danger' : undefined,
long: false
},
{
label: '资质状态',
value: c.qualificationComplete ? '已齐全' : '资质不全',
tagType: c.qualificationComplete ? 'success' : 'warning'
tagType: c.qualificationComplete ? 'success' : 'warning',
long: false
},
{ label: '创建时间', value: formatDateTime(c.createTime) },
{ label: '审核备注', value: c.auditRemark || '—', span: 'full' },
{ label: '备注', value: c.remark || '—', span: 'full' }
{ label: '备注', value: c.remark || '—', long: false, fullSpan: false }
]
})
const viewSections = computed<ViewSection[]>(() => {
if (!carrier.value) {
return [
{
title: '承运商信息',
fieldGroups: [
{ fieldLayout: 'table', tableMode: 'quad', fields: [] },
{ fieldLayout: 'table', tableMode: 'quad', fields: [] },
{ fieldLayout: 'table', tableMode: 'triple', fields: [] }
],
fields: []
},
{ title: '资质附件', fields: [], slot: 'carrierAttachments' },
{ title: '关联车辆', fields: [], slot: 'carrierVehicles' }
]
}
return [
{
title: '承运商信息',
fieldGroups: [
{
fieldLayout: 'table',
tableMode: 'quad',
fields: carrierInfoRow1Fields.value
},
{
fieldLayout: 'table',
tableMode: 'quad',
fields: carrierInfoRow3Fields.value
},
{
fieldLayout: 'table',
tableMode: 'triple',
fields: carrierInfoRow2Fields.value
}
],
fields: []
},
{
title: '资质附件',
fields: [],
slot: 'carrierAttachments'
},
{
title: '关联车辆',
fields: [],
slot: 'carrierVehicles'
}
]
})
@@ -171,6 +251,7 @@ async function loadDetail(carrierId: string) {
carrier.value = null
vehicleBundles.value = []
activeTab.value = ''
viewSessionKey.value += 1
try {
const [carrierRes, vehicleListRes] = await Promise.all([
carrierApi.get(carrierId),
@@ -188,47 +269,24 @@ async function loadDetail(carrierId: string) {
}
}
function handleClosed() {
carrier.value = null
vehicleBundles.value = []
activeTab.value = ''
function handleVisibleChange(value: boolean) {
if (!value) {
carrier.value = null
vehicleBundles.value = []
activeTab.value = ''
viewSessionKey.value += 1
}
}
async function reload() {
if (!props.visible || !props.carrierId) return
await loadDetail(props.carrierId)
}
defineExpose({ reload })
</script>
<style scoped lang="scss">
.carrier-view-body {
display: flex;
flex-direction: column;
gap: 20px;
min-height: 120px;
}
.carrier-view-hero-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding-bottom: 16px;
border-bottom: 1px solid #e2e8f0;
}
.carrier-view-block-title {
margin: 0 0 12px;
font-size: 15px;
font-weight: 600;
color: #1e293b;
}
.carrier-view-top {
padding-bottom: 16px;
border-bottom: 1px solid #e2e8f0;
}
.carrier-view-attachment-title {
margin-top: 20px;
}
.carrier-view-attachments {
display: grid;
gap: 16px;
@@ -238,13 +296,13 @@ function handleClosed() {
margin-bottom: 8px;
font-size: 11px;
font-weight: 500;
color: #1e293b;
color: #64748b;
}
.carrier-view-empty {
margin: 0;
font-size: 13px;
color: #1e293b;
color: var(--mint-text-muted, #64748b);
}
.carrier-vehicle-tabs {
@@ -257,3 +315,27 @@ function handleClosed() {
}
}
</style>
<style lang="scss">
.pl-carrier-view-dialog .view-section:first-of-type .view-field-group--spaced {
margin-top: 0;
}
.pl-carrier-view-dialog .view-section:first-of-type .view-field-group .view-kv-table-wrap {
border-radius: 0;
}
.pl-carrier-view-dialog .view-section:first-of-type .view-field-group:first-child .view-kv-table-wrap {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.pl-carrier-view-dialog .view-section:first-of-type .view-field-group--spaced .view-kv-table-wrap {
border-top: none;
}
.pl-carrier-view-dialog .view-section:first-of-type .view-field-group:last-child .view-kv-table-wrap {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
</style>
@@ -9,6 +9,92 @@
<div v-if="!certList.length" class="cert-empty">暂无证书,点击「添加证书」</div>
<el-table v-else-if="layout === 'table'" :data="certList" stripe class="list-table cert-form-table">
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
v-if="showVehiclePartColumn"
label="车牌号"
min-width="140"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-select
v-if="hasTrailerPlateOption"
:model-value="normalizeVehicleCertPart(row.vehiclePart)"
placeholder="请选择车牌号"
style="width: 100%"
@update:model-value="handleVehiclePartChange(row, $event)"
>
<el-option
v-for="option in vehiclePlateOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<span v-else>{{ headPlateLabel }}</span>
</template>
</el-table-column>
<el-table-column label="证书名称" min-width="160">
<template #default="{ row }">
<el-select
v-model="row.certName"
placeholder="请选择或输入"
filterable
allow-create
default-first-option
>
<el-option v-for="name in certNameOptions" :key="name" :label="name" :value="name" />
</el-select>
</template>
</el-table-column>
<el-table-column label="证号" min-width="140">
<template #default="{ row }">
<el-input v-model="row.certNo" placeholder="请输入证号" />
</template>
</el-table-column>
<el-table-column label="有效期" min-width="150">
<template #default="{ row }">
<el-date-picker
v-model="row.expireDate"
type="date"
placeholder="选择有效期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column label="提醒天数" min-width="110" align="center" header-align="center">
<template #default="{ row }">
<el-input-number
v-model="row.remindDays"
:min="1"
:max="365"
:controls="false"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column label="附件" min-width="220">
<template #default="{ row, $index }">
<AttachmentUploadField
:ref="(el) => setCertUploadRef($index, el)"
v-model="row.attachmentUrls"
:folder="uploadFolder"
compact
/>
</template>
</el-table-column>
<el-table-column label="操作" min-width="60" align="center" header-align="center">
<template #default="{ $index }">
<el-button link type="danger" @click="handleRemoveCert($index)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="cert-card-list">
<div v-for="(cert, index) in certList" :key="cert._key" class="cert-card">
<div class="cert-card-head">
@@ -43,9 +129,6 @@
/>
</el-form-item>
<el-form-item label="提醒天数">
<template #label>
提醒天数<span class="form-label-tip">默认 30 天</span>
</template>
<el-input-number
v-model="cert.remindDays"
:min="1"
@@ -71,6 +154,11 @@
import { Delete, Plus } from '@element-plus/icons-vue'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import type { CertRow } from '@/api/pl'
import {
PL_VEHICLE_CERT_PART_HEAD,
PL_VEHICLE_CERT_PART_TRAILER,
normalizeVehicleCertPart
} from '@/utils/pl-vehicle-cert'
type CertRowWithKey = CertRow & { _key: string }
type CertUploadRef = InstanceType<typeof AttachmentUploadField>
@@ -80,13 +168,45 @@ const props = withDefaults(
title?: string
uploadFolder: string
certNameOptions?: string[]
layout?: 'card' | 'table'
/** 列表模式:展示车牌号列,可从主车/挂车车牌中选择 */
showVehiclePartColumn?: boolean
licensePlate?: string
trailerNo?: string
}>(),
{
title: '证书',
certNameOptions: () => []
certNameOptions: () => [],
layout: 'card',
showVehiclePartColumn: false,
licensePlate: '',
trailerNo: ''
}
)
const headPlateLabel = computed(() => props.licensePlate?.trim() || '—')
const hasTrailerPlateOption = computed(() => Boolean(props.trailerNo?.trim()))
const vehiclePlateOptions = computed(() => {
const options = [
{
label: headPlateLabel.value,
value: PL_VEHICLE_CERT_PART_HEAD
}
]
if (hasTrailerPlateOption.value) {
options.push({
label: props.trailerNo?.trim() || '挂车',
value: PL_VEHICLE_CERT_PART_TRAILER
})
}
return options
})
function handleVehiclePartChange(row: CertRowWithKey, value: string) {
row.vehiclePart = normalizeVehicleCertPart(value)
}
const certList = defineModel<CertRowWithKey[]>('modelValue', { default: () => [] })
const certUploadRefs = ref<Record<number, CertUploadRef | null>>({})
@@ -94,6 +214,7 @@ const certUploadRefs = ref<Record<number, CertUploadRef | null>>({})
function createEmptyCert(): CertRowWithKey {
return {
_key: `cert-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
vehiclePart: PL_VEHICLE_CERT_PART_HEAD,
certName: '',
certNo: '',
expireDate: '',
@@ -161,6 +282,29 @@ defineExpose({ hasUploading, clearUploadRefs })
background: #f8fafc;
}
.cert-form-table {
border-top: 1px solid var(--mint-border, #d9e8e2);
:deep(.el-select),
:deep(.el-input),
:deep(.el-date-editor) {
width: 100%;
}
:deep(.attachment-upload--compact:not(.attachment-upload--readonly)) {
min-width: 0;
.attachment-upload {
width: auto;
}
.attachment-list {
flex-wrap: wrap;
align-items: flex-start;
}
}
}
.cert-card-list {
display: flex;
flex-direction: column;
@@ -207,7 +351,7 @@ defineExpose({ hasUploading, clearUploadRefs })
}
:deep(.el-form-item__label) {
font-size: 11px;
font-size: 13px;
color: var(--mint-text);
padding-bottom: 4px;
}
@@ -1,6 +1,85 @@
<template>
<div class="cert-view-section">
<p v-if="!certList.length" class="view-empty-hint">暂无证书</p>
<p v-if="!certList.length && layout === 'card'" class="view-empty-hint">暂无证书</p>
<div v-else-if="layout === 'table'" class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
:data="certList"
class="list-table"
stripe
empty-text="暂无证书"
>
<el-table-column
type="index"
label="序号"
min-width="60"
align="center"
header-align="center"
:index="index => index + 1"
/>
<el-table-column
v-if="showVehiclePartColumn"
label="车牌号"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
{{ resolveVehiclePartLabel(row) }}
</template>
</el-table-column>
<el-table-column
prop="certName"
label="证书名称"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="certNo"
label="证号"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
{{ row.certNo || '—' }}
</template>
</el-table-column>
<el-table-column label="有效期" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<el-tag
v-if="row.expireDate"
:type="getExpireTagType(row.expireDate)"
size="small"
>
{{ row.expireDate }}
</el-tag>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="提醒天数" min-width="96" align="center" header-align="center">
<template #default="{ row }">{{ row.remindDays ?? 30 }} 天</template>
</el-table-column>
<el-table-column label="附件" min-width="160" align="center" header-align="center">
<template #default="{ row }">
<div v-if="row.attachmentUrls" class="cert-view-attachment-cell">
<AttachmentUploadField
:model-value="row.attachmentUrls"
disabled
compact
/>
</div>
<span v-else>—</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
<div v-else class="cert-view-list">
<div
@@ -11,15 +90,16 @@
<h4 class="cert-view-card-title">证书 {{ index + 1 }}</h4>
<div class="cert-view-kv-table-group">
<ViewKvTable :fields="buildCertViewFields(cert)" mode="triple" />
<ViewKvTable :fields="buildCertViewFields(cert)" :mode="tableMode" />
<table
v-if="cert.attachmentUrls"
class="cert-view-attachment-table view-kv-table view-kv-table--triple"
class="cert-view-attachment-table view-kv-table"
:class="`view-kv-table--${tableMode}`"
>
<tbody>
<tr class="row-long">
<th>附件</th>
<td colspan="5">
<td :colspan="attachmentColspan">
<AttachmentUploadField
:model-value="cert.attachmentUrls"
disabled
@@ -37,18 +117,51 @@
<script setup lang="ts">
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import type { ViewField, ViewTableMode } from '@/components/business/system-view-dialog/types'
import type { CertRead } from '@/api/pl'
import {
PL_VEHICLE_CERT_PART_TRAILER,
normalizeVehicleCertPart
} from '@/utils/pl-vehicle-cert'
withDefaults(
const props = withDefaults(
defineProps<{
certList?: CertRead[]
tableMode?: ViewTableMode
/** card:卡片 + KV 表格;table:列表表格 + 小缩略图 */
layout?: 'card' | 'table'
/** 列表模式:有挂车时展示主车/挂车部位列 */
showVehiclePartColumn?: boolean
headLabel?: string
trailerLabel?: string
}>(),
{
certList: () => []
certList: () => [],
tableMode: 'triple',
layout: 'card',
showVehiclePartColumn: false,
headLabel: '主车',
trailerLabel: '挂车'
}
)
const attachmentColspanMap: Record<ViewTableMode, number> = {
pair: 1,
dual: 3,
triple: 5,
quad: 7,
quint: 9
}
const attachmentColspan = computed(() => attachmentColspanMap[props.tableMode])
function resolveVehiclePartLabel(cert: CertRead) {
if (normalizeVehicleCertPart(cert.vehiclePart) === PL_VEHICLE_CERT_PART_TRAILER) {
return props.trailerLabel?.trim() || '挂车'
}
return props.headLabel?.trim() || '主车'
}
function getExpireTagType(dateStr: string | undefined) {
if (!dateStr) return 'info'
const expDate = new Date(dateStr)
@@ -85,6 +198,20 @@ function buildCertViewFields(cert: CertRead): ViewField[] {
line-height: 1.5;
}
.cert-view-attachment-cell {
display: inline-flex;
justify-content: center;
width: 100%;
:deep(.attachment-upload) {
width: auto;
}
:deep(.attachment-list) {
justify-content: center;
}
}
.cert-view-list {
display: flex;
flex-direction: column;
@@ -129,7 +256,6 @@ function buildCertViewFields(cert: CertRead): ViewField[] {
border-top: 1px solid #eef2f6;
th {
width: 10%;
padding: 10px 12px;
background: var(--table-head, #f8fafc);
color: var(--text-muted, #64748b);
@@ -152,4 +278,12 @@ function buildCertViewFields(cert: CertRead): ViewField[] {
line-height: 1.45;
}
}
.cert-view-attachment-table.view-kv-table--triple th {
width: 10%;
}
.cert-view-attachment-table.view-kv-table--quint th {
width: 6.5%;
}
</style>
@@ -2,7 +2,7 @@
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="640px"
width="740px"
align-center
append-to-body
destroy-on-close
@@ -14,7 +14,9 @@
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
label-position="right"
label-width="100px"
class="pl-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -31,9 +33,7 @@
</el-form-item>
<el-form-item v-if="actionType === 'submit'" prop="approverIds" class="form-col-span-2">
<template #label>
选择审批人<span class="form-label-tip">支持搜索用户姓名</span>
</template>
<template #label>选择审批人</template>
<el-select
v-model="form.approverIds"
multiple
@@ -71,9 +71,7 @@
</el-form-item>
<el-form-item v-if="actionType === 'submit'" prop="remark" class="form-col-span-2">
<template #label>
提交说明<span class="form-label-tip">可选</span>
</template>
<template #label>提交说明</template>
<el-input
v-model="form.remark"
type="textarea"
@@ -60,7 +60,9 @@
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
label-position="right"
label-width="100px"
class="pl-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -15,7 +15,9 @@
v-loading="loading"
:model="form"
:rules="rules"
label-position="top"
label-position="right"
label-width="100px"
class="pl-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -30,30 +30,39 @@
<section v-if="settlement" class="view-section">
<h4 class="view-section-title">发票信息</h4>
<div class="view-kv-grid view-kv-grid--cols-3">
<div class="view-kv-item">
<span class="view-kv-label">发票号</span>
<span class="view-kv-value">{{ settlement.invoiceNo || '—' }}</span>
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
:data="invoiceRows"
class="list-table"
stripe
empty-text="暂无数据"
>
<el-table-column type="index" label="序号" min-width="60" align="center" />
<el-table-column prop="invoiceNo" label="发票号" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.invoiceNo || '—' }}</template>
</el-table-column>
<el-table-column prop="invoiceDate" label="开票日期" min-width="120" align="center">
<template #default="{ row }">{{ row.invoiceDate || '—' }}</template>
</el-table-column>
<el-table-column prop="invoiceAmount" label="发票金额(元)" min-width="120" align="right">
<template #default="{ row }">
{{ row.invoiceAmount != null ? formatAmount(row.invoiceAmount) : '—' }}
</template>
</el-table-column>
<el-table-column label="发票附件" min-width="160">
<template #default="{ row }">
<div v-if="row.invoiceAttachmentUrls" class="invoice-attachment-half">
<AttachmentUploadField
:model-value="row.invoiceAttachmentUrls"
disabled
/>
</div>
<span v-else class="view-kv-value">—</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="view-kv-item">
<span class="view-kv-label">开票日期</span>
<span class="view-kv-value">{{ settlement.invoiceDate || '—' }}</span>
</div>
<div class="view-kv-item">
<span class="view-kv-label">发票金额</span>
<span class="view-kv-value">{{
settlement.invoiceAmount ? formatAmount(settlement.invoiceAmount) : '—'
}}</span>
</div>
</div>
<div class="view-kv-item view-kv-item--full">
<span class="view-kv-label">发票附件</span>
<AttachmentUploadField
v-if="settlement.invoiceAttachmentUrls"
:model-value="settlement.invoiceAttachmentUrls"
disabled
/>
<span v-else class="view-kv-value">—</span>
</div>
<div class="view-kv-grid view-kv-grid--cols-2 view-kv-grid--mt">
<div class="view-kv-item">
@@ -120,9 +129,16 @@
</div>
</section>
<section v-if="settlement?.approvalFlow?.length" class="view-section">
<section
v-if="settlement && (settlement.approvalFlow?.length || settlement.approvalHistory?.length)"
class="view-section"
>
<h4 class="view-section-title">审批流程</h4>
<ApprovalFlowView :nodes="approvalNodes" :loading="false" />
<BxApprovalFlowTabs
:nodes="approvalNodes"
:history="approvalHistoryItems"
:loading="false"
/>
</section>
<section
@@ -170,18 +186,12 @@
</section>
</div>
<template #footer>
<el-button @click="visible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Close } from '@element-plus/icons-vue'
import { freightApi, type FreightSettlement } from '@/api/pl'
import ApprovalFlowView from '@/components/bx/base/ApprovalFlowView.vue'
import BxApprovalFlowTabs from '@/components/bx/base/BxApprovalFlowTabs.vue'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { normalizeAttachmentUrls } from '@/utils/download-attachment'
import {
@@ -201,6 +211,29 @@ const approvalNodes = computed(() =>
mapApprovalFlowToNodes(settlement.value?.approvalFlow || [])
)
const approvalHistoryItems = computed(() =>
mapApprovalFlowToNodes(settlement.value?.approvalHistory || [])
)
const invoiceRows = computed(() => {
const current = settlement.value
if (!current) return []
const hasInvoice =
Boolean(current.invoiceNo?.trim()) ||
Boolean(current.invoiceDate) ||
(current.invoiceAmount != null && Number(current.invoiceAmount) > 0) ||
Boolean(current.invoiceAttachmentUrls?.trim())
if (!hasInvoice) return []
return [
{
invoiceNo: current.invoiceNo,
invoiceDate: current.invoiceDate,
invoiceAmount: current.invoiceAmount,
invoiceAttachmentUrls: current.invoiceAttachmentUrls
}
]
})
function formatAmount(amount?: number) {
if (!amount) return '¥0.00'
return formatMoneyDisplay(amount)
@@ -357,6 +390,53 @@ defineExpose({ open, reload })
}
}
.invoice-attachment-half {
:deep(.attachment-list) {
gap: 6px;
}
:deep(.attachment-item) {
width: 70px;
}
:deep(.attachment-preview) {
height: 48px;
}
:deep(.attachment-preview__icon) {
width: 24px;
min-height: 24px;
.el-icon {
font-size: 14px;
}
}
:deep(.attachment-preview__ext) {
margin-top: 2px;
font-size: 9px;
}
:deep(.attachment-footer) {
padding: 3px 4px;
}
:deep(.attachment-name) {
font-size: 9px;
-webkit-line-clamp: 1;
}
:deep(.attachment-actions) {
gap: 4px;
margin-top: 2px;
}
:deep(.action-btn) {
width: 16px;
height: 16px;
}
}
@media (max-width: 900px) {
.view-kv-grid--cols-3 {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -17,7 +17,7 @@
<section class="pl-arrange-form-panel">
<div v-loading="loading" class="pl-arrange-form-scroll">
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">产品实际数量</h3>
<h4 class="pl-plan-subtitle">产品实际数量</h4>
<PlPlanFormProductTable
:products="productRows"
show-actual-qty
@@ -29,7 +29,7 @@
<h4 class="pl-plan-subtitle">运费信息</h4>
<el-form
:model="formData"
label-width="96px"
label-width="140px"
label-position="right"
class="pl-plan-freight-form"
>
@@ -119,6 +119,7 @@ import {
import PlPlanFormProductTable from '@/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue'
import PlPlanViewTabs from '@/components/business/pl-plan-view-tabs/index.vue'
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { enrichPlanProductsHazardFromCrm } from '@/composables/usePlToxicProductCertCheck'
import { usePlFreightCalc, resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
import { useDictStore } from '@/store/modules/dict'
import { pickBxData } from '@/utils/http/bx'
@@ -258,7 +259,9 @@ async function handleOpened() {
throw new Error('运输计划数据无效')
}
plan.value = data
productRows.value = mapPlanProducts(Array.isArray(data.products) ? data.products : [])
const rawProducts = Array.isArray(data.products) ? data.products : []
const enrichedProducts = await enrichPlanProductsHazardFromCrm(rawProducts)
productRows.value = mapPlanProducts(enrichedProducts)
await applyFreightPrefill(data)
} catch (error) {
innerVisible.value = false
@@ -356,6 +359,9 @@ async function handleSubmit() {
}
.pl-arrange-form-scroll {
display: flex;
flex-direction: column;
gap: 20px;
max-height: 52vh;
overflow-y: auto;
padding-right: 4px;
@@ -377,14 +383,21 @@ async function handleSubmit() {
}
.pl-plan-subtitle {
margin: 16px 0 10px;
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: #475569;
}
.pl-plan-subtitle:first-of-type {
margin-top: 0;
.pl-plan-freight-section {
.pl-plan-subtitle {
margin-top: 4px;
}
:deep(.el-form-item__label) {
width: 140px !important;
max-width: 140px;
}
}
.pl-plan-freight-form {
@@ -25,42 +25,13 @@
:title="
isRearrange
? hasSiteInventoryRecords
? '运输已调整,司机与现场链接已更新,旧链接已失效'
: '运输已调整,司机链接已更新,旧链接已失效'
? '运输已调整,司机与现场链接已更新,旧链接已失效。请在下方「运输信息」中复制司机链接。'
: '运输已调整,司机链接已更新,旧链接已失效。请在下方「运输信息」中复制司机链接。'
: hasSiteInventoryRecords
? '运输已安排,可复制司机链接、现场链接或简述发给相关人员'
: '运输已安排,可复制司机链接或计划简述'
? '运输已安排,请在下方「运输信息」中复制链接。'
: '运输已安排,请在下方「运输信息」中复制司机链接。'
"
/>
<div class="pl-arrange-link-row">
<span class="pl-arrange-link-label">司机链接</span>
<el-input :model-value="driverUrl || arrangeResult?.driverUrl || ''" readonly />
<el-button @click="copyDriverUrl(arrangeResult?.driverUrl)">
<el-icon><CopyDocument /></el-icon>复制链接
</el-button>
</div>
<template v-if="hasSiteInventoryRecords">
<div v-if="siteRecords.length === 1" class="pl-arrange-link-row">
<span class="pl-arrange-link-label">现场链接</span>
<el-input :model-value="siteRecords[0].siteUrl || ''" readonly />
<el-button @click="copySiteUrl(siteRecords[0].siteUrl)">
<el-icon><CopyDocument /></el-icon>复制链接
</el-button>
</div>
<template v-else>
<div
v-for="record in siteRecords"
:key="record.siteLinkId"
class="pl-arrange-link-row"
>
<span class="pl-arrange-link-label">{{ record.productName || '现场链接' }}</span>
<el-input :model-value="record.siteUrl || ''" readonly />
<el-button @click="copySiteUrl(record.siteUrl)">
<el-icon><CopyDocument /></el-icon>复制本条
</el-button>
</div>
</template>
</template>
</section>
<section v-else class="pl-plan-form-section">
@@ -73,7 +44,7 @@
:title="rearrangeBlockedMessage"
class="pl-arrange-block-alert"
/>
<el-form ref="formRef" :model="formData" :rules="formRules" label-position="top">
<el-form ref="formRef" :model="formData" :rules="formRules" label-position="right" label-width="100px" class="pl-dialog-form">
<h4 class="pl-plan-subtitle">车辆信息</h4>
<div class="form-grid-4">
<el-form-item label="选择车辆" prop="vehicleId">
@@ -130,8 +101,8 @@
<div class="pl-plan-logistics-row">
<div class="pl-plan-logistics-block">
<h4 class="pl-plan-subtitle">取货信息</h4>
<div class="form-grid-3">
<el-form-item label="取货地址">
<div class="pl-plan-logistics-fields">
<el-form-item label="取货地址" class="pl-logistics-address-item">
<PlAddressWithLibrary
v-model="formData.pickupAddress"
v-model:lng="formData.pickupLng"
@@ -139,18 +110,20 @@
placeholder="取货地址"
/>
</el-form-item>
<el-form-item label="取货时间">
<el-date-picker
v-model="formData.pickupTime"
type="date"
placeholder="选择取货时间"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="注意事项">
<el-input v-model="formData.pickupNotes" placeholder="取货注意事项" />
</el-form-item>
<div class="form-grid-2 pl-logistics-meta-row">
<el-form-item label="取货时间">
<el-date-picker
v-model="formData.pickupTime"
type="date"
placeholder="选择取货时间"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="注意事项">
<el-input v-model="formData.pickupNotes" placeholder="取货注意事项" />
</el-form-item>
</div>
</div>
</div>
@@ -177,8 +150,8 @@
<div class="pl-plan-logistics-block">
<h4 class="pl-plan-subtitle">送货信息</h4>
<div class="form-grid-3">
<el-form-item label="送货地址">
<div class="pl-plan-logistics-fields">
<el-form-item label="送货地址" class="pl-logistics-address-item">
<PlAddressWithLibrary
v-model="formData.destinationAddress"
v-model:lng="formData.destinationLng"
@@ -186,71 +159,27 @@
placeholder="送货地址"
/>
</el-form-item>
<el-form-item label="送达时间">
<el-date-picker
v-model="formData.deliveryTime"
type="date"
placeholder="选择送达时间"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="注意事项">
<el-input v-model="formData.destinationNotes" placeholder="送货注意事项" />
</el-form-item>
<div class="form-grid-2 pl-logistics-meta-row">
<el-form-item label="送达时间">
<el-date-picker
v-model="formData.deliveryTime"
type="date"
placeholder="选择送达时间"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="注意事项">
<el-input v-model="formData.destinationNotes" placeholder="送货注意事项" />
</el-form-item>
</div>
</div>
</div>
</div>
<h4 class="pl-plan-subtitle">运费信息</h4>
<div v-if="formData.hasFreight !== 1" class="pl-arrange-freight-muted">本计划无运输费用</div>
<div v-else class="form-grid-4">
<el-form-item label="运输公里数">
<el-input-number v-model="formData.distanceKm" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="运费计算方式">
<el-select v-model="formData.freightType">
<el-option label="按重量" value="by_weight" />
<el-option label="按趟" value="by_trip" />
</el-select>
</el-form-item>
<el-form-item
v-if="formData.freightType === 'by_weight'"
:label="`运输量(${freightUnitLabel})`"
>
<el-input-number
v-model="freightCalc.transportQty.value"
:min="0"
:precision="3"
style="width: 100%"
@change="freightCalc.markTransportQtyTouched()"
/>
</el-form-item>
<el-form-item
v-if="formData.freightType === 'by_weight'"
:label="`运输单价(元/${freightUnitLabel})`"
>
<el-input-number
v-model="freightCalc.freightPrice.value"
:min="0"
:precision="2"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="运费金额(元)">
<el-input-number
v-model="freightCalc.freightAmount.value"
:min="0"
:precision="2"
style="width: 100%"
@change="freightCalc.markFreightAmountTouched()"
/>
</el-form-item>
</div>
<h4 class="pl-plan-subtitle">安排项</h4>
<div class="form-grid-4">
<el-form-item label="运输介质">
<el-form-item label="运输介质" class="pl-arrange-medium-item">
<el-input :model-value="transportMediumText" readonly placeholder="—" />
</el-form-item>
<el-form-item label="备注" class="pl-arrange-remark-item">
@@ -287,9 +216,6 @@
勾选后将按产品进行地磅单生成(当月已有则复用),并在地磅出入库管理中产生对应记录。
</template>
</p>
<p v-if="planSupportsSiteInventory" class="pl-arrange-dibang-hint">
入库/出库计划安排运输时将自动生成工厂现场链接(与是否勾选地磅单无关),不会在地磅出入库管理中产生记录。
</p>
</div>
</el-form>
</section>
@@ -298,7 +224,15 @@
<section class="pl-arrange-view-panel">
<h3 class="pl-arrange-view-title">计划单查看</h3>
<PlPlanViewTabs v-if="planId" ref="tabsRef" :plan-id="planId" embedded />
<PlPlanViewTabs
v-if="planId"
ref="tabsRef"
:plan-id="planId"
embedded
:show-arrange-links="arrangeSuccess"
:arrange-driver-url="driverUrl || arrangeResult?.driverUrl || ''"
:arrange-site-records="siteRecords"
/>
</section>
</div>
@@ -345,12 +279,6 @@
>
<el-icon><CopyDocument /></el-icon>复制给现场
</el-button>
<el-button
v-if="hasSiteInventoryRecords && siteRecords.length > 1"
@click="copyAllSiteUrls()"
>
<el-icon><CopyDocument /></el-icon>复制全部现场链接
</el-button>
<el-button
v-if="hasDibangInventoryRecords"
type="primary"
@@ -393,9 +321,9 @@ import PlVehicleSummaryStrip from '@/components/business/pl-vehicle-summary-stri
import PlVehicleViewDialog from '@/components/business/pl-vehicle-view-dialog/index.vue'
import PlPlanViewTabs from '@/components/business/pl-plan-view-tabs/index.vue'
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { usePlFreightCalc, resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
import { usePlFreightCalc } from '@/composables/usePlFreightCalc'
import { loadVehicleViewBundle, type VehicleViewBundle } from '@/composables/usePlVehicleViewDetail'
import { buildMissingHazardCertMessage } from '@/composables/usePlToxicProductCertCheck'
import { buildMissingHazardCertReadyMessage } from '@/composables/usePlToxicProductCertCheck'
import { usePlPlanArrangeActions } from '@/composables/usePlPlanArrangeActions'
import { useDictStore } from '@/store/modules/dict'
import { isTonProductUnit } from '@/utils/crm-product-display'
@@ -438,8 +366,6 @@ const {
resetArrangeActions,
applyArrangeResult,
copyDriverUrl,
copySiteUrl,
copyAllSiteUrls,
copySiteSummary,
copyPlanSummary,
copyPoundSlipQrcode
@@ -492,8 +418,6 @@ const dialogTitle = computed(() => {
const isRearrange = computed(() => plan.value.transportStatus === 'arranged')
const freightUnitLabel = computed(() => resolveFreightUnitLabel(productRows.value))
function getUnitLabel(unit: string) {
return dictStore.getLabel('crm_product_unit', unit) || unit
}
@@ -811,9 +735,9 @@ async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
const certMessage = buildMissingHazardCertMessage(products.value)
const certMessage = buildMissingHazardCertReadyMessage(plan.value, products.value)
if (certMessage) {
await ElMessageBox.alert(`${certMessage}。请先上传购买证和合法使用证明。`, '无法安排运输', {
await ElMessageBox.alert(`${certMessage},请至【危化品证上传】页面操作。`, '无法安排运输', {
type: 'warning'
})
return
@@ -884,7 +808,8 @@ async function handleSubmit() {
applyArrangeResult(result, plan.value, products.value)
arrangeSuccess.value = true
ElMessage.success(isRearrange.value ? '运输调整成功' : '运输安排成功')
tabsRef.value?.reload()
await tabsRef.value?.reload()
tabsRef.value?.setActiveTab('transport')
emit('success')
} catch (e: unknown) {
if (e === 'cancel') return
@@ -899,20 +824,18 @@ async function handleSubmit() {
<style scoped lang="scss">
.pl-arrange-layout {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
min-height: 480px;
min-height: 0;
overflow: hidden;
}
.pl-arrange-form-panel {
flex: 0 0 auto;
max-height: 52vh;
overflow: hidden;
}
.pl-arrange-form-scroll {
max-height: 52vh;
overflow-y: auto;
padding-right: 4px;
}
@@ -931,10 +854,18 @@ async function handleSubmit() {
margin-top: 0;
}
.pl-arrange-freight-muted {
margin-bottom: 12px;
font-size: 13px;
color: #64748b;
.pl-plan-logistics-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.pl-logistics-address-item {
margin-bottom: 0;
}
.pl-logistics-meta-row {
gap: 8px 12px;
}
.pl-plan-logistics-row {
@@ -1004,10 +935,6 @@ async function handleSubmit() {
:deep(.el-form-item) {
margin-bottom: 0;
}
.form-grid-3 {
gap: 8px 12px;
}
}
@media (max-width: 1100px) {
@@ -1029,13 +956,40 @@ async function handleSubmit() {
.pl-arrange-view-panel {
flex: 1 1 auto;
min-height: 280px;
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
padding: 12px 16px 16px;
overflow: hidden;
:deep(.pl-plan-view-tabs) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
:deep(.pl-plan-view-tabs__nav) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
:deep(.pl-plan-view-tabs__nav > .el-tabs__header) {
flex-shrink: 0;
}
:deep(.pl-plan-view-tabs__nav > .el-tabs__content) {
flex: 1;
min-height: 0;
max-height: none;
overflow-y: auto;
padding-right: 4px;
}
}
.pl-arrange-view-title {
@@ -1090,6 +1044,7 @@ async function handleSubmit() {
min-width: 0;
}
.pl-arrange-medium-item,
.pl-arrange-remark-item {
grid-column: span 2;
}
@@ -1100,18 +1055,6 @@ async function handleSubmit() {
gap: 12px;
}
.pl-arrange-link-row {
display: grid;
grid-template-columns: 72px 1fr auto;
gap: 8px;
align-items: center;
}
.pl-arrange-link-label {
font-size: 13px;
color: #64748b;
}
.pl-arrange-footer {
display: flex;
flex-wrap: wrap;
@@ -1125,4 +1068,11 @@ async function handleSubmit() {
width: min(1840px, calc(100vw - 48px)) !important;
max-height: calc(100vh - 48px);
}
.el-dialog.pl-plan-arrange-dialog.dialog-form-layout .el-dialog__body {
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
</style>
@@ -19,7 +19,7 @@
type="info"
:closable="false"
show-icon
title="易制毒或易制爆产品须上传购买证、合法使用证明;安排运输时将校验是否齐全。"
title="易制毒或易制爆产品请按实际业务上传所需证书;可先点击「证书齐备」放行运输,之后再补充附件。"
class="cert-tip"
/>
@@ -32,7 +32,7 @@
<el-tag v-if="item.isToxic" type="warning" size="small">易制毒</el-tag>
</div>
<div class="form-grid-2 product-cert-grid">
<el-form-item label="购买证" required>
<el-form-item label="购买证">
<AttachmentUploadField
v-model="item.purchaseCertUrl"
folder="pl/plan-cert"
@@ -40,7 +40,7 @@
hint="支持图片、PDF 等,上传购买证"
/>
</el-form-item>
<el-form-item label="合法使用证明" required>
<el-form-item label="合法使用证明">
<AttachmentUploadField
v-model="item.transportCertUrl"
folder="pl/plan-cert"
@@ -67,7 +67,7 @@
import { Check, Close } from '@element-plus/icons-vue'
import { planApi, type Plan, type PlanProduct } from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
import { enrichPlanProductsHazardFromCrm, isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
type HazardProductRow = PlanProduct & {
purchaseCertUrl?: string
@@ -114,7 +114,8 @@ async function loadPlan(planId: string) {
const data = (res.data ?? res) as Plan
planBrief.planNo = data.planNo || ''
planBrief.customerName = data.customerName || ''
hazardProducts.value = (data.products || [])
const enrichedProducts = await enrichPlanProductsHazardFromCrm(data.products || [])
hazardProducts.value = enrichedProducts
.filter(p => isHazardProduct(p))
.map(p => ({
...p,
@@ -129,14 +130,6 @@ async function loadPlan(planId: string) {
async function handleSubmit() {
if (!props.planId || !hazardProducts.value.length) return
const incomplete = hazardProducts.value.filter(
p => !p.purchaseCertUrl?.trim() || !p.transportCertUrl?.trim()
)
if (incomplete.length) {
ElMessage.warning('请为每个危化品产品上传购买证和合法使用证明')
return
}
submitLoading.value = true
try {
await planApi.updateProductCerts(
@@ -62,6 +62,25 @@
</template>
</el-table-column>
<el-table-column v-else prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column
v-if="showSelect"
label="操作"
min-width="100"
align="center"
fixed="right"
class-name="col-actions"
>
<template #default="{ row, $index }">
<el-button
link
type="primary"
:disabled="isProductSelected(row)"
@click="emit('select', row, $index)"
>
<el-icon><Plus /></el-icon>选择
</el-button>
</template>
</el-table-column>
<el-table-column v-if="showDelete" label="操作" min-width="80" align="center" fixed="right">
<template #default="{ $index }">
<el-button link type="danger" @click="emit('remove', $index)">
@@ -75,15 +94,18 @@
</template>
<script setup lang="ts">
import { Delete } from '@element-plus/icons-vue'
import { Delete, Plus } from '@element-plus/icons-vue'
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { getDeliveryMethodLabel } from '@/composables/usePlContractToPlan'
import PlProductNameHazardCell from '@/components/business/pl-product-name-hazard-cell/index.vue'
import DictTag from '@/components/DictTag/index.vue'
defineProps<{
const props = defineProps<{
products: PlanFormProductRow[]
showDelete?: boolean
showSelect?: boolean
/** 已加入运输计划的产品 ID,用于禁用合同预览中的「选择」按钮 */
selectedProductIds?: string[]
editableRemark?: boolean
editableQty?: boolean
/** 新增/编辑弹窗默认不展示实际数量 */
@@ -94,15 +116,20 @@ defineProps<{
const emit = defineEmits<{
remove: [index: number]
select: [row: PlanFormProductRow, index: number]
}>()
const selectedProductIdSet = computed(
() => new Set((props.selectedProductIds || []).map(id => String(id)))
)
function isProductSelected(row: PlanFormProductRow) {
return selectedProductIdSet.value.has(String(row.productId))
}
</script>
<style scoped lang="scss">
.pl-plan-product-table-wrap {
margin-top: 8px;
}
.dialog-list-body-only {
margin-top: 0;
}
</style>
@@ -17,7 +17,7 @@
<div class="pl-plan-form-body">
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">基础信息</h3>
<el-form ref="basicFormRef" :model="formData" :rules="basicRules" label-position="top">
<el-form ref="basicFormRef" :model="formData" :rules="basicRules" label-position="right" label-width="100px" class="pl-dialog-form">
<div class="form-grid-4">
<el-form-item label="计划日期" prop="planDate">
<el-date-picker
@@ -74,13 +74,19 @@
</div>
<h4 class="pl-plan-subtitle">合同产品预览</h4>
<PlPlanFormProductTable :products="contractProductRows" />
<p class="pl-plan-form-empty-hint">点击「选择」将合同产品加入下方产品信息;已添加的产品不可重复选择</p>
<PlPlanFormProductTable
:products="contractProductRows"
show-select
:selected-product-ids="selectedPlanProductIds"
@select="handleSelectContractProduct"
/>
</template>
</section>
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">运输信息</h3>
<el-form :model="formData" label-position="top">
<el-form :model="formData" label-position="right" label-width="100px" class="pl-dialog-form">
<div class="form-grid-4">
<el-form-item label="车牌号">
<el-select
@@ -194,43 +200,45 @@
</div>
</div>
<h4 class="pl-plan-subtitle">运费信息</h4>
<div class="form-grid-4">
<el-form-item label="是否有运输费用">
<el-switch v-model="formData.hasFreight" :active-value="1" :inactive-value="0" />
</el-form-item>
<template v-if="formData.hasFreight === 1">
<el-form-item label="运输公里数">
<el-input-number v-model="formData.distanceKm" :min="0" :precision="2" style="width: 100%" />
<div class="pl-plan-freight-section">
<h4 class="pl-plan-subtitle">运费信息</h4>
<div class="form-grid-4">
<el-form-item label="是否有运输费用">
<el-switch v-model="formData.hasFreight" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="运费计算方式">
<el-select v-model="formData.freightType">
<el-option label="按重量" value="by_weight" />
<el-option label="按趟" value="by_trip" />
</el-select>
</el-form-item>
<el-form-item v-if="formData.freightType === 'by_weight'" :label="`运输量(${freightUnitLabel})`">
<el-input-number
v-model="freightCalc.transportQty.value"
:min="0"
:precision="3"
style="width: 100%"
@change="freightCalc.markTransportQtyTouched()"
/>
</el-form-item>
<el-form-item v-if="formData.freightType === 'by_weight'" :label="`运输单价(元/${freightUnitLabel})`">
<el-input-number v-model="freightCalc.freightPrice.value" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="运费金额(元)">
<el-input-number
v-model="freightCalc.freightAmount.value"
:min="0"
:precision="2"
style="width: 100%"
@change="freightCalc.markFreightAmountTouched()"
/>
</el-form-item>
</template>
<template v-if="formData.hasFreight === 1">
<el-form-item label="运输公里数">
<el-input-number v-model="formData.distanceKm" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="运费计算方式">
<el-select v-model="formData.freightType">
<el-option label="按重量" value="by_weight" />
<el-option label="按趟" value="by_trip" />
</el-select>
</el-form-item>
<el-form-item v-if="formData.freightType === 'by_weight'" :label="`运输量(${freightUnitLabel})`">
<el-input-number
v-model="freightCalc.transportQty.value"
:min="0"
:precision="3"
style="width: 100%"
@change="freightCalc.markTransportQtyTouched()"
/>
</el-form-item>
<el-form-item v-if="formData.freightType === 'by_weight'" :label="`运输单价(元/${freightUnitLabel})`">
<el-input-number v-model="freightCalc.freightPrice.value" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="运费金额(元)">
<el-input-number
v-model="freightCalc.freightAmount.value"
:min="0"
:precision="2"
style="width: 100%"
@change="freightCalc.markFreightAmountTouched()"
/>
</el-form-item>
</template>
</div>
</div>
</el-form>
</section>
@@ -467,6 +475,10 @@ const contractProductRows = computed<PlanFormProductRow[]>(() =>
mapContractProducts(contractSnapshot.value?.products)
)
const selectedPlanProductIds = computed(() =>
formData.products.map(item => String(item.productId))
)
function enrichProductsFromContract(products: PlanFormProductRow[], contract?: ContractVO | null) {
if (!contract?.products?.length) return products
const contractMap = new Map(
@@ -845,12 +857,24 @@ async function handleContractConfirm(contract: ContractVO) {
contractSnapshot.value = contract
Object.assign(formData, {
...mapped,
products: [],
followUserId: resolveCurrentUserId(),
followUserName: resolveCurrentUserName()
})
formData.products = await enrichPlanProductsHazardFromCrm(
enrichProductsFromContract(formData.products, contract)
freightCalc.resetFreightCalc()
}
async function handleSelectContractProduct(row: PlanFormProductRow) {
const productId = String(row.productId)
if (formData.products.some(item => String(item.productId) === productId)) {
ElMessage.warning('该产品已添加到运输计划中')
return
}
const [enriched] = await enrichPlanProductsHazardFromCrm(
enrichProductsFromContract([{ ...row }], contractSnapshot.value)
)
formData.products.push(enriched)
freightCalc.resetFreightCalc()
}
@@ -994,11 +1018,22 @@ async function handleSubmit() {
color: #1e293b;
}
.pl-plan-freight-section {
.pl-plan-subtitle {
margin-top: 16px;
}
:deep(.el-form-item__label) {
width: 140px !important;
max-width: 140px;
}
}
.pl-plan-form-empty-hint,
.text-muted {
margin: 0;
margin: 0 0 8px;
font-size: 13px;
color: #1e293b;
color: #64748b;
}
.pl-plan-contract-kv-rows {
@@ -1166,7 +1201,7 @@ async function handleSubmit() {
<style lang="scss">
.el-dialog.pl-plan-form-dialog.dialog-form-layout {
width: min(1680px, calc(100vw - 80px)) !important;
max-height: calc(100vh - 48px);
max-height: var(--dialog-max-height, calc(100vh - 40px));
}
.el-dialog.pl-plan-form-dialog.dialog-form-layout .el-dialog__body {
@@ -0,0 +1,88 @@
<template>
<div class="pl-plan-hazard-cert-view-table">
<p v-if="!products.length" class="pl-plan-hazard-cert-view-table__empty">暂无易制毒/易制爆产品</p>
<div v-else class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
:data="products"
class="list-table"
stripe
empty-text="暂无易制毒/易制爆产品"
>
<el-table-column
type="index"
label="序号"
min-width="60"
align="center"
header-align="center"
:index="index => index + 1"
/>
<el-table-column label="产品名称" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<PlProductNameHazardCell :product="row" />
</template>
</el-table-column>
<el-table-column label="购买证" min-width="160" align="center" header-align="center">
<template #default="{ row }">
<div v-if="row.purchaseCertUrl" class="pl-plan-hazard-cert-view-table__attachment">
<AttachmentUploadField :model-value="row.purchaseCertUrl" disabled compact />
</div>
<span v-else class="pl-plan-hazard-cert-view-table__missing">未上传</span>
</template>
</el-table-column>
<el-table-column label="合法使用证明" min-width="160" align="center" header-align="center">
<template #default="{ row }">
<div v-if="row.transportCertUrl" class="pl-plan-hazard-cert-view-table__attachment">
<AttachmentUploadField :model-value="row.transportCertUrl" disabled compact />
</div>
<span v-else class="pl-plan-hazard-cert-view-table__missing">未上传</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { PlanProduct } from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlProductNameHazardCell from '@/components/business/pl-product-name-hazard-cell/index.vue'
withDefaults(
defineProps<{
products?: PlanProduct[]
}>(),
{
products: () => []
}
)
</script>
<style scoped lang="scss">
.pl-plan-hazard-cert-view-table__empty {
margin: 0;
font-size: 13px;
color: var(--mint-text-muted);
}
.pl-plan-hazard-cert-view-table__attachment {
display: inline-flex;
justify-content: center;
width: 100%;
:deep(.attachment-upload) {
width: auto;
}
:deep(.attachment-list) {
justify-content: center;
}
}
.pl-plan-hazard-cert-view-table__missing {
color: var(--mint-text-muted);
font-size: 13px;
}
</style>
@@ -1,5 +1,9 @@
<template>
<div v-if="hazardProducts.length" class="pl-plan-list-cert-cell">
<div
v-if="hazardProducts.length"
class="pl-plan-list-cert-cell"
:class="{ 'pl-plan-list-cert-cell--compact': compact }"
>
<div
v-for="(product, index) in hazardProducts"
:key="product.id || `${product.productNo}-${index}`"
@@ -7,10 +11,22 @@
>
<div class="pl-plan-list-cert-cell__product">
<span class="pl-plan-list-cert-cell__name">{{ product.productName || '—' }}</span>
<el-tag v-if="product.isToxic" type="warning" size="small">易制毒</el-tag>
<el-tag v-if="product.isExplosive" type="danger" size="small">易制爆</el-tag>
<el-tag v-if="product.isToxic" type="warning" size="small">
{{ useShortHazardTag ? '毒' : '易制毒' }}
</el-tag>
<el-tag v-if="product.isExplosive" type="danger" size="small">
{{ useShortHazardTag ? '爆' : '易制爆' }}
</el-tag>
</div>
<div class="pl-plan-list-cert-cell__certs">
<div v-if="compact" class="pl-plan-list-cert-cell__certs pl-plan-list-cert-cell__certs--text">
<span class="pl-plan-list-cert-cell__cert-text">
购买证({{ product.purchaseCertUrl ? '有' : '无' }})
</span>
<span class="pl-plan-list-cert-cell__cert-text">
合法使用证明({{ product.transportCertUrl ? '有' : '无' }})
</span>
</div>
<div v-else class="pl-plan-list-cert-cell__certs">
<div class="pl-plan-list-cert-cell__cert-item">
<AttachmentUploadField
v-if="product.purchaseCertUrl"
@@ -44,12 +60,20 @@ import { isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
const props = withDefaults(
defineProps<{
products?: PlanProduct[]
/** 列表紧凑模式:文字展示证书有无,不显示缩略图 */
compact?: boolean
/** 危化标签短文案:毒 / 爆 */
shortHazardTag?: boolean
}>(),
{
products: () => []
products: () => [],
compact: false,
shortHazardTag: false
}
)
const useShortHazardTag = computed(() => props.compact || props.shortHazardTag)
const hazardProducts = computed(() => (props.products || []).filter(product => isHazardProduct(product)))
</script>
@@ -137,4 +161,29 @@ const hazardProducts = computed(() => (props.products || []).filter(product => i
:deep(.attachment-footer) {
display: none;
}
.pl-plan-list-cert-cell--compact {
gap: 6px;
.pl-plan-list-cert-cell__group {
gap: 4px;
}
.pl-plan-list-cert-cell__name {
font-size: 12px;
}
.pl-plan-list-cert-cell__certs--text {
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.pl-plan-list-cert-cell__cert-text {
color: var(--mint-text);
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
}
}
</style>
@@ -5,20 +5,40 @@
:key="product.id || `${product.productNo}-${index}`"
class="pl-plan-list-product-cell__row"
>
<span class="pl-plan-list-product-cell__line">
<span class="pl-plan-list-product-cell__text">{{ formatProductLine(product) }}</span>
<el-tag v-if="getHazardFlags(product).isToxic" type="warning" size="small" class="pl-plan-list-product-cell__mark">
毒
</el-tag>
<el-tag
v-if="getHazardFlags(product).isExplosive"
type="danger"
size="small"
class="pl-plan-list-product-cell__mark"
<span class="pl-plan-list-product-cell__index">{{ index + 1 }}.</span>
<div class="pl-plan-list-product-cell__content">
<span class="pl-plan-list-product-cell__line">
<span class="pl-plan-list-product-cell__text">{{ formatProductLine(product) }}</span>
<el-tag v-if="getHazardFlags(product).isToxic" type="warning" size="small" class="pl-plan-list-product-cell__mark">
毒
</el-tag>
<el-tag
v-if="getHazardFlags(product).isExplosive"
type="danger"
size="small"
class="pl-plan-list-product-cell__mark"
>
爆
</el-tag>
</span>
<div
v-if="showCertStatus && isHazardProduct(product)"
class="pl-plan-list-product-cell__certs"
>
爆
</el-tag>
</span>
<span
class="pl-plan-list-product-cell__cert-text"
:class="{ 'pl-plan-list-product-cell__cert-text--missing': !product.purchaseCertUrl }"
>
购买证({{ product.purchaseCertUrl ? '有' : '无' }})
</span>
<span
class="pl-plan-list-product-cell__cert-text"
:class="{ 'pl-plan-list-product-cell__cert-text--missing': !product.transportCertUrl }"
>
合法使用证明({{ product.transportCertUrl ? '有' : '无' }})
</span>
</div>
</div>
</div>
</div>
<span v-else class="pl-plan-list-product-cell__empty">—</span>
@@ -26,16 +46,19 @@
<script setup lang="ts">
import type { PlanProduct } from '@/api/pl'
import { resolveProductHazardFlags } from '@/composables/usePlToxicProductCertCheck'
import { isHazardProduct, resolveProductHazardFlags } from '@/composables/usePlToxicProductCertCheck'
import { useDictStore } from '@/store/modules/dict'
import { formatPlanProductListText } from '@/utils/pl-display'
withDefaults(
defineProps<{
products?: PlanProduct[]
/** 危化品行下方展示购买证/合法使用证明有无 */
showCertStatus?: boolean
}>(),
{
products: () => []
products: () => [],
showCertStatus: false
}
)
@@ -58,22 +81,47 @@ function getHazardFlags(product: PlanProduct) {
.pl-plan-list-product-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
width: 100%;
text-align: left;
}
.pl-plan-list-product-cell__row {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 4px;
width: 100%;
min-width: 0;
}
.pl-plan-list-product-cell__index {
flex-shrink: 0;
min-width: 1.25em;
font-size: 13px;
line-height: 1.5;
color: var(--mint-text);
text-align: right;
}
.pl-plan-list-product-cell__content {
flex: 1;
min-width: 0;
}
.pl-plan-list-product-cell__line {
display: inline-flex;
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 4px;
width: 100%;
max-width: 100%;
font-size: 13px;
line-height: 1.5;
color: var(--text, #1e293b);
text-align: left;
}
.pl-plan-list-product-cell__text {
@@ -85,6 +133,30 @@ function getHazardFlags(product: PlanProduct) {
font-weight: 600;
}
.pl-plan-list-product-cell__certs {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 0;
width: 100%;
margin-top: 2px;
text-align: left;
}
.pl-plan-list-product-cell__cert-text {
color: var(--mint-text);
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
&--missing {
color: var(--danger, #dc2626);
font-weight: 500;
}
}
.pl-plan-list-product-cell__empty {
color: var(--mint-text);
}
@@ -13,50 +13,32 @@
@closed="handleClosed"
@opened="handleOpened"
>
<section v-if="showArrangeActions" class="pl-plan-arrange-summary">
<h3 class="pl-plan-arrange-summary-title">运输安排结果</h3>
<div class="pl-arrange-link-row">
<span class="pl-arrange-link-label">司机链接</span>
<el-input :model-value="driverUrl" readonly />
<el-button @click="copyDriverUrl()">
<el-icon><CopyDocument /></el-icon>复制链接
<div
v-if="$slots['hero-actions'] || showArrangeActions"
class="pl-plan-view-hero-actions"
>
<template v-if="showArrangeActions">
<el-button @click="copyPlanSummary()">
<el-icon><CopyDocument /></el-icon>复制给司机
</el-button>
<el-button v-if="hasSiteInventoryRecords" @click="copySiteSummary()">
<el-icon><CopyDocument /></el-icon>复制给现场
</el-button>
<el-button v-if="hasDibangInventoryRecords" @click="copyPoundSlipQrcode()">
<el-icon><CopyDocument /></el-icon>复制磅单二维码
</el-button>
</div>
<p v-if="!hasSiteInventoryRecords && planSupportsInventory" class="pl-plan-arrange-summary-hint">
暂无现场链接
</p>
<template v-if="hasSiteInventoryRecords">
<h4 class="pl-plan-arrange-summary-subtitle">现场链接</h4>
<div v-if="siteRecords.length === 1" class="pl-arrange-link-row">
<span class="pl-arrange-link-label">现场链接</span>
<el-input :model-value="siteRecords[0].siteUrl || ''" readonly />
<el-button @click="copySiteUrl(siteRecords[0].siteUrl)">
<el-icon><CopyDocument /></el-icon>复制链接
</el-button>
</div>
<template v-else>
<div
v-for="record in siteRecords"
:key="record.siteLinkId"
class="pl-arrange-link-row"
>
<span class="pl-arrange-link-label">{{ record.productName || '现场链接' }}</span>
<el-input :model-value="record.siteUrl || ''" readonly />
<el-button @click="copySiteUrl(record.siteUrl)">
<el-icon><CopyDocument /></el-icon>复制本条
</el-button>
</div>
</template>
</template>
</section>
<div v-if="$slots['hero-actions']" class="pl-plan-view-hero-actions">
<slot name="hero-actions" />
</div>
<PlPlanViewTabs v-if="planId" ref="tabsRef" :plan-id="planId" />
<PlPlanViewTabs
v-if="planId"
ref="tabsRef"
:plan-id="planId"
:show-arrange-links="showArrangeActions"
:arrange-driver-url="driverUrl"
:arrange-site-records="siteRecords"
/>
<DbPoundSlipQrcodeDialog
v-model:visible="qrcodeVisible"
@@ -65,39 +47,11 @@
ref="qrcodeDialogRef"
/>
<template #footer>
<div class="dialog-footer pl-plan-view-footer">
<template v-if="showArrangeActions">
<el-button @click="copyPlanSummary()">
<el-icon><CopyDocument /></el-icon>复制给司机
</el-button>
<el-button v-if="hasSiteInventoryRecords" @click="copySiteSummary()">
<el-icon><CopyDocument /></el-icon>复制给现场
</el-button>
<el-button
v-if="hasSiteInventoryRecords && siteRecords.length > 1"
@click="copyAllSiteUrls()"
>
<el-icon><CopyDocument /></el-icon>复制全部现场链接
</el-button>
<el-button
v-if="hasDibangInventoryRecords"
type="primary"
@click="copyPoundSlipQrcode()"
>
<el-icon><CopyDocument /></el-icon>复制磅单二维码
</el-button>
</template>
<el-button @click="innerVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Close, CopyDocument } from '@element-plus/icons-vue'
import { CopyDocument } from '@element-plus/icons-vue'
import { planApi } from '@/api/pl'
import DbPoundSlipQrcodeDialog from '@/components/business/db/DbPoundSlipQrcodeDialog.vue'
import PlPlanViewTabs from '@/components/business/pl-plan-view-tabs/index.vue'
@@ -126,27 +80,17 @@ const {
hasDibangInventoryRecords,
driverUrl,
siteRecords,
inventoryRecords,
plan,
qrcodeVisible,
selectedInventoryId,
selectedInventoryUnit,
qrcodeDialogRef,
resetArrangeActions,
loadArrangeActions,
copyDriverUrl,
copySiteUrl,
copyAllSiteUrls,
copySiteSummary,
copyPlanSummary,
copyPoundSlipQrcode
} = usePlPlanArrangeActions()
const planSupportsInventory = computed(() => {
const planType = plan.value?.planType
return planType === 'inbound' || planType === 'outbound'
})
const dialogTitle = computed(() =>
planNo.value ? `查看运输计划 · ${planNo.value}` : '查看运输计划'
)
@@ -168,6 +112,21 @@ function handleClosed() {
planNo.value = ''
resetArrangeActions()
}
async function reload() {
if (!props.visible || !props.planId) return
try {
const res = await planApi.get(props.planId)
const data = res.data ?? res
planNo.value = data?.planNo || ''
await loadArrangeActions(props.planId)
await tabsRef.value?.reload()
} catch {
/* 查看弹窗仍打开时静默失败,避免打断用户 */
}
}
defineExpose({ reload })
</script>
<style scoped lang="scss">
@@ -186,52 +145,42 @@ function handleClosed() {
gap: 8px;
margin-bottom: 12px;
}
</style>
.pl-plan-arrange-summary {
margin-bottom: 12px;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #f8fafc;
<style lang="scss">
.el-dialog.pl-plan-view-dialog.dialog-form-layout {
width: min(1680px, calc(100vw - 80px)) !important;
height: var(--dialog-max-height, calc(100vh - 40px));
max-height: var(--dialog-max-height, calc(100vh - 40px));
}
.pl-plan-arrange-summary-title {
margin: 0 0 10px;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
border-left: 3px solid #1ca851;
line-height: 1;
.el-dialog.pl-plan-view-dialog.dialog-form-layout .el-dialog__body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.pl-plan-arrange-summary-subtitle {
margin: 12px 0 10px;
font-size: 13px;
font-weight: 600;
color: #475569;
.el-dialog.pl-plan-view-dialog.dialog-form-layout .pl-plan-view-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.pl-arrange-link-row {
display: grid;
grid-template-columns: 72px 1fr auto;
gap: 8px;
align-items: center;
& + & {
margin-top: 8px;
}
.el-dialog.pl-plan-view-dialog.dialog-form-layout .pl-plan-view-tabs__nav.el-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.pl-arrange-link-label {
font-size: 13px;
color: #64748b;
}
.pl-plan-arrange-summary-hint {
margin: 10px 0 0;
font-size: 12px;
color: #64748b;
line-height: 1.5;
.el-dialog.pl-plan-view-dialog.dialog-form-layout
.pl-plan-view-tabs__nav.el-tabs
> .el-tabs__content {
flex: 1;
min-height: 0;
max-height: none;
overflow-y: auto;
}
</style>
@@ -5,7 +5,7 @@
<el-tab-pane label="计划概况" name="overview">
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">基础信息</h3>
<ViewKvTable :fields="overviewBasicViewFields" mode="quad" />
<ViewKvTable :fields="overviewBasicViewFields" mode="quint" />
</section>
<section class="pl-plan-form-section">
@@ -16,40 +16,7 @@
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">危化品证书信息</h3>
<p v-if="!hazardProducts.length && !loading" class="pl-plan-form-empty-hint"
>暂无易制毒/易制爆产品</p
>
<div v-for="item in hazardProducts" :key="item.id" class="pl-plan-cert-block">
<div class="pl-plan-cert-head">
<span class="pl-plan-cert-name">{{ item.productName }}</span>
<el-tag v-if="item.isToxic" type="warning" size="small">易制毒</el-tag>
<el-tag v-if="item.isExplosive" type="danger" size="small">易制爆</el-tag>
</div>
<dl class="pl-plan-kv-grid pl-plan-kv-grid-2 pl-plan-cert-grid">
<div class="pl-plan-kv">
<dt>购买证</dt>
<dd>
<AttachmentUploadField
v-if="item.purchaseCertUrl"
:model-value="item.purchaseCertUrl"
disabled
/>
<span v-else>未上传</span>
</dd>
</div>
<div class="pl-plan-kv">
<dt>合法使用证明</dt>
<dd>
<AttachmentUploadField
v-if="item.transportCertUrl"
:model-value="item.transportCertUrl"
disabled
/>
<span v-else>未上传</span>
</dd>
</div>
</dl>
</div>
<PlPlanHazardCertViewTable :products="hazardProducts" />
</section>
<section class="pl-plan-form-section">
@@ -59,6 +26,45 @@
</el-tab-pane>
<el-tab-pane label="运输信息" name="transport">
<section v-if="showArrangeLinks" class="pl-plan-form-section pl-plan-arrange-links">
<h3 class="pl-plan-form-section-title">运输链接</h3>
<div class="pl-arrange-link-row">
<span class="pl-arrange-link-label">司机链接</span>
<el-input :model-value="arrangeDriverUrl" readonly />
<el-button @click="copyArrangeDriverUrl">
<el-icon><CopyDocument /></el-icon>复制链接
</el-button>
</div>
<template v-if="hasArrangeSiteLinks">
<div v-if="arrangeSiteRecords.length === 1" class="pl-arrange-link-row">
<span class="pl-arrange-link-label">现场链接</span>
<el-input :model-value="arrangeSiteRecords[0].siteUrl || ''" readonly />
<el-button @click="copyArrangeSiteUrl(arrangeSiteRecords[0].siteUrl)">
<el-icon><CopyDocument /></el-icon>复制链接
</el-button>
</div>
<template v-else>
<div
v-for="record in arrangeSiteRecords"
:key="record.siteLinkId"
class="pl-arrange-link-row"
>
<span class="pl-arrange-link-label">{{ record.productName || '现场链接' }}</span>
<el-input :model-value="record.siteUrl || ''" readonly />
<el-button @click="copyArrangeSiteUrl(record.siteUrl)">
<el-icon><CopyDocument /></el-icon>复制链接
</el-button>
</div>
</template>
</template>
<p
v-else-if="showArrangeLinks && planSupportsInventory"
class="pl-plan-form-empty-hint pl-plan-arrange-links-hint"
>
暂无现场链接
</p>
</section>
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">车辆与承运</h3>
<PlVehicleSummaryStrip
@@ -110,13 +116,47 @@
<div class="pl-plan-feedback-region">
<h4 class="pl-plan-feedback-region__title">司机回传信息</h4>
<p v-if="!hasDriverFeedback" class="pl-plan-form-empty-hint">暂无司机回传</p>
<template v-else>
<ViewKvTable :fields="driverFeedbackViewFields" mode="quad" />
<div v-if="feedbackImageCsv" class="pl-plan-feedback-attachments">
<span class="pl-plan-feedback-attachments__label">回传附件</span>
<AttachmentUploadField :model-value="feedbackImageCsv" disabled />
</div>
</template>
<div v-else class="view-subtable-wrap">
<el-table :data="driverFeedbackTableRows" class="view-subtable list-table" stripe>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
prop="feedbackTime"
label="回传时间"
min-width="160"
align="center"
/>
<el-table-column
prop="feedbackQty"
label="回传数量"
min-width="100"
align="center"
>
<template #default="{ row }">
{{ row.feedbackQty ?? '—' }}
</template>
</el-table-column>
<el-table-column
prop="feedbackRemark"
label="回传备注"
min-width="180"
show-overflow-tooltip
/>
<el-table-column label="回传附件" min-width="140" align="center" header-align="center">
<template #default="{ row }">
<div v-if="row.feedbackImages" class="pl-plan-feedback-table__attachment">
<AttachmentUploadField :model-value="row.feedbackImages" disabled compact />
</div>
<span v-else class="pl-plan-feedback-table__empty-cell">—</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
<div class="pl-plan-feedback-region">
@@ -124,38 +164,69 @@
<p v-if="!siteInventoryRows.length && !loading" class="pl-plan-form-empty-hint">
暂无现场链接
</p>
<template v-else>
<div
v-for="(row, idx) in siteInventoryRows"
:key="`site-feedback-${row.link.siteLinkId || idx}`"
class="pl-plan-site-feedback-block"
>
<div class="pl-plan-site-feedback-head">
<span class="pl-plan-dibang-product">{{ row.link.productName || '—' }}</span>
<div class="pl-plan-dibang-head-actions">
<el-button class="btn-op-item" @click="copySiteSummaryForRow(row)">
<div v-else class="view-subtable-wrap">
<el-table :data="siteFeedbackTableRows" class="view-subtable list-table" stripe>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
prop="productName"
label="产品"
min-width="140"
show-overflow-tooltip
/>
<el-table-column
prop="siteFeedbackTime"
label="保存时间"
min-width="160"
align="center"
/>
<el-table-column label="业务类型" min-width="100" align="center">
<template #default="{ row }">
<el-tag
v-if="row.businessType && row.businessType !== '—'"
:type="businessTypeTagType(row.businessType)"
size="small"
>
{{ row.businessType }}
</el-tag>
<span v-else class="pl-plan-feedback-table__empty-cell">—</span>
</template>
</el-table-column>
<el-table-column
prop="siteFeedbackRemark"
label="现场备注"
min-width="160"
show-overflow-tooltip
/>
<el-table-column label="现场附件" min-width="140" align="center" header-align="center">
<template #default="{ row }">
<div v-if="row.siteFeedbackImages" class="pl-plan-feedback-table__attachment">
<AttachmentUploadField :model-value="row.siteFeedbackImages" disabled compact />
</div>
<span v-else class="pl-plan-feedback-table__empty-cell">—</span>
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="120"
align="center"
fixed="right"
class-name="col-actions"
>
<template #default="{ row }">
<el-button class="btn-op-item" @click="copySiteSummaryForRow(row.sourceRow)">
<el-icon><CopyDocument /></el-icon>
复制给现场
</el-button>
<el-button class="btn-op-item" @click="copySiteLinkForRow(row)">
<el-icon><CopyDocument /></el-icon>
复制现场链接
</el-button>
</div>
</div>
<template v-if="hasSiteFeedback(row)">
<ViewKvTable :fields="siteFeedbackViewFields(row)" mode="quad" />
<div
v-if="siteFeedbackImageCsv(row)"
class="pl-plan-feedback-attachments pl-plan-site-feedback-attachments"
>
<span class="pl-plan-feedback-attachments__label">现场附件</span>
<AttachmentUploadField :model-value="siteFeedbackImageCsv(row)" disabled />
</div>
</template>
<p v-else class="pl-plan-form-empty-hint pl-plan-site-feedback-empty">暂无现场回传</p>
</div>
</template>
</template>
</el-table-column>
</el-table>
</div>
</div>
<h4 class="pl-plan-subtitle">地磅单信息</h4>
@@ -355,6 +426,7 @@
import { CopyDocument, View } from '@element-plus/icons-vue'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlPlanFormProductTable from '@/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue'
import PlPlanHazardCertViewTable from '@/components/business/pl-plan-hazard-cert-view-table/index.vue'
import PlVehicleSummaryStrip from '@/components/business/pl-vehicle-summary-strip/index.vue'
import PlVehicleViewDialog from '@/components/business/pl-vehicle-view-dialog/index.vue'
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
@@ -365,8 +437,10 @@
} from '@/components/business/db/db-related-view'
import type { ContractVO } from '@/api/crm/contract/types'
import { inventoryApi } from '@/api/db/inventory'
import type { TransportSiteLink } from '@/api/pl'
import { mapContractProducts, type PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { mapPlanProductToFormRow, usePlPlanViewDetail, type PlPlanTransportDibangRow, type PlPlanTransportSiteRow } from '@/composables/usePlPlanViewDetail'
import { resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
import { isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
import { useDictStore } from '@/store/modules/dict'
import { formatDate, formatDateTime } from '@/utils/format'
@@ -396,8 +470,16 @@
defineProps<{
planId: string
embedded?: boolean
showArrangeLinks?: boolean
arrangeDriverUrl?: string
arrangeSiteRecords?: TransportSiteLink[]
}>(),
{ embedded: false }
{
embedded: false,
showArrangeLinks: false,
arrangeDriverUrl: '',
arrangeSiteRecords: () => []
}
)
const activeTab = ref('overview')
@@ -436,22 +518,28 @@
return Array.isArray(imgs) ? imgs.filter(Boolean).join(',') : ''
}
function hasSiteFeedback(row: PlPlanTransportSiteRow) {
return Boolean(
row.link.siteFeedbackRemark ||
siteFeedbackImageCsv(row) ||
row.link.siteFeedbackTime
)
}
function siteFeedbackViewFields(row: PlPlanTransportSiteRow): ViewField[] {
const driverFeedbackTableRows = computed(() => {
if (!hasDriverFeedback.value) return []
return [
{ label: '保存时间', value: formatDateTime(row.link.siteFeedbackTime) || '—' },
{ label: '业务类型', value: row.link.businessType || '—' },
{ label: '现场备注', value: row.link.siteFeedbackRemark, span: 'full' }
{
feedbackTime: displayFeedbackTime.value,
feedbackQty: transport.value?.feedbackQty,
feedbackRemark: transport.value?.feedbackRemark || '—',
feedbackImages: feedbackImageCsv.value
}
]
}
})
const siteFeedbackTableRows = computed(() =>
siteInventoryRows.value.map((row) => ({
sourceRow: row,
productName: row.link.productName || '—',
siteFeedbackTime: formatDateTime(row.link.siteFeedbackTime) || '—',
businessType: row.link.businessType || '—',
siteFeedbackRemark: row.link.siteFeedbackRemark || '—',
siteFeedbackImages: siteFeedbackImageCsv(row)
}))
)
function dibangBasicViewFields(row: PlPlanTransportDibangRow): ViewField[] {
const businessType = row.inventory?.businessType
@@ -478,13 +566,32 @@
const siteInventoryRows = computed(() => siteRows.value)
async function copySiteLinkForRow(row: PlPlanTransportSiteRow) {
const url = row.link.siteUrl?.trim()
const hasArrangeSiteLinks = computed(
() => props.showArrangeLinks && (props.arrangeSiteRecords?.length ?? 0) > 0
)
const planSupportsInventory = computed(() => {
const planType = plan.value?.planType
return planType === 'inbound' || planType === 'outbound'
})
async function copyArrangeDriverUrl() {
const url = props.arrangeDriverUrl?.trim()
if (!url) {
ElMessage.warning('暂无现场链接')
ElMessage.warning('暂无司机链接')
return
}
await navigator.clipboard.writeText(url)
ElMessage.success('已复制司机链接')
}
async function copyArrangeSiteUrl(url?: string) {
const target = url?.trim()
if (!target) {
ElMessage.warning('暂无现场链接')
return
}
await navigator.clipboard.writeText(target)
ElMessage.success('已复制现场链接')
}
@@ -548,6 +655,18 @@
return amount ? formatMoneyDisplay(Number(amount)) : '—'
})
const freightUnitLabel = computed(() => {
const unitCode = resolveFreightUnitLabel(productRows.value)
return dictStore.getLabel('crm_product_unit', unitCode) || unitCode || '吨'
})
const displayHasInvoiceLabel = computed(() => {
if (transport.value?.hasInvoice != null) {
return getPlYesNoLabel(transport.value.hasInvoice)
}
return getPlHasInvoiceLabelFromSettlement(freightSettlement.value)
})
function mergeTaxRatesFromContract(
rows: PlanFormProductRow[],
contract: ContractVO | null
@@ -576,8 +695,8 @@
const pickupViewFields = computed<ViewField[]>(() => [
{ label: '取货地址', value: plan.value?.pickupAddress, span: 'full' },
{ label: '取货时间', value: formatDate(plan.value?.pickupTime) || '—' },
{ label: '注意事项', value: plan.value?.pickupNotes, span: 'full' }
{ label: '取货时间', value: formatDate(plan.value?.pickupTime) || '—', long: false },
{ label: '注意事项', value: plan.value?.pickupNotes, long: false, fullSpan: false }
])
const deliveryViewFields = computed<ViewField[]>(() => [
@@ -586,40 +705,38 @@
value: plan.value?.destinationAddress || plan.value?.destinationName,
span: 'full'
},
{ label: '送达时间', value: formatDate(plan.value?.deliveryTime) || '—' },
{ label: '注意事项', value: plan.value?.destinationNotes, span: 'full' }
{ label: '送达时间', value: formatDate(plan.value?.deliveryTime) || '—', long: false },
{ label: '注意事项', value: plan.value?.destinationNotes, long: false, fullSpan: false }
])
const freightViewFields = computed<ViewField[]>(() => {
const fields: ViewField[] = [
{ label: '是否有运输费用', value: getPlYesNoLabel(plan.value?.hasFreight) }
const hasFreight = plan.value?.hasFreight === 1
const unit = freightUnitLabel.value
const isByWeight = hasFreight && displayFreightType.value === 'by_weight'
return [
{ label: '是否有运输费用', value: getPlYesNoLabel(plan.value?.hasFreight) },
{ label: '运输公里数', value: hasFreight ? displayDistanceKm.value : '—' },
{
label: '运费计算方式',
value: hasFreight ? getPlFreightTypeLabel(displayFreightType.value) : '—'
},
{
label: `运输量(${unit})`,
value: isByWeight ? displayTransportQty.value : '—'
},
{
label: `运输单价(元/${unit})`,
value: isByWeight ? displayFreightPrice.value : '—',
money: isByWeight
},
{
label: '运费金额(元)',
value: hasFreight ? displayFreightAmount.value : '—',
money: hasFreight
},
{ label: '是否开票', value: hasFreight ? displayHasInvoiceLabel.value : '—' }
]
if (plan.value?.hasFreight !== 1) return fields
fields.push(
{ label: '运输公里数', value: displayDistanceKm.value },
{ label: '运费计算方式', value: getPlFreightTypeLabel(displayFreightType.value) }
)
if (displayFreightType.value === 'by_weight') {
fields.push(
{ label: '运输量(吨)', value: displayTransportQty.value },
{ label: '运输单价(元/吨)', value: displayFreightPrice.value, money: true }
)
}
fields.push(
{ label: '运费金额(元)', value: displayFreightAmount.value, money: true },
{ label: '是否开票', value: getPlHasInvoiceLabelFromSettlement(freightSettlement.value) }
)
return fields
})
const driverFeedbackViewFields = computed<ViewField[]>(() => {
const fields: ViewField[] = [{ label: '回传时间', value: displayFeedbackTime.value }]
if (transport.value?.feedbackQty != null) {
fields.push({ label: '回传数量', value: transport.value.feedbackQty })
}
fields.push({ label: '回传备注', value: transport.value?.feedbackRemark, span: 'full' })
return fields
})
const contractBasicViewFields = computed<ViewField[]>(() => {
@@ -712,7 +829,11 @@
if (props.planId) return loadDetail(props.planId)
}
defineExpose({ reload })
function setActiveTab(name: string) {
activeTab.value = name
}
defineExpose({ reload, setActiveTab })
</script>
<style scoped lang="scss">
@@ -785,6 +906,32 @@
color: var(--mint-text-muted);
}
.pl-plan-arrange-links {
display: flex;
flex-direction: column;
gap: 10px;
}
.pl-plan-arrange-links-hint {
margin-top: 2px;
}
.pl-arrange-link-row {
display: grid;
grid-template-columns: 72px 1fr auto;
gap: 8px;
align-items: center;
&--readonly {
grid-template-columns: 72px 1fr;
}
}
.pl-arrange-link-label {
font-size: 13px;
color: #64748b;
}
.pl-plan-view-remark {
margin: 0;
font-size: 13px;
@@ -931,43 +1078,52 @@
line-height: 1;
}
.pl-plan-feedback-attachments {
margin-top: 12px;
padding: 12px;
.pl-plan-feedback-region .view-subtable-wrap {
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
overflow: hidden;
background: #fff;
}
.pl-plan-feedback-attachments__label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: var(--mint-text-muted);
}
.pl-plan-feedback-region .view-subtable-wrap :deep(.el-table.view-subtable) {
--el-table-header-bg-color: #f8fafc;
--el-table-header-text-color: #1e293b;
--el-table-border-color: #e2e8f0;
width: 100%;
.pl-plan-cert-block {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
.el-table__header th.el-table__cell,
.el-table__body td.el-table__cell {
font-size: 14px;
padding: 8px 0;
}
& + & {
margin-top: 10px;
.el-table__header th.el-table__cell .cell {
font-weight: 500;
color: #1e293b;
}
.el-table__body td.el-table__cell .cell {
color: #1e293b;
}
}
.pl-plan-cert-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
.pl-plan-feedback-table__attachment {
display: inline-flex;
justify-content: center;
width: 100%;
:deep(.attachment-upload) {
width: auto;
}
:deep(.attachment-list) {
justify-content: center;
}
}
.pl-plan-cert-name {
.pl-plan-feedback-table__empty-cell {
color: var(--mint-text-muted);
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
.money-display {
@@ -986,38 +1142,6 @@
}
}
.pl-plan-site-feedback-block {
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
& + & {
margin-top: 10px;
}
}
.pl-plan-site-feedback-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.pl-plan-site-feedback-empty {
margin: 0;
}
.pl-plan-site-feedback-batch {
font-size: 12px;
color: var(--mint-text-muted);
}
.pl-plan-site-feedback-attachments {
margin-top: 10px;
}
.pl-plan-dibang-head {
display: flex;
align-items: center;
@@ -111,18 +111,11 @@
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="innerVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Close, RefreshLeft, Search, Select } from '@element-plus/icons-vue'
import { RefreshLeft, Search, Select } from '@element-plus/icons-vue'
import { transportAddressApi, type TransportAddress } from '@/api/pl/index'
import ListPagination from '@/components/ListPagination/index.vue'
import { pickBxPageResult } from '@/utils/http/bx'
@@ -1,6 +1,23 @@
<template>
<div class="vehicle-cert-part-tabs">
<el-tabs v-if="hasTrailer" v-model="activePart" class="vehicle-cert-part-tabs__tabs">
<PlCertFormList
v-if="layout === 'table'"
ref="unifiedCertFormRef"
v-model="certList"
:title="title"
:upload-folder="uploadFolder"
:cert-name-options="certNameOptions"
layout="table"
show-vehicle-part-column
:license-plate="licensePlate"
:trailer-no="trailerNo"
/>
<el-tabs
v-else-if="hasTrailer"
v-model="activePart"
class="vehicle-cert-part-tabs__tabs"
>
<el-tab-pane :label="headTabLabel" name="head">
<PlCertFormList
ref="headCertFormRef"
@@ -8,6 +25,7 @@
:title="headTabLabel"
:upload-folder="uploadFolder"
:cert-name-options="certNameOptions"
:layout="layout"
/>
</el-tab-pane>
<el-tab-pane :label="trailerTabLabel" name="trailer">
@@ -17,6 +35,7 @@
:title="trailerTabLabel"
:upload-folder="uploadFolder"
:cert-name-options="certNameOptions"
:layout="layout"
/>
</el-tab-pane>
</el-tabs>
@@ -28,6 +47,7 @@
:title="title"
:upload-folder="uploadFolder"
:cert-name-options="certNameOptions"
layout="card"
/>
</div>
</template>
@@ -52,18 +72,21 @@ const props = withDefaults(
uploadFolder: string
certNameOptions?: string[]
title?: string
layout?: 'card' | 'table'
}>(),
{
licensePlate: '',
trailerNo: '',
certNameOptions: () => [],
title: '车辆证书'
title: '车辆证书',
layout: 'card'
}
)
const certList = defineModel<CertRowWithKey[]>('modelValue', { default: () => [] })
const activePart = ref(PL_VEHICLE_CERT_PART_HEAD)
const unifiedCertFormRef = ref<CertFormListRef>()
const headCertFormRef = ref<CertFormListRef>()
const trailerCertFormRef = ref<CertFormListRef>()
@@ -102,6 +125,7 @@ watch(hasTrailer, value => {
[],
false
)
unifiedCertFormRef.value?.clearUploadRefs()
trailerCertFormRef.value?.clearUploadRefs()
return
}
@@ -111,12 +135,16 @@ watch(hasTrailer, value => {
})
function hasUploading() {
if (props.layout === 'table') {
return Boolean(unifiedCertFormRef.value?.hasUploading?.())
}
return Boolean(
headCertFormRef.value?.hasUploading?.() || trailerCertFormRef.value?.hasUploading?.()
)
}
function clearUploadRefs() {
unifiedCertFormRef.value?.clearUploadRefs()
headCertFormRef.value?.clearUploadRefs()
trailerCertFormRef.value?.clearUploadRefs()
}
@@ -1,23 +1,52 @@
<template>
<div class="vehicle-cert-part-view">
<el-tabs v-if="hasTrailer" v-model="activePart" class="vehicle-cert-part-view__tabs">
<el-tab-pane :label="headTabLabel" name="head">
<PlCertViewList :cert-list="headCertList" />
</el-tab-pane>
<el-tab-pane :label="trailerTabLabel" name="trailer">
<PlCertViewList :cert-list="trailerCertList" />
</el-tab-pane>
</el-tabs>
<PlCertViewList
v-if="certLayout === 'table'"
:cert-list="scopedCertList"
:table-mode="tableMode"
layout="table"
:show-vehicle-part-column="hasTrailer"
:head-label="headTabLabel"
:trailer-label="trailerTabLabel"
/>
<PlCertViewList v-else :cert-list="headCertList" />
<template v-else>
<el-tabs
v-if="hasTrailer"
:key="tabsInstanceKey"
v-model="activePart"
class="vehicle-cert-part-view__tabs"
>
<el-tab-pane :label="headTabLabelWithHint" :name="headPartName">
<PlCertViewList :cert-list="headCertList" :table-mode="tableMode" :layout="certLayout" />
</el-tab-pane>
<el-tab-pane :label="trailerTabLabelWithHint" :name="trailerPartName">
<PlCertViewList
:cert-list="trailerCertList"
:table-mode="tableMode"
:layout="certLayout"
/>
</el-tab-pane>
</el-tabs>
<PlCertViewList
v-else
:cert-list="headCertList"
:table-mode="tableMode"
:layout="certLayout"
/>
</template>
</div>
</template>
<script setup lang="ts">
import PlCertViewList from '@/components/business/pl-cert-view-list/index.vue'
import type { CertRead } from '@/api/pl'
import type { ViewTableMode } from '@/components/business/system-view-dialog/types'
import {
PL_VEHICLE_CERT_PART_HEAD,
PL_VEHICLE_CERT_PART_TRAILER,
getSelfVehiclePartCertMissingSuffix,
splitVehicleCertsByPart
} from '@/utils/pl-vehicle-cert'
@@ -26,22 +55,69 @@ const props = withDefaults(
certList?: CertRead[]
licensePlate?: string
trailerNo?: string
vehicleId?: string
checkSelfVehicleCerts?: boolean
tableMode?: ViewTableMode
certLayout?: 'card' | 'table'
}>(),
{
certList: () => [],
licensePlate: '',
trailerNo: ''
trailerNo: '',
vehicleId: '',
checkSelfVehicleCerts: false,
tableMode: 'triple',
certLayout: 'card'
}
)
const activePart = ref(PL_VEHICLE_CERT_PART_HEAD)
const activePart = ref('')
const tabsInstanceKey = computed(
() => `${props.vehicleId || props.licensePlate || 'vehicle'}-${props.trailerNo || 'head'}`
)
const headPartName = computed(
() => `${PL_VEHICLE_CERT_PART_HEAD}-${props.vehicleId || props.licensePlate || 'vehicle'}`
)
const trailerPartName = computed(
() =>
`${PL_VEHICLE_CERT_PART_TRAILER}-${props.vehicleId || props.trailerNo || props.licensePlate || 'vehicle'}`
)
const hasTrailer = computed(() => Boolean(props.trailerNo?.trim()))
const headTabLabel = computed(() => props.licensePlate?.trim() || '主车')
const trailerTabLabel = computed(() => props.trailerNo?.trim() || '挂车')
const headCertList = computed(() => splitVehicleCertsByPart(props.certList).headCerts)
const trailerCertList = computed(() => splitVehicleCertsByPart(props.certList).trailerCerts)
const scopedCertList = computed(() => {
const list = props.certList || []
if (!props.vehicleId) return list
return list.filter(cert => !cert.ownerId || cert.ownerId === props.vehicleId)
})
const headCertList = computed(() => splitVehicleCertsByPart(scopedCertList.value).headCerts)
const trailerCertList = computed(() => splitVehicleCertsByPart(scopedCertList.value).trailerCerts)
const headTabLabelWithHint = computed(() => {
if (!props.checkSelfVehicleCerts) return headTabLabel.value
return headTabLabel.value + getSelfVehiclePartCertMissingSuffix(headCertList.value, PL_VEHICLE_CERT_PART_HEAD)
})
const trailerTabLabelWithHint = computed(() => {
if (!props.checkSelfVehicleCerts) return trailerTabLabel.value
return (
trailerTabLabel.value +
getSelfVehiclePartCertMissingSuffix(trailerCertList.value, PL_VEHICLE_CERT_PART_TRAILER)
)
})
watch(
tabsInstanceKey,
() => {
activePart.value = headPartName.value
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
@@ -1,10 +1,15 @@
<template>
<el-tabs v-model="activeTab" class="vehicle-detail-tabs">
<el-tab-pane label="车辆证书" name="vehicle-cert">
<el-tabs :key="tabsInstanceKey" v-model="activeTab" class="vehicle-detail-tabs">
<el-tab-pane :label="vehicleCertTabLabel" :name="vehicleCertTabName">
<PlVehicleCertPartView
:key="`${vehicleId || licensePlate}-cert`"
:cert-list="certList"
:license-plate="licensePlate"
:trailer-no="trailerNo"
:vehicle-id="vehicleId"
:check-self-vehicle-certs="checkSelfVehicleCerts"
:table-mode="tableMode"
:cert-layout="certLayout"
/>
</el-tab-pane>
<el-tab-pane
@@ -13,7 +18,12 @@
:label="getPersonTabLabel(person)"
:name="getPersonKey(person, index)"
>
<PersonnelViewPanel :person="person" />
<PersonnelViewPanel
:person="person"
:check-self-vehicle-certs="checkSelfVehicleCerts"
:table-mode="tableMode"
:cert-layout="certLayout"
/>
</el-tab-pane>
</el-tabs>
</template>
@@ -23,26 +33,57 @@ import PlVehicleCertPartView from '@/components/business/pl-vehicle-cert-part-vi
import PersonnelViewPanel from '@/components/business/pl-vehicle-personnel-view-list/PersonnelViewPanel.vue'
import type { CertRead } from '@/api/pl'
import type { VehiclePersonnelViewRow } from '@/components/business/pl-vehicle-personnel-view-list/index.vue'
import type { ViewTableMode } from '@/components/business/system-view-dialog/types'
import { getSelfVehicleCertMissingSuffix } from '@/utils/pl-vehicle-cert'
import { getPersonnelCertMissingSuffix } from '@/utils/pl-personnel-cert'
const props = withDefaults(
defineProps<{
certList?: CertRead[]
licensePlate?: string
trailerNo?: string
vehicleId?: string
personnelList?: VehiclePersonnelViewRow[]
checkSelfVehicleCerts?: boolean
tableMode?: ViewTableMode
certLayout?: 'card' | 'table'
}>(),
{
certList: () => [],
licensePlate: '',
trailerNo: '',
personnelList: () => []
vehicleId: '',
personnelList: () => [],
checkSelfVehicleCerts: false,
tableMode: 'triple',
certLayout: 'card'
}
)
const activeTab = ref('vehicle-cert')
const vehicleCertTabName = computed(
() => `vehicle-cert-${props.vehicleId || props.licensePlate || 'default'}`
)
const tabsInstanceKey = computed(
() => `${props.vehicleId || props.licensePlate || 'vehicle'}-${props.trailerNo || 'none'}`
)
const vehicleCertTabLabel = computed(() => {
const label = '车辆证书'
if (!props.checkSelfVehicleCerts) return label
return (
label +
getSelfVehicleCertMissingSuffix({
certList: props.certList,
trailerNo: props.trailerNo
})
)
})
function getPersonKey(person: VehiclePersonnelViewRow, index: number) {
return `person-${person.personnelId || `${person.personnelName}-${index}`}`
return `person-${props.vehicleId || props.licensePlate || 'vehicle'}-${person.personnelId || `${person.personnelName}-${index}`}`
}
function getRoleLabel(role: string) {
@@ -56,23 +97,31 @@ function getRoleLabel(role: string) {
function getPersonTabLabel(person: VehiclePersonnelViewRow) {
const name = person.personnelName || '—'
const role = getRoleLabel(person.role)
return `${name}(${role})`
const suffix =
props.checkSelfVehicleCerts && person.role
? getPersonnelCertMissingSuffix(person.certList || [], person.role as 'driver' | 'escort')
: ''
return `${name}(${role})${suffix}`
}
watch(
tabsInstanceKey,
() => {
activeTab.value = vehicleCertTabName.value
},
{ immediate: true }
)
watch(
() => props.personnelList,
list => {
if (!list.length) {
activeTab.value = 'vehicle-cert'
return
}
if (activeTab.value === 'vehicle-cert') return
if (activeTab.value === vehicleCertTabName.value) return
const keys = list.map((person, index) => getPersonKey(person, index))
if (!keys.includes(activeTab.value)) {
activeTab.value = 'vehicle-cert'
activeTab.value = vehicleCertTabName.value
}
},
{ immediate: true, deep: true }
{ deep: true }
)
</script>
@@ -1,7 +1,7 @@
<template>
<div class="personnel-view-panel">
<h4 v-if="showTitle" class="personnel-view-card-title">
{{ person.personnelName || '—' }}
{{ person.personnelName || '—' }}{{ personnelCertMissingSuffix }}
<el-tag :type="getRoleTagType(person.role)" size="small">
{{ getRoleLabel(person.role) }}
</el-tag>
@@ -10,11 +10,15 @@
</el-tag>
</h4>
<ViewKvTable :fields="personnelViewFields" mode="triple" />
<ViewKvTable :fields="personnelViewFields" :mode="tableMode" />
<div class="personnel-certs-block">
<p class="personnel-certs-title">人员证书</p>
<PlCertViewList :cert-list="person.certList || []" />
<PlCertViewList
:cert-list="person.certList || []"
:table-mode="tableMode"
:layout="certLayout"
/>
</div>
</div>
</template>
@@ -22,8 +26,9 @@
<script setup lang="ts">
import PlCertViewList from '@/components/business/pl-cert-view-list/index.vue'
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import type { ViewField, ViewTableMode } from '@/components/business/system-view-dialog/types'
import type { CertRead, VehiclePersonnelRow } from '@/api/pl'
import { getPersonnelCertMissingSuffix } from '@/utils/pl-personnel-cert'
type VehiclePersonnelViewRow = VehiclePersonnelRow & { certList?: CertRead[] }
@@ -31,12 +36,26 @@ const props = withDefaults(
defineProps<{
person: VehiclePersonnelViewRow
showTitle?: boolean
checkSelfVehicleCerts?: boolean
tableMode?: ViewTableMode
certLayout?: 'card' | 'table'
}>(),
{
showTitle: false
showTitle: false,
checkSelfVehicleCerts: false,
tableMode: 'triple',
certLayout: 'card'
}
)
const personnelCertMissingSuffix = computed(() => {
if (!props.checkSelfVehicleCerts || !props.person.role) return ''
return getPersonnelCertMissingSuffix(
props.person.certList || [],
props.person.role as 'driver' | 'escort'
)
})
const personnelViewFields = computed<ViewField[]>(() => {
const person = props.person
const fields: ViewField[] = [
@@ -58,7 +77,7 @@ const personnelViewFields = computed<ViewField[]>(() => {
})
}
fields.push({ label: '备注', value: person.remark || '—', span: 'full' })
fields.push({ label: '备注', value: person.remark || '—', long: false })
return fields
})
@@ -13,7 +13,7 @@
:label="getTabLabel(person)"
:name="getPersonKey(person, index)"
>
<PersonnelViewPanel :person="person" />
<PersonnelViewPanel :person="person" :check-self-vehicle-certs="checkSelfVehicleCerts" />
</el-tab-pane>
</el-tabs>
@@ -23,7 +23,7 @@
:key="getPersonKey(person, index)"
class="personnel-view-card"
>
<PersonnelViewPanel :person="person" show-title />
<PersonnelViewPanel :person="person" show-title :check-self-vehicle-certs="checkSelfVehicleCerts" />
</div>
</div>
</div>
@@ -32,6 +32,7 @@
<script setup lang="ts">
import PersonnelViewPanel from './PersonnelViewPanel.vue'
import type { CertRead, VehiclePersonnelRow } from '@/api/pl'
import { getPersonnelCertMissingSuffix } from '@/utils/pl-personnel-cert'
export interface VehiclePersonnelViewRow extends VehiclePersonnelRow {
certList?: CertRead[]
@@ -41,10 +42,12 @@ const props = withDefaults(
defineProps<{
personnelList?: VehiclePersonnelViewRow[]
layout?: 'list' | 'tabs'
checkSelfVehicleCerts?: boolean
}>(),
{
personnelList: () => [],
layout: 'list'
layout: 'list',
checkSelfVehicleCerts: false
}
)
@@ -65,7 +68,11 @@ function getRoleLabel(role: string) {
function getTabLabel(person: VehiclePersonnelViewRow) {
const name = person.personnelName || '—'
const role = getRoleLabel(person.role)
return `${name}(${role})`
const suffix =
props.checkSelfVehicleCerts && person.role
? getPersonnelCertMissingSuffix(person.certList || [], person.role as 'driver' | 'escort')
: ''
return `${name}(${role})${suffix}`
}
watch(
@@ -14,7 +14,12 @@
<template #vehicleDetailTabs>
<PlVehicleCertPersonnelTabs
:cert-list="detail?.certList || []"
:license-plate="detail?.licensePlate"
:trailer-no="detail?.trailerNo"
:vehicle-id="detail?.id"
:personnel-list="personnelList"
:check-self-vehicle-certs="detail?.vehicleType === 'self'"
cert-layout="table"
/>
</template>
</SystemViewDialog>
@@ -19,13 +19,19 @@
<div v-show="!collapsible || expanded" class="vehicle-view-panel-body">
<section class="vehicle-view-section">
<h4 v-if="!collapsible" class="vehicle-view-section-title">车辆信息</h4>
<ViewKvTable :fields="vehicleViewFields" mode="triple" />
<ViewKvTable :fields="vehicleViewFields" :mode="tableMode" />
</section>
<section class="vehicle-view-section">
<PlVehicleCertPersonnelTabs
:key="detail.id"
:cert-list="detail.certList || []"
:license-plate="detail.licensePlate"
:trailer-no="detail.trailerNo"
:vehicle-id="detail.id"
:personnel-list="personnelList"
:table-mode="tableMode"
:cert-layout="certLayout"
/>
</section>
</div>
@@ -36,7 +42,7 @@
import { ArrowDown } from '@element-plus/icons-vue'
import PlVehicleCertPersonnelTabs from '@/components/business/pl-vehicle-cert-personnel-tabs/index.vue'
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import type { ViewField, ViewTableMode } from '@/components/business/system-view-dialog/types'
import type { VehiclePersonnelViewRow } from '@/components/business/pl-vehicle-personnel-view-list/index.vue'
import type { VehicleDetail } from '@/api/pl'
import { formatDateTime } from '@/utils/format'
@@ -53,11 +59,15 @@ const props = withDefaults(
personnelList?: VehiclePersonnelViewRow[]
collapsible?: boolean
defaultExpanded?: boolean
tableMode?: ViewTableMode
certLayout?: 'card' | 'table'
}>(),
{
personnelList: () => [],
collapsible: false,
defaultExpanded: false
defaultExpanded: false,
tableMode: 'triple',
certLayout: 'card'
}
)
@@ -78,11 +88,12 @@ const vehicleViewFields = computed<ViewField[]>(() => {
{
label: '车辆状态',
value: getPlVehicleStatusLabel(d.vehicleStatus),
tagType: getPlVehicleStatusTagType(d.vehicleStatus)
tagType: getPlVehicleStatusTagType(d.vehicleStatus),
long: false
},
{ label: '关联人员摘要', value: d.personnelSummary || '—', span: true },
{ label: '创建时间', value: formatDateTime(d.createTime) },
{ label: '备注', value: d.remark || '—', span: 'full' }
{ label: '创建时间', value: formatDateTime(d.createTime), long: false },
{ label: '关联人员摘要', value: d.personnelSummary || '—', long: false },
{ label: '备注', value: d.remark || '—', long: false }
]
})
@@ -258,12 +258,6 @@
</div>
</div>
<template #footer>
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
<el-icon><Close /></el-icon>
关闭
</el-button>
</template>
<el-dialog
v-model="previewVisible"
@@ -299,7 +293,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { Close, Document, Money, Picture, Setting, VideoPlay } from '@element-plus/icons-vue'
import { Document, Money, Picture, Setting, VideoPlay } from '@element-plus/icons-vue'
import { buildTreePathLabelFromFlat } from '@/utils/tree-path'
import {
fetchProductDetail,
@@ -0,0 +1,77 @@
<template>
<div v-if="items.length" class="view-highlights">
<span
v-for="(item, index) in items"
:key="item.label"
class="view-highlight-item"
:class="{
'is-emphasis': item.emphasis,
'is-warn': item.warn
}"
>
<span class="view-highlight-sep" v-if="index > 0">·</span>
<span class="view-highlight-label">{{ item.label }}</span>
<span class="view-highlight-value">
<el-tag v-if="item.tagType" :type="item.tagType" size="small">{{
formatViewValue(item.value)
}}</el-tag>
<span v-else :class="{ 'money-display': item.money }">{{
formatViewValue(item.value)
}}</span>
</span>
</span>
</div>
</template>
<script setup lang="ts">
import type { ViewHighlight } from './types'
import { formatViewValue } from './types'
defineProps<{
items: ViewHighlight[]
}>()
</script>
<style scoped lang="scss">
.view-highlights {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 0;
padding: 0 16px 10px;
font-size: 13px;
line-height: 1.45;
}
.view-highlight-item {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.view-highlight-sep {
margin: 0 10px;
color: #cbd5e1;
user-select: none;
}
.view-highlight-label {
color: var(--mint-text-muted, #64748b);
font-size: 12px;
}
.view-highlight-value {
font-weight: 600;
color: var(--text, #1e293b);
}
.view-highlight-item.is-emphasis .view-highlight-value {
color: var(--primary-dark, #059669);
font-weight: 700;
}
.view-highlight-item.is-warn .view-highlight-value {
color: #d97706;
}
</style>
@@ -28,7 +28,7 @@
</template>
<template v-else>
<th>{{ row.left.label }}</th>
<td>
<td :colspan="row.right ? undefined : 3">
<ViewKvTableCell :field="row.left" />
</td>
<template v-if="row.right">
@@ -37,10 +37,6 @@
<ViewKvTableCell :field="row.right" />
</td>
</template>
<template v-else>
<th></th>
<td></td>
</template>
</template>
</tr>
</template>
@@ -57,22 +53,22 @@
</td>
</template>
<template v-else>
<template v-for="slot in 3" :key="slot">
<template v-if="row.fields[slot - 1]">
<th>{{ row.fields[slot - 1].label }}</th>
<td>
<ViewKvTableCell :field="row.fields[slot - 1]" />
</td>
</template>
<template v-else>
<th></th>
<td></td>
</template>
<template v-for="(field, fi) in row.fields" :key="field.label">
<th>{{ field.label }}</th>
<td
:colspan="
fi === row.fields.length - 1 && row.fields.length < 3
? (3 - row.fields.length) * 2 + 1
: undefined
"
>
<ViewKvTableCell :field="field" />
</td>
</template>
</template>
</tr>
</template>
<template v-else>
<template v-else-if="mode === 'quad'">
<tr
v-for="(row, idx) in quadRows"
:key="quadRowKey(row, idx)"
@@ -85,17 +81,45 @@
</td>
</template>
<template v-else>
<template v-for="slot in 4" :key="slot">
<template v-if="row.fields[slot - 1]">
<th>{{ row.fields[slot - 1].label }}</th>
<td>
<ViewKvTableCell :field="row.fields[slot - 1]" />
</td>
</template>
<template v-else>
<th></th>
<td></td>
</template>
<template v-for="(field, fi) in row.fields" :key="field.label">
<th>{{ field.label }}</th>
<td
:colspan="
fi === row.fields.length - 1 && row.fields.length < 4
? (4 - row.fields.length) * 2 + 1
: undefined
"
>
<ViewKvTableCell :field="field" />
</td>
</template>
</template>
</tr>
</template>
<template v-else>
<tr
v-for="(row, idx) in quintRows"
:key="quintRowKey(row, idx)"
:class="{ 'row-long': row.type === 'long' }"
>
<template v-if="row.type === 'long'">
<th>{{ row.field.label }}</th>
<td colspan="9">
<ViewKvTableCell :field="row.field" />
</td>
</template>
<template v-else>
<template v-for="(field, fi) in row.fields" :key="field.label">
<th>{{ field.label }}</th>
<td
:colspan="
fi === row.fields.length - 1 && row.fields.length < 5
? (5 - row.fields.length) * 2 + 1
: undefined
"
>
<ViewKvTableCell :field="field" />
</td>
</template>
</template>
</tr>
@@ -113,11 +137,13 @@
import {
buildViewKvTableDualRows,
buildViewKvTableQuadRows,
buildViewKvTableQuintRows,
buildViewKvTableTripleRows,
getViewFieldDisplayText,
isViewFieldFullSpan,
isViewFieldLong,
type ViewKvTableQuadRow,
type ViewKvTableQuintRow,
type ViewKvTableRow,
type ViewKvTableTripleRow
} from '@/utils/view-field-layout'
@@ -125,8 +151,8 @@
const props = withDefaults(
defineProps<{
fields: ViewField[]
/** pair:一行一组;dual:一行两组(四列);triple:一行三组(六列);quad:一行四组(八列) */
mode?: 'pair' | 'dual' | 'triple' | 'quad'
/** pair:一行一组;dual:一行两组(四列);triple:一行三组(六列);quad:一行四组(八列);quint:一行五组(十列) */
mode?: 'pair' | 'dual' | 'triple' | 'quad' | 'quint'
}>(),
{
mode: 'pair'
@@ -136,6 +162,7 @@
const dualRows = computed(() => buildViewKvTableDualRows(props.fields, formatViewValue))
const tripleRows = computed(() => buildViewKvTableTripleRows(props.fields, formatViewValue))
const quadRows = computed(() => buildViewKvTableQuadRows(props.fields, formatViewValue))
const quintRows = computed(() => buildViewKvTableQuintRows(props.fields, formatViewValue))
const emit = defineEmits<{
(e: 'field-view', field: ViewField): void
@@ -162,6 +189,11 @@
if (row.type === 'long') return row.field.label
return row.fields.map((f) => f.label).join('-') || String(idx)
}
function quintRowKey(row: ViewKvTableQuintRow, idx: number) {
if (row.type === 'long') return row.field.label
return row.fields.map((f) => f.label).join('-') || String(idx)
}
</script>
<style scoped lang="scss">
@@ -261,4 +293,20 @@
.view-kv-table--quad tr.row-long td {
width: auto;
}
.view-kv-table--quint th {
width: 6.5%;
}
.view-kv-table--quint td {
width: 13%;
}
.view-kv-table--quint tr.row-long th {
width: 6.5%;
}
.view-kv-table--quint tr.row-long td {
width: auto;
}
</style>
@@ -9,6 +9,17 @@
<span v-else :class="{ 'money-display': field.money, 'muted-display': isMuted }">{{
formatViewValue(field.value)
}}</span>
<el-button
v-if="showMapBtn"
link
type="primary"
size="small"
class="view-kv-field-link"
title="在高德地图查看"
@click="openAmapLocation"
>
<el-icon><Location /></el-icon>
</el-button>
<el-button
v-if="showViewBtn"
link
@@ -24,9 +35,10 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import { View } from '@element-plus/icons-vue'
import { Location, View } from '@element-plus/icons-vue'
import type { ViewField } from './types'
import { formatViewValue } from './types'
import { buildAmapLocationUrl } from '@/utils/amap/navigation'
const VIEW_KV_FIELD_VIEW_KEY = 'viewKvFieldView'
@@ -48,9 +60,27 @@
return true
})
const amapLocationUrl = computed(() => {
if (!props.field.showMapLink) return null
return buildAmapLocationUrl({
lng: props.field.mapLng,
lat: props.field.mapLat,
name: props.field.mapName,
address: formatViewValue(props.field.value) === '—' ? undefined : String(props.field.value ?? '')
})
})
const showMapBtn = computed(() => Boolean(amapLocationUrl.value))
function handleView() {
onFieldView?.(props.field)
}
function openAmapLocation() {
const url = amapLocationUrl.value
if (!url) return
window.open(url, '_blank', 'noopener,noreferrer')
}
</script>
<style scoped lang="scss">
@@ -3,12 +3,16 @@
<template>
<el-dialog
v-model="innerVisible"
:title="title"
:width="width"
class="system-view-dialog"
:class="[dialogClass, { 'system-view-dialog--compact': compact }]"
:class="[
dialogClass,
{ 'system-view-dialog--compact': compact },
{ 'system-view-dialog--dense': dense }
]"
align-center
destroy-on-close
:show-close="false"
:append-to-body="appendToBody"
:modal-class="modalClass"
style="backdrop-filter: none"
@@ -34,7 +38,11 @@
:key="sectionKey(section, si)"
class="view-section"
>
<h3 v-if="section.title && section.titleStyle !== 'field'">{{ section.title }}</h3>
<h3
v-if="section.title && section.titleStyle !== 'field' && !(dense && section.hideTitleInDense)"
>
{{ section.title }}
</h3>
<div v-else-if="section.title && section.titleStyle === 'field'" class="view-field-label">
{{ section.title }}
</div>
@@ -108,9 +116,21 @@
</div>
</div>
<ViewHighlights v-if="highlights?.length" :items="highlights" />
<div :class="dense ? 'view-dense-body' : 'view-sections-stack'">
<!-- sections -->
<div v-for="(section, si) in sections" :key="sectionKey(section, si)" class="view-section">
<h3 v-if="section.title && section.titleStyle !== 'field'">{{ section.title }}</h3>
<div
v-for="(section, si) in sections"
:key="sectionKey(section, si)"
class="view-section"
:class="{ 'view-section--muted': dense && section.variant === 'muted' }"
>
<h3
v-if="section.title && section.titleStyle !== 'field' && !(dense && section.hideTitleInDense)"
>
{{ section.title }}
</h3>
<div v-else-if="section.title && section.titleStyle === 'field'" class="view-field-label">
{{ section.title }}
</div>
@@ -162,23 +182,20 @@
<slot :name="section.slot" />
</div>
</div>
</div>
<slot name="append" />
</template>
</div>
<template #footer>
<el-button class="btn-ghost" @click="innerVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, useSlots, type Component } from 'vue'
import { Close, Document } from '@element-plus/icons-vue'
import { Document } from '@element-plus/icons-vue'
import ViewKvGrid from './ViewKvGrid.vue'
import ViewKvTable from './ViewKvTable.vue'
import ViewHighlights from './ViewHighlights.vue'
import {
isIconComponent,
type TagType,
@@ -186,15 +203,26 @@
type ViewFieldGroup,
type ViewFieldLayout,
type ViewGridColumns,
type ViewHighlight,
type ViewSection,
type ViewTableMode
} from './types'
export type { TagType, ViewField, ViewFieldGroup, ViewFieldLayout, ViewSection, ViewTableMode }
export type {
TagType,
ViewField,
ViewFieldGroup,
ViewFieldLayout,
ViewHighlight,
ViewSection,
ViewTableMode
}
const props = withDefaults(
defineProps<{
visible: boolean
/** 推荐:v-model:visible;兼容旧写法 v-model(modelValue) */
visible?: boolean
modelValue?: boolean
title: string
icon?: Component
meta?: string
@@ -215,6 +243,13 @@
dialogClass?: string
/** 紧凑布局:缩小头部/区块间距与内容区最大高度 */
compact?: boolean
/**
* 聚拢布局:核心指标条 + 单面板分区,信息密度更高。
* 各业务页仅需配置 highlights / sections,无需单独写样式。
*/
dense?: boolean
/** dense 模式下 hero 下方的核心指标(2~6 项) */
highlights?: ViewHighlight[]
/** 详情异步加载中 */
loading?: boolean
/** default 单列滚动;split 左右分栏(需 split-right 插槽) */
@@ -229,6 +264,8 @@
modalClass: '',
dialogClass: '',
compact: false,
dense: false,
highlights: () => [],
loading: false,
layout: 'default',
splitGridColumns: 1
@@ -241,18 +278,24 @@
() => props.layout === 'split' || Boolean(slots['split-right'])
)
/** 紧凑模式降为 2 列,避免 4 列过密 */
const effectiveGridColumns = computed<ViewGridColumns>(() =>
props.compact ? 2 : props.gridColumns
)
/** 紧凑(非 dense)降为 2 列;聚拢模式保持 4 列密度 */
const effectiveGridColumns = computed<ViewGridColumns>(() => {
if (props.dense) return props.gridColumns
if (props.compact) return 2
return props.gridColumns
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'update:modelValue', val: boolean): void
}>()
const innerVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
get: () => props.visible ?? props.modelValue ?? false,
set: (val) => {
emit('update:visible', val)
emit('update:modelValue', val)
}
})
/** 须为 @element-plus/icons-vue 组件;字符串或未传时使用默认 Document */
@@ -272,8 +315,9 @@
<style scoped lang="scss">
.dialog-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
max-height: calc(100vh - 120px);
&--split {
overflow: hidden;
@@ -286,7 +330,6 @@
display: flex;
flex: 1;
min-height: 0;
max-height: calc(100vh - 120px);
}
.view-split-left {
@@ -464,10 +507,6 @@
}
.system-view-dialog--compact {
.dialog-scroll {
max-height: min(68vh, 560px);
}
.view-hero {
gap: 12px;
padding: 12px 18px;
@@ -489,7 +528,7 @@
}
.view-hero-meta {
font-size: 12px;
font-size: 13px;
}
.view-section {
@@ -511,7 +550,7 @@
.view-field-label {
margin-bottom: 6px;
font-size: 12px;
font-size: 13px;
}
.view-field-group--spaced {
@@ -519,7 +558,93 @@
}
.view-field-group .view-empty-hint {
font-size: 12px;
font-size: 13px;
}
}
.system-view-dialog--dense {
:deep(.view-kv-grid) {
gap: 10px 14px;
}
:deep(.view-kv) {
display: flex;
flex-direction: column;
gap: 3px;
align-items: stretch;
dt {
margin: 0;
font-size: 13px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
white-space: normal;
}
dd {
margin: 0;
font-size: 13px;
font-weight: 600;
min-width: 0;
word-break: break-word;
line-height: 1.4;
}
}
:deep(.view-kv-span-2) {
grid-column: span 2;
}
:deep(.view-kv-span-full) {
grid-column: 1 / -1;
dd {
font-weight: 400;
line-height: 1.5;
}
}
:deep(.view-kv-table-wrap) {
border: none;
border-radius: 0;
}
:deep(.view-kv-table) {
border: none;
th {
padding: 5px 8px;
font-size: 13px;
background: transparent;
border-bottom: 1px solid #eef4f1;
border-right: none;
width: auto;
min-width: 72px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
}
td {
padding: 5px 10px 5px 0;
font-size: 13px;
font-weight: 600;
border-bottom: 1px solid #eef4f1;
}
tr:last-child th,
tr:last-child td {
border-bottom: none;
}
}
.view-field-label {
margin-bottom: 6px;
font-size: 13px;
}
.view-section-slot .view-empty-hint {
font-size: 13px;
}
}
</style>
@@ -531,14 +656,71 @@
</style>
<style lang="scss">
.system-view-dialog {
.el-dialog__header {
display: none;
/* dense 布局间距:非 scoped,确保 append-to-body 时 hero 与信息区间距与 Demo 一致 */
.system-view-dialog.system-view-dialog--dense,
.el-dialog.system-view-dialog--dense {
.view-hero {
align-items: flex-start;
gap: 10px;
padding: 12px 16px 8px;
background: #fff !important;
border-bottom: 1px solid #f1f5f9;
}
.view-hero-icon {
display: none !important;
}
.view-hero-main h2 {
margin-bottom: 2px;
font-size: 16px;
}
.view-hero-meta {
font-size: 13px;
}
.view-dense-body {
margin: 0;
padding: 16px 16px 12px;
border: none;
border-radius: 0;
background: transparent;
}
.view-dense-body > .view-section {
padding: 0 !important;
margin: 0;
border-bottom: none !important;
&.view-section--muted {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e2ebe6;
}
> h3 {
display: none;
}
}
.view-dense-body .view-field-label {
margin: 10px 0 6px;
font-size: 13px;
}
.view-dense-body .view-section:first-child .view-field-label {
margin-top: 0;
}
}
.system-view-dialog {
.el-dialog__body {
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
.el-dialog__footer {
@@ -601,7 +783,7 @@
}
.view-section-slot .view-empty-hint {
font-size: 12px;
font-size: 13px;
}
}
</style>
@@ -14,17 +14,22 @@ export interface ViewField {
span?: boolean | 'full'
/** 显式长值,等同 span:true */
long?: boolean
/** 显式占满整行,等同 span:'full' */
/** 显式占满整行,等同 span:'full';false 时禁止按超长文本自动占满 */
fullSpan?: boolean
tagType?: TagType
money?: boolean
/** 值后展示「查看」按钮,点击时 ViewKvTable 抛出 field-view */
viewTarget?: 'customer' | 'opportunity' | 'quote'
viewId?: string | number | null
/** 为 true 时,值后展示地图图标跳转高德查看位置(须配合 mapLng/mapLat 或地址文案) */
showMapLink?: boolean
mapLng?: number | null
mapLat?: number | null
mapName?: string
}
export type ViewFieldLayout = 'grid' | 'table'
export type ViewTableMode = 'pair' | 'dual' | 'triple' | 'quad'
export type ViewTableMode = 'pair' | 'dual' | 'triple' | 'quad' | 'quint'
export interface ViewFieldGroup {
title?: string
@@ -40,6 +45,10 @@ export interface ViewSection {
title: string
fields: ViewField[]
fieldGroups?: ViewFieldGroup[]
/** dense 模式下:muted = 底部分隔区(描述/备注等),默认 default */
variant?: ViewSectionVariant
/** dense 模式下隐藏区块大标题(信息并入单表) */
hideTitleInDense?: boolean
titleStyle?: 'section' | 'field'
/** 默认 grid;table 时使用 ViewKvTable(如 triple = 一行三组 / 2 行 3 列) */
fieldLayout?: ViewFieldLayout
@@ -51,6 +60,19 @@ export interface ViewSection {
export type ViewGridColumns = 1 | 2 | 3 | 4
/** 查看弹窗顶部核心指标条(各业务页按场景配置 2~6 项) */
export interface ViewHighlight {
label: string
value?: string | number | boolean | null | undefined
money?: boolean
/** 主指标:字号略大、保持主色强调 */
emphasis?: boolean
warn?: boolean
tagType?: TagType
}
export type ViewSectionVariant = 'default' | 'muted'
export function isIconComponent(val: unknown): val is Component {
return val != null && (typeof val === 'object' || typeof val === 'function')
}
@@ -0,0 +1,42 @@
<template>
<div class="dlg-view-section dlg-view-section-tabs">
<el-tabs v-model="activeTab" class="dlg-view-flow-tabs">
<el-tab-pane label="审批进度" name="flow">
<div class="flow-scroll">
<ApprovalFlowView :nodes="nodes" :loading="loading" />
</div>
</el-tab-pane>
<el-tab-pane label="审批历史" name="history">
<div class="flow-scroll">
<ApprovalHistoryView :items="history" :loading="loading" />
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import ApprovalFlowView from './ApprovalFlowView.vue'
import ApprovalHistoryView from './ApprovalHistoryView.vue'
const props = withDefaults(
defineProps<{
nodes: unknown[]
history: unknown[]
loading?: boolean
}>(),
{
loading: false
}
)
const activeTab = ref('flow')
watch(
() => [props.nodes, props.history],
() => {
activeTab.value = 'flow'
}
)
</script>
@@ -1,22 +1,11 @@
<script setup lang="ts">
import { Refresh, Search } from '@element-plus/icons-vue'
withDefaults(
defineProps<{
/** 是否与「展开更多条件」联动(收起/展开文案);无更多筛选项时可不传 */
showMore?: boolean
/** 为 true 时才显示「展开更多条件 / 收起更多条件」按钮 */
hasExpandableFilters?: boolean
loading?: boolean
}>(),
{
showMore: false,
hasExpandableFilters: false,
}
)
defineProps<{
loading?: boolean
}>()
const emit = defineEmits<{
toggleMore: []
reset: []
search: []
}>()
@@ -28,14 +17,7 @@ const emit = defineEmits<{
<slot />
</div>
<div class="filter-actions">
<el-button
v-if="hasExpandableFilters"
link
class="btn-link"
@click="emit('toggleMore')"
>
{{ showMore ? '收起更多条件' : '展开更多条件' }}
</el-button>
<div class="filter-actions-start" />
<div class="filter-actions-end">
<el-button class="filter-reset" :loading="loading" @click="emit('reset')">
<el-icon class="ic"><Refresh /></el-icon>
@@ -8,9 +8,6 @@ import BaseTable from '../base/BaseTable.vue'
const props = withDefaults(
defineProps<{
panelTitle: string
showMore?: boolean
/** 为 true 时显示「展开更多条件」;筛选项全部在主区域时不要传或传 false */
hasExpandableFilters?: boolean
selectedCount: number
rows: any[]
rangeText: string
@@ -28,13 +25,10 @@ const props = withDefaults(
{
pageSizes: () => [10, 20, 30],
showSummary: false,
showMore: false,
hasExpandableFilters: false,
}
)
const emit = defineEmits<{
toggleMore: []
reset: []
search: []
'update:currentPage': [value: number]
@@ -56,14 +50,7 @@ const pageSizeModel = computed({
<template>
<DataTablePanel :title="panelTitle">
<FilterBlock
:show-more="showMore"
:has-expandable-filters="hasExpandableFilters"
:loading="loading"
@toggle-more="emit('toggleMore')"
@reset="emit('reset')"
@search="emit('search')"
>
<FilterBlock :loading="loading" @reset="emit('reset')" @search="emit('search')">
<slot name="filters" />
</FilterBlock>
@@ -74,9 +61,9 @@ const pageSizeModel = computed({
<div class="table-scroll" :class="tableScrollClass">
<BaseTable
:data="rows"
:show-summary="showSummary"
:summary-method="summaryMethod"
:max-height="tableMaxHeight"
:show-summary="showSummary"
:summary-method="summaryMethod"
:max-height="tableMaxHeight"
@selection-change="(rows: any[]) => emit('selectionChange', rows)"
>
<slot name="table-columns" />
@@ -384,12 +384,6 @@
* 监听窗口尺寸变化,自动处理移动端菜单
*/
watch(width, (newWidth) => {
if (isPortalMenu.value) {
settingStore.setMenuOpen(true)
showMobileModal.value = false
return
}
if (newWidth < MOBILE_BREAKPOINT) {
settingStore.setMenuOpen(false)
if (!menuOpen.value) {
@@ -1,26 +1,34 @@
<!-- 一体化门户顶栏 -->
<template>
<header class="portal-header">
<div class="portal-header-brand" @click="toHome">
<ArtLogo class="portal-header-logo" src="/logo-sx.svg" :size="38" />
<p class="portal-header-title">{{ AppConfig.systemInfo.name }}</p>
<div class="portal-header-start">
<div class="portal-header-brand" @click="toHome">
<ArtLogo class="portal-header-logo" src="/logo-sx.svg" :size="38" />
<p class="portal-header-title">{{ AppConfig.systemInfo.name }}</p>
</div>
<div class="portal-header-nav">
<ArtIconButton
icon="fold"
class="portal-header-icon-btn portal-header-fold-btn"
@click="toggleSidebar"
/>
<ArtPortalModuleSelect />
</div>
</div>
<div class="portal-header-nav">
<ArtPortalModuleSelect />
<ArtPortalSubNav class="portal-header-sub-nav" />
<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">
<ArtPortalMobileMenu />
<ArtIconButton
v-if="shouldShowFullscreen"
icon="fullscreen"
:class="[
'portal-header-icon-btn',
'portal-header-icon-btn max-md:!hidden',
!isFullscreen ? 'full-screen-btn' : 'exit-full-screen-btn'
]"
class="max-md:!hidden"
@click="toggleFullScreen"
/>
<ArtUserMenu />
@@ -29,19 +37,34 @@
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, 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'
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)
@@ -50,14 +73,20 @@
const toggleFullScreen = (): void => {
toggleFullscreen()
}
const toggleSidebar = (): void => {
settingStore.setMenuOpen(!menuOpen.value)
}
</script>
<style lang="scss" scoped>
.portal-header {
position: relative;
box-sizing: border-box;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
width: 100%;
height: 56px;
padding: 0 var(--mint-content-padding, 24px);
@@ -66,6 +95,15 @@
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 {
display: flex;
flex-shrink: 0;
@@ -93,15 +131,33 @@
.portal-header-nav {
display: flex;
flex: 1;
gap: 12px;
align-items: center;
min-width: 0;
}
.portal-header-sub-nav {
flex: 1;
min-width: 0;
.portal-header-center {
position: absolute;
left: 50%;
z-index: 0;
display: flex;
align-items: center;
justify-content: center;
max-width: min(520px, calc(100% - 420px));
pointer-events: none;
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-actions {
@@ -109,11 +165,11 @@
flex-shrink: 0;
gap: 8px;
align-items: center;
z-index: 1;
}
:deep(.portal-module-select-trigger) {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.28);
background: transparent;
.portal-module-select-trigger__icon,
.portal-module-select-trigger__label,
@@ -122,58 +178,7 @@
}
&:hover:not(.is-disabled) {
background: rgba(255, 255, 255, 0.18);
border-color: rgba(255, 255, 255, 0.45);
}
}
:deep(.portal-sub-nav-card__icon),
:deep(.portal-sub-nav-card__label) {
color: rgba(255, 255, 255, 0.88);
}
:deep(.portal-sub-nav-menu > .el-sub-menu .el-sub-menu__icon-arrow) {
color: rgba(255, 255, 255, 0.88);
}
:deep(.portal-sub-nav-menu > .el-sub-menu.el-sub-menu__hide-arrow .el-sub-menu__title) {
color: #fff !important;
}
:deep(.portal-sub-nav-menu > .el-sub-menu.el-sub-menu__hide-arrow .el-sub-menu__icon-more),
:deep(.portal-sub-nav-menu > .el-sub-menu.el-sub-menu__hide-arrow .el-icon) {
color: #fff !important;
}
:deep(.portal-sub-nav-menu > .el-sub-menu.el-sub-menu__hide-arrow:hover .el-sub-menu__title) {
background: rgba(255, 255, 255, 0.14) !important;
border-radius: 8px;
}
:deep(.portal-sub-nav-menu > .el-menu-item:hover .portal-sub-nav-card),
:deep(.portal-sub-nav-menu > .el-sub-menu:hover .portal-sub-nav-card) {
background: rgba(255, 255, 255, 0.14);
}
:deep(.portal-sub-nav-menu > .el-menu-item.is-active .portal-sub-nav-card),
:deep(.portal-sub-nav-menu > .el-sub-menu.is-active .portal-sub-nav-card) {
background: #fff;
.portal-sub-nav-card__icon,
.portal-sub-nav-card__label {
color: var(--mint-primary, #1ca851);
}
}
:deep(.portal-mobile-menu-trigger) {
color: #fff;
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.28);
&:hover {
color: #fff;
background: rgba(255, 255, 255, 0.18);
border-color: rgba(255, 255, 255, 0.45);
background: transparent;
}
}
@@ -190,8 +195,12 @@
display: none;
}
.portal-header-sub-nav {
display: none !important;
.portal-header-center {
max-width: min(240px, calc(100% - 220px));
}
.portal-header-page-title {
font-size: 15px;
}
}
</style>
@@ -119,12 +119,9 @@
height: 36px;
padding: 0 12px;
cursor: pointer;
background: var(--primary-bg, #e6f8ed);
border: 1px solid var(--mint-border, #d9e8e2);
background: transparent;
border: none;
border-radius: 8px;
transition:
background-color 0.2s ease,
border-color 0.2s ease;
&__icon {
flex-shrink: 0;
@@ -149,12 +146,13 @@
}
&:hover:not(.is-disabled) {
border-color: var(--primary, #1ca851);
background: transparent;
}
&.is-disabled {
cursor: not-allowed;
background: var(--mint-bg-page, #f8fafc);
background: transparent;
opacity: 0.72;
}
}
</style>
@@ -173,34 +173,6 @@ export function usePlPlanArrangeActions() {
ElMessage.success('已复制司机链接')
}
function buildAllSiteLinksText(records?: TransportSiteLink[]) {
const list = records || siteRecords.value
return list
.filter(item => item.siteUrl)
.map(item => `${item.productName || '现场链接'}:${item.siteUrl}`)
.join('\n')
}
async function copySiteUrl(url?: string) {
const target = (url || siteRecords.value.find(item => item.siteUrl)?.siteUrl || '').trim()
if (!target) {
ElMessage.warning('暂无现场链接')
return
}
await navigator.clipboard.writeText(target)
ElMessage.success('已复制现场链接')
}
async function copyAllSiteUrls(records?: TransportSiteLink[]) {
const text = buildAllSiteLinksText(records)
if (!text) {
ElMessage.warning('暂无现场链接')
return
}
await navigator.clipboard.writeText(text)
ElMessage.success('已复制全部现场链接')
}
function resolveSiteRecordProductText(record: TransportSiteLink) {
const product = products.value.find(item => item.id === record.planProductId)
if (product) {
@@ -316,8 +288,6 @@ export function usePlPlanArrangeActions() {
buildPlanSummaryText,
buildSiteSummaryText,
copyDriverUrl,
copySiteUrl,
copyAllSiteUrls,
copySiteSummary,
copyPlanSummary,
copyPoundSlipQrcode
@@ -45,30 +45,20 @@ export function formatPlProductNameWithHazardMarks(product: HazardFlagProduct) {
return `${name}${marks.join(' ')}`
}
/** 从 CRM 产品主数据补全易制毒/易制爆标识(合同明细通常不带该字段) */
/** 从 CRM 产品主数据读取易制毒/易制爆标识(覆盖计划产品快照字段) */
export async function enrichPlanProductsHazardFromCrm<T extends HazardFlagProduct & { productId: string }>(
products: T[]
): Promise<T[]> {
if (!products.length) return products
const needLookupIds = [
...new Set(
products
.filter(product => {
if (!product.productId) return false
const flags = resolveProductHazardFlags(product)
const needsHazard = !flags.isToxic && !flags.isExplosive
const needsTaxRate = product.taxRate == null
return needsHazard || needsTaxRate
})
.map(product => product.productId)
)
const lookupIds = [
...new Set(products.map(product => product.productId).filter(Boolean))
]
if (!needLookupIds.length) return products
if (!lookupIds.length) return products
const hazardMap = new Map<string, { isToxic: number; isExplosive: number; taxRate?: number }>()
await Promise.all(
needLookupIds.map(async productId => {
lookupIds.map(async productId => {
try {
const res = await fetchProductDetail({ id: productId })
const data = (res as { data?: Record<string, unknown> }).data ?? res
@@ -86,13 +76,12 @@ export async function enrichPlanProductsHazardFromCrm<T extends HazardFlagProduc
)
return products.map(product => {
const flags = resolveProductHazardFlags(product)
const fromCrm = hazardMap.get(product.productId)
if (!fromCrm) return product
return {
...product,
isToxic: flags.isToxic ? product.isToxic : fromCrm.isToxic,
isExplosive: flags.isExplosive ? product.isExplosive : fromCrm.isExplosive,
isToxic: fromCrm.isToxic,
isExplosive: fromCrm.isExplosive,
taxRate: product.taxRate ?? fromCrm.taxRate
}
})
@@ -104,6 +93,32 @@ export function findMissingHazardCerts(products: PlanProduct[]) {
)
}
export function planHasHazardProducts(products: PlanProduct[] | undefined) {
return (products ?? []).some(p => isHazardProduct(p))
}
export function isHazardCertReady(plan: { hazardCertReady?: number } | null | undefined) {
return Number(plan?.hazardCertReady ?? 0) === 1
}
/** 含危化品且尚未人工确认证书齐备时,阻断安排运输 */
export function shouldBlockTransportForHazardCert(
plan: { hazardCertReady?: number; products?: PlanProduct[] } | null | undefined,
products?: PlanProduct[]
) {
const prods = products ?? plan?.products
if (!planHasHazardProducts(prods)) return false
return !isHazardCertReady(plan)
}
export function buildMissingHazardCertReadyMessage(
plan: { hazardCertReady?: number; products?: PlanProduct[] } | null | undefined,
products?: PlanProduct[]
) {
if (!shouldBlockTransportForHazardCert(plan, products)) return ''
return '该计划危化品证书尚未确认齐备'
}
export function buildMissingHazardCertMessage(products: PlanProduct[]) {
const missing = findMissingHazardCerts(products)
if (!missing.length) return ''
+1 -1
View File
@@ -30,7 +30,7 @@ export const SETTING_DEFAULT_CONFIG = {
/** 菜单类型 */
menuType: MenuTypeEnum.PORTAL,
/** 菜单展开宽度 */
menuOpenWidth: 230,
menuOpenWidth: 220,
/** 菜单是否展开 */
menuOpen: true,
/** 双菜单是否显示文本 */
+12 -12
View File
@@ -18,6 +18,13 @@ export const plRoutes: AppRouteRecord[] = [
component: '/pl/carrier/index',
meta: { title: '承运商管理', keepAlive: true, menuCode: 'pl:carrier:list' }
},
{
id: 'PL00000000000000000000000000003',
name: 'PlVehicle',
path: 'vehicle',
component: '/pl/vehicle/index',
meta: { title: '全部车辆管理', keepAlive: true, menuCode: 'pl:vehicle:list' }
},
{
id: 'PL00000000000000000000000000003S',
name: 'PlSelfVehicle',
@@ -26,11 +33,11 @@ export const plRoutes: AppRouteRecord[] = [
meta: { title: '山峡自有车辆', keepAlive: true, menuCode: 'pl:self-vehicle:list' }
},
{
id: 'PL00000000000000000000000000003',
name: 'PlVehicle',
path: 'vehicle',
component: '/pl/vehicle/index',
meta: { title: '车辆管理', keepAlive: true, menuCode: 'pl:vehicle:list' }
id: 'PL00000000000000000000000000004',
name: 'PlPersonnel',
path: 'personnel',
component: '/pl/personnel/index',
meta: { title: '驾押人员管理', keepAlive: true, menuCode: 'pl:driver:list' }
},
{
id: 'PL00000000000000000000000000004A',
@@ -39,13 +46,6 @@ export const plRoutes: AppRouteRecord[] = [
component: '/pl/transport-address/index',
meta: { title: '地址库', keepAlive: true, menuCode: 'pl:transport-address:list' }
},
{
id: 'PL00000000000000000000000000004',
name: 'PlPersonnel',
path: 'personnel',
component: '/pl/personnel/index',
meta: { title: '人员管理', keepAlive: true, menuCode: 'pl:driver:list' }
},
{
id: 'PL00000000000000000000000000004R',
name: 'PlDriverRedirect',
+2 -2
View File
@@ -458,7 +458,7 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
path: 'plans',
component: '/db/plans/index',
meta: {
title: '计划管理',
title: '地磅计划',
keepAlive: true,
menuCode: 'db:plan:list'
}
@@ -469,7 +469,7 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
path: 'inventory',
component: '/db/inventory/index',
meta: {
title: '出入库管理',
title: '地磅出入库',
keepAlive: true,
menuCode: 'db:inventory:list'
}
+65 -13
View File
@@ -316,23 +316,22 @@
color: var(--text);
}
/* 列表合计:第 4 列常为「本页合计(n单)」加粗(备用金申请页除外,见下条) */
.table-scroll.table-page-summary:not(.advance-apply-summary-merge)
.el-table__footer-wrapper
td:nth-child(4)
.cell {
/* 列表合计:金额列等 footer 单元格样式(如有需要可在此扩展) */
.table-scroll.table-page-summary .el-table__footer-wrapper td:nth-child(4) .cell {
font-weight: 600;
color: var(--text);
}
/* 备用金申请:合计行首格 colspan=4 合并多选+序号+单号+申请人 */
.table-scroll.table-page-summary.advance-apply-summary-merge
.el-table__footer-wrapper
td:first-child
.cell {
.table-scroll.table-page-summary .el-table__footer-wrapper td.col-apply-code .cell {
font-weight: 600;
color: var(--text);
text-align: center;
}
/* 合并申请单列表:金额合计按备用金 / 报销单分列展示 */
.table-scroll.table-page-summary-biz-split .el-table__footer-wrapper td.col-money .cell {
white-space: normal;
line-height: 1.45;
font-size: 13px;
}
.bx-table .col-actions .cell > * {
@@ -463,7 +462,7 @@
overflow: hidden;
display: flex;
flex-direction: column;
max-height: calc(100vh - 64px);
max-height: var(--dialog-max-height, calc(100vh - 40px));
}
.bx-dialog .el-dialog__header {
@@ -483,7 +482,7 @@
padding: 20px 22px;
overflow-y: auto;
flex: 1;
max-height: calc(100vh - 200px);
min-height: 0;
}
.bx-dialog .el-dialog__footer {
@@ -605,6 +604,27 @@
margin-top: 14px;
}
.dlg-view-section-tabs {
padding-top: 12px;
}
.dlg-view-flow-tabs {
.el-tabs__header {
margin: 0 0 14px;
}
.el-tabs__nav-wrap::after {
height: 1px;
background-color: var(--border);
}
.el-tabs__item {
height: 40px;
padding: 0 16px;
font-size: 14px;
}
}
/* @deprecated 旧版左右 KV,迁移至 BxViewKvGrid 后删除 */
.dlg-kv {
display: grid;
@@ -945,6 +965,10 @@
margin-top: 12px;
}
.offset-pager + .el-form-item {
margin-top: 16px;
}
.list-toolbar-actions .bx-btn.el-button:not(.el-button--primary) {
background: var(--surface);
border-color: var(--border);
@@ -955,3 +979,31 @@
background: var(--row-alt);
}
/* 报销新增/编辑弹窗:label 左、控件右,垂直居中,label 可换行(栅格列数不变) */
.bx-dialog-form {
.el-form-item {
align-items: center;
}
[class*='form-grid-'] > .el-form-item {
margin-bottom: 0;
}
[class*='form-grid-'] {
row-gap: 16px;
}
.el-form-item__label {
white-space: normal;
word-break: break-word;
line-height: 1.45;
height: auto !important;
padding-right: 12px;
align-self: center;
}
.el-form-item__content {
min-width: 0;
}
}
+33 -6
View File
@@ -8,7 +8,8 @@
> .list-toolbar-selected,
> .list-toolbar-date-shortcuts,
> .el-input,
> .quick-search-group,
> .quick-search-input,
> .el-button {
flex-shrink: 0;
}
@@ -17,7 +18,7 @@
flex-wrap: nowrap;
}
.db-toolbar-quick-search-input.el-input {
.quick-search-group .quick-search-input {
width: 360px;
max-width: 360px;
flex: 0 0 360px;
@@ -53,10 +54,6 @@
--el-button-hover-border-color: rgba(28, 168, 81, 0.45);
}
.db-quick-search-icon {
cursor: pointer;
}
.db-op-actions {
display: flex;
justify-content: center;
@@ -502,3 +499,33 @@
color: var(--text-muted);
margin-top: 4px;
}
/* 地磅新增/编辑弹窗:label 左、控件右,垂直居中,label 可换行(栅格列数不变) */
.db-dialog-form {
.el-form-item {
align-items: center;
}
&[class*='form-grid-'] > .el-form-item,
[class*='form-grid-'] > .el-form-item {
margin-bottom: 0;
}
&[class*='form-grid-'],
[class*='form-grid-'] {
row-gap: 16px;
}
.el-form-item__label {
white-space: normal;
word-break: break-word;
line-height: 1.45;
height: auto !important;
padding-right: 12px;
align-self: center;
}
.el-form-item__content {
min-width: 0;
}
}
+31
View File
@@ -0,0 +1,31 @@
/* 运输(pl)模块样式 */
/* 新增/编辑弹窗:label 左、控件右,垂直居中,label 可换行(栅格列数不变) */
.pl-dialog-form {
.el-form-item {
align-items: center;
}
&[class*='form-grid-'] > .el-form-item,
[class*='form-grid-'] > .el-form-item {
margin-bottom: 0;
}
&[class*='form-grid-'],
[class*='form-grid-'] {
row-gap: 16px;
}
.el-form-item__label {
white-space: normal;
word-break: break-word;
line-height: 1.45;
height: auto !important;
padding-right: 12px;
align-self: center;
}
.el-form-item__content {
min-width: 0;
}
}
+140 -6
View File
@@ -3,8 +3,8 @@
--van-success-color: #1ca851;
--van-button-primary-background: #1ca851;
--van-button-primary-border-color: #1ca851;
--van-nav-bar-background: #e6f8ed;
--van-nav-bar-title-text-color: #168f45;
--van-nav-bar-background: #059669;
--van-nav-bar-title-text-color: #fff;
--van-cell-group-inset-padding: 0;
--van-uploader-size: 96px;
--van-uploader-upload-background: #e6f8ed;
@@ -13,16 +13,70 @@
}
.public-pl-transport-page {
--van-cell-group-title-padding: 2px 10px;
--van-cell-group-inset-title-padding: 2px 10px;
--van-cell-group-title-font-size: 12px;
--van-cell-group-title-line-height: 1.1;
--van-button-primary-background: #059669;
--van-button-primary-border-color: #059669;
padding-bottom: 72px;
}
.public-pl-transport-page .section-card .van-cell-group__title,
.public-pl-transport-page .section-card .van-cell-group__title--inset {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
margin: 0;
min-height: 0;
line-height: 1.1;
font-size: 12px;
font-weight: 600;
color: #047857;
letter-spacing: 0.01em;
background: linear-gradient(90deg, #ecfdf5 0%, rgb(236 253 245 / 0%) 72%);
&::before {
content: '';
flex-shrink: 0;
width: 3px;
height: 12px;
border-radius: 2px;
background: #059669;
}
}
.public-pl-transport-page .section-card .van-cell {
padding: 7px 14px;
line-height: 1.35;
font-size: 14px;
}
.public-pl-transport-page .section-card .van-field {
padding: 4px 14px 10px;
}
.public-pl-transport-page .section-card .van-field__control {
line-height: 1.35;
font-size: 14px;
}
.public-pl-transport-page .section-card .van-cell::after {
right: 14px;
left: 14px;
}
.public-pl-transport-page .van-cell__title {
color: #64748b;
line-height: 1.35;
}
.public-pl-transport-page .van-cell__value {
color: #1e293b;
font-weight: 600;
line-height: 1.35;
}
.public-pl-transport-page .route-cell .van-cell__value {
@@ -30,18 +84,26 @@
line-height: 1.5;
}
.public-pl-transport-page .product-cell .van-cell__title {
.public-pl-transport-page .product-cell .van-cell__title,
.public-pl-site-page .product-cell .van-cell__title {
color: #1e293b;
font-weight: 600;
line-height: 1.5;
line-height: 1.35;
white-space: normal;
}
.public-pl-transport-page .product-cell .van-cell__label {
.public-pl-transport-page .product-cell .van-cell__label,
.public-pl-site-page .product-cell .van-cell__label {
display: none;
}
.public-pl-transport-page .page-footer {
.public-pl-transport-page .product-cell .van-cell__value,
.public-pl-site-page .product-cell .van-cell__value {
display: none;
}
.public-pl-transport-page .page-footer,
.public-pl-site-page .page-footer {
position: fixed;
left: 50%;
bottom: 0;
@@ -51,4 +113,76 @@
background: rgba(248, 250, 252, 0.96);
border-top: 1px solid #d9e8e2;
box-sizing: border-box;
.van-button--primary {
background: #059669;
border-color: #059669;
}
}
.public-pl-site-page {
--van-cell-group-title-padding: 2px 10px;
--van-cell-group-inset-title-padding: 2px 10px;
--van-cell-group-title-font-size: 12px;
--van-cell-group-title-line-height: 1.1;
--van-button-primary-background: #059669;
--van-button-primary-border-color: #059669;
padding-bottom: 72px;
}
.public-pl-site-page .section-card .van-cell-group__title,
.public-pl-site-page .section-card .van-cell-group__title--inset {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
margin: 0;
min-height: 0;
line-height: 1.1;
font-size: 12px;
font-weight: 600;
color: #047857;
letter-spacing: 0.01em;
background: linear-gradient(90deg, #ecfdf5 0%, rgb(236 253 245 / 0%) 72%);
&::before {
content: '';
flex-shrink: 0;
width: 3px;
height: 12px;
border-radius: 2px;
background: #059669;
}
}
.public-pl-site-page .section-card .van-cell {
padding: 7px 14px;
line-height: 1.35;
font-size: 14px;
}
.public-pl-site-page .section-card .van-field {
padding: 4px 14px 10px;
}
.public-pl-site-page .section-card .van-field__control {
line-height: 1.35;
font-size: 14px;
}
.public-pl-site-page .section-card .van-cell::after {
right: 14px;
left: 14px;
}
.public-pl-site-page .van-cell__title {
color: #64748b;
line-height: 1.35;
}
.public-pl-site-page .van-cell__value {
color: #1e293b;
font-weight: 600;
line-height: 1.35;
}
+31
View File
@@ -0,0 +1,31 @@
/* 系统管理(sys)模块样式 */
/* 新增/编辑弹窗:label 左、控件右,垂直居中,label 可换行(栅格列数不变) */
.sys-dialog-form {
.el-form-item {
align-items: center;
}
&[class*='form-grid-'] > .el-form-item,
[class*='form-grid-'] > .el-form-item {
margin-bottom: 0;
}
&[class*='form-grid-'],
[class*='form-grid-'] {
row-gap: 16px;
}
.el-form-item__label {
white-space: normal;
word-break: break-word;
line-height: 1.45;
height: auto !important;
padding-right: 12px;
align-self: center;
}
.el-form-item__content {
min-width: 0;
}
}
+31
View File
@@ -0,0 +1,31 @@
/* 工作流(wf)模块样式 */
/* 新增/编辑弹窗:label 左、控件右,垂直居中,label 可换行(栅格列数不变) */
.wf-dialog-form {
.el-form-item {
align-items: center;
}
&[class*='form-grid-'] > .el-form-item,
[class*='form-grid-'] > .el-form-item {
margin-bottom: 0;
}
&[class*='form-grid-'],
[class*='form-grid-'] {
row-gap: 16px;
}
.el-form-item__label {
white-space: normal;
word-break: break-word;
line-height: 1.45;
height: auto !important;
padding-right: 12px;
align-self: center;
}
.el-form-item__content {
min-width: 0;
}
}
+35
View File
@@ -13,6 +13,41 @@ export function buildAmapNavigationUrl(
return `https://uri.amap.com/navigation?to=${lngText},${latText},${toName}&mode=car&coordinate=gaode&callnative=1`
}
/** 构建高德 URI 标记点链接(Web/App 查看位置) */
export function buildAmapMarkerUrl(
lng?: number | null,
lat?: number | null,
name?: string
): string | null {
if (lng == null || lat == null || Number.isNaN(Number(lng)) || Number.isNaN(Number(lat))) {
return null
}
const lngText = Number(lng).toFixed(6)
const latText = Number(lat).toFixed(6)
const markerName = encodeURIComponent((name || '位置').trim() || '位置')
return `https://uri.amap.com/marker?position=${lngText},${latText}&name=${markerName}&coordinate=gaode&callnative=0`
}
/** 按地址关键词打开高德地图搜索(无坐标时的兜底) */
export function buildAmapSearchUrl(keyword?: string | null): string | null {
const text = keyword?.trim()
if (!text) return null
return `https://www.amap.com/search?query=${encodeURIComponent(text)}`
}
/** 优先坐标标记,否则按地址搜索 */
export function buildAmapLocationUrl(options: {
lng?: number | null
lat?: number | null
name?: string
address?: string
}): string | null {
return (
buildAmapMarkerUrl(options.lng, options.lat, options.name || options.address) ||
buildAmapSearchUrl(options.address || options.name)
)
}
export function appendAmapNavLine(lines: string[], label: string, url: string | null) {
if (url) {
lines.push(`${label}:${url}`)
+72
View File
@@ -0,0 +1,72 @@
import { formatSumMoney } from './format'
export interface SummaryColumn {
property?: string
type?: string
}
export interface PageSummaryParams {
columns: SummaryColumn[]
data: Record<string, unknown>[]
}
export interface BuildPageSummaryOptions {
/** 放置合计标签的列 prop,默认 applyCode;默认不展示标签文案 */
labelProp?: string
/** 自定义标签文案;默认返回空字符串 */
labelFormatter?: (count: number) => string
/** 列 prop -> 合计值(已格式化字符串) */
sumByProp?: Record<string, (data: Record<string, unknown>[]) => string>
}
/** 对数值字段求和并格式化为合计金额 */
export function sumField(
data: Record<string, unknown>[],
field: string,
optional = false
): string {
const total = data.reduce((sum, row) => {
const value = row[field]
if (optional && (value == null || value === '')) return sum
return sum + Number(value || 0)
}, 0)
return formatSumMoney(total)
}
/** 按业务类型(备用金 / 报销单)分列求和,用于合并列表合计行 */
export function sumFieldByBizType(
data: Record<string, unknown>[],
field: string,
bizTypeField = 'bizType'
): string {
let advanceTotal = 0
let reimburseTotal = 0
for (const row of data) {
const amount = Number(row[field] || 0)
const bizType = row[bizTypeField]
if (bizType === '备用金') advanceTotal += amount
else if (bizType === '报销单') reimburseTotal += amount
}
return `备用金总额 ${formatSumMoney(advanceTotal)} / 报销单总额 ${formatSumMoney(reimburseTotal)}`
}
/**
* 生成 Element Plus 表格合计行方法;按当前可见 columns 动态对齐,兼容列显隐。
*/
export function buildPageSummaryMethod(options: BuildPageSummaryOptions = {}) {
const {
labelProp = 'applyCode',
labelFormatter = () => '',
sumByProp = {}
} = options
return ({ columns, data }: PageSummaryParams) => {
const label = labelFormatter(data.length)
return columns.map((col) => {
const prop = col.property
if (prop === labelProp) return label
if (prop && sumByProp[prop]) return sumByProp[prop](data)
return ''
})
}
}
+9 -1
View File
@@ -1,4 +1,4 @@
import type { TagType, ViewField } from '@/components/business/system-view-dialog/types'
import type { TagType, ViewField, ViewHighlight } from '@/components/business/system-view-dialog/types'
import { formatViewValue } from '@/components/business/system-view-dialog/types'
type DictStoreLike = {
@@ -37,3 +37,11 @@ export function crmTagField(
): ViewField {
return { label, value: display || '—', tagType, ...extra }
}
export function crmHighlight(
label: string,
value: unknown,
extra?: Partial<ViewHighlight>
): ViewHighlight {
return { label, value: formatViewValue(value), ...extra }
}
+24 -1
View File
@@ -112,6 +112,29 @@ export function formatPlanProductListText(
return `${name}${specPart},${qtyUnit}`
}
/** 司机公开页产品一行展示(含运输计划产品备注) */
export function formatPublicTransportProductLine(product: {
productName?: string
model?: string
packageSpec?: string
unit?: string
quantity?: number | string | null
remark?: string
}) {
const name = product.productName?.trim() || '—'
const parts: string[] = []
const model = product.model?.trim()
const packageSpec = product.packageSpec?.trim()
if (model) parts.push(model)
if (packageSpec) parts.push(packageSpec)
const qty = getPlanProductPlannedQuantity(product)
const unitRaw = product.unit || '吨'
parts.push(qty !== '—' ? `${qty} ${unitRaw}` : unitRaw)
const remark = product.remark?.trim()
parts.push(`备注:${remark || '—'}`)
return `${name}(${parts.join(',')})`
}
export function getPlPlanCompanyLabel(company?: string) {
const map: Record<string, string> = { shanxia: '安庆山峡', yundong: '安徽云栋' }
return company ? map[company] || company : '—'
@@ -142,7 +165,7 @@ export function getPlPlanFollowStatusLabel(status?: string) {
draft: '草稿',
processing: '进行中',
completed: '已完成',
cancelled: '已取消'
cancelled: '已作废'
}
return status ? map[status] || status : '—'
}
+71
View File
@@ -0,0 +1,71 @@
import type { CertRow } from '@/api/pl'
export const PL_PERSONNEL_CERT_MISSING_SUFFIX = '(证书缺失)'
export const PL_PERSONNEL_DRIVER_REQUIRED_CERT_NAMES = [
'身份证',
'驾驶证',
'从业资格证'
] as const
export const PL_PERSONNEL_ESCORT_REQUIRED_CERT_NAMES = ['身份证', '从业资格证'] as const
export type PersonnelCertRole = 'driver' | 'escort'
export function matchRequiredCertName(
actualName: string | undefined,
requiredName: string
): boolean {
const name = actualName?.trim() || ''
if (!name) return false
if (requiredName === '从业资格证') {
return (
name === '从业资格证' ||
name === '危化品从业资格证' ||
name.includes('从业资格证')
)
}
return name === requiredName
}
export function isPersonnelCertUploaded(cert?: Pick<CertRow, 'certName' | 'attachmentUrls'>) {
return Boolean(cert?.certName?.trim() && cert?.attachmentUrls?.trim())
}
export function getRequiredPersonnelCertNames(role: PersonnelCertRole): readonly string[] {
return role === 'driver'
? PL_PERSONNEL_DRIVER_REQUIRED_CERT_NAMES
: PL_PERSONNEL_ESCORT_REQUIRED_CERT_NAMES
}
export function getMissingPersonnelCertNames(
role: PersonnelCertRole,
certList: CertRow[] = []
): string[] {
return getRequiredPersonnelCertNames(role).filter(requiredName => {
const matched = certList.find(cert => matchRequiredCertName(cert.certName, requiredName))
return !isPersonnelCertUploaded(matched)
})
}
export function hasPersonnelCertMissing(
role: PersonnelCertRole,
certList: CertRow[] = []
): boolean {
return getMissingPersonnelCertNames(role, certList).length > 0
}
/** 人员主数据页:至少满足押运员必备证书(身份证 + 从业资格证) */
export function hasPersonnelMasterCertMissing(certList: CertRow[] = []): boolean {
return hasPersonnelCertMissing('escort', certList)
}
export function getPersonnelCertMissingSuffix(
certList: CertRow[] = [],
role?: PersonnelCertRole
): string {
if (role) {
return hasPersonnelCertMissing(role, certList) ? PL_PERSONNEL_CERT_MISSING_SUFFIX : ''
}
return hasPersonnelMasterCertMissing(certList) ? PL_PERSONNEL_CERT_MISSING_SUFFIX : ''
}
+88 -27
View File
@@ -3,8 +3,24 @@ import type { CertRow } from '@/api/pl'
export const PL_VEHICLE_CERT_PART_HEAD = 'head'
export const PL_VEHICLE_CERT_PART_TRAILER = 'trailer'
export const PL_VEHICLE_CERT_MISSING_SUFFIX = '(证书缺失)'
/** @deprecated 保存不再阻断,仅保留供历史引用 */
export const PL_VEHICLE_REQUIRED_CERT_NAMES = ['行驶证', '营运证'] as const
export const PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES = [
'行驶证',
'营运证',
'保险单'
] as const
export const PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES = [
'行驶证',
'营运证',
'保险单',
'罐检报告'
] as const
export const PL_VEHICLE_CERT_NAME_OPTIONS = [
'行驶证',
'营运证',
@@ -12,7 +28,8 @@ export const PL_VEHICLE_CERT_NAME_OPTIONS = [
'驾驶证',
'押运员证',
'危化品从业资格证',
'保险单'
'保险单',
'罐检报告'
]
export type VehicleCertPart = typeof PL_VEHICLE_CERT_PART_HEAD | typeof PL_VEHICLE_CERT_PART_TRAILER
@@ -53,16 +70,61 @@ export function mergeVehicleCertsByPart<T extends CertRow>(
return [...normalizedHead, ...normalizedTrailer]
}
function isCertFilled(cert?: CertRow) {
if (!cert) return false
return Boolean(
cert.certName?.trim() &&
cert.certNo?.trim() &&
cert.expireDate &&
cert.attachmentUrls?.trim()
)
export function isVehicleCertUploaded(cert?: Pick<CertRow, 'certName' | 'attachmentUrls'>) {
return Boolean(cert?.certName?.trim() && cert?.attachmentUrls?.trim())
}
export function getMissingSelfVehiclePartCertNames(
certList: CertRow[] = [],
part: VehicleCertPart
): string[] {
const requiredNames =
part === PL_VEHICLE_CERT_PART_TRAILER
? PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
: PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES
return requiredNames.filter(requiredName => {
const matched = certList.find(cert => cert.certName?.trim() === requiredName)
return !isVehicleCertUploaded(matched)
})
}
export function hasSelfVehicleCertMissing(options: {
certList?: CertRow[]
trailerNo?: string | null
}): boolean {
const { certList = [], trailerNo } = options
const { headCerts, trailerCerts } = splitVehicleCertsByPart(certList)
const hasTrailer = Boolean(trailerNo?.trim())
if (getMissingSelfVehiclePartCertNames(headCerts, PL_VEHICLE_CERT_PART_HEAD).length > 0) {
return true
}
if (hasTrailer && getMissingSelfVehiclePartCertNames(trailerCerts, PL_VEHICLE_CERT_PART_TRAILER).length > 0) {
return true
}
return false
}
export function getSelfVehicleCertMissingSuffix(options: {
certList?: CertRow[]
trailerNo?: string | null
}): string {
return hasSelfVehicleCertMissing(options) ? PL_VEHICLE_CERT_MISSING_SUFFIX : ''
}
export function getSelfVehiclePartCertMissingSuffix(
certList: CertRow[] = [],
part: VehicleCertPart
): string {
return getMissingSelfVehiclePartCertNames(certList, part).length > 0
? PL_VEHICLE_CERT_MISSING_SUFFIX
: ''
}
/** @deprecated 保存不再阻断证书完整性,仅校验证书名称非空等基础项 */
export function validateVehicleRequiredCerts(options: {
headCerts: CertRow[]
trailerCerts: CertRow[]
@@ -71,28 +133,12 @@ export function validateVehicleRequiredCerts(options: {
trailerNo: string
onError: (message: string) => void
}) {
const { headCerts, trailerCerts, hasTrailer, licensePlate, trailerNo, onError } = options
const parts: Array<{ label: string; certs: CertRow[] }> = [
{ label: licensePlate?.trim() || '主车', certs: headCerts }
]
if (hasTrailer) {
parts.push({ label: trailerNo?.trim() || '挂车', certs: trailerCerts })
}
for (const part of parts) {
for (const certName of PL_VEHICLE_REQUIRED_CERT_NAMES) {
const matched = part.certs.find(item => item.certName?.trim() === certName)
if (!isCertFilled(matched)) {
onError(`请完善「${part.label}」的${certName}(证号、有效期、附件均必填)`)
return false
}
}
}
const { headCerts, trailerCerts, hasTrailer } = options
for (const certs of [headCerts, ...(hasTrailer ? [trailerCerts] : [])]) {
for (const cert of certs) {
if (!cert.certName?.trim()) {
onError('证书名称不能为空')
options.onError('证书名称不能为空')
return false
}
}
@@ -108,3 +154,18 @@ export function filterVehicleCertsForSubmit<T extends CertRow>(
) {
return mergeVehicleCertsByPart(headCerts, trailerCerts, hasTrailer)
}
export function validateVehicleCertNamesNotEmpty(
certLists: CertRow[][],
onError: (message: string) => void
): boolean {
for (const certs of certLists) {
for (const cert of certs) {
if (!cert.certName?.trim()) {
onError('证书名称不能为空')
return false
}
}
}
return true
}
+141
View File
@@ -0,0 +1,141 @@
const TOOLBAR_SELECTOR = '.page-container .list-toolbar, .console-container .list-toolbar'
const FILTER_AUTO_TOGGLE_CLASS = 'btn-filter-toggle-auto'
const FILTER_TOGGLE_ICON_CLASS = 'btn-list-filter-toggle'
const QUICK_SEARCH_GROUP_CLASS = 'quick-search-group'
const ensureQuickSearchGroup = (quickSearchInput: HTMLElement): HTMLElement => {
const parent = quickSearchInput.parentElement
if (parent?.classList.contains(QUICK_SEARCH_GROUP_CLASS)) {
return parent
}
const group = document.createElement('div')
group.className = QUICK_SEARCH_GROUP_CLASS
quickSearchInput.replaceWith(group)
group.appendChild(quickSearchInput)
return group
}
const findPrecedingFilterBlock = (toolbar: HTMLElement): HTMLElement | null => {
if (toolbar.closest('.el-dialog')) return null
let prev = toolbar.previousElementSibling as HTMLElement | null
while (prev) {
if (prev.classList.contains('filter-block')) return prev
if (
prev.classList.contains('list-body') ||
prev.classList.contains('list-toolbar') ||
prev.classList.contains('panel-top') ||
prev.classList.contains('stat-grid')
) {
return null
}
prev = prev.previousElementSibling as HTMLElement | null
}
return null
}
const setFilterBlockExpanded = (
filterBlock: HTMLElement,
button: HTMLButtonElement,
expanded: boolean
) => {
filterBlock.classList.toggle('is-filter-collapsed', !expanded)
filterBlock.classList.toggle('is-filter-expanded', expanded)
button.setAttribute('aria-expanded', expanded ? 'true' : 'false')
button.setAttribute('aria-label', expanded ? '收起检索' : '展开检索')
button.setAttribute('title', expanded ? '收起检索' : '展开检索')
}
const createToggleButton = (filterBlock: HTMLElement): HTMLButtonElement => {
const button = document.createElement('button')
button.type = 'button'
button.className = FILTER_TOGGLE_ICON_CLASS
button.setAttribute('aria-expanded', 'false')
button.setAttribute('aria-label', '展开检索')
button.setAttribute('title', '展开检索')
button.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
const expanded = filterBlock.classList.contains('is-filter-expanded')
setFilterBlockExpanded(filterBlock, button, !expanded)
})
return button
}
const resolveFilterToggleAnchor = (
toolbar: HTMLElement
): { anchor: HTMLElement; quickSearchInput: HTMLElement | null } | null => {
const quickSearchInput = toolbar.querySelector<HTMLElement>('.quick-search-input')
if (quickSearchInput) {
return { anchor: ensureQuickSearchGroup(quickSearchInput), quickSearchInput }
}
const selectedAnchor = toolbar.querySelector<HTMLElement>('.list-toolbar-selected')
if (selectedAnchor) {
return { anchor: selectedAnchor, quickSearchInput: null }
}
const rowAnchor = toolbar.querySelector<HTMLElement>('.list-toolbar-row')
if (rowAnchor) {
return { anchor: rowAnchor, quickSearchInput: null }
}
return null
}
const ensureToolbarFilterToggle = (toolbar: HTMLElement, filterBlock: HTMLElement) => {
if (!filterBlock.classList.contains('is-filter-expanded')) {
filterBlock.classList.add('is-filter-collapsed')
}
const resolved = resolveFilterToggleAnchor(toolbar)
if (!resolved) return
const { anchor } = resolved
const existingToggle = toolbar.querySelector<HTMLButtonElement>(`.${FILTER_TOGGLE_ICON_CLASS}`)
if (existingToggle && existingToggle.parentElement !== anchor) {
existingToggle.remove()
}
let toggle = anchor.querySelector<HTMLButtonElement>(`:scope > .${FILTER_TOGGLE_ICON_CLASS}`)
if (!toggle) {
toggle = createToggleButton(filterBlock)
anchor.insertBefore(toggle, anchor.firstChild)
}
setFilterBlockExpanded(
filterBlock,
toggle,
filterBlock.classList.contains('is-filter-expanded')
)
}
const removeAutoFilterToggles = (): void => {
document.querySelectorAll<HTMLElement>(`.${FILTER_AUTO_TOGGLE_CLASS}`).forEach((node) => {
node.remove()
})
}
export const refreshFilterCollapse = (): void => {
removeAutoFilterToggles()
document.querySelectorAll<HTMLElement>(TOOLBAR_SELECTOR).forEach((toolbar) => {
const filterBlock = findPrecedingFilterBlock(toolbar)
if (!filterBlock) return
ensureToolbarFilterToggle(toolbar, filterBlock)
})
}
export const setupFilterCollapseObserver = (): MutationObserver => {
refreshFilterCollapse()
const observer = new MutationObserver(() => {
refreshFilterCollapse()
})
observer.observe(document.body, {
childList: true,
subtree: true
})
return observer
}
@@ -0,0 +1,29 @@
const INTERACTIVE_SELECTOR =
'button, a, .el-link, .el-dropdown, input, textarea, select, .el-switch, .el-radio, .el-select, .btn-op-item, .el-table__expand-icon'
/**
* 主列表行单击切换多选:点击行即选中/取消,点击链接、按钮、展开图标等交互元素时不触发。
*/
export function setupListTableRowSelect() {
const handler = (event: MouseEvent) => {
const target = event.target as HTMLElement | null
if (!target) return
if (target.closest('.el-table-column--selection')) return
const row = target.closest('.list-table .el-table__body-wrapper tbody tr.el-table__row')
if (!row) return
if (target.closest(INTERACTIVE_SELECTOR)) return
const checkboxInput = row.querySelector(
'.el-table-column--selection .el-checkbox__input'
) as HTMLElement | null
if (!checkboxInput) return
checkboxInput.click()
}
document.body.addEventListener('click', handler)
return () => document.body.removeEventListener('click', handler)
}
@@ -0,0 +1,35 @@
import type { Ref } from 'vue'
export function syncViewRowFromFlatList<T extends { id: string | number }>(
viewRow: Ref<T | null>,
list: T[]
) {
const current = viewRow.value
if (!current?.id) return
const updated = list.find(row => row.id === current.id)
if (updated) viewRow.value = updated
}
export function findNodeInTreeById<T extends { id: string | number; children?: T[] }>(
nodes: T[],
id: string | number
): T | undefined {
for (const node of nodes) {
if (node.id === id) return node
if (node.children?.length) {
const found = findNodeInTreeById(node.children, id)
if (found) return found
}
}
return undefined
}
export function syncViewRowFromTree<T extends { id: string | number; children?: T[] }>(
viewRow: Ref<T | null>,
roots: T[]
) {
const current = viewRow.value
if (!current?.id) return
const updated = findNodeInTreeById(roots, current.id)
if (updated) viewRow.value = updated
}
+34
View File
@@ -34,6 +34,7 @@ export function isViewFieldLong(field: ViewField, displayText: string): boolean
/** 是否占满整行(备注超长、显式 fullSpan / span:'full') */
export function isViewFieldFullSpan(field: ViewField, displayText: string): boolean {
if (field.fullSpan === false) return false
if (field.fullSpan === true || field.span === 'full') return true
if (displayText === '—') return false
return displayText.length >= VIEW_KV_FULL_MIN_LEN
@@ -156,3 +157,36 @@ export function buildViewKvTableQuadRows(
}
return rows
}
export type ViewKvTableQuintRow =
| { type: 'long'; field: ViewField }
| { type: 'quint'; fields: ViewField[] }
/** KV 表格模式(五组十列):长字段独占行,短字段最多五个一组 */
export function buildViewKvTableQuintRows(
fields: ViewField[],
formatValue: (val: unknown) => string
): ViewKvTableQuintRow[] {
const rows: ViewKvTableQuintRow[] = []
let i = 0
while (i < fields.length) {
const field = fields[i]
const text = getViewFieldDisplayText(field, formatValue)
if (isViewFieldLong(field, text) || isViewFieldFullSpan(field, text)) {
rows.push({ type: 'long', field })
i += 1
continue
}
const group: ViewField[] = [field]
i += 1
while (i < fields.length && group.length < 5) {
const next = fields[i]
const nextText = getViewFieldDisplayText(next, formatValue)
if (isViewFieldLong(next, nextText) || isViewFieldFullSpan(next, nextText)) break
group.push(next)
i += 1
}
rows.push({ type: 'quint', fields: group })
}
return rows
}
+118 -175
View File
@@ -2,13 +2,6 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<div class="stats">
<div v-for="item in stats" :key="item.name" class="stat" :class="{ 'stat-money': item.name.includes('金额') }">
<p class="k">{{ item.name }}</p>
<p class="v">{{ item.value }}</p>
</div>
</div>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">备用金申请列表</h2>
@@ -42,35 +35,17 @@
style="width: 100%"
/>
</el-form-item>
</div>
<div v-show="showMoreFilters" id="advance-apply-filter-extra" class="filter-extra">
<div class="filter-grid">
<el-form-item label="当前节点">
<el-select v-model="filterForm.currentNode" placeholder="全部" clearable>
<el-option label="草稿" value="草稿" />
<el-option label="发起人主管审批" value="发起人主管审批" />
<el-option label="财务复核" value="财务复核" />
<el-option label="总经理审批" value="总经理审批" />
</el-select>
</el-form-item>
</div>
<el-form-item label="当前节点">
<el-select v-model="filterForm.currentNode" placeholder="全部" clearable>
<el-option label="草稿" value="草稿" />
<el-option label="发起人主管审批" value="发起人主管审批" />
<el-option label="财务复核" value="财务复核" />
<el-option label="总经理审批" value="总经理审批" />
</el-select>
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-start">
<button
type="button"
class="btn-link btn-filter-toggle"
:aria-expanded="showMoreFilters"
aria-controls="advance-apply-filter-extra"
@click="toggleMoreFilters"
>
{{ showMoreFilters ? '收起更多' : '展开更多' }}
<el-icon>
<ArrowUpBold v-if="showMoreFilters" />
<ArrowDownBold v-else />
</el-icon>
</button>
</div>
<div class="filter-actions-start" />
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
@@ -88,6 +63,12 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="申请单号/申请原因"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-main" type="primary" @click="openAdd">
<el-icon><Plus /></el-icon>新增备用金申请
</el-button>
@@ -117,13 +98,21 @@
<el-button class="btn-ghost" @click="openFlowDialog()">
<el-icon><View /></el-icon>查看审批流
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="handleSearch" />
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="handleSearch"
@reset-columns="resetToDefault"
/>
</div>
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary advance-apply-summary-merge">
<div class="table-scroll table-page-summary">
<el-table
class="list-table"
:data="rows"
@@ -138,6 +127,7 @@
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applyTime')"
prop="applyTime"
label="申请时间"
min-width="160"
@@ -148,6 +138,7 @@
<el-table-column
prop="applyCode"
label="申请单号"
class-name="col-apply-code"
min-width="140"
align="center"
header-align="center"
@@ -158,6 +149,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('applicantName')"
prop="applicantName"
label="申请人"
min-width="100"
@@ -165,6 +157,7 @@
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('reason')"
prop="reason"
label="申请原因"
min-width="160"
@@ -173,6 +166,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('amount')"
prop="amount"
label="申请金额(元)"
:formatter="moneyTableFormatter"
@@ -182,6 +176,8 @@
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('actualAmount')"
prop="actualAmount"
label="实际发放金额(元)"
class-name="col-money"
min-width="130"
@@ -197,6 +193,8 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('offsetAmount')"
prop="offsetAmount"
label="冲抵总金额(元)"
class-name="col-money"
min-width="130"
@@ -207,14 +205,28 @@
{{ formatMoney(row.offsetAmount || 0) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" min-width="110" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('status')"
prop="status"
label="状态"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="currentNode" label="当前节点" min-width="120" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('currentNode')"
prop="currentNode"
label="当前节点"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
v-if="shouldShowWorkflowNodeTag(row.currentNode, row.status)"
@@ -257,7 +269,9 @@
ref="addFormRef"
:model="addForm"
:rules="addFormRules"
label-position="top"
label-position="right"
label-width="100px"
class="bx-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -379,25 +393,12 @@
</el-table>
</BaseDetailTableSection>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批进度</h4>
<div class="flow-scroll">
<ApprovalFlowView :nodes="viewFlowNodes" :loading="viewFlowLoading" />
</div>
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批历史</h4>
<div class="flow-scroll">
<ApprovalHistoryView :items="viewApprovalHistory" :loading="viewFlowLoading" />
</div>
</div>
<BxApprovalFlowTabs
:nodes="viewFlowNodes"
:history="viewApprovalHistory"
:loading="viewFlowLoading"
/>
</div>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.view = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
<el-dialog
@@ -408,30 +409,16 @@
align-center
destroy-on-close
>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批进度</h4>
<div class="flow-scroll">
<ApprovalFlowView :nodes="flowNodes" :loading="flowLoading" />
</div>
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批历史</h4>
<div class="flow-scroll">
<ApprovalHistoryView :items="flowHistory" :loading="flowLoading" />
</div>
</div>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.viewFlow = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
<BxApprovalFlowTabs :nodes="flowNodes" :history="flowHistory" :loading="flowLoading" />
</el-dialog>
</div>
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { reactive, ref, computed, onMounted, nextTick, watch } from 'vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { reactive, ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import {
@@ -443,16 +430,13 @@ import {
Delete,
Check,
Search,
RefreshLeft,
ArrowUpBold,
ArrowDownBold
RefreshLeft
} from '@element-plus/icons-vue'
import { advanceApi, paymentApi } from '@/api/bx'
import { canDeleteByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
statusLabel,
statusTagType,
formatSumMoney,
formatMoney,
formatMoneyYuan,
moneyTableFormatter,
@@ -462,16 +446,15 @@ import {
shouldShowWorkflowNodeTag
} from '@/utils/bx/format'
import { useServerPaged } from '@/hooks/bx/useServerPaged'
import ApprovalFlowView from '@/components/bx/base/ApprovalFlowView.vue'
import ApprovalHistoryView from '@/components/bx/base/ApprovalHistoryView.vue'
import BxApprovalFlowTabs from '@/components/bx/base/BxApprovalFlowTabs.vue'
import BxViewKvTable from '@/components/bx/base/BxViewKvTable.vue'
import BaseDetailTableSection from '@/components/bx/base/BaseDetailTableSection.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import { bxViewKv, bxViewKvLong, bxViewKvMoney } from '@/utils/bx/view-kv'
import { bxViewKv, bxViewKvMoney } from '@/utils/bx/view-kv'
import { buildPageSummaryMethod, sumField } from '@/utils/bx/table-summary'
type DialogKey = 'add' | 'view' | 'viewFlow'
const showMoreFilters = ref(false)
const loadingMap = reactive<Record<string, boolean>>({})
const dialogs = reactive<Record<DialogKey, boolean>>({
add: false,
@@ -485,40 +468,26 @@ const { rows, total, currentPage, pageSize, rangeText, loading, refresh, search,
return res.data
})
const myAdvanceStats = ref({
pendingCount: 0,
monthApplyAmount: 0,
issuedAmount: 0,
offsetTotal: 0,
pendingIssueCount: 0
})
const quickSearchKeyword = ref('')
async function loadMyAdvanceStats() {
try {
const res: any = await advanceApi.myStats()
const d = res.data || {}
myAdvanceStats.value = {
pendingCount: Number(d.pendingCount ?? 0),
monthApplyAmount: Number(d.monthApplyAmount ?? 0),
issuedAmount: Number(d.issuedAmount ?? 0),
offsetTotal: Number(d.offsetTotal ?? 0),
pendingIssueCount: Number(d.pendingIssueCount ?? 0)
}
} catch {
/* 拦截器已提示 */
}
}
const listColumnDefs = [
{ prop: 'applyTime', label: '申请时间' },
{ prop: 'applicantName', label: '申请人' },
{ prop: 'reason', label: '申请原因' },
{ prop: 'amount', label: '申请金额(元)' },
{ prop: 'actualAmount', label: '实际发放金额(元)' },
{ prop: 'offsetAmount', label: '冲抵总金额(元)', defaultVisible: false },
{ prop: 'status', label: '状态' },
{ prop: 'currentNode', label: '当前节点', defaultVisible: false }
]
const stats = computed(() => {
const s = myAdvanceStats.value
return [
{ name: '待审批申请', value: String(s.pendingCount) },
{ name: '本月申请金额(元)', value: formatMoney(s.monthApplyAmount) },
{ name: '已发放金额(元)', value: formatMoney(s.issuedAmount) },
{ name: '累计已冲抵金额(元)', value: formatMoney(s.offsetTotal) },
{ name: '待发放申请', value: String(s.pendingIssueCount) }
]
})
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('bx-advance-apply-list-columns', listColumnDefs)
const filterForm = reactive({
keyword: '',
@@ -554,7 +523,7 @@ const viewMainKvFields = computed<ViewField[]>(() => {
bxViewKvMoney('冲抵总金额(元)', formatMoneyYuan(d.offsetAmount || 0)),
bxViewKv('当前节点', workflowNodeDisplay(d.currentNode, d.status)),
bxViewKv('申请时间', d.applyTime),
bxViewKvLong('申请原因', d.reason)
bxViewKv('申请原因', d.reason, { long: false })
]
})
const selectedRows = ref<any[]>([])
@@ -567,56 +536,47 @@ const viewApprovalHistory = ref<any[]>([])
const viewFlowLoading = ref(false)
const viewOffsetRecords = ref<any[]>([])
const ADVANCE_APPLY_SUMMARY_COLS = 11
const ADVANCE_APPLY_SUMMARY_LABEL_COLSPAN = 4
function mergeAdvanceApplySummaryFooter() {
nextTick(() => {
const root = document.querySelector('.advance-apply-summary-merge .el-table__footer-wrapper')
if (!root) return
const tr = root.querySelector('tfoot tr') || root.querySelector('table tr')
if (!tr) return
const cells = Array.from(tr.querySelectorAll('td')) as HTMLTableCellElement[]
if (cells.length !== ADVANCE_APPLY_SUMMARY_COLS) return
const first = cells[0]
if (first.colSpan === ADVANCE_APPLY_SUMMARY_LABEL_COLSPAN) return
first.colSpan = ADVANCE_APPLY_SUMMARY_LABEL_COLSPAN
for (let i = ADVANCE_APPLY_SUMMARY_LABEL_COLSPAN - 1; i >= 1; i--) {
cells[i].remove()
}
})
}
function onSelectionChange(rows: any[]) {
selectedRows.value = rows
}
async function refreshListAndStats() {
async function refreshList() {
await refresh()
await loadMyAdvanceStats()
}
onMounted(() => {
refreshListAndStats()
refreshList()
})
watch([rows, loading], () => mergeAdvanceApplySummaryFooter(), { flush: 'post' })
function toggleMoreFilters() {
showMoreFilters.value = !showMoreFilters.value
function buildSearchQuery() {
const q: Record<string, string> = {}
const kw = (quickSearchKeyword.value.trim() || filterForm.keyword.trim())
if (kw) q.keyword = kw
if (filterForm.status) q.status = filterForm.status
if (filterForm.applyDate) q.applyDate = filterForm.applyDate
return q
}
function handleSearch() {
const q: Record<string, string> = { keyword: filterForm.keyword, status: filterForm.status }
if (filterForm.applyDate) q.applyDate = filterForm.applyDate
search(q)
quickSearchKeyword.value = ''
search(buildSearchQuery())
}
function handleReset() {
quickSearchKeyword.value = ''
Object.assign(filterForm, { keyword: '', status: '', amountRange: '', applyDate: null, currentNode: '' })
search()
}
function handleQuickSearch() {
search(buildSearchQuery())
}
function handleQuickSearchClear() {
quickSearchKeyword.value = ''
search(buildSearchQuery())
}
function isDraftEditable(row?: any) {
if (!row) return false
return row.status === 'draft' || row.status === 'rejected'
@@ -659,7 +619,7 @@ async function handleToolbarDelete() {
await advanceApi.delete(row.id)
}
ElMessage.success('删除成功')
await refreshListAndStats()
await refreshList()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e?.message || '删除失败')
@@ -717,7 +677,7 @@ async function handleToolbarSubmit() {
await advanceApi.submit(row.id)
}
ElMessage.success('提交成功')
await refreshListAndStats()
await refreshList()
} catch (e: any) {
ElMessage.error(e?.message || '提交失败')
} finally {
@@ -754,7 +714,7 @@ async function submitAdd() {
dialogs.add = false
Object.assign(addForm, { reason: '', amount: '', remark: '' })
editingId.value = null
await refreshListAndStats()
await refreshList()
} catch (e: any) {
ElMessage.error(e.message || (editingId.value ? '保存失败' : '新增失败'))
} finally {
@@ -819,7 +779,7 @@ async function handleSubmit(row: any) {
try {
await advanceApi.submit(row.id)
ElMessage.success('提交成功')
await refreshListAndStats()
await refreshList()
} catch (e: any) {
ElMessage.error(e.message || '提交失败')
} finally {
@@ -841,7 +801,7 @@ async function handleDelete(row: any) {
loadingMap[`del_${row.id}`] = true
await advanceApi.delete(row.id)
ElMessage.success('删除成功')
await refreshListAndStats()
await refreshList()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e?.message || '删除失败')
@@ -851,28 +811,11 @@ async function handleDelete(row: any) {
}
}
function summaryMethod(params: { data: any[] }) {
const data = params.data
const n = data.length
const totalApply = data.reduce((s, r) => s + Number(r.amount || 0), 0)
const totalActual = data.reduce((s, r) => {
if (r.actualAmount != null && r.actualAmount !== '') return s + Number(r.actualAmount)
return s
}, 0)
const totalOffset = data.reduce((s, r) => s + Number(r.offsetAmount || 0), 0)
const lbl = `本页合计(${n}单)`
return [
lbl,
'',
'',
'',
'',
'',
formatSumMoney(totalApply),
formatSumMoney(totalActual),
formatSumMoney(totalOffset),
'',
''
]
}
const summaryMethod = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumField(data, 'amount'),
actualAmount: (data) => sumField(data, 'actualAmount', true),
offsetAmount: (data) => sumField(data, 'offsetAmount')
}
})
</script>
+146 -63
View File
@@ -2,13 +2,6 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<div class="stats">
<div v-for="item in stats" :key="item.name" class="stat" :class="{ 'stat-money': item.name.includes('金额') }">
<p class="k">{{ item.name }}</p>
<p class="v">{{ item.value }}</p>
</div>
</div>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">备用金审批</h2>
@@ -16,7 +9,7 @@
<el-tabs
v-model="activeTab"
class="panel-pill-tabs panel-pill-tabs--lg"
class="panel-tabs"
@tab-change="handleTabChange"
>
<!-- 待我审批 -->
@@ -67,6 +60,12 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<ListQuickSearchInput
v-model="pendingQuickSearchKeyword"
placeholder="申请单号/申请原因"
@search="handlePendingQuickSearch"
@clear="handlePendingQuickSearchClear"
/>
<el-button class="btn-main" type="primary" @click="handleBatchApprove">
<el-icon><Check /></el-icon>通过
</el-button>
@@ -74,7 +73,15 @@
<el-icon><Close /></el-icon>驳回
</el-button>
<ListToolbarEndActions :loading="pendingLoading" @refresh="refreshPending" />
<ListToolbarEndActions
:loading="pendingLoading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="refreshPending"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -94,6 +101,7 @@
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applyTime')"
prop="applyTime"
label="申请时间"
min-width="160"
@@ -104,6 +112,7 @@
<el-table-column
prop="applyCode"
label="申请单号"
class-name="col-apply-code"
min-width="140"
align="center"
header-align="center"
@@ -113,8 +122,16 @@
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applicantName')"
prop="applicantName"
label="申请人"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('reason')"
prop="reason"
label="申请原因"
min-width="160"
@@ -123,6 +140,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('amount')"
prop="amount"
label="申请金额(元)"
:formatter="moneyTableFormatter"
@@ -131,7 +149,14 @@
align="right"
header-align="center"
/>
<el-table-column prop="currentNode" label="当前节点" min-width="120" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('currentNode')"
prop="currentNode"
label="当前节点"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
v-if="shouldShowWorkflowNodeTag(row.currentNode, row.status)"
@@ -203,6 +228,26 @@
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
v-model="approvedQuickSearchKeyword"
placeholder="申请单号/申请原因"
@search="handleApprovedQuickSearch"
@clear="handleApprovedQuickSearchClear"
/>
<ListToolbarEndActions
:loading="approvedLoading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="handleApprovedSearch"
@reset-columns="resetToDefault"
/>
</div>
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary">
<el-table
@@ -217,6 +262,7 @@
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applyTime')"
prop="applyTime"
label="申请时间"
min-width="160"
@@ -227,6 +273,7 @@
<el-table-column
prop="applyCode"
label="申请单号"
class-name="col-apply-code"
min-width="140"
align="center"
header-align="center"
@@ -236,8 +283,16 @@
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applicantName')"
prop="applicantName"
label="申请人"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('reason')"
prop="reason"
label="申请原因"
min-width="160"
@@ -246,6 +301,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('amount')"
prop="amount"
label="申请金额(元)"
:formatter="moneyTableFormatter"
@@ -254,14 +310,28 @@
align="right"
header-align="center"
/>
<el-table-column prop="status" label="状态" min-width="100" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('status')"
prop="status"
label="状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="currentNode" label="当前节点" min-width="120" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('currentNode')"
prop="currentNode"
label="当前节点"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
v-if="shouldShowWorkflowNodeTag(row.currentNode, row.status)"
@@ -329,25 +399,12 @@
<BxViewKvTable :fields="viewMainKvFields" mode="dual" />
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批进度</h4>
<div class="flow-scroll">
<ApprovalFlowView :nodes="viewFlowNodes" :loading="viewFlowLoading" />
</div>
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批历史</h4>
<div class="flow-scroll">
<ApprovalHistoryView :items="viewApprovalHistory" :loading="viewFlowLoading" />
</div>
</div>
<BxApprovalFlowTabs
:nodes="viewFlowNodes"
:history="viewApprovalHistory"
:loading="viewFlowLoading"
/>
</div>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.view = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
<!-- 审批确认 -->
@@ -360,7 +417,7 @@
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top">
<el-form label-position="right" label-width="100px" class="bx-dialog-form">
<div class="form-grid-2">
<el-form-item label="审批时间">
<el-input :model-value="approveTime" disabled />
@@ -394,16 +451,16 @@
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { ElMessage } from 'element-plus'
import { Check, Close, Search, RefreshLeft } from '@element-plus/icons-vue'
import { advanceApi } from '@/api/bx'
import { advanceApproverStats, refreshApproveMenuBadges } from '@/hooks/bx/useApproveMenuBadge'
import { refreshApproveMenuBadges } from '@/hooks/bx/useApproveMenuBadge'
import { useMenuStore } from '@/store/modules/menu'
import {
statusLabel,
statusTagType,
formatSumMoney,
formatMoney,
formatMoneyYuan,
moneyTableFormatter,
workflowNodeDisplay,
@@ -413,14 +470,31 @@ import {
} from '@/utils/bx/format'
import { useServerPaged } from '@/hooks/bx/useServerPaged'
import { useApplicantOptions } from '@/hooks/bx/useApplicantOptions'
import ApprovalFlowView from '@/components/bx/base/ApprovalFlowView.vue'
import ApprovalHistoryView from '@/components/bx/base/ApprovalHistoryView.vue'
import BxApprovalFlowTabs from '@/components/bx/base/BxApprovalFlowTabs.vue'
import BxViewKvTable from '@/components/bx/base/BxViewKvTable.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import { bxViewKv, bxViewKvLong, bxViewKvMoney } from '@/utils/bx/view-kv'
import { buildPageSummaryMethod, sumField } from '@/utils/bx/table-summary'
type DialogKey = 'view' | 'approve'
const listColumnDefs = [
{ prop: 'applyTime', label: '申请时间' },
{ prop: 'applicantName', label: '申请人' },
{ prop: 'reason', label: '申请原因' },
{ prop: 'amount', label: '申请金额(元)' },
{ prop: 'status', label: '状态', defaultVisible: false },
{ prop: 'currentNode', label: '当前节点', defaultVisible: false }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('bx-advance-approve-list-columns', listColumnDefs)
const loadingMap = reactive<Record<string, boolean>>({})
const dialogs = reactive<Record<DialogKey, boolean>>({
view: false,
@@ -459,15 +533,10 @@ const {
const tabLoaded = reactive({ pending: false, approved: false })
const activeTab = ref('pending')
const menuStore = useMenuStore()
const pendingQuickSearchKeyword = ref('')
const approvedQuickSearchKeyword = ref('')
const stats = computed(() => {
const s = advanceApproverStats.value
return [
{ name: '待审批', value: String(s.pendingTotal) },
{ name: '本月审批金额(元)', value: formatMoney(s.monthPendingAmount) }
]
})
const menuStore = useMenuStore()
const viewData = ref<Record<string, any>>({})
@@ -520,7 +589,7 @@ const approvedFilterForm = reactive({
function buildPendingQuery() {
const q: Record<string, string | number> = {}
const code = pendingFilterForm.code.trim()
const code = (pendingQuickSearchKeyword.value.trim() || pendingFilterForm.code.trim())
if (code) q.keyword = code
if (pendingFilterForm.applicantId != null) q.applicantId = pendingFilterForm.applicantId
if (pendingFilterForm.applyDate) q.applyDate = pendingFilterForm.applyDate
@@ -529,7 +598,7 @@ function buildPendingQuery() {
function buildApprovedQuery() {
const q: Record<string, string | number> = {}
const code = approvedFilterForm.code.trim()
const code = (approvedQuickSearchKeyword.value.trim() || approvedFilterForm.code.trim())
if (code) q.keyword = code
if (approvedFilterForm.applicantId != null) q.applicantId = approvedFilterForm.applicantId
if (approvedFilterForm.applyDate) q.applyDate = approvedFilterForm.applyDate
@@ -544,23 +613,45 @@ onMounted(async () => {
})
function handlePendingSearch() {
pendingQuickSearchKeyword.value = ''
searchPending(buildPendingQuery())
}
function handlePendingReset() {
pendingQuickSearchKeyword.value = ''
Object.assign(pendingFilterForm, { code: '', applicantId: undefined, applyDate: null })
searchPending()
}
function handlePendingQuickSearch() {
searchPending(buildPendingQuery())
}
function handlePendingQuickSearchClear() {
pendingQuickSearchKeyword.value = ''
searchPending(buildPendingQuery())
}
function handleApprovedSearch() {
approvedQuickSearchKeyword.value = ''
searchApproved(buildApprovedQuery())
}
function handleApprovedReset() {
approvedQuickSearchKeyword.value = ''
Object.assign(approvedFilterForm, { code: '', applicantId: undefined, applyDate: null })
searchApproved()
}
function handleApprovedQuickSearch() {
searchApproved(buildApprovedQuery())
}
function handleApprovedQuickSearchClear() {
approvedQuickSearchKeyword.value = ''
searchApproved(buildApprovedQuery())
}
function handleTabChange(tab: string | number) {
if (tab === 'pending' && !tabLoaded.pending) {
searchPending()
@@ -619,21 +710,13 @@ async function openView(row: any) {
}
}
function pendingSummaryMethod(params: { data: any[] }) {
const data = params.data
const n = data.length
const t = data.reduce((s, r) => s + Number(r.amount || 0), 0)
const lbl = `本页合计(${n}单)`
return ['', '', '', lbl, '', '', formatSumMoney(t), '', '', '']
}
function approvedSummaryMethod(params: { data: any[] }) {
const data = params.data
const n = data.length
const t = data.reduce((s, r) => s + Number(r.amount || 0), 0)
const lbl = `本页合计(${n}单)`
return ['', '', '', lbl, '', formatSumMoney(t), '', '', '', '']
}
const amountSummary = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumField(data, 'amount')
}
})
const pendingSummaryMethod = amountSummary
const approvedSummaryMethod = amountSummary
async function handleConfirmApprove() {
const key = approveAction.value === 'approve' ? 'approvePass' : 'approveReject'
+136 -74
View File
@@ -2,13 +2,6 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<div class="stats">
<div v-for="item in stats" :key="item.name" class="stat" :class="{ 'stat-money': item.name.includes('金额') }">
<p class="k">{{ item.name }}</p>
<p class="v">{{ item.value }}</p>
</div>
</div>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">备用金发放</h2>
@@ -16,7 +9,7 @@
<el-tabs
v-model="activeTab"
class="panel-pill-tabs panel-pill-tabs--lg"
class="panel-tabs"
@tab-change="handleTabChange"
>
<el-tab-pane label="待发放" name="pending">
@@ -66,13 +59,27 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="申请单号/申请原因"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-main" type="primary" @click="handleToolbarIssue">
<el-icon><Check /></el-icon>发放
</el-button>
<el-button class="btn-ghost" :loading="loadingMap.export" @click="handleExport">
<el-icon><Download /></el-icon>导出
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="handleSearch" />
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="handleSearch"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -93,6 +100,7 @@
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applyTime')"
prop="applyTime"
label="申请时间"
min-width="160"
@@ -103,6 +111,7 @@
<el-table-column
prop="applyCode"
label="申请单号"
class-name="col-apply-code"
min-width="140"
align="center"
header-align="center"
@@ -112,8 +121,16 @@
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applicantName')"
prop="applicantName"
label="申请人"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('reason')"
prop="reason"
label="申请原因"
min-width="160"
@@ -122,6 +139,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('amount')"
prop="amount"
label="申请金额(元)"
:formatter="moneyTableFormatter"
@@ -131,6 +149,8 @@
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('actualAmount')"
prop="actualAmount"
label="实际发放金额(元)"
class-name="col-money"
min-width="130"
@@ -146,6 +166,8 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('offsetAmount')"
prop="offsetAmount"
label="冲抵总金额(元)"
class-name="col-money"
min-width="130"
@@ -157,6 +179,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('approveTime')"
prop="approveTime"
label="审批通过时间"
min-width="140"
@@ -225,11 +248,25 @@
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="申请单号/申请原因"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-ghost" :loading="loadingMap.export" @click="handleExport">
<el-icon><Download /></el-icon>导出
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="handleSearch" />
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="handleSearch"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -247,6 +284,7 @@
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applyTime')"
prop="applyTime"
label="申请时间"
min-width="160"
@@ -257,6 +295,7 @@
<el-table-column
prop="applyCode"
label="申请单号"
class-name="col-apply-code"
min-width="140"
align="center"
header-align="center"
@@ -266,8 +305,16 @@
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applicantName')"
prop="applicantName"
label="申请人"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('reason')"
prop="reason"
label="申请原因"
min-width="160"
@@ -276,6 +323,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('amount')"
prop="amount"
label="申请金额(元)"
:formatter="moneyTableFormatter"
@@ -285,6 +333,8 @@
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('actualAmount')"
prop="actualAmount"
label="实际发放金额(元)"
class-name="col-money"
min-width="130"
@@ -300,6 +350,8 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('offsetAmount')"
prop="offsetAmount"
label="冲抵总金额(元)"
class-name="col-money"
min-width="130"
@@ -310,8 +362,22 @@
{{ formatSumMoney(Number(row.offsetAmount ?? 0)) }}
</template>
</el-table-column>
<el-table-column prop="issueTime" label="发放时间" min-width="140" align="center" header-align="center" />
<el-table-column prop="issueUserName" label="发放操作人" min-width="110" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('issueTime')"
prop="issueTime"
label="发放时间"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('issueUserName')"
prop="issueUserName"
label="发放操作人"
min-width="110"
align="center"
header-align="center"
/>
</el-table>
</div>
@@ -341,7 +407,7 @@
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top">
<el-form label-position="right" label-width="100px" class="bx-dialog-form">
<div class="form-grid-2">
<el-form-item label="发放金额(元)" class="field-amount">
<el-input :model-value="String(currentRow.amount || '')" disabled placeholder="发放金额" />
@@ -439,31 +505,20 @@
<BxAttachmentReadonly :files="viewAttachments" />
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批进度</h4>
<div class="flow-scroll">
<ApprovalFlowView :nodes="approvalNodes" :loading="approvalLoading" />
</div>
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批历史</h4>
<div class="flow-scroll">
<ApprovalHistoryView :items="approvalHistory" :loading="approvalLoading" />
</div>
</div>
<BxApprovalFlowTabs
:nodes="approvalNodes"
:history="approvalHistory"
:loading="approvalLoading"
/>
</div>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.view = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { reactive, ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Check, Download, Close, Search, RefreshLeft } from '@element-plus/icons-vue'
@@ -472,17 +527,37 @@ import { type BxAttachmentItem, loadBxAttachments } from '@/utils/bx/attachment'
import { formatSumMoney, formatMoney, formatMoneyYuan, moneyTableFormatter } from '@/utils/bx/format'
import { useServerPaged } from '@/hooks/bx/useServerPaged'
import { useApplicantOptions } from '@/hooks/bx/useApplicantOptions'
import ApprovalFlowView from '@/components/bx/base/ApprovalFlowView.vue'
import ApprovalHistoryView from '@/components/bx/base/ApprovalHistoryView.vue'
import BxApprovalFlowTabs from '@/components/bx/base/BxApprovalFlowTabs.vue'
import BxViewKvTable from '@/components/bx/base/BxViewKvTable.vue'
import BaseDetailTableSection from '@/components/bx/base/BaseDetailTableSection.vue'
import BxAttachmentUpload from '@/components/bx/base/BxAttachmentUpload.vue'
import BxAttachmentReadonly from '@/components/bx/base/BxAttachmentReadonly.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import { bxViewKv } from '@/utils/bx/view-kv'
import { buildPageSummaryMethod, sumField } from '@/utils/bx/table-summary'
type DialogKey = 'issue' | 'view'
const listColumnDefs = [
{ prop: 'applyTime', label: '申请时间' },
{ prop: 'applicantName', label: '申请人' },
{ prop: 'reason', label: '申请原因' },
{ prop: 'amount', label: '申请金额(元)' },
{ prop: 'actualAmount', label: '实际发放金额(元)' },
{ prop: 'offsetAmount', label: '冲抵总金额(元)', defaultVisible: false },
{ prop: 'approveTime', label: '审批通过时间', defaultVisible: false },
{ prop: 'issueTime', label: '发放时间', defaultVisible: false },
{ prop: 'issueUserName', label: '发放操作人', defaultVisible: false }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('bx-advance-issue-list-columns', listColumnDefs)
const loadingMap = reactive<Record<string, boolean>>({})
const dialogs = reactive<Record<DialogKey, boolean>>({
issue: false,
@@ -511,28 +586,7 @@ const { rows, total, currentPage, pageSize, rangeText, loading, refresh, search,
}
)
const stats = ref([
{ name: '待发放', value: '-' },
{ name: '已发放', value: '-' },
{ name: '本月发放金额(元)', value: '-' },
{ name: '待发放金额(元)', value: '-' }
])
async function loadAdvanceIssueStats() {
try {
const res: any = await paymentApi.advanceIssueStats()
const d = res.data
if (!d) return
stats.value = [
{ name: '待发放', value: String(d.pendingCount ?? 0) },
{ name: '已发放', value: String(d.issuedCount ?? 0) },
{ name: '本月发放金额(元)', value: formatMoney(d.issuedMonthAmount ?? 0) },
{ name: '待发放金额(元)', value: formatMoney(d.pendingAmount ?? 0) }
]
} catch {
/* interceptor */
}
}
const quickSearchKeyword = ref('')
const currentRow = ref<Record<string, any>>({})
const selectedRows = ref<any[]>([])
@@ -574,24 +628,17 @@ function issueStatusTagType(status: string): 'success' | 'warning' {
return status === 'issued' ? 'success' : 'warning'
}
function advanceIssueSummary(params: { data: any[] }) {
const data = params.data
const n = data.length
const tAmt = data.reduce((s, r) => s + Number(r.amount || 0), 0)
const tAct = data.reduce((s, r) => s + Number(r.actualAmount ?? 0), 0)
const tOff = data.reduce((s, r) => s + Number(r.offsetAmount ?? 0), 0)
const lbl = `本页合计(${n}单)`
const body = ['', '', '', lbl, '', '', formatSumMoney(tAmt), formatSumMoney(tAct), formatSumMoney(tOff)]
if (activeTab.value === 'pending') {
return [...body, '', '']
const advanceIssueSummary = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumField(data, 'amount'),
actualAmount: (data) => sumField(data, 'actualAmount'),
offsetAmount: (data) => sumField(data, 'offsetAmount')
}
return [...body, '', '', '']
}
})
onMounted(() => {
loadApplicantOptions()
refresh()
loadAdvanceIssueStats()
})
function onSelectionChange(rows: any[]) {
@@ -608,24 +655,40 @@ function handleToolbarIssue() {
function handleTabChange(tab: string | number) {
activeTab.value = String(tab)
quickSearchKeyword.value = ''
Object.assign(filterForm, { keyword: '', applicantId: undefined, applyDate: null })
search()
}
function handleSearch() {
function buildSearchQuery() {
const q: Record<string, string | number> = {}
const kw = filterForm.keyword.trim()
const kw = (quickSearchKeyword.value.trim() || filterForm.keyword.trim())
if (kw) q.keyword = kw
if (filterForm.applicantId != null) q.applicantId = filterForm.applicantId
if (filterForm.applyDate) q.applyDate = filterForm.applyDate
search(q)
return q
}
function handleSearch() {
quickSearchKeyword.value = ''
search(buildSearchQuery())
}
function handleReset() {
quickSearchKeyword.value = ''
Object.assign(filterForm, { keyword: '', applicantId: undefined, applyDate: null })
search()
}
function handleQuickSearch() {
search(buildSearchQuery())
}
function handleQuickSearchClear() {
quickSearchKeyword.value = ''
search(buildSearchQuery())
}
async function handleExport() {
loadingMap.export = true
try {
@@ -678,7 +741,6 @@ async function submitIssue() {
ElMessage.success('发放成功')
dialogs.issue = false
await refresh()
await loadAdvanceIssueStats()
} catch (e: any) {
ElMessage.error(e.message || '发放失败')
} finally {
+93 -9
View File
@@ -48,6 +48,12 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="项目名称/项目编码"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-main" type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>新增一级项目
</el-button>
@@ -84,7 +90,15 @@
<el-icon><SortUp /></el-icon>收起全部
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="handleSearch" />
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="handleSearch"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -98,6 +112,7 @@
row-key="id"
:tree-props="{ children: 'children', hasChildren: 'childCount > 0' }"
@selection-change="onSelectionChange"
@row-dblclick="openEditDialog"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -111,6 +126,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('itemCode')"
prop="itemCode"
label="项目编码"
min-width="160"
@@ -118,7 +134,14 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column prop="level" label="层级" min-width="100" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('level')"
prop="level"
label="层级"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="row.level === 1 ? 'primary' : 'info'" size="small">
{{ row.level === 1 ? '一级' : '二级' }}
@@ -126,14 +149,29 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('childCount')"
prop="childCount"
label="子项数量"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column prop="sortOrder" label="排序号" min-width="80" align="center" header-align="center" />
<el-table-column prop="status" label="状态" min-width="100" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('sortOrder')"
prop="sortOrder"
label="排序号"
min-width="80"
align="center"
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('status')"
prop="status"
label="状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
@@ -159,7 +197,9 @@
ref="addFormRef"
:model="formData"
:rules="formRules"
label-position="top"
label-position="right"
label-width="100px"
class="bx-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -211,7 +251,9 @@
ref="editFormRef"
:model="formData"
:rules="formRules"
label-position="top"
label-position="right"
label-width="100px"
class="bx-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -277,7 +319,9 @@
ref="addChildFormRef"
:model="formData"
:rules="formRules"
label-position="top"
label-position="right"
label-width="100px"
class="bx-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -321,6 +365,8 @@
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { reactive, ref, onMounted, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
@@ -349,6 +395,24 @@ const dialogs = reactive<Record<DialogKey, boolean>>({
addChild: false
})
const listColumnDefs = [
{ prop: 'itemCode', label: '项目编码' },
{ prop: 'level', label: '层级' },
{ prop: 'childCount', label: '子项数量' },
{ prop: 'sortOrder', label: '排序号', defaultVisible: false },
{ prop: 'status', label: '状态' }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('bx-expense-item-list-columns', listColumnDefs)
const quickSearchKeyword = ref('')
const searchForm = reactive({
name: '',
code: '',
@@ -396,6 +460,7 @@ function buildSubmitPayload() {
}
function filterTreeNodes(nodes: any[]): any[] {
const qk = quickSearchKeyword.value.trim().toLowerCase()
return nodes
.map((node) => {
const children = node.children?.length ? filterTreeNodes(node.children) : []
@@ -403,7 +468,15 @@ function filterTreeNodes(nodes: any[]): any[] {
const codeOk = !searchForm.code || String(node.itemCode || '').includes(searchForm.code)
const levelOk = !searchForm.level || String(node.level) === searchForm.level
const statusOk = !searchForm.status || String(node.status) === searchForm.status
const selfMatch = nameOk && codeOk && levelOk && statusOk
const quickOk =
!qk ||
String(node.itemName || '')
.toLowerCase()
.includes(qk) ||
String(node.itemCode || '')
.toLowerCase()
.includes(qk)
const selfMatch = nameOk && codeOk && levelOk && statusOk && quickOk
if (selfMatch || children.length) {
return { ...node, children: children.length ? children : node.children?.length ? [] : undefined }
}
@@ -413,7 +486,8 @@ function filterTreeNodes(nodes: any[]): any[] {
}
const filteredTree = computed(() => {
const hasFilter = searchForm.name || searchForm.code || searchForm.level || searchForm.status
const hasFilter =
searchForm.name || searchForm.code || searchForm.level || searchForm.status || quickSearchKeyword.value.trim()
if (!hasFilter) return expenseTree.value
return filterTreeNodes(expenseTree.value)
})
@@ -597,10 +671,12 @@ function collapseAll() {
}
function handleSearch() {
quickSearchKeyword.value = ''
loadTree()
}
function handleReset() {
quickSearchKeyword.value = ''
searchForm.name = ''
searchForm.code = ''
searchForm.level = ''
@@ -608,6 +684,14 @@ function handleReset() {
loadTree()
}
function handleQuickSearch() {
/* filteredTree reacts to quickSearchKeyword */
}
function handleQuickSearchClear() {
quickSearchKeyword.value = ''
}
watch(
() => formData.level,
(level) => {
+192 -93
View File
@@ -2,13 +2,6 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<div class="stats">
<div v-for="item in stats" :key="item.name" class="stat" :class="{ 'stat-money': item.name.includes('金额') }">
<p class="k">{{ item.name }}</p>
<p class="v">{{ item.value }}</p>
</div>
</div>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">支付管理</h2>
@@ -16,7 +9,7 @@
<el-tabs
v-model="activeTab"
class="panel-pill-tabs panel-pill-tabs--lg"
class="panel-tabs"
@tab-change="handleTabChange"
>
<!-- 待处理 -->
@@ -67,13 +60,27 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="单号/报销标题"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-main" type="primary" @click="handleToolbarIssue">
<el-icon><Money /></el-icon>发放
</el-button>
<el-button class="btn-ghost" @click="handleToolbarOffset">
<el-icon><Switch /></el-icon>冲抵备用金
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="handleSearch" />
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="handleSearch"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -94,6 +101,7 @@
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applyTime')"
prop="applyTime"
label="申请时间"
min-width="160"
@@ -104,6 +112,7 @@
<el-table-column
prop="applyCode"
label="单号"
class-name="col-apply-code"
min-width="140"
align="center"
header-align="center"
@@ -113,8 +122,16 @@
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applicantName')"
prop="applicantName"
label="申请人"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('title')"
prop="title"
label="报销标题"
min-width="180"
@@ -123,6 +140,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('amount')"
prop="amount"
label="报销金额(元)"
:formatter="moneyTableFormatter"
@@ -131,12 +149,28 @@
align="right"
header-align="center"
/>
<el-table-column label="冲抵金额(元)" class-name="col-money" min-width="120" align="right" header-align="center">
<el-table-column
v-if="isColumnVisible('offsetAmount')"
prop="offsetAmount"
label="冲抵金额(元)"
class-name="col-money"
min-width="120"
align="right"
header-align="center"
>
<template #default="{ row }">
{{ formatSumMoney(Number(row.offsetAmount || 0)) }}
</template>
</el-table-column>
<el-table-column label="发放金额(元)" class-name="col-money" min-width="120" align="right" header-align="center">
<el-table-column
v-if="isColumnVisible('payAmount')"
prop="actualAmount"
label="发放金额(元)"
class-name="col-money"
min-width="120"
align="right"
header-align="center"
>
<template #default="{ row }">
{{
row.actualAmount != null && row.actualAmount !== ''
@@ -215,6 +249,26 @@
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
v-model="processedQuickSearchKeyword"
placeholder="单号/报销标题"
@search="handleProcessedQuickSearch"
@clear="handleProcessedQuickSearchClear"
/>
<ListToolbarEndActions
:loading="processedLoading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="handleProcessedSearch"
@reset-columns="resetToDefault"
/>
</div>
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary">
<el-table
@@ -229,6 +283,7 @@
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applyTime')"
prop="applyTime"
label="申请时间"
min-width="160"
@@ -239,6 +294,7 @@
<el-table-column
prop="applyCode"
label="单号"
class-name="col-apply-code"
min-width="140"
align="center"
header-align="center"
@@ -248,8 +304,16 @@
<el-link type="primary" @click="openProcessedView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applicantName')"
prop="applicantName"
label="申请人"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('title')"
prop="title"
label="标题"
min-width="180"
@@ -258,6 +322,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('amount')"
prop="amount"
label="报销金额(元)"
:formatter="moneyTableFormatter"
@@ -266,12 +331,28 @@
align="right"
header-align="center"
/>
<el-table-column label="冲抵金额(元)" class-name="col-money" min-width="120" align="right" header-align="center">
<el-table-column
v-if="isColumnVisible('offsetAmount')"
prop="offsetAmount"
label="冲抵金额(元)"
class-name="col-money"
min-width="120"
align="right"
header-align="center"
>
<template #default="{ row }">
{{ formatSumMoney(Number(row.offsetAmount || 0)) }}
</template>
</el-table-column>
<el-table-column label="发放金额(元)" class-name="col-money" min-width="120" align="right" header-align="center">
<el-table-column
v-if="isColumnVisible('payAmount')"
prop="actualAmount"
label="发放金额(元)"
class-name="col-money"
min-width="120"
align="right"
header-align="center"
>
<template #default="{ row }">
{{
row.actualAmount != null && row.actualAmount !== ''
@@ -280,14 +361,27 @@
}}
</template>
</el-table-column>
<el-table-column label="处理状态" min-width="100" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('processStatus')"
label="处理状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="processedStatusTagType(row.status, row.payStatus)" size="small">
{{ processedStatusLabel(row.status, row.payStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="payTime" label="处理时间" min-width="160" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('payTime')"
prop="payTime"
label="处理时间"
min-width="160"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ row.payTime || '-' }}
</template>
@@ -355,11 +449,6 @@
<BxAttachmentReadonly :files="viewAttachments" />
</div>
</div>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.viewDetail = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
<!-- 标记已发放 -->
@@ -372,7 +461,7 @@
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top">
<el-form label-position="right" label-width="100px" class="bx-dialog-form">
<div class="form-grid-2">
<el-form-item label="发放金额(元)" class="field-amount">
<el-input :model-value="String(currentRow.amount || '')" disabled placeholder="发放金额" />
@@ -417,7 +506,7 @@
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top">
<el-form label-position="right" label-width="100px" class="bx-dialog-form">
<h4 class="dialog-section-title">报销单信息</h4>
<div class="form-grid-2">
<el-form-item label="报销单号">
@@ -485,7 +574,7 @@
/>
</div>
<el-form-item label="备注" style="margin-top: 16px">
<el-form-item label="备注">
<el-input v-model="offsetRemark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
@@ -577,31 +666,20 @@
</BaseDetailTableSection>
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批进度</h4>
<div class="flow-scroll">
<ApprovalFlowView :nodes="processedApprovals" :loading="processedApprovalLoading" />
</div>
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批历史</h4>
<div class="flow-scroll">
<ApprovalHistoryView :items="processedApprovalHistory" :loading="processedApprovalLoading" />
</div>
</div>
<BxApprovalFlowTabs
:nodes="processedApprovals"
:history="processedApprovalHistory"
:loading="processedApprovalLoading"
/>
</div>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.processedDetail = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { reactive, ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
@@ -618,8 +696,7 @@ import { type BxAttachmentItem, loadBxAttachments } from '@/utils/bx/attachment'
import { statusLabel, statusTagType, formatSumMoney, formatMoney, formatMoneyYuan, moneyTableFormatter } from '@/utils/bx/format'
import { useServerPaged } from '@/hooks/bx/useServerPaged'
import { useApplicantOptions } from '@/hooks/bx/useApplicantOptions'
import ApprovalFlowView from '@/components/bx/base/ApprovalFlowView.vue'
import ApprovalHistoryView from '@/components/bx/base/ApprovalHistoryView.vue'
import BxApprovalFlowTabs from '@/components/bx/base/BxApprovalFlowTabs.vue'
import BxViewKvGrid from '@/components/bx/base/BxViewKvGrid.vue'
import BxViewKvTable from '@/components/bx/base/BxViewKvTable.vue'
import BaseDetailTableSection from '@/components/bx/base/BaseDetailTableSection.vue'
@@ -627,9 +704,29 @@ import BxAttachmentUpload from '@/components/bx/base/BxAttachmentUpload.vue'
import BxAttachmentReadonly from '@/components/bx/base/BxAttachmentReadonly.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import { bxViewKv, bxViewKvMoney } from '@/utils/bx/view-kv'
import { buildPageSummaryMethod, sumField } from '@/utils/bx/table-summary'
type DialogKey = 'viewDetail' | 'issue' | 'offset' | 'processedDetail'
const listColumnDefs = [
{ prop: 'applyTime', label: '申请时间' },
{ prop: 'applicantName', label: '申请人' },
{ prop: 'title', label: '报销标题' },
{ prop: 'amount', label: '报销金额(元)' },
{ prop: 'offsetAmount', label: '冲抵金额(元)', defaultVisible: false },
{ prop: 'payAmount', label: '发放金额(元)' },
{ prop: 'processStatus', label: '处理状态', defaultVisible: false },
{ prop: 'payTime', label: '处理时间', defaultVisible: false }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('bx-payment-list-columns', listColumnDefs)
const { applicantOptions, loadApplicantOptions } = useApplicantOptions()
const loadingMap = reactive<Record<string, boolean>>({})
const dialogs = reactive<Record<DialogKey, boolean>>({
@@ -652,28 +749,8 @@ const { rows, total, currentPage, pageSize, rangeText, loading, refresh, search,
}
)
const stats = ref([
{ name: '待处理单据', value: '-' },
{ name: '已打款', value: '-' },
{ name: '冲抵备用金', value: '-' },
{ name: '本月处理金额(元)', value: '-' }
])
async function loadPaymentFinanceStats() {
try {
const res: any = await paymentApi.financeStats()
const d = res.data
if (!d) return
stats.value = [
{ name: '待处理单据', value: String(d.pendingCount ?? 0) },
{ name: '已打款', value: String(d.paidCount ?? 0) },
{ name: '冲抵备用金', value: String(d.offsetCount ?? 0) },
{ name: '本月处理金额(元)', value: formatMoney(d.monthProcessedAmount ?? 0) }
]
} catch {
/* interceptor */
}
}
const quickSearchKeyword = ref('')
const processedQuickSearchKeyword = ref('')
const filterForm = reactive({
code: '',
@@ -836,7 +913,6 @@ const processedOffsetRecords = ref<any[]>([])
onMounted(() => {
loadApplicantOptions()
refresh()
loadPaymentFinanceStats()
})
function onSelectionChange(rows: any[]) {
@@ -859,20 +935,35 @@ function handleToolbarOffset() {
openOffsetDialog(selectedRows.value[0])
}
function handleSearch() {
function buildPendingQuery() {
const q: Record<string, string | number> = {}
const kw = filterForm.code.trim()
const kw = (quickSearchKeyword.value.trim() || filterForm.code.trim())
if (kw) q.keyword = kw
if (filterForm.applicantId != null) q.applicantId = filterForm.applicantId
if (filterForm.applyDate) q.applyDate = filterForm.applyDate
search(q)
return q
}
function handleSearch() {
quickSearchKeyword.value = ''
search(buildPendingQuery())
}
function handleReset() {
quickSearchKeyword.value = ''
Object.assign(filterForm, { code: '', applicantId: undefined, applyDate: null, status: '' })
search()
}
function handleQuickSearch() {
search(buildPendingQuery())
}
function handleQuickSearchClear() {
quickSearchKeyword.value = ''
search(buildPendingQuery())
}
function handleTabChange(tab: string | number) {
if (tab === 'processed' && !tabLoaded.processed) {
processedSearch()
@@ -880,15 +971,35 @@ function handleTabChange(tab: string | number) {
}
}
function buildProcessedQuery() {
const q: Record<string, string | number> = {}
const kw = (processedQuickSearchKeyword.value.trim() || processedFilterForm.code.trim())
if (kw) q.keyword = kw
if (processedFilterForm.applicantId != null) q.applicantId = processedFilterForm.applicantId
if (processedFilterForm.applyDate) q.applyDate = processedFilterForm.applyDate
return q
}
function handleProcessedSearch() {
processedSearch()
processedQuickSearchKeyword.value = ''
processedSearch(buildProcessedQuery())
}
function handleProcessedReset() {
processedQuickSearchKeyword.value = ''
Object.assign(processedFilterForm, { code: '', applicantId: undefined, applyDate: null, status: '' })
processedSearch()
}
function handleProcessedQuickSearch() {
processedSearch(buildProcessedQuery())
}
function handleProcessedQuickSearchClear() {
processedQuickSearchKeyword.value = ''
processedSearch(buildProcessedQuery())
}
async function openView(row: any) {
viewData.value = row
viewAttachments.value = []
@@ -928,7 +1039,6 @@ async function submitIssue() {
ElMessage.success('发放成功')
dialogs.issue = false
await refresh()
await loadPaymentFinanceStats()
} catch (e: any) {
ElMessage.error(e.message || '发放失败')
} finally {
@@ -1071,7 +1181,6 @@ async function submitOffset() {
ElMessage.success('冲抵成功')
dialogs.offset = false
await refresh()
await loadPaymentFinanceStats()
} catch (e: any) {
ElMessage.error(e.message || '冲抵失败')
} finally {
@@ -1079,25 +1188,15 @@ async function submitOffset() {
}
}
function summaryMethod(params: { data: any[] }) {
const data = params.data
const n = data.length
const tr = data.reduce((s, r) => s + Number(r.amount || 0), 0)
const toff = data.reduce((s, r) => s + Number(r.offsetAmount || 0), 0)
const tpay = data.reduce((s, r) => s + Number(r.actualAmount ?? 0), 0)
const lbl = `本页合计(${n}单)`
return ['', '', '', lbl, '', '', formatSumMoney(tr), formatSumMoney(toff), formatSumMoney(tpay), '']
}
function processedSummaryMethod(params: { data: any[] }) {
const data = params.data
const n = data.length
const tr = data.reduce((s, r) => s + Number(r.amount || 0), 0)
const toff = data.reduce((s, r) => s + Number(r.offsetAmount || 0), 0)
const tpay = data.reduce((s, r) => s + Number(r.actualAmount ?? 0), 0)
const lbl = `本页合计(${n}单)`
return ['', '', '', lbl, '', formatSumMoney(tr), formatSumMoney(toff), formatSumMoney(tpay), '', '', '']
}
const paymentSummary = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumField(data, 'amount'),
offsetAmount: (data) => sumField(data, 'offsetAmount'),
actualAmount: (data) => sumField(data, 'actualAmount')
}
})
const summaryMethod = paymentSummary
const processedSummaryMethod = paymentSummary
async function openProcessedView(row: any) {
processedViewData.value = row
@@ -2,13 +2,6 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<div class="stats">
<div v-for="item in stats" :key="item.name" class="stat" :class="{ 'stat-money': item.name.includes('金额') }">
<p class="k">{{ item.name }}</p>
<p class="v">{{ item.value }}</p>
</div>
</div>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">报销申请列表</h2>
@@ -60,6 +53,12 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="报销单号/标题/费用项目"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-main" type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>新增报销申请
</el-button>
@@ -89,7 +88,15 @@
<el-button class="btn-ghost" @click="openFlowDialog()">
<el-icon><View /></el-icon>查看审批流
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="handleSearch" />
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="handleSearch"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -110,6 +117,7 @@
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applyTime')"
prop="applyTime"
label="申请时间"
min-width="160"
@@ -120,6 +128,7 @@
<el-table-column
prop="applyCode"
label="报销单号"
class-name="col-apply-code"
min-width="140"
align="center"
header-align="center"
@@ -130,6 +139,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('title')"
prop="title"
label="报销标题"
min-width="180"
@@ -138,6 +148,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('expenseName')"
prop="expenseName"
label="费用项目"
min-width="140"
@@ -146,6 +157,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('amount')"
prop="amount"
label="报销金额(元)"
:formatter="moneyTableFormatter"
@@ -154,14 +166,28 @@
align="right"
header-align="center"
/>
<el-table-column prop="status" label="状态" min-width="110" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('status')"
prop="status"
label="状态"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="currentNode" label="当前节点" min-width="120" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('currentNode')"
prop="currentNode"
label="当前节点"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
v-if="shouldShowWorkflowNodeTag(row.currentNode, row.status)"
@@ -202,7 +228,7 @@
>
<p class="dlg-note">提交后进入顺序审批流程,可在「查看审批流」中确认节点与审批人。</p>
<el-form label-position="top">
<el-form label-position="right" label-width="100px" class="bx-dialog-form">
<div class="form-grid-2">
<el-form-item label="报销日期">
<el-date-picker
@@ -299,7 +325,7 @@
/>
</div>
<el-form label-position="top">
<el-form label-position="right" label-width="100px" class="bx-dialog-form">
<el-form-item label="备注说明">
<el-input v-model="addForm.remark" type="textarea" :rows="4" placeholder="请输入报销说明" />
</el-form-item>
@@ -424,25 +450,12 @@
<BxAttachmentReadonly :files="viewOffsetAttachments" />
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批进度</h4>
<div class="flow-scroll">
<ApprovalFlowView :nodes="viewFlowNodes" :loading="viewFlowLoading" />
</div>
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批历史</h4>
<div class="flow-scroll">
<ApprovalHistoryView :items="viewApprovalHistory" :loading="viewFlowLoading" />
</div>
</div>
<BxApprovalFlowTabs
:nodes="viewFlowNodes"
:history="viewApprovalHistory"
:loading="viewFlowLoading"
/>
</div>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.view = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
<el-dialog
@@ -453,29 +466,15 @@
align-center
destroy-on-close
>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批进度</h4>
<div class="flow-scroll">
<ApprovalFlowView :nodes="flowNodes" :loading="flowLoading" />
</div>
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批历史</h4>
<div class="flow-scroll">
<ApprovalHistoryView :items="flowHistory" :loading="flowLoading" />
</div>
</div>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.viewFlow = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
<BxApprovalFlowTabs :nodes="flowNodes" :history="flowHistory" :loading="flowLoading" />
</el-dialog>
</div>
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { reactive, ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
@@ -496,8 +495,6 @@ import { useExpenseItemTree, type ExpenseItem } from '@/hooks/bx/useExpenseItemT
import {
statusLabel,
statusTagType,
formatSumMoney,
formatMoney,
formatMoneyYuan,
moneyTableFormatter,
workflowNodeDisplay,
@@ -506,14 +503,14 @@ import {
shouldShowWorkflowNodeTag
} from '@/utils/bx/format'
import { useServerPaged } from '@/hooks/bx/useServerPaged'
import ApprovalFlowView from '@/components/bx/base/ApprovalFlowView.vue'
import ApprovalHistoryView from '@/components/bx/base/ApprovalHistoryView.vue'
import BxApprovalFlowTabs from '@/components/bx/base/BxApprovalFlowTabs.vue'
import BxViewKvGrid from '@/components/bx/base/BxViewKvGrid.vue'
import BxViewKvTable from '@/components/bx/base/BxViewKvTable.vue'
import BxAttachmentUpload from '@/components/bx/base/BxAttachmentUpload.vue'
import BxAttachmentReadonly from '@/components/bx/base/BxAttachmentReadonly.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import { bxViewKv, bxViewKvLong, bxViewKvMoney } from '@/utils/bx/view-kv'
import { buildPageSummaryMethod, sumField } from '@/utils/bx/table-summary'
type DialogKey = 'add' | 'view' | 'viewFlow'
@@ -549,37 +546,24 @@ function markDisabledExpenseNodes(tree: ExpenseItem[]): ExpenseItem[] {
const expenseTreeForSelect = computed(() => markDisabledExpenseNodes(expenseTree.value))
const myReimburseStats = ref({
pendingCount: 0,
monthApplyAmount: 0,
approvedAmount: 0,
approvedCount: 0
})
const quickSearchKeyword = ref('')
async function loadMyReimburseStats() {
try {
const res: any = await reimburseApi.myStats()
const d = res.data || {}
myReimburseStats.value = {
pendingCount: Number(d.pendingCount ?? 0),
monthApplyAmount: Number(d.monthApplyAmount ?? 0),
approvedAmount: Number(d.approvedAmount ?? 0),
approvedCount: Number(d.approvedCount ?? 0)
}
} catch {
/* 拦截器已提示 */
}
}
const listColumnDefs = [
{ prop: 'applyTime', label: '申请时间' },
{ prop: 'title', label: '报销标题' },
{ prop: 'expenseName', label: '费用项目' },
{ prop: 'amount', label: '报销金额(元)' },
{ prop: 'status', label: '状态' },
{ prop: 'currentNode', label: '当前节点', defaultVisible: false }
]
const stats = computed(() => {
const s = myReimburseStats.value
return [
{ name: '待审批申请', value: String(s.pendingCount) },
{ name: '本月申请金额(元)', value: formatMoney(s.monthApplyAmount) },
{ name: '已审批金额(元)', value: formatMoney(s.approvedAmount) },
{ name: '已通过申请', value: String(s.approvedCount) }
]
})
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('bx-reimburse-apply-list-columns', listColumnDefs)
const filterForm = reactive({
keyword: '',
@@ -730,7 +714,7 @@ async function handleToolbarDelete() {
await reimburseApi.delete(row.id)
}
ElMessage.success('删除成功')
await refreshListAndStats()
await refreshList()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e?.message || '删除失败')
@@ -796,26 +780,44 @@ function onSelectionChange(rows: any[]) {
selectedRows.value = rows
}
async function refreshListAndStats() {
async function refreshList() {
await refresh()
await loadMyReimburseStats()
}
onMounted(() => {
refreshListAndStats()
refreshList()
})
function handleSearch() {
const q: Record<string, string> = { keyword: filterForm.keyword, status: filterForm.status }
function buildSearchQuery() {
const q: Record<string, string> = {}
const kw = (quickSearchKeyword.value.trim() || filterForm.keyword.trim())
if (kw) q.keyword = kw
if (filterForm.status) q.status = filterForm.status
if (filterForm.expenseName) q.expenseName = filterForm.expenseName
if (filterForm.applyDate) q.applyDate = filterForm.applyDate
search(q)
return q
}
function handleSearch() {
quickSearchKeyword.value = ''
search(buildSearchQuery())
}
function handleReset() {
quickSearchKeyword.value = ''
Object.assign(filterForm, { keyword: '', status: '', expenseName: '', applyDate: null })
search()
}
function handleQuickSearch() {
search(buildSearchQuery())
}
function handleQuickSearchClear() {
quickSearchKeyword.value = ''
search(buildSearchQuery())
}
async function handleToolbarSubmit() {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先勾选要提交的报销单')
@@ -827,7 +829,7 @@ async function handleToolbarSubmit() {
await reimburseApi.submit(row.id)
}
ElMessage.success('提交成功')
await refreshListAndStats()
await refreshList()
} catch (e: any) {
ElMessage.error(e?.message || '提交失败')
} finally {
@@ -894,7 +896,7 @@ async function submitAdd() {
addForm.details = [{ expenseItemId: '', amount: '', remark: '' }]
uploadedFiles.value = []
editingId.value = null
await refreshListAndStats()
await refreshList()
} catch (e: any) {
ElMessage.error(e.message || (editingId.value ? '保存失败' : '新增失败'))
} finally {
@@ -982,7 +984,7 @@ async function handleSubmit(row: any) {
try {
await reimburseApi.submit(row.id)
ElMessage.success('提交成功')
await refreshListAndStats()
await refreshList()
} catch (e: any) {
ElMessage.error(e.message || '提交失败')
} finally {
@@ -1004,7 +1006,7 @@ async function handleDelete(row: any) {
loadingMap[`del_${row.id}`] = true
await reimburseApi.delete(row.id)
ElMessage.success('删除成功')
await refreshListAndStats()
await refreshList()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e?.message || '删除失败')
@@ -1014,13 +1016,11 @@ async function handleDelete(row: any) {
}
}
function summaryMethod(params: { data: any[] }) {
const data = params.data
const n = data.length
const totalAmt = data.reduce((s, r) => s + Number(r.amount || 0), 0)
const lbl = `本页合计(${n}单)`
return ['', '', '', lbl, '', '', formatSumMoney(totalAmt), '', '']
}
const summaryMethod = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumField(data, 'amount')
}
})
function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) {
const sums: string[] = []
+172 -134
View File
@@ -2,13 +2,6 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<div class="stats">
<div v-for="item in stats" :key="item.name" class="stat" :class="{ 'stat-money': item.name.includes('金额') }">
<p class="k">{{ item.name }}</p>
<p class="v">{{ item.value }}</p>
</div>
</div>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">报销审批</h2>
@@ -16,7 +9,7 @@
<el-tabs
v-model="activeTab"
class="panel-pill-tabs panel-pill-tabs--lg"
class="panel-tabs"
@tab-change="handleTabChange"
>
<!-- 待我审批 -->
@@ -64,41 +57,19 @@
style="width: 100%"
/>
</el-form-item>
</div>
<div
v-show="showMorePendingFilters"
id="reimburse-approve-pending-filter-extra"
class="filter-extra"
>
<div class="filter-grid">
<el-form-item label="当前节点">
<el-select v-model="pendingFilterForm.currentNode" placeholder="请选择节点" clearable>
<el-option label="发起人主管审批" value="发起人主管审批" />
<el-option label="财务复核" value="财务复核" />
<el-option label="总经理审批" value="总经理审批" />
</el-select>
</el-form-item>
<el-form-item label="金额区间(元)">
<el-input v-model="pendingFilterForm.amountRange" placeholder="如 1000-5000" clearable />
</el-form-item>
</div>
<el-form-item label="当前节点">
<el-select v-model="pendingFilterForm.currentNode" placeholder="请选择节点" clearable>
<el-option label="发起人主管审批" value="发起人主管审批" />
<el-option label="财务复核" value="财务复核" />
<el-option label="总经理审批" value="总经理审批" />
</el-select>
</el-form-item>
<el-form-item label="金额区间(元)">
<el-input v-model="pendingFilterForm.amountRange" placeholder="如 1000-5000" clearable />
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-start">
<button
type="button"
class="btn-link btn-filter-toggle"
:aria-expanded="showMorePendingFilters"
aria-controls="reimburse-approve-pending-filter-extra"
@click="showMorePendingFilters = !showMorePendingFilters"
>
{{ showMorePendingFilters ? '收起更多' : '展开更多' }}
<el-icon>
<ArrowUpBold v-if="showMorePendingFilters" />
<ArrowDownBold v-else />
</el-icon>
</button>
</div>
<div class="filter-actions-start" />
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="pendingLoading" @click="handlePendingReset">
<el-icon><RefreshLeft /></el-icon>重置
@@ -116,6 +87,12 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<ListQuickSearchInput
v-model="pendingQuickSearchKeyword"
placeholder="报销单号/报销标题"
@search="handlePendingQuickSearch"
@clear="handlePendingQuickSearchClear"
/>
<el-button class="btn-main" type="primary" @click="handleBatchPass">
<el-icon><Check /></el-icon>通过
</el-button>
@@ -123,7 +100,15 @@
<el-icon><Close /></el-icon>驳回
</el-button>
<ListToolbarEndActions :loading="pendingLoading" @refresh="refreshPending" />
<ListToolbarEndActions
:loading="pendingLoading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="refreshPending"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -143,6 +128,7 @@
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applyTime')"
prop="applyTime"
label="申请时间"
min-width="160"
@@ -153,6 +139,7 @@
<el-table-column
prop="applyCode"
label="报销单号"
class-name="col-apply-code"
min-width="140"
align="center"
header-align="center"
@@ -162,8 +149,16 @@
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applicantName')"
prop="applicantName"
label="申请人"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('title')"
prop="title"
label="报销标题"
min-width="180"
@@ -172,6 +167,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('expenseName')"
prop="expenseName"
label="费用项目"
min-width="140"
@@ -180,6 +176,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('amount')"
prop="amount"
label="报销金额(元)"
:formatter="moneyTableFormatter"
@@ -188,7 +185,14 @@
align="right"
header-align="center"
/>
<el-table-column prop="currentNode" label="当前节点" min-width="120" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('currentNode')"
prop="currentNode"
label="当前节点"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
v-if="shouldShowWorkflowNodeTag(row.currentNode, row.status)"
@@ -262,41 +266,19 @@
style="width: 100%"
/>
</el-form-item>
</div>
<div
v-show="showMoreApprovedFilters"
id="reimburse-approve-approved-filter-extra"
class="filter-extra"
>
<div class="filter-grid">
<el-form-item label="当前节点">
<el-select v-model="approvedFilterForm.currentNode" placeholder="请选择节点" clearable>
<el-option label="发起人主管审批" value="发起人主管审批" />
<el-option label="财务复核" value="财务复核" />
<el-option label="总经理审批" value="总经理审批" />
</el-select>
</el-form-item>
<el-form-item label="金额区间(元)">
<el-input v-model="approvedFilterForm.amountRange" placeholder="如 1000-5000" clearable />
</el-form-item>
</div>
<el-form-item label="当前节点">
<el-select v-model="approvedFilterForm.currentNode" placeholder="请选择节点" clearable>
<el-option label="发起人主管审批" value="发起人主管审批" />
<el-option label="财务复核" value="财务复核" />
<el-option label="总经理审批" value="总经理审批" />
</el-select>
</el-form-item>
<el-form-item label="金额区间(元)">
<el-input v-model="approvedFilterForm.amountRange" placeholder="如 1000-5000" clearable />
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-start">
<button
type="button"
class="btn-link btn-filter-toggle"
:aria-expanded="showMoreApprovedFilters"
aria-controls="reimburse-approve-approved-filter-extra"
@click="showMoreApprovedFilters = !showMoreApprovedFilters"
>
{{ showMoreApprovedFilters ? '收起更多' : '展开更多' }}
<el-icon>
<ArrowUpBold v-if="showMoreApprovedFilters" />
<ArrowDownBold v-else />
</el-icon>
</button>
</div>
<div class="filter-actions-start" />
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="approvedLoading" @click="handleApprovedReset">
<el-icon><RefreshLeft /></el-icon>重置
@@ -309,6 +291,26 @@
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
v-model="approvedQuickSearchKeyword"
placeholder="报销单号/报销标题"
@search="handleApprovedQuickSearch"
@clear="handleApprovedQuickSearchClear"
/>
<ListToolbarEndActions
:loading="approvedLoading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="handleApprovedSearch"
@reset-columns="resetToDefault"
/>
</div>
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary">
<el-table
@@ -323,6 +325,7 @@
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applyTime')"
prop="applyTime"
label="申请时间"
min-width="160"
@@ -333,6 +336,7 @@
<el-table-column
prop="applyCode"
label="报销单号"
class-name="col-apply-code"
min-width="140"
align="center"
header-align="center"
@@ -342,8 +346,16 @@
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applicantName')"
prop="applicantName"
label="申请人"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('title')"
prop="title"
label="报销标题"
min-width="180"
@@ -352,6 +364,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('expenseName')"
prop="expenseName"
label="费用项目"
min-width="140"
@@ -360,6 +373,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('amount')"
prop="amount"
label="报销金额(元)"
:formatter="moneyTableFormatter"
@@ -368,14 +382,28 @@
align="right"
header-align="center"
/>
<el-table-column prop="status" label="状态" min-width="110" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('status')"
prop="status"
label="状态"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="currentNode" label="当前节点" min-width="120" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('currentNode')"
prop="currentNode"
label="当前节点"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
v-if="shouldShowWorkflowNodeTag(row.currentNode, row.status)"
@@ -473,25 +501,12 @@
<BxAttachmentReadonly :files="viewAttachments" />
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批进度</h4>
<div class="flow-scroll">
<ApprovalFlowView :nodes="viewFlowNodes" :loading="viewFlowLoading" />
</div>
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批历史</h4>
<div class="flow-scroll">
<ApprovalHistoryView :items="viewApprovalHistory" :loading="viewFlowLoading" />
</div>
</div>
<BxApprovalFlowTabs
:nodes="viewFlowNodes"
:history="viewApprovalHistory"
:loading="viewFlowLoading"
/>
</div>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.viewDetail = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
<!-- 审批确认 -->
@@ -504,7 +519,7 @@
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top">
<el-form label-position="right" label-width="100px" class="bx-dialog-form">
<div class="form-grid-2">
<el-form-item label="审批时间">
<el-input :model-value="approveTime" disabled />
@@ -538,25 +553,23 @@
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { ElMessage } from 'element-plus'
import {
View,
Check,
Close,
Search,
RefreshLeft,
ArrowUpBold,
ArrowDownBold
RefreshLeft
} from '@element-plus/icons-vue'
import { reimburseApi } from '@/api/bx'
import { type BxAttachmentItem, loadBxAttachments } from '@/utils/bx/attachment'
import { reimburseApproverStats, refreshApproveMenuBadges } from '@/hooks/bx/useApproveMenuBadge'
import { refreshApproveMenuBadges } from '@/hooks/bx/useApproveMenuBadge'
import { useMenuStore } from '@/store/modules/menu'
import {
statusLabel,
statusTagType,
formatSumMoney,
formatMoney,
formatMoneyYuan,
moneyTableFormatter,
workflowNodeDisplay,
@@ -567,17 +580,36 @@ import {
import { useServerPaged } from '@/hooks/bx/useServerPaged'
import { useExpenseItemTree } from '@/hooks/bx/useExpenseItemTree'
import { useApplicantOptions } from '@/hooks/bx/useApplicantOptions'
import ApprovalFlowView from '@/components/bx/base/ApprovalFlowView.vue'
import ApprovalHistoryView from '@/components/bx/base/ApprovalHistoryView.vue'
import BxApprovalFlowTabs from '@/components/bx/base/BxApprovalFlowTabs.vue'
import BxViewKvTable from '@/components/bx/base/BxViewKvTable.vue'
import BxAttachmentReadonly from '@/components/bx/base/BxAttachmentReadonly.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import { bxViewKv, bxViewKvLong } from '@/utils/bx/view-kv'
import { buildPageSummaryMethod, sumField } from '@/utils/bx/table-summary'
type DialogKey = 'viewDetail' | 'approve'
const showMorePendingFilters = ref(false)
const showMoreApprovedFilters = ref(false)
const listColumnDefs = [
{ prop: 'applyTime', label: '申请时间' },
{ prop: 'applicantName', label: '申请人' },
{ prop: 'title', label: '报销标题' },
{ prop: 'expenseName', label: '费用项目' },
{ prop: 'amount', label: '报销金额(元)' },
{ prop: 'status', label: '状态', defaultVisible: false },
{ prop: 'currentNode', label: '当前节点', defaultVisible: false }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('bx-reimburse-approve-list-columns', listColumnDefs)
const pendingQuickSearchKeyword = ref('')
const approvedQuickSearchKeyword = ref('')
const { applicantOptions, loadApplicantOptions } = useApplicantOptions()
const { expenseTree } = useExpenseItemTree()
const loadingMap = reactive<Record<string, boolean>>({})
@@ -619,14 +651,6 @@ const tabLoaded = reactive({ pending: false, approved: false })
const activeTab = ref('pending')
const menuStore = useMenuStore()
const stats = computed(() => {
const s = reimburseApproverStats.value
return [
{ name: '待审批', value: String(s.pendingTotal) },
{ name: '本月审批金额(元)', value: formatMoney(s.monthPendingAmount) }
]
})
const viewData = ref<Record<string, any>>({})
const viewMainKvFields = computed<ViewField[]>(() => {
@@ -684,7 +708,7 @@ const approvedFilterForm = reactive({
function buildPendingQuery() {
const q: Record<string, string | number> = {}
const code = pendingFilterForm.code.trim()
const code = (pendingQuickSearchKeyword.value.trim() || pendingFilterForm.code.trim())
if (code) q.keyword = code
if (pendingFilterForm.applicantId != null) q.applicantId = pendingFilterForm.applicantId
if (pendingFilterForm.applyDate) q.applyDate = pendingFilterForm.applyDate
@@ -693,7 +717,7 @@ function buildPendingQuery() {
function buildApprovedQuery() {
const q: Record<string, string | number> = {}
const code = approvedFilterForm.code.trim()
const code = (approvedQuickSearchKeyword.value.trim() || approvedFilterForm.code.trim())
if (code) q.keyword = code
if (approvedFilterForm.applicantId != null) q.applicantId = approvedFilterForm.applicantId
if (approvedFilterForm.applyDate) q.applyDate = approvedFilterForm.applyDate
@@ -719,10 +743,12 @@ function handleTabChange(tab: string | number) {
}
function handlePendingSearch() {
pendingQuickSearchKeyword.value = ''
searchPending(buildPendingQuery())
}
function handlePendingReset() {
pendingQuickSearchKeyword.value = ''
Object.assign(pendingFilterForm, {
code: '',
applicantId: undefined,
@@ -734,11 +760,22 @@ function handlePendingReset() {
searchPending()
}
function handlePendingQuickSearch() {
searchPending(buildPendingQuery())
}
function handlePendingQuickSearchClear() {
pendingQuickSearchKeyword.value = ''
searchPending(buildPendingQuery())
}
function handleApprovedSearch() {
approvedQuickSearchKeyword.value = ''
searchApproved(buildApprovedQuery())
}
function handleApprovedReset() {
approvedQuickSearchKeyword.value = ''
Object.assign(approvedFilterForm, {
code: '',
applicantId: undefined,
@@ -750,6 +787,15 @@ function handleApprovedReset() {
searchApproved()
}
function handleApprovedQuickSearch() {
searchApproved(buildApprovedQuery())
}
function handleApprovedQuickSearchClear() {
approvedQuickSearchKeyword.value = ''
searchApproved(buildApprovedQuery())
}
const selectedRows = ref<any[]>([])
function onSelectionChange(rows: any[]) {
@@ -823,21 +869,13 @@ async function handleConfirmApprove() {
}
}
function pendingSummaryMethod(params: { data: any[] }) {
const data = params.data
const n = data.length
const t = data.reduce((s, r) => s + Number(r.amount || 0), 0)
const lbl = `本页合计(${n}单)`
return ['', '', '', lbl, '', '', '', formatSumMoney(t), '', '']
}
function approvedSummaryMethod(params: { data: any[] }) {
const data = params.data
const n = data.length
const t = data.reduce((s, r) => s + Number(r.amount || 0), 0)
const lbl = `本页合计(${n}单)`
return ['', '', '', lbl, '', '', formatSumMoney(t), '', '', '']
}
const amountSummary = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumField(data, 'amount')
}
})
const pendingSummaryMethod = amountSummary
const approvedSummaryMethod = amountSummary
function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) {
const sums: string[] = []
+79 -124
View File
@@ -2,13 +2,6 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<div class="stats">
<div v-for="item in stats" :key="item.name" class="stat" :class="{ 'stat-money': item.name.includes('金额') }">
<p class="k">{{ item.name }}</p>
<p class="v">{{ item.value }}</p>
</div>
</div>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">全部申请单</h2>
@@ -40,45 +33,27 @@
<el-option v-for="s in statusOptions" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
</div>
<div v-show="showMoreFilters" id="all-apply-filter-extra" class="filter-extra">
<div class="filter-grid">
<el-form-item label="申请时间">
<el-date-picker
v-model="filterForm.applyDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item label="金额区间(元)">
<el-input v-model="filterForm.amountRange" placeholder="如 1000-5000" clearable />
</el-form-item>
<el-form-item label="当前节点">
<el-select v-model="filterForm.currentNode" placeholder="全部" clearable>
<el-option v-for="n in nodeOptions" :key="n" :label="n" :value="n" />
</el-select>
</el-form-item>
</div>
<el-form-item label="申请时间">
<el-date-picker
v-model="filterForm.applyDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item label="金额区间(元)">
<el-input v-model="filterForm.amountRange" placeholder="如 1000-5000" clearable />
</el-form-item>
<el-form-item label="当前节点">
<el-select v-model="filterForm.currentNode" placeholder="全部" clearable>
<el-option v-for="n in nodeOptions" :key="n" :label="n" :value="n" />
</el-select>
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-start">
<button
type="button"
class="btn-link btn-filter-toggle"
:aria-expanded="showMoreFilters"
aria-controls="all-apply-filter-extra"
@click="toggleMoreFilters"
>
{{ showMoreFilters ? '收起更多' : '展开更多' }}
<el-icon>
<ArrowUpBold v-if="showMoreFilters" />
<ArrowDownBold v-else />
</el-icon>
</button>
</div>
<div class="filter-actions-start" />
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
@@ -93,6 +68,12 @@
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="单号/标题/申请人"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
@@ -106,7 +87,7 @@
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary">
<div class="table-scroll table-page-summary table-page-summary-biz-split">
<el-table
class="list-table"
:data="pager.pagedRows.value"
@@ -115,7 +96,10 @@
show-summary
:summary-method="summaryMethod"
:max-height="'min(520px, calc(100vh - 300px))'"
@row-dblclick="openViewDetail"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applyTime')"
@@ -129,6 +113,7 @@
<el-table-column
prop="applyCode"
label="申请单号"
class-name="col-apply-code"
min-width="140"
align="center"
header-align="center"
@@ -175,7 +160,7 @@
label="申请金额(元)"
:formatter="moneyTableFormatter"
class-name="col-money"
min-width="120"
min-width="240"
align="right"
header-align="center"
/>
@@ -259,41 +244,28 @@
<h4 class="dlg-view-section-title">基础信息</h4>
<BxViewKvTable :fields="viewDetailKvFields" mode="triple" />
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批进度</h4>
<div class="flow-scroll">
<ApprovalFlowView :nodes="viewFlowNodes" :loading="viewFlowLoading" />
</div>
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批历史</h4>
<div class="flow-scroll">
<ApprovalHistoryView :items="viewApprovalHistory" :loading="viewFlowLoading" />
</div>
</div>
<BxApprovalFlowTabs
:nodes="viewFlowNodes"
:history="viewApprovalHistory"
:loading="viewFlowLoading"
/>
</div>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.viewDetail = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Close, Search, RefreshLeft, ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue'
import { Close, Search, RefreshLeft } from '@element-plus/icons-vue'
import { advanceApi, reimburseApi } from '@/api/bx'
import {
bizTypeTagType,
statusLabel,
statusTagType,
formatSumMoney,
formatMoneyYuan,
moneyTableFormatter,
workflowNodeDisplay,
@@ -301,18 +273,18 @@ import {
workflowNodeTagType,
shouldShowWorkflowNodeTag
} from '@/utils/bx/format'
import ApprovalFlowView from '@/components/bx/base/ApprovalFlowView.vue'
import ApprovalHistoryView from '@/components/bx/base/ApprovalHistoryView.vue'
import BxApprovalFlowTabs from '@/components/bx/base/BxApprovalFlowTabs.vue'
import BxViewKvTable from '@/components/bx/base/BxViewKvTable.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import { bxViewKv, bxViewKvMoney } from '@/utils/bx/view-kv'
import { buildPageSummaryMethod, sumFieldByBizType } from '@/utils/bx/table-summary'
import { usePagedTable } from '@/hooks/bx/usePagedTable'
import { useApplicantOptions } from '@/hooks/bx/useApplicantOptions'
type DialogKey = 'viewDetail'
const showMoreFilters = ref(false)
const loading = ref(false)
const selectedRows = ref<any[]>([])
const dialogs = reactive<Record<DialogKey, boolean>>({
viewDetail: false
})
@@ -338,6 +310,8 @@ const {
resetToDefault
} = usePersistedListColumns('bx-view-all-list-columns', listColumnDefs)
const quickSearchKeyword = ref('')
const filterForm = reactive({
code: '',
bizType: '' as string,
@@ -350,6 +324,21 @@ const filterForm = reactive({
function filterMergedRows(rows: any[]) {
let list = [...rows]
const quickKw = quickSearchKeyword.value.trim().toLowerCase()
if (quickKw) {
list = list.filter(
(r: any) =>
String(r.applyCode || '')
.toLowerCase()
.includes(quickKw) ||
String(r.title || '')
.toLowerCase()
.includes(quickKw) ||
String(r.applicantName || '')
.toLowerCase()
.includes(quickKw)
)
}
const code = filterForm.code.trim()
if (code) list = list.filter((r: any) => String(r.applyCode || '').includes(code))
if (filterForm.bizType && filterForm.bizType !== '全部') {
@@ -415,35 +404,16 @@ const viewFlowNodes = ref<any[]>([])
const viewApprovalHistory = ref<any[]>([])
const viewFlowLoading = ref(false)
const stats = ref([
{ name: '全部单据', value: '-' },
{ name: '审批中', value: '-' },
{ name: '已通过', value: '-' },
{ name: '已发放', value: '-' },
{ name: '已驳回', value: '-' }
])
const bizTypeOptions = ['全部', '报销单', '备用金']
const statusOptions = ['全部', '待提交', '审批中', '审批通过', '审批驳回', '已发放']
const nodeOptions = ['全部', '草稿', '发起人主管审批', '财务复核', '总经理审批', '已完成', '已驳回', '已发放']
function countStatus(m: Record<string, number> | undefined, key: string) {
return Number(m?.[key] ?? 0)
}
function sumStatusMap(m: Record<string, number> | undefined) {
if (!m) return 0
return Object.values(m).reduce((acc, v) => acc + Number(v || 0), 0)
}
async function loadData() {
loading.value = true
try {
const [advRes, reimRes, advCntRes, reimCntRes]: any[] = await Promise.all([
const [advRes, reimRes]: any[] = await Promise.all([
advanceApi.list({ page: 1, pageSize: 999 }),
reimburseApi.list({ page: 1, pageSize: 999 }),
advanceApi.statusCounts(),
reimburseApi.statusCounts()
reimburseApi.list({ page: 1, pageSize: 999 })
])
const advRows = (advRes.data?.rows || []).map((r: any) => ({
...r,
@@ -462,24 +432,6 @@ async function loadData() {
applicantId: r.applicantId
}))
mergedRows.value = [...advRows, ...reimRows]
const ac = advCntRes.data as Record<string, number> | undefined
const rc = reimCntRes.data as Record<string, number> | undefined
const totalAll = sumStatusMap(rc) + sumStatusMap(ac)
const pending = countStatus(rc, 'pending') + countStatus(ac, 'pending')
const approved = countStatus(rc, 'approved') + countStatus(ac, 'approved')
const issued =
countStatus(rc, 'processed') +
countStatus(rc, 'paid') +
countStatus(rc, 'offset') +
countStatus(ac, 'issued')
const rejected = countStatus(rc, 'rejected') + countStatus(ac, 'rejected')
stats.value = [
{ name: '全部单据', value: String(totalAll) },
{ name: '审批中', value: String(pending) },
{ name: '已通过', value: String(approved) },
{ name: '已发放', value: String(issued) },
{ name: '已驳回', value: String(rejected) }
]
} catch {
// error handled by interceptor
} finally {
@@ -487,15 +439,13 @@ async function loadData() {
}
}
function toggleMoreFilters() {
showMoreFilters.value = !showMoreFilters.value
}
function handleSearch() {
quickSearchKeyword.value = ''
pager.currentPage.value = 1
}
function handleReset() {
quickSearchKeyword.value = ''
Object.assign(filterForm, {
code: '',
bizType: '',
@@ -508,18 +458,23 @@ function handleReset() {
pager.currentPage.value = 1
}
function summaryMethod(params: { columns: { property?: string; type?: string }[]; data: Array<{ amount?: number }> }) {
const totalAmount = params.data.reduce((sum, row) => sum + Number(row.amount || 0), 0)
let labelPlaced = false
return params.columns.map((col) => {
if (col.type === 'index') return ''
if (!labelPlaced) {
labelPlaced = true
return `本页合计(${params.data.length}单)`
}
if (col.property === 'amount') return formatSumMoney(totalAmount)
return ''
})
function handleQuickSearch() {
pager.currentPage.value = 1
}
function handleQuickSearchClear() {
quickSearchKeyword.value = ''
pager.currentPage.value = 1
}
const summaryMethod = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumFieldByBizType(data, 'amount')
}
})
function handleSelectionChange(rows: any[]) {
selectedRows.value = rows
}
async function openViewDetail(row: any) {
+63 -80
View File
@@ -2,13 +2,6 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<div class="stats">
<div v-for="item in stats" :key="item.name" class="stat" :class="{ 'stat-money': item.name.includes('金额') }">
<p class="k">{{ item.name }}</p>
<p class="v">{{ item.value }}</p>
</div>
</div>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">我的申请单</h2>
@@ -47,30 +40,12 @@
style="width: 100%"
/>
</el-form-item>
</div>
<div v-show="showMoreFilters" id="mine-apply-filter-extra" class="filter-extra">
<div class="filter-grid">
<el-form-item label="金额区间(元)">
<el-input v-model="filterForm.amountRange" placeholder="如 1000-5000" clearable />
</el-form-item>
</div>
<el-form-item label="金额区间(元)">
<el-input v-model="filterForm.amountRange" placeholder="如 1000-5000" clearable />
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-start">
<button
type="button"
class="btn-link btn-filter-toggle"
:aria-expanded="showMoreFilters"
aria-controls="mine-apply-filter-extra"
@click="toggleMoreFilters"
>
{{ showMoreFilters ? '收起更多' : '展开更多' }}
<el-icon>
<ArrowUpBold v-if="showMoreFilters" />
<ArrowDownBold v-else />
</el-icon>
</button>
</div>
<div class="filter-actions-start" />
<div class="filter-actions-end">
<el-button native-type="button" class="btn-ghost" :loading="searchLoading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
@@ -85,6 +60,12 @@
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="单号/申请标题"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<ListToolbarEndActions
:loading="tableLoading"
:column-defs="listColumnDefs"
@@ -98,14 +79,19 @@
</div>
<div class="list-body list-wrap">
<div class="table-scroll">
<div class="table-scroll table-page-summary table-page-summary-biz-split">
<el-table
class="list-table"
:data="pagedRows"
v-loading="tableLoading"
stripe
show-summary
:summary-method="summaryMethod"
:max-height="'min(520px, calc(100vh - 300px))'"
@row-dblclick="openViewDetail"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applyTime')"
@@ -157,7 +143,7 @@
label="申请金额(元)"
:formatter="moneyTableFormatter"
class-name="col-money"
min-width="120"
min-width="240"
align="right"
header-align="center"
/>
@@ -240,35 +226,23 @@
<h4 class="dlg-view-section-title">基础信息</h4>
<BxViewKvTable :fields="viewDetailKvFields" mode="triple" />
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批进度</h4>
<div class="flow-scroll">
<ApprovalFlowView :nodes="viewFlowNodes" :loading="viewFlowLoading" />
</div>
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批历史</h4>
<div class="flow-scroll">
<ApprovalHistoryView :items="viewApprovalHistory" :loading="viewFlowLoading" />
</div>
</div>
<BxApprovalFlowTabs
:nodes="viewFlowNodes"
:history="viewApprovalHistory"
:loading="viewFlowLoading"
/>
</div>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.viewDetail = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Close, Search, RefreshLeft, ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue'
import { Close, Search, RefreshLeft } from '@element-plus/icons-vue'
import { advanceApi, reimburseApi } from '@/api/bx'
import {
bizTypeTagType,
@@ -281,18 +255,18 @@ import {
workflowNodeTagType,
shouldShowWorkflowNodeTag
} from '@/utils/bx/format'
import ApprovalFlowView from '@/components/bx/base/ApprovalFlowView.vue'
import ApprovalHistoryView from '@/components/bx/base/ApprovalHistoryView.vue'
import BxApprovalFlowTabs from '@/components/bx/base/BxApprovalFlowTabs.vue'
import BxViewKvTable from '@/components/bx/base/BxViewKvTable.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import { bxViewKv, bxViewKvMoney } from '@/utils/bx/view-kv'
import { buildPageSummaryMethod, sumFieldByBizType } from '@/utils/bx/table-summary'
import { usePagedTable } from '@/hooks/bx/usePagedTable'
type DialogKey = 'viewDetail'
const showMoreFilters = ref(false)
const tableLoading = ref(false)
const searchLoading = ref(false)
const selectedRows = ref<any[]>([])
const dialogs = reactive<Record<DialogKey, boolean>>({
viewDetail: false
})
@@ -316,6 +290,8 @@ const {
resetToDefault
} = usePersistedListColumns('bx-view-mine-list-columns', listColumnDefs)
const quickSearchKeyword = ref('')
const filterForm = reactive({
code: '',
bizType: '' as string,
@@ -338,6 +314,13 @@ function syncAppliedFilter() {
function filterMineRows(rows: any[]) {
let list = [...rows]
const kw = appliedFilter.code.trim()
if (kw) {
list = list.filter(
(r: any) =>
String(r.applyCode || '').includes(kw) || String(r.title || '').includes(kw)
)
}
if (appliedFilter.bizType) {
list = list.filter((r: any) => r.bizType === appliedFilter.bizType)
}
@@ -383,13 +366,6 @@ const viewFlowNodes = ref<any[]>([])
const viewApprovalHistory = ref<any[]>([])
const viewFlowLoading = ref(false)
const stats = ref([
{ name: '全部申请', value: '-' },
{ name: '审批中', value: '-' },
{ name: '已通过', value: '-' },
{ name: '已驳回', value: '-' }
])
function buildApiQuery() {
const q: Record<string, string> = {}
const code = appliedFilter.code.trim()
@@ -421,17 +397,21 @@ function sortMergedRows(rows: any[]) {
return [...rows].sort((a, b) => String(b.applyTime || '').localeCompare(String(a.applyTime || '')))
}
const summaryMethod = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumFieldByBizType(data, 'amount')
}
})
async function loadData(options: { buttonLoading?: boolean } = {}) {
if (options.buttonLoading) searchLoading.value = true
tableLoading.value = true
try {
const apiQuery = buildApiQuery()
const listParams = { page: 1, pageSize: 9999, ...apiQuery }
const [advRes, reimRes, advSt, reimSt] = await Promise.allSettled([
const [advRes, reimRes] = await Promise.allSettled([
advanceApi.my(listParams),
reimburseApi.my(listParams),
advanceApi.myStats(),
reimburseApi.myStats()
reimburseApi.my(listParams)
])
const advRows =
advRes.status === 'fulfilled'
@@ -442,19 +422,6 @@ async function loadData(options: { buttonLoading?: boolean } = {}) {
? (reimRes.value.data?.rows || []).map(mapReimburseMineRow)
: []
mergedRows.value = sortMergedRows([...advRows, ...reimRows])
const a = advSt.status === 'fulfilled' ? advSt.value.data || {} : {}
const r = reimSt.status === 'fulfilled' ? reimSt.value.data || {} : {}
const totalCount = Number(r.totalCount ?? 0) + Number(a.totalCount ?? 0)
const pending = Number(r.pendingCount ?? 0) + Number(a.pendingCount ?? 0)
const approved =
Number(r.approvedCount ?? 0) + Number(a.approvedPassCount ?? 0) + Number(a.issuedCount ?? 0)
const rejected = Number(r.rejectedCount ?? 0) + Number(a.rejectedCount ?? 0)
stats.value = [
{ name: '全部申请', value: String(totalCount) },
{ name: '审批中', value: String(pending) },
{ name: '已通过', value: String(approved) },
{ name: '已驳回', value: String(rejected) }
]
} catch {
// error handled by interceptor
} finally {
@@ -463,23 +430,39 @@ async function loadData(options: { buttonLoading?: boolean } = {}) {
}
}
function toggleMoreFilters() {
showMoreFilters.value = !showMoreFilters.value
}
function handleSearch() {
quickSearchKeyword.value = ''
syncAppliedFilter()
currentPage.value = 1
loadData({ buttonLoading: true })
}
function handleReset() {
quickSearchKeyword.value = ''
Object.assign(filterForm, { code: '', bizType: '', status: '', applyDate: null, amountRange: '' })
Object.assign(appliedFilter, { code: '', bizType: '', status: '', applyDate: null, amountRange: '' })
currentPage.value = 1
loadData({ buttonLoading: true })
}
function handleQuickSearch() {
appliedFilter.code = quickSearchKeyword.value.trim()
currentPage.value = 1
loadData()
}
function handleQuickSearchClear() {
quickSearchKeyword.value = ''
appliedFilter.code = ''
currentPage.value = 1
loadData()
}
function handleSelectionChange(rows: any[]) {
selectedRows.value = rows
}
async function openViewDetail(row: any) {
currentRow.value = row
viewFlowNodes.value = []
+105 -84
View File
@@ -2,13 +2,6 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<div class="stats">
<div v-for="item in stats" :key="item.name" class="stat" :class="{ 'stat-money': item.name.includes('金额') }">
<p class="k">{{ item.name }}</p>
<p class="v">{{ item.value }}</p>
</div>
</div>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">待我审批</h2>
@@ -46,30 +39,12 @@
style="width: 100%"
/>
</el-form-item>
</div>
<div v-show="showMoreFilters" id="todo-approve-filter-extra" class="filter-extra">
<div class="filter-grid">
<el-form-item label="金额区间(元)">
<el-input v-model="filterForm.amountRange" placeholder="如 1000-5000" clearable />
</el-form-item>
</div>
<el-form-item label="金额区间(元)">
<el-input v-model="filterForm.amountRange" placeholder="如 1000-5000" clearable />
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-start">
<button
type="button"
class="btn-link btn-filter-toggle"
:aria-expanded="showMoreFilters"
aria-controls="todo-approve-filter-extra"
@click="toggleMoreFilters"
>
{{ showMoreFilters ? '收起更多' : '展开更多' }}
<el-icon>
<ArrowUpBold v-if="showMoreFilters" />
<ArrowDownBold v-else />
</el-icon>
</button>
</div>
<div class="filter-actions-start" />
<div class="filter-actions-end">
<el-button native-type="button" class="btn-ghost" :loading="searchLoading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
@@ -87,6 +62,12 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="单号/标题/申请人"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-main" type="primary" :loading="loadingMap.batchPass" @click="handleBatchPass">
<el-icon><Check /></el-icon>批量通过
</el-button>
@@ -94,7 +75,15 @@
<el-icon><Close /></el-icon>批量驳回
</el-button>
<ListToolbarEndActions :loading="tableLoading" @refresh="loadData" />
<ListToolbarEndActions
:loading="tableLoading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="loadData"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -114,6 +103,7 @@
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applyTime')"
prop="applyTime"
label="申请时间"
min-width="160"
@@ -124,6 +114,7 @@
<el-table-column
prop="applyCode"
label="申请单号"
class-name="col-apply-code"
min-width="140"
align="center"
header-align="center"
@@ -133,15 +124,30 @@
<el-link type="primary" @click="openViewDetail(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="bizType" label="业务类型" min-width="100" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('bizType')"
prop="bizType"
label="业务类型"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="bizTypeTagType(row.bizType)" size="small">
{{ row.bizType }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applicantName')"
prop="applicantName"
label="申请人"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('title')"
prop="title"
label="申请标题"
min-width="180"
@@ -150,6 +156,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('amount')"
prop="amount"
label="申请金额(元)"
:formatter="moneyTableFormatter"
@@ -158,7 +165,14 @@
align="right"
header-align="center"
/>
<el-table-column prop="currentNode" label="当前节点" min-width="120" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('currentNode')"
prop="currentNode"
label="当前节点"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
v-if="shouldShowWorkflowNodeTag(row.currentNode, row.status)"
@@ -234,25 +248,12 @@
<h4 class="dlg-view-section-title">基础信息</h4>
<BxViewKvTable :fields="viewDetailKvFields" mode="triple" />
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批进度</h4>
<div class="flow-scroll">
<ApprovalFlowView :nodes="viewFlowNodes" :loading="viewFlowLoading" />
</div>
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">审批历史</h4>
<div class="flow-scroll">
<ApprovalHistoryView :items="viewApprovalHistory" :loading="viewFlowLoading" />
</div>
</div>
<BxApprovalFlowTabs
:nodes="viewFlowNodes"
:history="viewApprovalHistory"
:loading="viewFlowLoading"
/>
</div>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.viewDetail = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
<el-dialog
@@ -264,7 +265,7 @@
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top">
<el-form label-position="right" label-width="100px" class="bx-dialog-form">
<el-form-item label="审批意见">
<el-input v-model="approveOpinion" type="textarea" :rows="4" placeholder="请输入审批意见" />
</el-form-item>
@@ -286,20 +287,19 @@
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
Check,
Close,
Search,
RefreshLeft,
ArrowUpBold,
ArrowDownBold
RefreshLeft
} from '@element-plus/icons-vue'
import { advanceApi, reimburseApi } from '@/api/bx'
import {
bizTypeTagType,
formatSumMoney,
formatMoneyYuan,
moneyTableFormatter,
workflowNodeDisplay,
@@ -307,17 +307,16 @@ import {
workflowNodeLabel,
workflowNodeTagType
} from '@/utils/bx/format'
import ApprovalFlowView from '@/components/bx/base/ApprovalFlowView.vue'
import ApprovalHistoryView from '@/components/bx/base/ApprovalHistoryView.vue'
import BxApprovalFlowTabs from '@/components/bx/base/BxApprovalFlowTabs.vue'
import BxViewKvTable from '@/components/bx/base/BxViewKvTable.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import { bxViewKv, bxViewKvMoney } from '@/utils/bx/view-kv'
import { buildPageSummaryMethod, sumField } from '@/utils/bx/table-summary'
import { usePagedTable } from '@/hooks/bx/usePagedTable'
import { useApplicantOptions } from '@/hooks/bx/useApplicantOptions'
type DialogKey = 'viewDetail' | 'approve'
const showMoreFilters = ref(false)
const tableLoading = ref(false)
const searchLoading = ref(false)
const loadingMap = reactive<Record<string, boolean>>({})
@@ -329,6 +328,25 @@ const dialogs = reactive<Record<DialogKey, boolean>>({
const mergedRows = ref<any[]>([])
const { applicantOptions, loadApplicantOptions } = useApplicantOptions()
const quickSearchKeyword = ref('')
const listColumnDefs = [
{ prop: 'applyTime', label: '申请时间' },
{ prop: 'bizType', label: '业务类型' },
{ prop: 'applicantName', label: '申请人' },
{ prop: 'title', label: '申请标题' },
{ prop: 'amount', label: '申请金额(元)' },
{ prop: 'currentNode', label: '当前节点', defaultVisible: false }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('bx-view-todo-list-columns', listColumnDefs)
const filterForm = reactive({
code: '',
bizType: '' as string,
@@ -351,6 +369,15 @@ function syncAppliedFilter() {
function filterTodoRows(rows: any[]) {
let list = [...rows]
const kw = appliedFilter.code.trim()
if (kw) {
list = list.filter(
(r: any) =>
String(r.applyCode || '').includes(kw) ||
String(r.title || '').includes(kw) ||
String(r.applicantName || '').includes(kw)
)
}
if (appliedFilter.bizType) {
list = list.filter((r: any) => r.bizType === appliedFilter.bizType)
}
@@ -397,14 +424,6 @@ const viewFlowNodes = ref<any[]>([])
const viewApprovalHistory = ref<any[]>([])
const viewFlowLoading = ref(false)
const stats = ref([
{ name: '待审批报销', value: '-' },
{ name: '待审批备用金', value: '-' },
{ name: '待审批合计', value: '-' },
{ name: '紧急单据', value: '0' }
])
function buildApiQuery() {
const q: Record<string, string | number> = {}
const code = appliedFilter.code.trim()
@@ -452,16 +471,6 @@ async function loadData(options: { buttonLoading?: boolean } = {}) {
const reimRows =
reimRes.status === 'fulfilled' ? (reimRes.value.data?.rows || []).map(mapReimburseTodoRow) : []
mergedRows.value = [...advRows, ...reimRows]
const reimTotal =
reimRes.status === 'fulfilled' ? Number(reimRes.value.data?.total ?? reimRows.length) : 0
const advTotal =
advRes.status === 'fulfilled' ? Number(advRes.value.data?.total ?? advRows.length) : 0
stats.value = [
{ name: '待审批报销', value: String(reimTotal) },
{ name: '待审批备用金', value: String(advTotal) },
{ name: '待审批合计', value: String(reimTotal + advTotal) },
{ name: '紧急单据', value: '0' }
]
} catch {
// error handled by interceptor
} finally {
@@ -523,10 +532,6 @@ async function handleDialogReject() {
}
}
function toggleMoreFilters() {
showMoreFilters.value = !showMoreFilters.value
}
const selectedRows = ref<any[]>([])
function onSelectionChange(rows: any[]) {
@@ -574,12 +579,14 @@ async function handleBatchReject() {
}
function handleSearch() {
quickSearchKeyword.value = ''
syncAppliedFilter()
currentPage.value = 1
loadData({ buttonLoading: true })
}
function handleReset() {
quickSearchKeyword.value = ''
Object.assign(filterForm, {
code: '',
bizType: '',
@@ -598,11 +605,25 @@ function handleReset() {
loadData({ buttonLoading: true })
}
function summaryMethod(params: { data: Array<{ amount?: number }> }) {
const totalAmount = params.data.reduce((sum, row) => sum + Number(row.amount || 0), 0)
return ['', '', `本页合计(${params.data.length}单)`, '', '', '', '', formatSumMoney(totalAmount), '', '']
function handleQuickSearch() {
appliedFilter.code = quickSearchKeyword.value.trim()
currentPage.value = 1
loadData()
}
function handleQuickSearchClear() {
quickSearchKeyword.value = ''
appliedFilter.code = ''
currentPage.value = 1
loadData()
}
const summaryMethod = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumField(data, 'amount')
}
})
function openViewDetailFromRow(row: any) {
openViewDetail(row)
}
@@ -22,6 +22,12 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="节点名称/审批人"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-main" type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>新增节点
</el-button>
@@ -52,17 +58,26 @@
<el-icon><Upload /></el-icon>保存配置
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="loadWfDef" />
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="loadWfDef"
@reset-columns="resetToDefault"
/>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="nodes"
:data="filteredNodes"
v-loading="loading"
stripe
@selection-change="onSelectionChange"
@row-dblclick="openEditDialog"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -74,8 +89,16 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column prop="nodeOrder" label="节点顺序" min-width="100" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('nodeOrder')"
prop="nodeOrder"
label="节点顺序"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('approverName')"
prop="approverName"
label="审批人"
min-width="120"
@@ -83,7 +106,13 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="配置状态" min-width="110" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('configStatus')"
label="配置状态"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="(row.approverId ?? row.approver_id) ? 'success' : 'warning'" size="small">
{{ (row.approverId ?? row.approver_id) ? '已配置' : '未配置' }}
@@ -107,7 +136,9 @@
ref="addFormRef"
:model="formData"
:rules="formRules"
label-position="top"
label-position="right"
label-width="100px"
class="bx-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -155,7 +186,9 @@
ref="editFormRef"
:model="formData"
:rules="formRules"
label-position="top"
label-position="right"
label-width="100px"
class="bx-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -195,6 +228,8 @@
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
@@ -220,10 +255,39 @@ const dialogs = reactive<Record<DialogKey, boolean>>({
editNode: false
})
const quickSearchKeyword = ref('')
const listColumnDefs = [
{ prop: 'nodeOrder', label: '节点顺序' },
{ prop: 'approverName', label: '审批人' },
{ prop: 'configStatus', label: '配置状态' }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('bx-workflow-advance-list-columns', listColumnDefs)
const loading = ref(false)
const selectedRows = ref<Record<string, any>[]>([])
const wfDef = ref<Record<string, any>>({})
const nodes = computed(() => wfDef.value.nodes || [])
const allNodes = computed(() => wfDef.value.nodes || [])
const filteredNodes = computed(() => {
const kw = quickSearchKeyword.value.trim().toLowerCase()
if (!kw) return allNodes.value
return allNodes.value.filter(
(n: any) =>
String(n.nodeName || '')
.toLowerCase()
.includes(kw) ||
String(n.approverName || '')
.toLowerCase()
.includes(kw)
)
})
const nodes = allNodes
const userOptions = ref<
{ id: string; realName: string; username: string; roles: string; displayName: string }[]
>([])
@@ -283,6 +347,14 @@ function onSelectionChange(rows: Record<string, any>[]) {
selectedRows.value = rows
}
function handleQuickSearch() {
/* filteredNodes reacts to quickSearchKeyword */
}
function handleQuickSearchClear() {
quickSearchKeyword.value = ''
}
function handleToolbarEdit() {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请先勾选一条节点')
@@ -22,6 +22,12 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="节点名称/审批人"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-main" type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>新增节点
</el-button>
@@ -52,17 +58,26 @@
<el-icon><Upload /></el-icon>保存配置
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="loadWfDef" />
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="loadWfDef"
@reset-columns="resetToDefault"
/>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="nodes"
:data="filteredNodes"
v-loading="loading"
stripe
@selection-change="onSelectionChange"
@row-dblclick="openEditDialog"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -74,8 +89,16 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column prop="nodeOrder" label="节点顺序" min-width="100" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('nodeOrder')"
prop="nodeOrder"
label="节点顺序"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('approverName')"
prop="approverName"
label="审批人"
min-width="120"
@@ -83,7 +106,13 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="配置状态" min-width="110" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('configStatus')"
label="配置状态"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="(row.approverId ?? row.approver_id) ? 'success' : 'warning'" size="small">
{{ (row.approverId ?? row.approver_id) ? '已配置' : '未配置' }}
@@ -107,7 +136,9 @@
ref="addFormRef"
:model="formData"
:rules="formRules"
label-position="top"
label-position="right"
label-width="100px"
class="bx-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -155,7 +186,9 @@
ref="editFormRef"
:model="formData"
:rules="formRules"
label-position="top"
label-position="right"
label-width="100px"
class="bx-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -195,6 +228,8 @@
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
@@ -220,10 +255,39 @@ const dialogs = reactive<Record<DialogKey, boolean>>({
editNode: false
})
const quickSearchKeyword = ref('')
const listColumnDefs = [
{ prop: 'nodeOrder', label: '节点顺序' },
{ prop: 'approverName', label: '审批人' },
{ prop: 'configStatus', label: '配置状态' }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('bx-workflow-reimburse-list-columns', listColumnDefs)
const loading = ref(false)
const selectedRows = ref<Record<string, any>[]>([])
const wfDef = ref<Record<string, any>>({})
const nodes = computed(() => wfDef.value.nodes || [])
const allNodes = computed(() => wfDef.value.nodes || [])
const filteredNodes = computed(() => {
const kw = quickSearchKeyword.value.trim().toLowerCase()
if (!kw) return allNodes.value
return allNodes.value.filter(
(n: any) =>
String(n.nodeName || '')
.toLowerCase()
.includes(kw) ||
String(n.approverName || '')
.toLowerCase()
.includes(kw)
)
})
const nodes = allNodes
const userOptions = ref<
{ id: string; realName: string; username: string; roles: string; displayName: string }[]
>([])
@@ -283,6 +347,14 @@ function onSelectionChange(rows: Record<string, any>[]) {
selectedRows.value = rows
}
function handleQuickSearch() {
/* filteredNodes reacts to quickSearchKeyword */
}
function handleQuickSearchClear() {
quickSearchKeyword.value = ''
}
function handleToolbarEdit() {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请先勾选一条节点')
+3 -1
View File
@@ -17,7 +17,9 @@
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
label-position="right"
label-width="100px"
class="crm-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
+3 -1
View File
@@ -39,7 +39,9 @@
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
label-position="right"
label-width="100px"
class="crm-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
+68 -62
View File
@@ -15,7 +15,7 @@
<el-button
v-if="hasCluePerm('edit')"
class="btn-ghost"
:disabled="clueInfo.isConverted === 1"
:disabled="clueInfo.isConverted === 1 || clueInfo.status === 'invalid'"
@click="handleEdit"
>
<el-icon><Edit /></el-icon>修改
@@ -141,10 +141,8 @@
import { computed, ref } from 'vue'
import { User, Edit, Delete, Plus, Refresh, CircleClose } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/modules/user'
import SystemViewDialog, {
type ViewField,
type ViewSection
} from '@/components/business/system-view-dialog/index.vue'
import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { crmDictField, crmField, crmLongField } from '@/utils/crm/view-fields'
import { getClue, listClueFollow, delClueFollow, delClue } from '@/api/crm/clue'
import type { ClueVO, ClueFollowVO, ClueFollowQuery } from '@/api/crm/clue/types'
import { useDictStore } from '@/store/modules/dict'
@@ -200,29 +198,6 @@
return parts.join(' · ')
})
function tagField(
label: string,
dictType: string,
value?: string | null,
extra?: Partial<ViewField>
): ViewField {
return {
label,
value: value ? dictStore.getLabel(dictType, value) : '—',
tagType: value ? dictStore.getTagType(dictType, value) : undefined,
...extra
}
}
function viewTableSection(title: string, fields: ViewField[]): ViewSection {
return {
title,
fields,
fieldLayout: 'table',
tableMode: 'dual'
}
}
function parseClueRemark(remark?: string | null) {
if (!remark?.trim()) return { remarkText: '', invalidReason: '' }
const marker = '无效原因:'
@@ -240,60 +215,68 @@
const address = [c.province, c.city, c.district, c.address].filter(Boolean).join(' ')
const basicFields: ViewField[] = [
{ label: '公司名称', value: c.company },
{ label: '手机号', value: c.mobile },
{ label: '职位', value: c.position },
{ label: '邮箱', value: c.email },
tagField('客户类型', 'crm_customer_type', c.customerType),
{ label: '客户网站', value: c.website, span: true },
tagField('所属行业', 'crm_customer_industry', c.industry),
tagField('所在园区', 'crm_customer_park', c.parkArea),
{ label: '地址', value: address, span: true }
const basicFields = [
crmField('公司名称', c.company),
crmField('手机号', c.mobile),
crmField('职位', c.position),
crmField('邮箱', c.email),
crmDictField('客户类型', 'crm_customer_type', c.customerType, dictStore),
crmField('客户网站', c.website, { long: false }),
crmDictField('所属行业', 'crm_customer_industry', c.industry, dictStore),
crmDictField('所在园区', 'crm_customer_park', c.parkArea, dictStore),
crmLongField('地址', address)
]
const clueFields: ViewField[] = [
tagField('线索等级', 'crm_clue_level', c.level),
tagField('线索来源', 'crm_clue_source', c.source),
tagField('线索状态', 'crm_clue_status', c.status),
{ label: '负责人', value: c.ownerUserName },
{ label: '下次联系时间', value: c.nextTime },
{ label: '最后跟进时间', value: c.lastTime },
{ label: '跟进次数', value: `${c.followCount ?? 0} 次` },
{ label: '创建人', value: (c as any).createByName || (c as any).createBy },
{ label: '创建时间', value: c.createTime }
const clueFields = [
crmDictField('线索等级', 'crm_clue_level', c.level, dictStore),
crmDictField('线索来源', 'crm_clue_source', c.source, dictStore),
crmDictField('线索状态', 'crm_clue_status', c.status, dictStore),
crmField('负责人', c.ownerUserName),
crmField('下次联系时间', c.nextTime, { long: false }),
crmField('最后跟进时间', c.lastTime, { long: false }),
crmField('跟进次数', `${c.followCount ?? 0} 次`, { span: 'full' }),
crmField('创建人', (c as any).createByName || (c as any).createBy),
crmField('创建时间', c.createTime, { long: false })
]
if (c.isConverted === 1) {
clueFields.push({ label: '转化时间', value: c.convertedTime })
clueFields.push(crmField('转化时间', c.convertedTime))
}
const sections: ViewSection[] = [
viewTableSection('基本信息', basicFields),
viewTableSection('线索信息', clueFields)
{ title: '基本信息', fields: basicFields, fieldLayout: 'table', tableMode: 'dual' },
{ title: '线索信息', fields: clueFields, fieldLayout: 'table', tableMode: 'dual' }
]
if (c.intentProducts || c.requirement) {
sections.push(
viewTableSection('需求信息', [
{ label: '意向产品', value: c.intentProducts },
{ label: '需求描述', value: c.requirement }
])
)
sections.push({
title: '需求信息',
fieldLayout: 'table',
tableMode: 'dual',
fields: [
crmField('意向产品', c.intentProducts, { long: false, fullSpan: false }),
crmField('需求描述', c.requirement, { long: false, fullSpan: false })
]
})
}
const { remarkText, invalidReason } = parseClueRemark(c.remark)
if (remarkText || invalidReason) {
const remarkFields: ViewField[] = []
const remarkFields = []
if (remarkText) {
remarkFields.push({ label: '备注', value: remarkText })
remarkFields.push(crmLongField('备注', remarkText, { fullSpan: true }))
}
if (invalidReason) {
remarkFields.push({ label: '无效原因', value: invalidReason })
remarkFields.push(crmLongField('无效原因', invalidReason, { fullSpan: true }))
}
sections.push(viewTableSection('备注信息', remarkFields))
sections.push({ title: '备注信息', fields: remarkFields, fieldLayout: 'table', tableMode: 'dual' })
} else if (c.remark) {
sections.push(viewTableSection('备注信息', [{ label: '备注', value: c.remark }]))
sections.push({
title: '备注信息',
fieldLayout: 'table',
tableMode: 'dual',
fields: [crmLongField('备注', c.remark, { fullSpan: true })]
})
}
return sections
@@ -525,5 +508,28 @@
.view-split-right .clue-view-follow-section {
padding: 0;
}
/* 左侧信息区:两列表格模式(dual = 一行两组标签/值) */
.view-split-left .view-kv-table-wrap {
border-color: #eef4f1;
border-radius: 8px;
}
.view-split-left .view-kv-table th {
background: #f8fafc;
font-size: 12px;
padding: 8px 10px;
}
.view-split-left .view-kv-table td {
font-size: 13px;
padding: 8px 12px;
}
.view-split-left .view-kv-table tr.row-long td {
font-weight: 400;
color: #475569;
line-height: 1.55;
}
}
</style>
+3 -1
View File
@@ -13,7 +13,9 @@
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
label-position="right"
label-width="100px"
class="crm-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
+4 -2
View File
@@ -13,7 +13,9 @@
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
label-position="right"
label-width="100px"
class="crm-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -34,7 +36,7 @@
<el-form-item label="公司名称" prop="company">
<el-input v-model="form.company" placeholder="请输入公司名称" maxlength="200" />
</el-form-item>
<el-form-item label="客户类型" prop="customerType" class="form-field-span-2">
<el-form-item label="客户类型" prop="customerType">
<el-select v-model="form.customerType" placeholder="请选择客户类型" clearable>
<el-option
v-for="opt in dictStore.getOptions('crm_customer_type')"
+62 -96
View File
@@ -8,7 +8,7 @@
<el-tabs
v-model="activeView"
class="panel-pill-tabs panel-pill-tabs--lg"
class="panel-tabs"
@tab-change="handleViewChange"
>
<el-tab-pane label="我的线索" name="mine" />
@@ -55,78 +55,60 @@
/>
</el-select>
</el-form-item>
</div>
<div v-show="filterExpanded" id="clue-filter-extra" class="filter-extra">
<div class="filter-grid">
<el-form-item label="线索等级">
<el-select v-model="searchForm.level" placeholder="全部" clearable>
<el-option
v-for="opt in dictStore.getOptions('crm_clue_level')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="线索状态">
<el-select v-model="searchForm.status" placeholder="全部" clearable>
<el-option
v-for="opt in dictStore.getOptions('crm_clue_status')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="所属行业">
<el-select v-model="searchForm.industry" placeholder="全部" clearable>
<el-option
v-for="opt in dictStore.getOptions('crm_customer_industry')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="所在园区">
<el-select v-model="searchForm.parkArea" placeholder="全部" clearable filterable>
<el-option
v-for="opt in dictStore.getOptions('crm_customer_park')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
<el-form-item label="线索等级">
<el-select v-model="searchForm.level" placeholder="全部" clearable>
<el-option
v-for="opt in dictStore.getOptions('crm_clue_level')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-form-item>
</div>
</el-select>
</el-form-item>
<el-form-item label="线索状态">
<el-select v-model="searchForm.status" placeholder="全部" clearable>
<el-option
v-for="opt in dictStore.getOptions('crm_clue_status')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="所属行业">
<el-select v-model="searchForm.industry" placeholder="全部" clearable>
<el-option
v-for="opt in dictStore.getOptions('crm_customer_industry')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="所在园区">
<el-select v-model="searchForm.parkArea" placeholder="全部" clearable filterable>
<el-option
v-for="opt in dictStore.getOptions('crm_customer_park')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-start">
<button
type="button"
class="btn-link btn-filter-toggle"
:aria-expanded="filterExpanded"
aria-controls="clue-filter-extra"
@click="filterExpanded = !filterExpanded"
>
{{ filterExpanded ? '收起更多' : '展开更多' }}
<el-icon>
<ArrowUpBold v-if="filterExpanded" />
<ArrowDownBold v-else />
</el-icon>
</button>
</div>
<div class="filter-actions-start" />
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
@@ -144,25 +126,23 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-input
<ListQuickSearchInput
v-model="quickSearchKeyword"
class="quick-search-input"
placeholder="姓名/公司/手机号"
clearable
@keyup.enter="handleQuickSearch"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
>
<template #suffix>
<el-icon class="quick-search-icon" @click="handleQuickSearch"><Search /></el-icon>
</template>
</el-input>
/>
<el-button v-if="hasCluePerm('add')" class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增线索
</el-button>
<el-button
v-if="hasCluePerm('edit')"
class="btn-ghost"
:disabled="single || selectedRows[0]?.isConverted === 1"
:disabled="
single ||
selectedRows[0]?.isConverted === 1 ||
selectedRows[0]?.status === 'invalid'
"
@click="handleEdit()"
>
<el-icon><Edit /></el-icon>编辑
@@ -454,7 +434,7 @@
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top">
<el-form label-position="right" label-width="100px" class="crm-dialog-form">
<el-form-item label="导入模板">
<el-button class="btn-ghost" :loading="templateLoading" @click="downloadTemplate">
<el-icon><Download /></el-icon>下载模板
@@ -504,8 +484,6 @@
Search,
RefreshLeft,
User,
ArrowUpBold,
ArrowDownBold,
Edit,
ChatLineRound,
Refresh,
@@ -534,6 +512,7 @@
import InvalidForm from './invalidForm.vue'
import DetailDrawer from './detailDrawer.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
const dictStore = useDictStore()
@@ -550,7 +529,6 @@
parkArea: ''
})
const dateRange = ref<[string, string]>()
const filterExpanded = ref(false)
const quickSearchKeyword = ref('')
const activeView = ref('mine')
@@ -687,7 +665,6 @@
searchForm.parkArea = ''
dateRange.value = undefined
quickSearchKeyword.value = ''
filterExpanded.value = false
pagination.page = 1
getList()
}
@@ -922,14 +899,3 @@
getList()
})
</script>
<style scoped lang="scss">
.quick-search-input {
width: 280px;
margin-right: 8px;
}
.quick-search-icon {
cursor: pointer;
}
</style>
+3 -1
View File
@@ -102,7 +102,9 @@
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
label-position="right"
label-width="100px"
class="crm-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -68,6 +68,12 @@
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="合同编号/合同名称/审批人"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button
v-if="hasContractPerm('approvalExport')"
class="btn-ghost"
@@ -77,7 +83,15 @@
<el-icon><Download /></el-icon>导出
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="getList" />
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="getList"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -89,7 +103,9 @@
stripe
row-key="id"
@row-dblclick="handleView"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
@@ -113,6 +129,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('contractName')"
prop="contractName"
label="合同名称"
min-width="200"
@@ -120,17 +137,18 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="审批级别" min-width="100" align="center" header-align="center">
<el-table-column label="审批级别" v-if="isColumnVisible('step')" min-width="100" align="center" header-align="center">
<template #default="{ row }">第{{ row.step }}级</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('approverName')"
prop="approverName"
label="审批人"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column label="审批结果" min-width="100" align="center" header-align="center">
<el-table-column label="审批结果" v-if="isColumnVisible('approvalResult')" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag v-if="row.approvalResult === 'approved'" type="success" size="small"
>通过</el-tag
@@ -142,6 +160,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('approvalOpinion')"
prop="approvalOpinion"
label="审批意见"
min-width="200"
@@ -149,7 +168,7 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="审批时间" min-width="160" align="center" header-align="center">
<el-table-column label="审批时间" v-if="isColumnVisible('approvalTime')" min-width="160" align="center" header-align="center">
<template #default="{ row }">{{ parseTime(row.approvalTime) }}</template>
</el-table-column>
</el-table>
@@ -197,7 +216,8 @@
v-model="approvalDialog.visible"
title="审批"
width="600px"
class="dialog-form-layout order-module-dialog-mask"
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
align-center
destroy-on-close
:close-on-click-modal="false"
@@ -207,7 +227,9 @@
ref="approvalFormRef"
:model="approvalForm"
:rules="approvalRules"
label-position="top"
label-position="right"
label-width="100px"
class="crm-dialog-form"
scroll-to-error
>
<div class="form-grid-2">
@@ -251,6 +273,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 { useRouter } from 'vue-router'
import {
@@ -279,10 +302,30 @@
import { parseTime } from '@/utils/format'
import { $modal } from '@/utils/modal'
import { useContractPerm } from '@/hooks/business/useContractPerm'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
const router = useRouter()
const { loadPerms, hasContractPerm } = useContractPerm()
const quickSearchKeyword = ref('')
const listColumnDefs = [
{ prop: 'contractName', label: '合同名称' },
{ prop: 'step', label: '审批级别' },
{ prop: 'approverName', label: '审批人' },
{ prop: 'approvalResult', label: '审批结果' },
{ prop: 'approvalOpinion', label: '审批意见' },
{ prop: 'approvalTime', label: '审批时间' }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('crm_contract_approval_visible_columns', listColumnDefs)
const queryParams = ref<ApprovalQuery>({
pageNum: 1,
pageSize: 20,
@@ -318,6 +361,7 @@
const approvalList = ref<ApprovalVO[]>([])
const loading = ref(true)
const selectedRows = ref<ApprovalVO[]>([])
const submitLoading = ref(false)
const exportLoading = ref(false)
const total = ref(0)
@@ -390,16 +434,40 @@
}
const handleQuery = () => {
quickSearchKeyword.value = ''
queryParams.value.pageNum = 1
getList()
}
const resetQuery = () => {
dateRange.value = undefined
quickSearchKeyword.value = ''
queryFormRef.value?.resetFields()
handleQuery()
}
const handleQuickSearch = () => {
const keyword = quickSearchKeyword.value?.trim()
queryParams.value.contractNo = keyword || undefined
queryParams.value.contractName = keyword || undefined
queryParams.value.approverName = keyword || undefined
queryParams.value.pageNum = 1
getList()
}
const handleQuickSearchClear = () => {
quickSearchKeyword.value = ''
queryParams.value.contractNo = undefined
queryParams.value.contractName = undefined
queryParams.value.approverName = undefined
queryParams.value.pageNum = 1
getList()
}
const handleSelectionChange = (rows: ApprovalVO[]) => {
selectedRows.value = rows
}
const handleView = async (row: ApprovalVO) => {
const res = await getApproval(row.id)
viewDialog.data = res.data
@@ -442,6 +510,7 @@
}
approvalDialog.visible = false
await getList()
await refreshViewDialogIfOpen()
} catch (error) {
console.error(error)
} finally {
@@ -450,6 +519,12 @@
})
}
async function refreshViewDialogIfOpen() {
if (!viewDialog.visible || !viewDialog.data?.id) return
const res = await getApproval(viewDialog.data.id)
viewDialog.data = res.data
}
const handleExport = async () => {
if (dateRange.value && dateRange.value.length === 2) {
queryParams.value.beginTime = dateRange.value[0]
@@ -73,6 +73,12 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="合同编号/合同名称"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button
v-if="hasContractPerm('changeAdd')"
class="btn-main"
@@ -85,8 +91,18 @@
v-if="hasContractPerm('changeRemove')"
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0 || !allRowsDeletableByCreator(selectedRows)"
:title="allRowsDeletableByCreator(selectedRows) ? '' : CREATOR_DELETE_TIP"
:disabled="
selectedRows.length === 0 ||
!allRowsDeletableByCreator(selectedRows) ||
!selectedRows.every((row) => row.approvalStatus === 'pending')
"
:title="
selectedRows.length && !selectedRows.every((row) => row.approvalStatus === 'pending')
? '只能删除待审批状态的变更记录'
: allRowsDeletableByCreator(selectedRows)
? ''
: CREATOR_DELETE_TIP
"
@click="handleDelete()"
>
<el-icon><Delete /></el-icon>删除
@@ -124,7 +140,15 @@
<el-icon><Download /></el-icon>导出
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="getList" />
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="getList"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -162,6 +186,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('contractName')"
prop="contractName"
label="合同名称"
min-width="180"
@@ -169,7 +194,7 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="变更类型" min-width="120" align="center" header-align="center">
<el-table-column label="变更类型" v-if="isColumnVisible('changeType')" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('crm_contract_change_type', row.changeType)"
@@ -180,6 +205,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('changeReason')"
prop="changeReason"
label="变更原因"
min-width="200"
@@ -187,13 +213,13 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="变更金额" min-width="130" align="center" header-align="center">
<el-table-column label="变更金额" v-if="isColumnVisible('changeAmount')" min-width="130" align="center" header-align="center">
<template #default="{ row }">
<span v-if="row.changeAmount">¥{{ row.changeAmount.toLocaleString() }}</span>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="审批状态" min-width="100" align="center" header-align="center">
<el-table-column label="审批状态" v-if="isColumnVisible('approvalStatus')" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag v-if="row.approvalStatus === 'pending'" type="warning" size="small"
>待审批</el-tag
@@ -207,13 +233,14 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('approverName')"
prop="approverName"
label="审批人"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column label="创建时间" min-width="160" align="center" header-align="center">
<el-table-column label="创建时间" v-if="isColumnVisible('createTime')" min-width="160" align="center" header-align="center">
<template #default="{ row }">{{ parseTime(row.createTime) }}</template>
</el-table-column>
</el-table>
@@ -235,7 +262,8 @@
v-model="dialog.visible"
:title="dialog.title"
width="800px"
class="dialog-form-layout order-module-dialog-mask"
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
align-center
destroy-on-close
:close-on-click-modal="false"
@@ -246,7 +274,9 @@
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
label-position="right"
label-width="100px"
class="crm-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -388,6 +418,7 @@
<script setup lang="ts" name="ContractChange">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
@@ -431,11 +462,32 @@
allRowsDeletableByCreator,
CREATOR_DELETE_TIP
} from '@/utils/creator-delete'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
const router = useRouter()
const dictStore = useDictStore()
const { loadPerms, hasContractPerm } = useContractPerm()
const quickSearchKeyword = ref('')
const listColumnDefs = [
{ prop: 'contractName', label: '合同名称' },
{ prop: 'changeType', label: '变更类型' },
{ prop: 'changeReason', label: '变更原因' },
{ prop: 'changeAmount', label: '变更金额' },
{ prop: 'approvalStatus', label: '审批状态' },
{ prop: 'approverName', label: '审批人' },
{ prop: 'createTime', label: '创建时间' }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('crm_contract_change_visible_columns', listColumnDefs)
const queryParams = ref<ChangeQuery>({
pageNum: 1,
pageSize: 20,
@@ -567,16 +619,34 @@
}
const handleQuery = () => {
quickSearchKeyword.value = ''
queryParams.value.pageNum = 1
getList()
}
const resetQuery = () => {
dateRange.value = undefined
quickSearchKeyword.value = ''
queryFormRef.value?.resetFields()
handleQuery()
}
const handleQuickSearch = () => {
const keyword = quickSearchKeyword.value?.trim()
queryParams.value.contractNo = keyword || undefined
queryParams.value.contractName = keyword || undefined
queryParams.value.pageNum = 1
getList()
}
const handleQuickSearchClear = () => {
quickSearchKeyword.value = ''
queryParams.value.contractNo = undefined
queryParams.value.contractName = undefined
queryParams.value.pageNum = 1
getList()
}
const handleSelectionChange = (selection: ChangeVO[]) => {
selectedRows.value = selection
ids.value = selection.map((item) => item.id)
@@ -594,6 +664,12 @@
viewDialog.visible = true
}
async function refreshViewDialogIfOpen() {
if (!viewDialog.visible || !viewDialog.data?.id) return
const res = await getChange(viewDialog.data.id)
viewDialog.data = res.data
}
const handleViewContract = (row: ChangeVO) => {
router.push({
path: '/crm/contract/detail',
@@ -615,6 +691,7 @@
}
dialog.visible = false
await getList()
await refreshViewDialogIfOpen()
} catch (error) {
console.error(error)
} finally {
@@ -625,20 +702,26 @@
const handleDelete = async (row?: ChangeVO) => {
const targets = row ? [row] : selectedRows.value
const deletable = filterDeletableByCreator(targets)
const pendingTargets = targets.filter((item) => item.approvalStatus === 'pending')
if (!pendingTargets.length) {
$modal.msgWarning('只能删除待审批状态的变更记录')
return
}
const deletable = filterDeletableByCreator(pendingTargets)
if (!deletable.length) {
$modal.msgWarning(CREATOR_DELETE_TIP)
return
}
if (deletable.length < targets.length) {
if (deletable.length < pendingTargets.length) {
$modal.msgWarning(CREATOR_DELETE_TIP)
return
}
const idList = row?.id ? [row.id] : ids.value
const idList = row?.id ? [row.id] : pendingTargets.map((item) => item.id)
await $modal.confirm('是否确认删除选中的变更记录?')
await delChange(idList)
$modal.msgSuccess('删除成功')
await getList()
await refreshViewDialogIfOpen()
}
async function handleDetailDelete() {
@@ -669,6 +752,7 @@
await submitChangeApproval({ id: row.id, approverIds: [] })
$modal.msgSuccess('提交成功')
await getList()
await refreshViewDialogIfOpen()
}
const handleExecute = async (row: ChangeVO) => {
@@ -676,6 +760,7 @@
await executeChange(row.id)
$modal.msgSuccess('执行成功')
await getList()
await refreshViewDialogIfOpen()
}
const handleWithdraw = async (row: ChangeVO) => {
@@ -683,6 +768,7 @@
await withdrawChange(row.id)
$modal.msgSuccess('撤回成功')
await getList()
await refreshViewDialogIfOpen()
}
const handleExport = async () => {
@@ -14,7 +14,9 @@
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
label-position="right"
label-width="100px"
class="crm-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -38,7 +38,11 @@
<el-icon><Edit /></el-icon>修改
</el-button>
<el-button
v-if="hasContractPerm('remove') && canDeleteByCreator(detailContract)"
v-if="
hasContractPerm('remove') &&
detailContract?.status === 'draft' &&
canDeleteByCreator(detailContract)
"
class="btn-ghost"
@click="handleDetailDelete"
>
@@ -361,7 +365,14 @@
</div>
</div>
<div class="list-body list-wrap">
<el-table :data="detailPaymentList" class="list-table" stripe v-loading="detailPaymentLoading" empty-text="暂无回款记录">
<el-table
:data="detailPaymentList"
class="list-table"
stripe
v-loading="detailPaymentLoading"
empty-text="暂无回款记录"
@row-dblclick="handleDetailViewPayment"
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column label="回款单号" prop="paymentNo" min-width="150" align="center" header-align="center">
<template #default="scope">
@@ -402,7 +413,14 @@
</div>
</div>
<div class="list-body list-wrap">
<el-table :data="detailInvoiceList" class="list-table" stripe v-loading="detailInvoiceLoading" empty-text="暂无开票记录">
<el-table
:data="detailInvoiceList"
class="list-table"
stripe
v-loading="detailInvoiceLoading"
empty-text="暂无开票记录"
@row-dblclick="handleDetailViewInvoice"
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column label="发票号码" prop="invoiceNo" min-width="150" align="center" header-align="center">
<template #default="scope">
@@ -721,11 +739,6 @@
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<el-button class="btn-ghost" @click="visible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
<!-- 生成合同对话框 -->
<el-dialog
@@ -12,7 +12,7 @@
@opened="handleOpened"
>
<div class="contract-form-body">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" label-width="120px">
<el-form ref="formRef" :model="form" :rules="rules" label-position="right" label-width="120px" class="crm-dialog-form">
<!-- 合同基础信息(一行 5 列) -->
<div class="contract-basic-info">
<el-form-item label="合同编号" prop="contractNo">
@@ -128,45 +128,29 @@
<div class="parties-row">
<div class="party-panel">
<div class="party-panel__title">供方信息</div>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="单位名称(章)" prop="partyA">
<el-input v-model="form.partyA" placeholder="请输入单位名称" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="单位地址" prop="partyAAddress">
<el-input v-model="form.partyAAddress" placeholder="请输入单位地址" maxlength="200" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="法定代表人" prop="partyALegalPerson">
<el-input v-model="form.partyALegalPerson" placeholder="请输入法定代表人" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="代理人" prop="partyAAgent">
<el-input v-model="form.partyAAgent" placeholder="请输入代理人(可选)" maxlength="100" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="电话/传真" prop="partyAPhoneFax">
<el-input v-model="form.partyAPhoneFax" placeholder="请输入电话/传真" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="开户银行" prop="partyABank">
<el-input v-model="form.partyABank" placeholder="请输入开户银行" maxlength="200" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="账号" prop="partyAAccount">
<el-input v-model="form.partyAAccount" placeholder="请输入账号" maxlength="100" />
</el-form-item>
</el-col>
</el-row>
<div class="form-grid-3">
<el-form-item label="单位名称(章)" prop="partyA">
<el-input v-model="form.partyA" placeholder="请输入单位名称" maxlength="100" />
</el-form-item>
<el-form-item label="单位地址" prop="partyAAddress">
<el-input v-model="form.partyAAddress" placeholder="请输入单位地址" maxlength="200" />
</el-form-item>
<el-form-item label="法定代表人" prop="partyALegalPerson">
<el-input v-model="form.partyALegalPerson" placeholder="请输入法定代表人" maxlength="100" />
</el-form-item>
<el-form-item label="代理人" prop="partyAAgent">
<el-input v-model="form.partyAAgent" placeholder="请输入代理人(可选)" maxlength="100" />
</el-form-item>
<el-form-item label="电话/传真" prop="partyAPhoneFax">
<el-input v-model="form.partyAPhoneFax" placeholder="请输入电话/传真" maxlength="100" />
</el-form-item>
<el-form-item label="开户银行" prop="partyABank">
<el-input v-model="form.partyABank" placeholder="请输入开户银行" maxlength="200" />
</el-form-item>
<el-form-item label="账号" prop="partyAAccount">
<el-input v-model="form.partyAAccount" placeholder="请输入账号" maxlength="100" />
</el-form-item>
</div>
</div>
<div class="party-flow-bridge" aria-hidden="true">
@@ -192,45 +176,29 @@
<div class="party-panel">
<div class="party-panel__title">需方信息</div>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="单位名称(章)" prop="partyB">
<el-input v-model="form.partyB" placeholder="从客户开票信息带出" maxlength="200" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="单位地址" prop="partyBAddress">
<el-input v-model="form.partyBAddress" placeholder="从客户开票信息带出" maxlength="500" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="法定代表人" prop="partyBLegalPerson">
<el-input v-model="form.partyBLegalPerson" placeholder="从客户开票信息带出" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="代理人" prop="partyBAgent">
<el-input v-model="form.partyBAgent" placeholder="从客户开票信息带出(可选)" maxlength="100" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="电话/传真" prop="partyBPhoneFax">
<el-input v-model="form.partyBPhoneFax" placeholder="从客户开票信息带出" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="开户银行" prop="partyBBank">
<el-input v-model="form.partyBBank" placeholder="从客户开票信息带出" maxlength="200" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="账号" prop="partyBAccount">
<el-input v-model="form.partyBAccount" placeholder="从客户开票信息带出" maxlength="100" />
</el-form-item>
</el-col>
</el-row>
<div class="form-grid-3">
<el-form-item label="单位名称(章)" prop="partyB">
<el-input v-model="form.partyB" placeholder="从客户开票信息带出" maxlength="200" />
</el-form-item>
<el-form-item label="单位地址" prop="partyBAddress">
<el-input v-model="form.partyBAddress" placeholder="从客户开票信息带出" maxlength="500" />
</el-form-item>
<el-form-item label="法定代表人" prop="partyBLegalPerson">
<el-input v-model="form.partyBLegalPerson" placeholder="从客户开票信息带出" maxlength="100" />
</el-form-item>
<el-form-item label="代理人" prop="partyBAgent">
<el-input v-model="form.partyBAgent" placeholder="从客户开票信息带出(可选)" maxlength="100" />
</el-form-item>
<el-form-item label="电话/传真" prop="partyBPhoneFax">
<el-input v-model="form.partyBPhoneFax" placeholder="从客户开票信息带出" maxlength="100" />
</el-form-item>
<el-form-item label="开户银行" prop="partyBBank">
<el-input v-model="form.partyBBank" placeholder="从客户开票信息带出" maxlength="200" />
</el-form-item>
<el-form-item label="账号" prop="partyBAccount">
<el-input v-model="form.partyBAccount" placeholder="从客户开票信息带出" maxlength="100" />
</el-form-item>
</div>
</div>
</div>
</div>
@@ -2108,10 +2076,6 @@ const loadSupplierList = async () => {
border-radius: 10px;
background: #fff;
:deep(.el-row + .el-row) {
margin-top: 12px;
}
:deep(.el-form-item) {
margin-bottom: 0;
}
@@ -22,7 +22,9 @@
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
label-position="right"
label-width="100px"
class="crm-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -21,7 +21,9 @@
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
label-position="right"
label-width="100px"
class="crm-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -65,7 +65,6 @@
class="list-table"
stripe
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
:max-height="400"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
@@ -308,13 +307,6 @@ const handleSelectionChange = (selection: ProductVO[]) => {
selectedProducts.value = selection;
};
/** 单击行选中/取消选中产品(点击复选框列时不重复切换) */
const handleRowClick = (row: ProductVO, _column: unknown, event: MouseEvent) => {
const target = event.target as HTMLElement
if (target.closest('.el-checkbox')) return
tableRef.value?.toggleRowSelection(row)
}
/** 移除已选产品 */
const handleRemoveSelection = (product: ProductVO) => {
const index = selectedProducts.value.findIndex((p) => p.id === product.id);
@@ -2,7 +2,7 @@
<el-dialog v-model="visible" title="合同续签" width="800px" :close-on-click-modal="false" @close="handleClose">
<el-alert title="提示" type="info" :closable="false" class="mb-4"> 系统将自动复制原合同信息,您可以修改金额、期限等字段 </el-alert>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form ref="formRef" :model="form" :rules="rules" label-position="right" label-width="120px" class="crm-dialog-form">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="原合同" prop="parentContractId">
+6 -2
View File
@@ -29,7 +29,7 @@
<el-icon><Edit /></el-icon>修改
</el-button>
<el-button
v-if="hasContractPerm('remove') && canDeleteByCreator(contract)"
v-if="hasContractPerm('remove') && contract?.status === 'draft' && canDeleteByCreator(contract)"
type="danger"
@click="handleDelete"
>
@@ -75,7 +75,7 @@
<p v-if="contract" class="detail-subtitle">{{ contract.contractNo }} — {{ contract.contractName }}</p>
<el-tabs v-model="activeTab" class="panel-pill-tabs panel-pill-tabs--lg">
<el-tabs v-model="activeTab" class="panel-tabs">
<!-- 合同信息 -->
<el-tab-pane label="合同信息" name="contract">
<el-form label-width="120px" v-if="contract">
@@ -1291,6 +1291,10 @@ const handleEdit = () => {
/** 删除合同 */
const handleDelete = async () => {
if (contract.value?.status !== 'draft') {
$modal.msgWarning('只能删除草稿状态的合同');
return;
}
try {
await $modal.confirm('是否确认删除该合同?删除后将无法恢复。');
await delContract([contract.value?.id]);
+32 -53
View File
@@ -7,7 +7,7 @@
<el-tabs
v-model="activeView"
class="panel-pill-tabs panel-pill-tabs--lg"
class="panel-tabs"
@tab-change="handleViewChange"
>
<el-tab-pane label="我的合同" name="my" />
@@ -42,57 +42,39 @@
<el-form-item label="客户名称" prop="customerName">
<el-input v-model="queryParams.customerName" placeholder="客户名称" clearable @keyup.enter="handleQuery" />
</el-form-item>
</div>
<div v-show="filterExpanded" id="contract-filter-extra" class="filter-extra">
<div class="filter-grid">
<el-form-item label="合同类型" prop="contractType">
<el-select v-model="queryParams.contractType" placeholder="全部" clearable>
<el-option
v-for="opt in dictStore.getOptions('crm_contract_type')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="合同状态" prop="status">
<el-select v-model="queryParams.status" placeholder="全部" clearable>
<el-option
v-for="opt in dictStore.getOptions('crm_contract_status')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="审批状态" prop="approvalStatus">
<el-select v-model="queryParams.approvalStatus" placeholder="全部" clearable>
<el-option
v-for="opt in dictStore.getOptions('crm_approval_status')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</div>
<el-form-item label="合同类型" prop="contractType">
<el-select v-model="queryParams.contractType" placeholder="全部" clearable>
<el-option
v-for="opt in dictStore.getOptions('crm_contract_type')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="合同状态" prop="status">
<el-select v-model="queryParams.status" placeholder="全部" clearable>
<el-option
v-for="opt in dictStore.getOptions('crm_contract_status')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="审批状态" prop="approvalStatus">
<el-select v-model="queryParams.approvalStatus" placeholder="全部" clearable>
<el-option
v-for="opt in dictStore.getOptions('crm_approval_status')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-start">
<button
type="button"
class="btn-link btn-filter-toggle"
:aria-expanded="filterExpanded"
aria-controls="contract-filter-extra"
@click="filterExpanded = !filterExpanded"
>
{{ filterExpanded ? '收起更多' : '展开更多' }}
<el-icon>
<ArrowUpBold v-if="filterExpanded" />
<ArrowDownBold v-else />
</el-icon>
</button>
</div>
<div class="filter-actions-start" />
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="resetQuery">
<el-icon><RefreshLeft /></el-icon>重置
@@ -523,8 +505,6 @@ import {
type ContractVO
} from '@/api/crm/contract';
import {
ArrowDownBold,
ArrowUpBold,
CircleCheck,
CircleClose,
Clock,
@@ -665,7 +645,6 @@ const contractList = ref<ContractVO[]>([]);
const loading = ref(true);
const total = ref(0);
const expandLoadingMap = reactive<Record<string, boolean>>({});
const filterExpanded = ref(false);
const selectedRows = ref<ContractVO[]>([]);
const single = ref(true);
const multiple = ref(true);
@@ -53,6 +53,12 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="模板名称/模板编号"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button
v-if="hasContractPerm('templateAdd')"
class="btn-main"
@@ -88,7 +94,15 @@
<el-icon><Download /></el-icon>导出
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="getList" />
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="getList"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -123,7 +137,7 @@
<el-link type="primary" @click="handleView(row)">{{ row.templateName }}</el-link>
</template>
</el-table-column>
<el-table-column label="模板类型" min-width="120" align="center" header-align="center">
<el-table-column label="模板类型" v-if="isColumnVisible('templateType')" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('crm_contract_type', row.templateType)"
@@ -134,6 +148,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('templateNo')"
prop="templateNo"
label="模板编号"
min-width="150"
@@ -141,7 +156,7 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="状态" min-width="100" align="center" header-align="center">
<el-table-column label="状态" v-if="isColumnVisible('isActive')" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-switch
v-if="hasContractPerm('templateEdit')"
@@ -156,6 +171,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('usageCount')"
prop="usageCount"
label="使用次数"
min-width="100"
@@ -163,13 +179,14 @@
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('sort')"
prop="sort"
label="排序"
min-width="80"
align="center"
header-align="center"
/>
<el-table-column label="创建时间" min-width="160" align="center" header-align="center">
<el-table-column label="创建时间" v-if="isColumnVisible('createTime')" min-width="160" align="center" header-align="center">
<template #default="{ row }">
{{ parseTime(row.createTime) }}
</template>
@@ -193,7 +210,8 @@
v-model="dialog.visible"
:title="dialog.title"
width="900px"
class="dialog-form-layout order-module-dialog-mask"
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
align-center
destroy-on-close
:close-on-click-modal="false"
@@ -204,7 +222,9 @@
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
label-position="right"
label-width="100px"
class="crm-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -326,6 +346,7 @@
<script setup lang="ts" name="ContractTemplate">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { ref, reactive, computed, onMounted } from 'vue'
import {
Document,
@@ -366,10 +387,30 @@
allRowsDeletableByCreator,
CREATOR_DELETE_TIP
} from '@/utils/creator-delete'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
const dictStore = useDictStore()
const { loadPerms, hasContractPerm } = useContractPerm()
const quickSearchKeyword = ref('')
const listColumnDefs = [
{ prop: 'templateType', label: '模板类型' },
{ prop: 'templateNo', label: '模板编号' },
{ prop: 'isActive', label: '状态' },
{ prop: 'usageCount', label: '使用次数' },
{ prop: 'sort', label: '排序', defaultVisible: false },
{ prop: 'createTime', label: '创建时间' }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('crm_contract_template_visible_columns', listColumnDefs)
const queryParams = ref<TemplateQuery>({
pageNum: 1,
pageSize: 20,
@@ -481,15 +522,33 @@
}
const handleQuery = () => {
quickSearchKeyword.value = ''
queryParams.value.pageNum = 1
getList()
}
const resetQuery = () => {
quickSearchKeyword.value = ''
queryFormRef.value?.resetFields()
handleQuery()
}
const handleQuickSearch = () => {
const keyword = quickSearchKeyword.value?.trim()
queryParams.value.templateName = keyword || undefined
queryParams.value.templateNo = keyword || undefined
queryParams.value.pageNum = 1
getList()
}
const handleQuickSearchClear = () => {
quickSearchKeyword.value = ''
queryParams.value.templateName = undefined
queryParams.value.templateNo = undefined
queryParams.value.pageNum = 1
getList()
}
const handleSelectionChange = (selection: TemplateVO[]) => {
selectedRows.value = selection
ids.value = selection.map((item) => item.id)
@@ -575,7 +575,7 @@ function viewTableSection(title: string, fields: ViewField[]): ViewSection {
title,
fields,
fieldLayout: 'table',
tableMode: 'pair'
tableMode: 'triple'
}
}
@@ -604,12 +604,13 @@ const viewSections = computed<ViewSection[]>(() => {
tagField('成交状态', 'crm_deal_status', c.dealStatus),
{ label: '负责人', value: c.ownerUserName },
{ label: '创建人', value: c.createByName || c.createBy },
{ label: '创建时间', value: c.createTime },
{ label: '创建时间', value: c.createTime, long: false },
{ label: '跟进天数', value: `${calculateDays(c.createTime)} 天` },
{
label: '下次联系时间',
value: c.nextTime,
tagType: isOverdue.value ? 'danger' : isToday.value ? 'warning' : undefined
tagType: isOverdue.value ? 'danger' : isToday.value ? 'warning' : undefined,
long: false
}
]
@@ -1017,9 +1018,9 @@ defineExpose({ open })
}
.view-split-left {
flex: 0 0 580px;
width: 580px;
max-width: 50%;
flex: 0 0 720px;
width: 720px;
max-width: 55%;
}
.view-split-left .view-hero {
@@ -1053,5 +1054,28 @@ defineExpose({ open })
.view-split-right .customer-view-tabs-section {
padding: 0;
}
/* 左侧信息区:三列表格模式(triple = 一行三组标签/值) */
.view-split-left .view-kv-table-wrap {
border-color: #eef4f1;
border-radius: 8px;
}
.view-split-left .view-kv-table th {
background: #f8fafc;
font-size: 12px;
padding: 8px 10px;
}
.view-split-left .view-kv-table td {
font-size: 13px;
padding: 8px 12px;
}
.view-split-left .view-kv-table tr.row-long td {
font-weight: 400;
color: #475569;
line-height: 1.55;
}
}
</style>
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff. Show more