提交
This commit is contained in:
1 parent
33c038b8d9
commit
54b27a3f0e
253 files changed
+30060
-14298
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
+25
-6
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user