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

No files matched your search

@@ -1,5 +1,11 @@
<template>
<div class="attachment-upload">
<div
class="attachment-upload"
:class="{
'attachment-upload--compact': compact,
'attachment-upload--readonly': compact && disabled
}"
>
<div class="attachment-list">
<el-upload
v-if="!disabled"
@@ -11,7 +17,7 @@
@change="handleAttachmentChange"
>
<div class="upload-card">
<el-icon :size="28" color="#1ca851"><Plus /></el-icon>
<el-icon :size="previewIconSize" color="#1ca851"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
</div>
</el-upload>
@@ -23,18 +29,18 @@
>
<div
class="attachment-preview"
:class="{ 'is-clickable': canPreview(file) }"
@click="canPreview(file) ? handlePreviewAttachment(file) : undefined"
:class="{ 'is-clickable': Boolean(file.fileUrl) }"
@click="file.fileUrl ? handleAttachmentClick(file) : undefined"
>
<template v-if="isImageFile(file) && file.fileUrl">
<img :src="resolveFileUrl(file.fileUrl)" :alt="file.fileName" />
</template>
<template v-else-if="isImageFile(file) && !file.fileUrl">
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
<el-icon :size="previewIconSize" color="#94a3b8"><Loading /></el-icon>
</template>
<template v-else-if="isVideoFile(file)">
<div class="attachment-preview__icon attachment-preview__icon--video">
<el-icon :size="28"><VideoPlay /></el-icon>
<el-icon :size="previewIconSize"><VideoPlay /></el-icon>
</div>
</template>
<template v-else>
@@ -42,13 +48,22 @@
class="attachment-preview__icon"
:class="isPdfFile(file) ? 'attachment-preview__icon--pdf' : 'attachment-preview__icon--doc'"
>
<el-icon :size="28"><Document /></el-icon>
<el-icon :size="previewIconSize"><Document /></el-icon>
<span v-if="getFileExtension(file.fileName)" class="attachment-preview__ext">
{{ getFileExtension(file.fileName) }}
</span>
</div>
</template>
</div>
<button
v-if="compact && !disabled"
type="button"
class="attachment-compact-delete"
title="删除"
@click.stop="removeAttachment(index)"
>
<el-icon :size="12"><Delete /></el-icon>
</button>
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div v-if="!disabled" class="attachment-actions">
@@ -71,7 +86,7 @@
</div>
</div>
</div>
<span v-if="!disabled" class="upload-hint">{{ hint }}</span>
<span v-if="!disabled && !compact" class="upload-hint">{{ hint }}</span>
<el-dialog
v-model="previewVisible"
@@ -148,6 +163,8 @@ const props = withDefaults(
accept?: string
hint?: string
disabled?: boolean
/** 紧凑模式:小缩略图,只读时隐藏文件名与操作栏 */
compact?: boolean
}>(),
{
modelValue: '',
@@ -155,7 +172,8 @@ const props = withDefaults(
limit: 15,
accept: 'image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar',
hint: '支持图片、视频、PDF、Word、Excel等文件',
disabled: false
disabled: false,
compact: false
}
)
@@ -170,6 +188,11 @@ const previewFile = ref<AttachmentItem | null>(null)
const previewPdfSrc = ref('')
const previewLoading = ref(false)
const previewIconSize = computed(() => {
if (!props.compact) return 28
return props.disabled ? 14 : 20
})
function resolveFileUrl(url: string) {
const trimmed = String(url || '').trim()
if (!trimmed) return ''
@@ -325,6 +348,18 @@ function handlePreviewAttachment(file: AttachmentItem) {
})
}
function handleAttachmentClick(file: AttachmentItem) {
if (!file.fileUrl) return
if (canPreview(file)) {
handlePreviewAttachment(file)
return
}
const url = resolveFileUrl(file.fileUrl)
if (url) {
window.open(url, '_blank', 'noopener,noreferrer')
}
}
function handlePreviewClosed() {
previewFile.value = null
previewPdfSrc.value = ''
@@ -532,4 +567,108 @@ defineExpose({ hasUploading })
:deep(.el-upload) {
display: block;
}
.attachment-upload--compact {
.attachment-list {
gap: 6px;
}
.upload-card-text {
display: none;
}
.attachment-preview__ext {
display: none;
}
.attachment-footer {
display: none;
}
}
/* 表格编辑:中等缩略图,支持预览 + 角标删除 */
.attachment-upload--compact:not(.attachment-upload--readonly) {
.upload-card {
width: 88px;
height: 66px;
gap: 0;
border-radius: 8px;
}
.attachment-item {
position: relative;
width: 88px;
border-radius: 8px;
&:hover {
box-shadow: 0 2px 6px rgba(28, 168, 81, 0.12);
}
}
.attachment-preview {
height: 66px;
}
.attachment-preview__icon {
width: 36px;
min-height: 36px;
border-radius: 8px;
}
.attachment-compact-delete {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 999px;
background: rgba(15, 23, 42, 0.72);
color: #fff;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: #ef4444;
}
}
}
/* 只读紧凑:更小缩略图(查看弹窗等) */
.attachment-upload--compact.attachment-upload--readonly {
.attachment-list {
gap: 4px;
}
.upload-card {
width: 40px;
height: 40px;
gap: 0;
border-radius: 6px;
}
.attachment-item {
width: 40px;
border-radius: 6px;
&:hover {
box-shadow: 0 1px 4px rgba(28, 168, 81, 0.12);
}
}
.attachment-preview {
height: 40px;
}
.attachment-preview__icon {
width: 24px;
min-height: 24px;
border-radius: 6px;
}
}
</style>
@@ -0,0 +1,33 @@
<!-- 列表工具栏多功能检索框(漏斗切换由 filter-collapse 注入到 .quick-search-group) -->
<template>
<el-input
:model-value="modelValue"
class="quick-search-input"
:placeholder="placeholder"
clearable
@update:model-value="emit('update:modelValue', $event ?? '')"
@keyup.enter="emit('search')"
@clear="emit('clear')"
>
<template #suffix>
<el-icon class="quick-search-icon" @click="emit('search')"><Search /></el-icon>
</template>
</el-input>
</template>
<script setup lang="ts">
import { Search } from '@element-plus/icons-vue'
defineOptions({ name: 'ListQuickSearchInput' })
defineProps<{
modelValue: string
placeholder: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
search: []
clear: []
}>()
</script>
@@ -1,90 +1,84 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<el-dialog
v-model="innerVisible"
<SystemViewDialog
v-model:visible="innerVisible"
:title="carrier?.carrierName || '承运商详情'"
width="1080px"
align-center
destroy-on-close
class="dialog-form-layout carrier-view-dialog"
:icon="OfficeBuilding"
:meta="heroMeta"
:sections="viewSections"
:loading="loading"
width="1400px"
dialog-class="pl-carrier-view-dialog"
modal-class="order-module-dialog-mask"
append-to-body
style="backdrop-filter: none"
@closed="handleClosed"
@update:visible="handleVisibleChange"
>
<div class="carrier-view-body" v-loading="loading">
<div v-if="$slots['hero-actions']" class="carrier-view-hero-actions">
<slot name="hero-actions" />
</div>
<section class="carrier-view-top">
<h3 class="carrier-view-block-title">承运商信息</h3>
<ViewKvTable v-if="carrier" :fields="carrierViewFields" mode="triple" />
<template #hero-actions>
<slot name="hero-actions" />
</template>
<h3 class="carrier-view-block-title carrier-view-attachment-title">资质附件</h3>
<div v-if="carrier" class="carrier-view-attachments form-grid-2">
<div class="carrier-view-attachment-item">
<div class="carrier-view-attachment-label">营业执照</div>
<AttachmentUploadField
:model-value="carrier.businessLicenseUrls || ''"
disabled
folder="pl/carrier/license"
/>
</div>
<div class="carrier-view-attachment-item">
<div class="carrier-view-attachment-label">道路经营许可证</div>
<AttachmentUploadField
:model-value="carrier.roadTransportLicenseUrls || ''"
disabled
folder="pl/carrier/permit"
/>
</div>
<div class="carrier-view-attachment-item form-col-span-2">
<div class="carrier-view-attachment-label">运输协议</div>
<AttachmentUploadField
:model-value="carrier.transportAgreementUrls || ''"
disabled
folder="pl/carrier/agreement"
/>
</div>
<template #carrierAttachments>
<div v-if="carrier" class="carrier-view-attachments form-grid-3">
<div class="carrier-view-attachment-item">
<div class="carrier-view-attachment-label">营业执照</div>
<AttachmentUploadField
:model-value="carrier.businessLicenseUrls || ''"
disabled
folder="pl/carrier/license"
/>
</div>
<div class="carrier-view-attachment-item">
<div class="carrier-view-attachment-label">运输协议</div>
<AttachmentUploadField
:model-value="carrier.transportAgreementUrls || ''"
disabled
folder="pl/carrier/agreement"
/>
</div>
<div class="carrier-view-attachment-item">
<div class="carrier-view-attachment-label">道路经营许可证</div>
<AttachmentUploadField
:model-value="carrier.roadTransportLicenseUrls || ''"
disabled
folder="pl/carrier/permit"
/>
</div>
</section>
<section class="carrier-view-bottom">
<h3 class="carrier-view-block-title">关联车辆</h3>
<p v-if="!vehicleBundles.length && !loading" class="carrier-view-empty">暂无关联车辆</p>
<el-tabs v-else v-model="activeTab" class="carrier-vehicle-tabs">
<el-tab-pane
v-for="bundle in vehicleBundles"
:key="bundle.detail.id"
:label="bundle.detail.licensePlate"
:name="bundle.detail.id"
>
<PlVehicleViewPanel
:detail="bundle.detail"
:personnel-list="bundle.personnelList"
/>
</el-tab-pane>
</el-tabs>
</section>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="innerVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</div>
</template>
</el-dialog>
<template #carrierVehicles>
<p v-if="!vehicleBundles.length && !loading" class="carrier-view-empty">暂无关联车辆</p>
<el-tabs v-else v-model="activeTab" class="carrier-vehicle-tabs">
<el-tab-pane
v-for="bundle in vehicleBundles"
:key="bundle.detail.id"
:label="bundle.detail.licensePlate"
:name="bundle.detail.id"
lazy
>
<PlVehicleViewPanel
v-if="activeTab === bundle.detail.id"
:key="`${viewSessionKey}-${bundle.detail.id}`"
:detail="bundle.detail"
:personnel-list="bundle.personnelList"
table-mode="quint"
cert-layout="table"
/>
</el-tab-pane>
</el-tabs>
</template>
</SystemViewDialog>
</template>
<script setup lang="ts">
import { Close } from '@element-plus/icons-vue'
import { OfficeBuilding } from '@element-plus/icons-vue'
import { carrierApi, vehicleApi, type Carrier } from '@/api/pl'
import { pickBxList } from '@/utils/http/bx'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlVehicleViewPanel from '@/components/business/pl-vehicle-view-panel/index.vue'
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
import SystemViewDialog, {
type ViewSection
} from '@/components/business/system-view-dialog/index.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import {
loadVehicleViewBundles,
@@ -116,44 +110,130 @@ const loading = ref(false)
const carrier = ref<Carrier | null>(null)
const vehicleBundles = ref<VehicleViewBundle[]>([])
const activeTab = ref('')
const viewSessionKey = ref(0)
const carrierViewFields = computed<ViewField[]>(() => {
const heroMeta = computed(() => {
const c = carrier.value
if (!c) return ''
const parts = [
c.contactName || undefined,
c.contactPhone || undefined,
getPlCarrierStatusLabel(c.status),
getPlCarrierAuditStatusLabel(c.auditStatus)
].filter(Boolean)
return parts.join(' · ')
})
const carrierInfoRow1Fields = computed<ViewField[]>(() => {
const c = carrier.value
if (!c) return []
return [
{ label: '承运商名称', value: c.carrierName },
{ label: '联系人', value: c.contactName || '—' },
{ label: '联系电话', value: c.contactPhone || '—' },
{ label: '承运商名称', value: c.carrierName, long: false },
{ label: '联系人', value: c.contactName || '—', long: false },
{ label: '联系电话', value: c.contactPhone || '—', long: false },
{
label: '合作状态',
value: getPlCarrierStatusLabel(c.status),
tagType: getPlCarrierStatusTagType(c.status)
},
tagType: getPlCarrierStatusTagType(c.status),
long: false
}
]
})
const carrierInfoRow2Fields = computed<ViewField[]>(() => {
const c = carrier.value
if (!c) return []
return [
{
label: '审核状态',
value: getPlCarrierAuditStatusLabel(c.auditStatus),
tagType: getPlCarrierAuditStatusTagType(c.auditStatus)
tagType: getPlCarrierAuditStatusTagType(c.auditStatus),
long: false
},
{
label: '审核时间',
value: c.auditTime ? formatDateTime(c.auditTime) : '—'
value: c.auditTime ? formatDateTime(c.auditTime) : '—',
long: false
},
{ label: '审核备注', value: c.auditRemark || '—', long: false, fullSpan: false }
]
})
const carrierInfoRow3Fields = computed<ViewField[]>(() => {
const c = carrier.value
if (!c) return []
return [
{ label: '创建时间', value: formatDateTime(c.createTime), long: false },
{
label: '协议有效期',
value: c.transportAgreementExpired
? '协议已过期'
: c.transportAgreementExpireDate || '—',
tagType: c.transportAgreementExpired ? 'danger' : undefined
tagType: c.transportAgreementExpired ? 'danger' : undefined,
long: false
},
{
label: '资质状态',
value: c.qualificationComplete ? '已齐全' : '资质不全',
tagType: c.qualificationComplete ? 'success' : 'warning'
tagType: c.qualificationComplete ? 'success' : 'warning',
long: false
},
{ label: '创建时间', value: formatDateTime(c.createTime) },
{ label: '审核备注', value: c.auditRemark || '—', span: 'full' },
{ label: '备注', value: c.remark || '—', span: 'full' }
{ label: '备注', value: c.remark || '—', long: false, fullSpan: false }
]
})
const viewSections = computed<ViewSection[]>(() => {
if (!carrier.value) {
return [
{
title: '承运商信息',
fieldGroups: [
{ fieldLayout: 'table', tableMode: 'quad', fields: [] },
{ fieldLayout: 'table', tableMode: 'quad', fields: [] },
{ fieldLayout: 'table', tableMode: 'triple', fields: [] }
],
fields: []
},
{ title: '资质附件', fields: [], slot: 'carrierAttachments' },
{ title: '关联车辆', fields: [], slot: 'carrierVehicles' }
]
}
return [
{
title: '承运商信息',
fieldGroups: [
{
fieldLayout: 'table',
tableMode: 'quad',
fields: carrierInfoRow1Fields.value
},
{
fieldLayout: 'table',
tableMode: 'quad',
fields: carrierInfoRow3Fields.value
},
{
fieldLayout: 'table',
tableMode: 'triple',
fields: carrierInfoRow2Fields.value
}
],
fields: []
},
{
title: '资质附件',
fields: [],
slot: 'carrierAttachments'
},
{
title: '关联车辆',
fields: [],
slot: 'carrierVehicles'
}
]
})
@@ -171,6 +251,7 @@ async function loadDetail(carrierId: string) {
carrier.value = null
vehicleBundles.value = []
activeTab.value = ''
viewSessionKey.value += 1
try {
const [carrierRes, vehicleListRes] = await Promise.all([
carrierApi.get(carrierId),
@@ -188,47 +269,24 @@ async function loadDetail(carrierId: string) {
}
}
function handleClosed() {
carrier.value = null
vehicleBundles.value = []
activeTab.value = ''
function handleVisibleChange(value: boolean) {
if (!value) {
carrier.value = null
vehicleBundles.value = []
activeTab.value = ''
viewSessionKey.value += 1
}
}
async function reload() {
if (!props.visible || !props.carrierId) return
await loadDetail(props.carrierId)
}
defineExpose({ reload })
</script>
<style scoped lang="scss">
.carrier-view-body {
display: flex;
flex-direction: column;
gap: 20px;
min-height: 120px;
}
.carrier-view-hero-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding-bottom: 16px;
border-bottom: 1px solid #e2e8f0;
}
.carrier-view-block-title {
margin: 0 0 12px;
font-size: 15px;
font-weight: 600;
color: #1e293b;
}
.carrier-view-top {
padding-bottom: 16px;
border-bottom: 1px solid #e2e8f0;
}
.carrier-view-attachment-title {
margin-top: 20px;
}
.carrier-view-attachments {
display: grid;
gap: 16px;
@@ -238,13 +296,13 @@ function handleClosed() {
margin-bottom: 8px;
font-size: 11px;
font-weight: 500;
color: #1e293b;
color: #64748b;
}
.carrier-view-empty {
margin: 0;
font-size: 13px;
color: #1e293b;
color: var(--mint-text-muted, #64748b);
}
.carrier-vehicle-tabs {
@@ -257,3 +315,27 @@ function handleClosed() {
}
}
</style>
<style lang="scss">
.pl-carrier-view-dialog .view-section:first-of-type .view-field-group--spaced {
margin-top: 0;
}
.pl-carrier-view-dialog .view-section:first-of-type .view-field-group .view-kv-table-wrap {
border-radius: 0;
}
.pl-carrier-view-dialog .view-section:first-of-type .view-field-group:first-child .view-kv-table-wrap {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.pl-carrier-view-dialog .view-section:first-of-type .view-field-group--spaced .view-kv-table-wrap {
border-top: none;
}
.pl-carrier-view-dialog .view-section:first-of-type .view-field-group:last-child .view-kv-table-wrap {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
</style>
@@ -9,6 +9,92 @@
<div v-if="!certList.length" class="cert-empty">暂无证书,点击「添加证书」</div>
<el-table v-else-if="layout === 'table'" :data="certList" stripe class="list-table cert-form-table">
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
v-if="showVehiclePartColumn"
label="车牌号"
min-width="140"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-select
v-if="hasTrailerPlateOption"
:model-value="normalizeVehicleCertPart(row.vehiclePart)"
placeholder="请选择车牌号"
style="width: 100%"
@update:model-value="handleVehiclePartChange(row, $event)"
>
<el-option
v-for="option in vehiclePlateOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<span v-else>{{ headPlateLabel }}</span>
</template>
</el-table-column>
<el-table-column label="证书名称" min-width="160">
<template #default="{ row }">
<el-select
v-model="row.certName"
placeholder="请选择或输入"
filterable
allow-create
default-first-option
>
<el-option v-for="name in certNameOptions" :key="name" :label="name" :value="name" />
</el-select>
</template>
</el-table-column>
<el-table-column label="证号" min-width="140">
<template #default="{ row }">
<el-input v-model="row.certNo" placeholder="请输入证号" />
</template>
</el-table-column>
<el-table-column label="有效期" min-width="150">
<template #default="{ row }">
<el-date-picker
v-model="row.expireDate"
type="date"
placeholder="选择有效期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column label="提醒天数" min-width="110" align="center" header-align="center">
<template #default="{ row }">
<el-input-number
v-model="row.remindDays"
:min="1"
:max="365"
:controls="false"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column label="附件" min-width="220">
<template #default="{ row, $index }">
<AttachmentUploadField
:ref="(el) => setCertUploadRef($index, el)"
v-model="row.attachmentUrls"
:folder="uploadFolder"
compact
/>
</template>
</el-table-column>
<el-table-column label="操作" min-width="60" align="center" header-align="center">
<template #default="{ $index }">
<el-button link type="danger" @click="handleRemoveCert($index)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="cert-card-list">
<div v-for="(cert, index) in certList" :key="cert._key" class="cert-card">
<div class="cert-card-head">
@@ -43,9 +129,6 @@
/>
</el-form-item>
<el-form-item label="提醒天数">
<template #label>
提醒天数<span class="form-label-tip">默认 30 天</span>
</template>
<el-input-number
v-model="cert.remindDays"
:min="1"
@@ -71,6 +154,11 @@
import { Delete, Plus } from '@element-plus/icons-vue'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import type { CertRow } from '@/api/pl'
import {
PL_VEHICLE_CERT_PART_HEAD,
PL_VEHICLE_CERT_PART_TRAILER,
normalizeVehicleCertPart
} from '@/utils/pl-vehicle-cert'
type CertRowWithKey = CertRow & { _key: string }
type CertUploadRef = InstanceType<typeof AttachmentUploadField>
@@ -80,13 +168,45 @@ const props = withDefaults(
title?: string
uploadFolder: string
certNameOptions?: string[]
layout?: 'card' | 'table'
/** 列表模式:展示车牌号列,可从主车/挂车车牌中选择 */
showVehiclePartColumn?: boolean
licensePlate?: string
trailerNo?: string
}>(),
{
title: '证书',
certNameOptions: () => []
certNameOptions: () => [],
layout: 'card',
showVehiclePartColumn: false,
licensePlate: '',
trailerNo: ''
}
)
const headPlateLabel = computed(() => props.licensePlate?.trim() || '—')
const hasTrailerPlateOption = computed(() => Boolean(props.trailerNo?.trim()))
const vehiclePlateOptions = computed(() => {
const options = [
{
label: headPlateLabel.value,
value: PL_VEHICLE_CERT_PART_HEAD
}
]
if (hasTrailerPlateOption.value) {
options.push({
label: props.trailerNo?.trim() || '挂车',
value: PL_VEHICLE_CERT_PART_TRAILER
})
}
return options
})
function handleVehiclePartChange(row: CertRowWithKey, value: string) {
row.vehiclePart = normalizeVehicleCertPart(value)
}
const certList = defineModel<CertRowWithKey[]>('modelValue', { default: () => [] })
const certUploadRefs = ref<Record<number, CertUploadRef | null>>({})
@@ -94,6 +214,7 @@ const certUploadRefs = ref<Record<number, CertUploadRef | null>>({})
function createEmptyCert(): CertRowWithKey {
return {
_key: `cert-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
vehiclePart: PL_VEHICLE_CERT_PART_HEAD,
certName: '',
certNo: '',
expireDate: '',
@@ -161,6 +282,29 @@ defineExpose({ hasUploading, clearUploadRefs })
background: #f8fafc;
}
.cert-form-table {
border-top: 1px solid var(--mint-border, #d9e8e2);
:deep(.el-select),
:deep(.el-input),
:deep(.el-date-editor) {
width: 100%;
}
:deep(.attachment-upload--compact:not(.attachment-upload--readonly)) {
min-width: 0;
.attachment-upload {
width: auto;
}
.attachment-list {
flex-wrap: wrap;
align-items: flex-start;
}
}
}
.cert-card-list {
display: flex;
flex-direction: column;
@@ -207,7 +351,7 @@ defineExpose({ hasUploading, clearUploadRefs })
}
:deep(.el-form-item__label) {
font-size: 11px;
font-size: 13px;
color: var(--mint-text);
padding-bottom: 4px;
}
@@ -1,6 +1,85 @@
<template>
<div class="cert-view-section">
<p v-if="!certList.length" class="view-empty-hint">暂无证书</p>
<p v-if="!certList.length && layout === 'card'" class="view-empty-hint">暂无证书</p>
<div v-else-if="layout === 'table'" class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
:data="certList"
class="list-table"
stripe
empty-text="暂无证书"
>
<el-table-column
type="index"
label="序号"
min-width="60"
align="center"
header-align="center"
:index="index => index + 1"
/>
<el-table-column
v-if="showVehiclePartColumn"
label="车牌号"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
{{ resolveVehiclePartLabel(row) }}
</template>
</el-table-column>
<el-table-column
prop="certName"
label="证书名称"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="certNo"
label="证号"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
{{ row.certNo || '—' }}
</template>
</el-table-column>
<el-table-column label="有效期" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<el-tag
v-if="row.expireDate"
:type="getExpireTagType(row.expireDate)"
size="small"
>
{{ row.expireDate }}
</el-tag>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="提醒天数" min-width="96" align="center" header-align="center">
<template #default="{ row }">{{ row.remindDays ?? 30 }} 天</template>
</el-table-column>
<el-table-column label="附件" min-width="160" align="center" header-align="center">
<template #default="{ row }">
<div v-if="row.attachmentUrls" class="cert-view-attachment-cell">
<AttachmentUploadField
:model-value="row.attachmentUrls"
disabled
compact
/>
</div>
<span v-else>—</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
<div v-else class="cert-view-list">
<div
@@ -11,15 +90,16 @@
<h4 class="cert-view-card-title">证书 {{ index + 1 }}</h4>
<div class="cert-view-kv-table-group">
<ViewKvTable :fields="buildCertViewFields(cert)" mode="triple" />
<ViewKvTable :fields="buildCertViewFields(cert)" :mode="tableMode" />
<table
v-if="cert.attachmentUrls"
class="cert-view-attachment-table view-kv-table view-kv-table--triple"
class="cert-view-attachment-table view-kv-table"
:class="`view-kv-table--${tableMode}`"
>
<tbody>
<tr class="row-long">
<th>附件</th>
<td colspan="5">
<td :colspan="attachmentColspan">
<AttachmentUploadField
:model-value="cert.attachmentUrls"
disabled
@@ -37,18 +117,51 @@
<script setup lang="ts">
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import type { ViewField, ViewTableMode } from '@/components/business/system-view-dialog/types'
import type { CertRead } from '@/api/pl'
import {
PL_VEHICLE_CERT_PART_TRAILER,
normalizeVehicleCertPart
} from '@/utils/pl-vehicle-cert'
withDefaults(
const props = withDefaults(
defineProps<{
certList?: CertRead[]
tableMode?: ViewTableMode
/** card:卡片 + KV 表格;table:列表表格 + 小缩略图 */
layout?: 'card' | 'table'
/** 列表模式:有挂车时展示主车/挂车部位列 */
showVehiclePartColumn?: boolean
headLabel?: string
trailerLabel?: string
}>(),
{
certList: () => []
certList: () => [],
tableMode: 'triple',
layout: 'card',
showVehiclePartColumn: false,
headLabel: '主车',
trailerLabel: '挂车'
}
)
const attachmentColspanMap: Record<ViewTableMode, number> = {
pair: 1,
dual: 3,
triple: 5,
quad: 7,
quint: 9
}
const attachmentColspan = computed(() => attachmentColspanMap[props.tableMode])
function resolveVehiclePartLabel(cert: CertRead) {
if (normalizeVehicleCertPart(cert.vehiclePart) === PL_VEHICLE_CERT_PART_TRAILER) {
return props.trailerLabel?.trim() || '挂车'
}
return props.headLabel?.trim() || '主车'
}
function getExpireTagType(dateStr: string | undefined) {
if (!dateStr) return 'info'
const expDate = new Date(dateStr)
@@ -85,6 +198,20 @@ function buildCertViewFields(cert: CertRead): ViewField[] {
line-height: 1.5;
}
.cert-view-attachment-cell {
display: inline-flex;
justify-content: center;
width: 100%;
:deep(.attachment-upload) {
width: auto;
}
:deep(.attachment-list) {
justify-content: center;
}
}
.cert-view-list {
display: flex;
flex-direction: column;
@@ -129,7 +256,6 @@ function buildCertViewFields(cert: CertRead): ViewField[] {
border-top: 1px solid #eef2f6;
th {
width: 10%;
padding: 10px 12px;
background: var(--table-head, #f8fafc);
color: var(--text-muted, #64748b);
@@ -152,4 +278,12 @@ function buildCertViewFields(cert: CertRead): ViewField[] {
line-height: 1.45;
}
}
.cert-view-attachment-table.view-kv-table--triple th {
width: 10%;
}
.cert-view-attachment-table.view-kv-table--quint th {
width: 6.5%;
}
</style>
@@ -2,7 +2,7 @@
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="640px"
width="740px"
align-center
append-to-body
destroy-on-close
@@ -14,7 +14,9 @@
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
label-position="right"
label-width="100px"
class="pl-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -31,9 +33,7 @@
</el-form-item>
<el-form-item v-if="actionType === 'submit'" prop="approverIds" class="form-col-span-2">
<template #label>
选择审批人<span class="form-label-tip">支持搜索用户姓名</span>
</template>
<template #label>选择审批人</template>
<el-select
v-model="form.approverIds"
multiple
@@ -71,9 +71,7 @@
</el-form-item>
<el-form-item v-if="actionType === 'submit'" prop="remark" class="form-col-span-2">
<template #label>
提交说明<span class="form-label-tip">可选</span>
</template>
<template #label>提交说明</template>
<el-input
v-model="form.remark"
type="textarea"
@@ -60,7 +60,9 @@
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
label-position="right"
label-width="100px"
class="pl-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -15,7 +15,9 @@
v-loading="loading"
:model="form"
:rules="rules"
label-position="top"
label-position="right"
label-width="100px"
class="pl-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
@@ -30,30 +30,39 @@
<section v-if="settlement" class="view-section">
<h4 class="view-section-title">发票信息</h4>
<div class="view-kv-grid view-kv-grid--cols-3">
<div class="view-kv-item">
<span class="view-kv-label">发票号</span>
<span class="view-kv-value">{{ settlement.invoiceNo || '—' }}</span>
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
:data="invoiceRows"
class="list-table"
stripe
empty-text="暂无数据"
>
<el-table-column type="index" label="序号" min-width="60" align="center" />
<el-table-column prop="invoiceNo" label="发票号" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.invoiceNo || '—' }}</template>
</el-table-column>
<el-table-column prop="invoiceDate" label="开票日期" min-width="120" align="center">
<template #default="{ row }">{{ row.invoiceDate || '—' }}</template>
</el-table-column>
<el-table-column prop="invoiceAmount" label="发票金额(元)" min-width="120" align="right">
<template #default="{ row }">
{{ row.invoiceAmount != null ? formatAmount(row.invoiceAmount) : '—' }}
</template>
</el-table-column>
<el-table-column label="发票附件" min-width="160">
<template #default="{ row }">
<div v-if="row.invoiceAttachmentUrls" class="invoice-attachment-half">
<AttachmentUploadField
:model-value="row.invoiceAttachmentUrls"
disabled
/>
</div>
<span v-else class="view-kv-value">—</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="view-kv-item">
<span class="view-kv-label">开票日期</span>
<span class="view-kv-value">{{ settlement.invoiceDate || '—' }}</span>
</div>
<div class="view-kv-item">
<span class="view-kv-label">发票金额</span>
<span class="view-kv-value">{{
settlement.invoiceAmount ? formatAmount(settlement.invoiceAmount) : '—'
}}</span>
</div>
</div>
<div class="view-kv-item view-kv-item--full">
<span class="view-kv-label">发票附件</span>
<AttachmentUploadField
v-if="settlement.invoiceAttachmentUrls"
:model-value="settlement.invoiceAttachmentUrls"
disabled
/>
<span v-else class="view-kv-value">—</span>
</div>
<div class="view-kv-grid view-kv-grid--cols-2 view-kv-grid--mt">
<div class="view-kv-item">
@@ -120,9 +129,16 @@
</div>
</section>
<section v-if="settlement?.approvalFlow?.length" class="view-section">
<section
v-if="settlement && (settlement.approvalFlow?.length || settlement.approvalHistory?.length)"
class="view-section"
>
<h4 class="view-section-title">审批流程</h4>
<ApprovalFlowView :nodes="approvalNodes" :loading="false" />
<BxApprovalFlowTabs
:nodes="approvalNodes"
:history="approvalHistoryItems"
:loading="false"
/>
</section>
<section
@@ -170,18 +186,12 @@
</section>
</div>
<template #footer>
<el-button @click="visible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Close } from '@element-plus/icons-vue'
import { freightApi, type FreightSettlement } from '@/api/pl'
import ApprovalFlowView from '@/components/bx/base/ApprovalFlowView.vue'
import BxApprovalFlowTabs from '@/components/bx/base/BxApprovalFlowTabs.vue'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { normalizeAttachmentUrls } from '@/utils/download-attachment'
import {
@@ -201,6 +211,29 @@ const approvalNodes = computed(() =>
mapApprovalFlowToNodes(settlement.value?.approvalFlow || [])
)
const approvalHistoryItems = computed(() =>
mapApprovalFlowToNodes(settlement.value?.approvalHistory || [])
)
const invoiceRows = computed(() => {
const current = settlement.value
if (!current) return []
const hasInvoice =
Boolean(current.invoiceNo?.trim()) ||
Boolean(current.invoiceDate) ||
(current.invoiceAmount != null && Number(current.invoiceAmount) > 0) ||
Boolean(current.invoiceAttachmentUrls?.trim())
if (!hasInvoice) return []
return [
{
invoiceNo: current.invoiceNo,
invoiceDate: current.invoiceDate,
invoiceAmount: current.invoiceAmount,
invoiceAttachmentUrls: current.invoiceAttachmentUrls
}
]
})
function formatAmount(amount?: number) {
if (!amount) return '¥0.00'
return formatMoneyDisplay(amount)
@@ -357,6 +390,53 @@ defineExpose({ open, reload })
}
}
.invoice-attachment-half {
:deep(.attachment-list) {
gap: 6px;
}
:deep(.attachment-item) {
width: 70px;
}
:deep(.attachment-preview) {
height: 48px;
}
:deep(.attachment-preview__icon) {
width: 24px;
min-height: 24px;
.el-icon {
font-size: 14px;
}
}
:deep(.attachment-preview__ext) {
margin-top: 2px;
font-size: 9px;
}
:deep(.attachment-footer) {
padding: 3px 4px;
}
:deep(.attachment-name) {
font-size: 9px;
-webkit-line-clamp: 1;
}
:deep(.attachment-actions) {
gap: 4px;
margin-top: 2px;
}
:deep(.action-btn) {
width: 16px;
height: 16px;
}
}
@media (max-width: 900px) {
.view-kv-grid--cols-3 {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -17,7 +17,7 @@
<section class="pl-arrange-form-panel">
<div v-loading="loading" class="pl-arrange-form-scroll">
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">产品实际数量</h3>
<h4 class="pl-plan-subtitle">产品实际数量</h4>
<PlPlanFormProductTable
:products="productRows"
show-actual-qty
@@ -29,7 +29,7 @@
<h4 class="pl-plan-subtitle">运费信息</h4>
<el-form
:model="formData"
label-width="96px"
label-width="140px"
label-position="right"
class="pl-plan-freight-form"
>
@@ -119,6 +119,7 @@ import {
import PlPlanFormProductTable from '@/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue'
import PlPlanViewTabs from '@/components/business/pl-plan-view-tabs/index.vue'
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { enrichPlanProductsHazardFromCrm } from '@/composables/usePlToxicProductCertCheck'
import { usePlFreightCalc, resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
import { useDictStore } from '@/store/modules/dict'
import { pickBxData } from '@/utils/http/bx'
@@ -258,7 +259,9 @@ async function handleOpened() {
throw new Error('运输计划数据无效')
}
plan.value = data
productRows.value = mapPlanProducts(Array.isArray(data.products) ? data.products : [])
const rawProducts = Array.isArray(data.products) ? data.products : []
const enrichedProducts = await enrichPlanProductsHazardFromCrm(rawProducts)
productRows.value = mapPlanProducts(enrichedProducts)
await applyFreightPrefill(data)
} catch (error) {
innerVisible.value = false
@@ -356,6 +359,9 @@ async function handleSubmit() {
}
.pl-arrange-form-scroll {
display: flex;
flex-direction: column;
gap: 20px;
max-height: 52vh;
overflow-y: auto;
padding-right: 4px;
@@ -377,14 +383,21 @@ async function handleSubmit() {
}
.pl-plan-subtitle {
margin: 16px 0 10px;
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: #475569;
}
.pl-plan-subtitle:first-of-type {
margin-top: 0;
.pl-plan-freight-section {
.pl-plan-subtitle {
margin-top: 4px;
}
:deep(.el-form-item__label) {
width: 140px !important;
max-width: 140px;
}
}
.pl-plan-freight-form {
@@ -25,42 +25,13 @@
:title="
isRearrange
? hasSiteInventoryRecords
? '运输已调整,司机与现场链接已更新,旧链接已失效'
: '运输已调整,司机链接已更新,旧链接已失效'
? '运输已调整,司机与现场链接已更新,旧链接已失效。请在下方「运输信息」中复制司机链接。'
: '运输已调整,司机链接已更新,旧链接已失效。请在下方「运输信息」中复制司机链接。'
: hasSiteInventoryRecords
? '运输已安排,可复制司机链接、现场链接或简述发给相关人员'
: '运输已安排,可复制司机链接或计划简述'
? '运输已安排,请在下方「运输信息」中复制链接。'
: '运输已安排,请在下方「运输信息」中复制司机链接。'
"
/>
<div class="pl-arrange-link-row">
<span class="pl-arrange-link-label">司机链接</span>
<el-input :model-value="driverUrl || arrangeResult?.driverUrl || ''" readonly />
<el-button @click="copyDriverUrl(arrangeResult?.driverUrl)">
<el-icon><CopyDocument /></el-icon>复制链接
</el-button>
</div>
<template v-if="hasSiteInventoryRecords">
<div v-if="siteRecords.length === 1" class="pl-arrange-link-row">
<span class="pl-arrange-link-label">现场链接</span>
<el-input :model-value="siteRecords[0].siteUrl || ''" readonly />
<el-button @click="copySiteUrl(siteRecords[0].siteUrl)">
<el-icon><CopyDocument /></el-icon>复制链接
</el-button>
</div>
<template v-else>
<div
v-for="record in siteRecords"
:key="record.siteLinkId"
class="pl-arrange-link-row"
>
<span class="pl-arrange-link-label">{{ record.productName || '现场链接' }}</span>
<el-input :model-value="record.siteUrl || ''" readonly />
<el-button @click="copySiteUrl(record.siteUrl)">
<el-icon><CopyDocument /></el-icon>复制本条
</el-button>
</div>
</template>
</template>
</section>
<section v-else class="pl-plan-form-section">
@@ -73,7 +44,7 @@
:title="rearrangeBlockedMessage"
class="pl-arrange-block-alert"
/>
<el-form ref="formRef" :model="formData" :rules="formRules" label-position="top">
<el-form ref="formRef" :model="formData" :rules="formRules" label-position="right" label-width="100px" class="pl-dialog-form">
<h4 class="pl-plan-subtitle">车辆信息</h4>
<div class="form-grid-4">
<el-form-item label="选择车辆" prop="vehicleId">
@@ -130,8 +101,8 @@
<div class="pl-plan-logistics-row">
<div class="pl-plan-logistics-block">
<h4 class="pl-plan-subtitle">取货信息</h4>
<div class="form-grid-3">
<el-form-item label="取货地址">
<div class="pl-plan-logistics-fields">
<el-form-item label="取货地址" class="pl-logistics-address-item">
<PlAddressWithLibrary
v-model="formData.pickupAddress"
v-model:lng="formData.pickupLng"
@@ -139,18 +110,20 @@
placeholder="取货地址"
/>
</el-form-item>
<el-form-item label="取货时间">
<el-date-picker
v-model="formData.pickupTime"
type="date"
placeholder="选择取货时间"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="注意事项">
<el-input v-model="formData.pickupNotes" placeholder="取货注意事项" />
</el-form-item>
<div class="form-grid-2 pl-logistics-meta-row">
<el-form-item label="取货时间">
<el-date-picker
v-model="formData.pickupTime"
type="date"
placeholder="选择取货时间"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="注意事项">
<el-input v-model="formData.pickupNotes" placeholder="取货注意事项" />
</el-form-item>
</div>
</div>
</div>
@@ -177,8 +150,8 @@
<div class="pl-plan-logistics-block">
<h4 class="pl-plan-subtitle">送货信息</h4>
<div class="form-grid-3">
<el-form-item label="送货地址">
<div class="pl-plan-logistics-fields">
<el-form-item label="送货地址" class="pl-logistics-address-item">
<PlAddressWithLibrary
v-model="formData.destinationAddress"
v-model:lng="formData.destinationLng"
@@ -186,71 +159,27 @@
placeholder="送货地址"
/>
</el-form-item>
<el-form-item label="送达时间">
<el-date-picker
v-model="formData.deliveryTime"
type="date"
placeholder="选择送达时间"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="注意事项">
<el-input v-model="formData.destinationNotes" placeholder="送货注意事项" />
</el-form-item>
<div class="form-grid-2 pl-logistics-meta-row">
<el-form-item label="送达时间">
<el-date-picker
v-model="formData.deliveryTime"
type="date"
placeholder="选择送达时间"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="注意事项">
<el-input v-model="formData.destinationNotes" placeholder="送货注意事项" />
</el-form-item>
</div>
</div>
</div>
</div>
<h4 class="pl-plan-subtitle">运费信息</h4>
<div v-if="formData.hasFreight !== 1" class="pl-arrange-freight-muted">本计划无运输费用</div>
<div v-else class="form-grid-4">
<el-form-item label="运输公里数">
<el-input-number v-model="formData.distanceKm" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="运费计算方式">
<el-select v-model="formData.freightType">
<el-option label="按重量" value="by_weight" />
<el-option label="按趟" value="by_trip" />
</el-select>
</el-form-item>
<el-form-item
v-if="formData.freightType === 'by_weight'"
:label="`运输量(${freightUnitLabel})`"
>
<el-input-number
v-model="freightCalc.transportQty.value"
:min="0"
:precision="3"
style="width: 100%"
@change="freightCalc.markTransportQtyTouched()"
/>
</el-form-item>
<el-form-item
v-if="formData.freightType === 'by_weight'"
:label="`运输单价(元/${freightUnitLabel})`"
>
<el-input-number
v-model="freightCalc.freightPrice.value"
:min="0"
:precision="2"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="运费金额(元)">
<el-input-number
v-model="freightCalc.freightAmount.value"
:min="0"
:precision="2"
style="width: 100%"
@change="freightCalc.markFreightAmountTouched()"
/>
</el-form-item>
</div>
<h4 class="pl-plan-subtitle">安排项</h4>
<div class="form-grid-4">
<el-form-item label="运输介质">
<el-form-item label="运输介质" class="pl-arrange-medium-item">
<el-input :model-value="transportMediumText" readonly placeholder="—" />
</el-form-item>
<el-form-item label="备注" class="pl-arrange-remark-item">
@@ -287,9 +216,6 @@
勾选后将按产品进行地磅单生成(当月已有则复用),并在地磅出入库管理中产生对应记录。
</template>
</p>
<p v-if="planSupportsSiteInventory" class="pl-arrange-dibang-hint">
入库/出库计划安排运输时将自动生成工厂现场链接(与是否勾选地磅单无关),不会在地磅出入库管理中产生记录。
</p>
</div>
</el-form>
</section>
@@ -298,7 +224,15 @@
<section class="pl-arrange-view-panel">
<h3 class="pl-arrange-view-title">计划单查看</h3>
<PlPlanViewTabs v-if="planId" ref="tabsRef" :plan-id="planId" embedded />
<PlPlanViewTabs
v-if="planId"
ref="tabsRef"
:plan-id="planId"
embedded
:show-arrange-links="arrangeSuccess"
:arrange-driver-url="driverUrl || arrangeResult?.driverUrl || ''"
:arrange-site-records="siteRecords"
/>
</section>
</div>
@@ -345,12 +279,6 @@
>
<el-icon><CopyDocument /></el-icon>复制给现场
</el-button>
<el-button
v-if="hasSiteInventoryRecords && siteRecords.length > 1"
@click="copyAllSiteUrls()"
>
<el-icon><CopyDocument /></el-icon>复制全部现场链接
</el-button>
<el-button
v-if="hasDibangInventoryRecords"
type="primary"
@@ -393,9 +321,9 @@ import PlVehicleSummaryStrip from '@/components/business/pl-vehicle-summary-stri
import PlVehicleViewDialog from '@/components/business/pl-vehicle-view-dialog/index.vue'
import PlPlanViewTabs from '@/components/business/pl-plan-view-tabs/index.vue'
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { usePlFreightCalc, resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
import { usePlFreightCalc } from '@/composables/usePlFreightCalc'
import { loadVehicleViewBundle, type VehicleViewBundle } from '@/composables/usePlVehicleViewDetail'
import { buildMissingHazardCertMessage } from '@/composables/usePlToxicProductCertCheck'
import { buildMissingHazardCertReadyMessage } from '@/composables/usePlToxicProductCertCheck'
import { usePlPlanArrangeActions } from '@/composables/usePlPlanArrangeActions'
import { useDictStore } from '@/store/modules/dict'
import { isTonProductUnit } from '@/utils/crm-product-display'
@@ -438,8 +366,6 @@ const {
resetArrangeActions,
applyArrangeResult,
copyDriverUrl,
copySiteUrl,
copyAllSiteUrls,
copySiteSummary,
copyPlanSummary,
copyPoundSlipQrcode
@@ -492,8 +418,6 @@ const dialogTitle = computed(() => {
const isRearrange = computed(() => plan.value.transportStatus === 'arranged')
const freightUnitLabel = computed(() => resolveFreightUnitLabel(productRows.value))
function getUnitLabel(unit: string) {
return dictStore.getLabel('crm_product_unit', unit) || unit
}
@@ -811,9 +735,9 @@ async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
const certMessage = buildMissingHazardCertMessage(products.value)
const certMessage = buildMissingHazardCertReadyMessage(plan.value, products.value)
if (certMessage) {
await ElMessageBox.alert(`${certMessage}。请先上传购买证和合法使用证明。`, '无法安排运输', {
await ElMessageBox.alert(`${certMessage},请至【危化品证上传】页面操作。`, '无法安排运输', {
type: 'warning'
})
return
@@ -884,7 +808,8 @@ async function handleSubmit() {
applyArrangeResult(result, plan.value, products.value)
arrangeSuccess.value = true
ElMessage.success(isRearrange.value ? '运输调整成功' : '运输安排成功')
tabsRef.value?.reload()
await tabsRef.value?.reload()
tabsRef.value?.setActiveTab('transport')
emit('success')
} catch (e: unknown) {
if (e === 'cancel') return
@@ -899,20 +824,18 @@ async function handleSubmit() {
<style scoped lang="scss">
.pl-arrange-layout {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
min-height: 480px;
min-height: 0;
overflow: hidden;
}
.pl-arrange-form-panel {
flex: 0 0 auto;
max-height: 52vh;
overflow: hidden;
}
.pl-arrange-form-scroll {
max-height: 52vh;
overflow-y: auto;
padding-right: 4px;
}
@@ -931,10 +854,18 @@ async function handleSubmit() {
margin-top: 0;
}
.pl-arrange-freight-muted {
margin-bottom: 12px;
font-size: 13px;
color: #64748b;
.pl-plan-logistics-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.pl-logistics-address-item {
margin-bottom: 0;
}
.pl-logistics-meta-row {
gap: 8px 12px;
}
.pl-plan-logistics-row {
@@ -1004,10 +935,6 @@ async function handleSubmit() {
:deep(.el-form-item) {
margin-bottom: 0;
}
.form-grid-3 {
gap: 8px 12px;
}
}
@media (max-width: 1100px) {
@@ -1029,13 +956,40 @@ async function handleSubmit() {
.pl-arrange-view-panel {
flex: 1 1 auto;
min-height: 280px;
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
padding: 12px 16px 16px;
overflow: hidden;
:deep(.pl-plan-view-tabs) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
:deep(.pl-plan-view-tabs__nav) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
:deep(.pl-plan-view-tabs__nav > .el-tabs__header) {
flex-shrink: 0;
}
:deep(.pl-plan-view-tabs__nav > .el-tabs__content) {
flex: 1;
min-height: 0;
max-height: none;
overflow-y: auto;
padding-right: 4px;
}
}
.pl-arrange-view-title {
@@ -1090,6 +1044,7 @@ async function handleSubmit() {
min-width: 0;
}
.pl-arrange-medium-item,
.pl-arrange-remark-item {
grid-column: span 2;
}
@@ -1100,18 +1055,6 @@ async function handleSubmit() {
gap: 12px;
}
.pl-arrange-link-row {
display: grid;
grid-template-columns: 72px 1fr auto;
gap: 8px;
align-items: center;
}
.pl-arrange-link-label {
font-size: 13px;
color: #64748b;
}
.pl-arrange-footer {
display: flex;
flex-wrap: wrap;
@@ -1125,4 +1068,11 @@ async function handleSubmit() {
width: min(1840px, calc(100vw - 48px)) !important;
max-height: calc(100vh - 48px);
}
.el-dialog.pl-plan-arrange-dialog.dialog-form-layout .el-dialog__body {
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
</style>
@@ -19,7 +19,7 @@
type="info"
:closable="false"
show-icon
title="易制毒或易制爆产品须上传购买证、合法使用证明;安排运输时将校验是否齐全。"
title="易制毒或易制爆产品请按实际业务上传所需证书;可先点击「证书齐备」放行运输,之后再补充附件。"
class="cert-tip"
/>
@@ -32,7 +32,7 @@
<el-tag v-if="item.isToxic" type="warning" size="small">易制毒</el-tag>
</div>
<div class="form-grid-2 product-cert-grid">
<el-form-item label="购买证" required>
<el-form-item label="购买证">
<AttachmentUploadField
v-model="item.purchaseCertUrl"
folder="pl/plan-cert"
@@ -40,7 +40,7 @@
hint="支持图片、PDF 等,上传购买证"
/>
</el-form-item>
<el-form-item label="合法使用证明" required>
<el-form-item label="合法使用证明">
<AttachmentUploadField
v-model="item.transportCertUrl"
folder="pl/plan-cert"
@@ -67,7 +67,7 @@
import { Check, Close } from '@element-plus/icons-vue'
import { planApi, type Plan, type PlanProduct } from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
import { enrichPlanProductsHazardFromCrm, isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
type HazardProductRow = PlanProduct & {
purchaseCertUrl?: string
@@ -114,7 +114,8 @@ async function loadPlan(planId: string) {
const data = (res.data ?? res) as Plan
planBrief.planNo = data.planNo || ''
planBrief.customerName = data.customerName || ''
hazardProducts.value = (data.products || [])
const enrichedProducts = await enrichPlanProductsHazardFromCrm(data.products || [])
hazardProducts.value = enrichedProducts
.filter(p => isHazardProduct(p))
.map(p => ({
...p,
@@ -129,14 +130,6 @@ async function loadPlan(planId: string) {
async function handleSubmit() {
if (!props.planId || !hazardProducts.value.length) return
const incomplete = hazardProducts.value.filter(
p => !p.purchaseCertUrl?.trim() || !p.transportCertUrl?.trim()
)
if (incomplete.length) {
ElMessage.warning('请为每个危化品产品上传购买证和合法使用证明')
return
}
submitLoading.value = true
try {
await planApi.updateProductCerts(
@@ -62,6 +62,25 @@
</template>
</el-table-column>
<el-table-column v-else prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column
v-if="showSelect"
label="操作"
min-width="100"
align="center"
fixed="right"
class-name="col-actions"
>
<template #default="{ row, $index }">
<el-button
link
type="primary"
:disabled="isProductSelected(row)"
@click="emit('select', row, $index)"
>
<el-icon><Plus /></el-icon>选择
</el-button>
</template>
</el-table-column>
<el-table-column v-if="showDelete" label="操作" min-width="80" align="center" fixed="right">
<template #default="{ $index }">
<el-button link type="danger" @click="emit('remove', $index)">
@@ -75,15 +94,18 @@
</template>
<script setup lang="ts">
import { Delete } from '@element-plus/icons-vue'
import { Delete, Plus } from '@element-plus/icons-vue'
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { getDeliveryMethodLabel } from '@/composables/usePlContractToPlan'
import PlProductNameHazardCell from '@/components/business/pl-product-name-hazard-cell/index.vue'
import DictTag from '@/components/DictTag/index.vue'
defineProps<{
const props = defineProps<{
products: PlanFormProductRow[]
showDelete?: boolean
showSelect?: boolean
/** 已加入运输计划的产品 ID,用于禁用合同预览中的「选择」按钮 */
selectedProductIds?: string[]
editableRemark?: boolean
editableQty?: boolean
/** 新增/编辑弹窗默认不展示实际数量 */
@@ -94,15 +116,20 @@ defineProps<{
const emit = defineEmits<{
remove: [index: number]
select: [row: PlanFormProductRow, index: number]
}>()
const selectedProductIdSet = computed(
() => new Set((props.selectedProductIds || []).map(id => String(id)))
)
function isProductSelected(row: PlanFormProductRow) {
return selectedProductIdSet.value.has(String(row.productId))
}
</script>
<style scoped lang="scss">
.pl-plan-product-table-wrap {
margin-top: 8px;
}
.dialog-list-body-only {
margin-top: 0;
}
</style>
@@ -17,7 +17,7 @@
<div class="pl-plan-form-body">
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">基础信息</h3>
<el-form ref="basicFormRef" :model="formData" :rules="basicRules" label-position="top">
<el-form ref="basicFormRef" :model="formData" :rules="basicRules" label-position="right" label-width="100px" class="pl-dialog-form">
<div class="form-grid-4">
<el-form-item label="计划日期" prop="planDate">
<el-date-picker
@@ -74,13 +74,19 @@
</div>
<h4 class="pl-plan-subtitle">合同产品预览</h4>
<PlPlanFormProductTable :products="contractProductRows" />
<p class="pl-plan-form-empty-hint">点击「选择」将合同产品加入下方产品信息;已添加的产品不可重复选择</p>
<PlPlanFormProductTable
:products="contractProductRows"
show-select
:selected-product-ids="selectedPlanProductIds"
@select="handleSelectContractProduct"
/>
</template>
</section>
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">运输信息</h3>
<el-form :model="formData" label-position="top">
<el-form :model="formData" label-position="right" label-width="100px" class="pl-dialog-form">
<div class="form-grid-4">
<el-form-item label="车牌号">
<el-select
@@ -194,43 +200,45 @@
</div>
</div>
<h4 class="pl-plan-subtitle">运费信息</h4>
<div class="form-grid-4">
<el-form-item label="是否有运输费用">
<el-switch v-model="formData.hasFreight" :active-value="1" :inactive-value="0" />
</el-form-item>
<template v-if="formData.hasFreight === 1">
<el-form-item label="运输公里数">
<el-input-number v-model="formData.distanceKm" :min="0" :precision="2" style="width: 100%" />
<div class="pl-plan-freight-section">
<h4 class="pl-plan-subtitle">运费信息</h4>
<div class="form-grid-4">
<el-form-item label="是否有运输费用">
<el-switch v-model="formData.hasFreight" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="运费计算方式">
<el-select v-model="formData.freightType">
<el-option label="按重量" value="by_weight" />
<el-option label="按趟" value="by_trip" />
</el-select>
</el-form-item>
<el-form-item v-if="formData.freightType === 'by_weight'" :label="`运输量(${freightUnitLabel})`">
<el-input-number
v-model="freightCalc.transportQty.value"
:min="0"
:precision="3"
style="width: 100%"
@change="freightCalc.markTransportQtyTouched()"
/>
</el-form-item>
<el-form-item v-if="formData.freightType === 'by_weight'" :label="`运输单价(元/${freightUnitLabel})`">
<el-input-number v-model="freightCalc.freightPrice.value" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="运费金额(元)">
<el-input-number
v-model="freightCalc.freightAmount.value"
:min="0"
:precision="2"
style="width: 100%"
@change="freightCalc.markFreightAmountTouched()"
/>
</el-form-item>
</template>
<template v-if="formData.hasFreight === 1">
<el-form-item label="运输公里数">
<el-input-number v-model="formData.distanceKm" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="运费计算方式">
<el-select v-model="formData.freightType">
<el-option label="按重量" value="by_weight" />
<el-option label="按趟" value="by_trip" />
</el-select>
</el-form-item>
<el-form-item v-if="formData.freightType === 'by_weight'" :label="`运输量(${freightUnitLabel})`">
<el-input-number
v-model="freightCalc.transportQty.value"
:min="0"
:precision="3"
style="width: 100%"
@change="freightCalc.markTransportQtyTouched()"
/>
</el-form-item>
<el-form-item v-if="formData.freightType === 'by_weight'" :label="`运输单价(元/${freightUnitLabel})`">
<el-input-number v-model="freightCalc.freightPrice.value" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="运费金额(元)">
<el-input-number
v-model="freightCalc.freightAmount.value"
:min="0"
:precision="2"
style="width: 100%"
@change="freightCalc.markFreightAmountTouched()"
/>
</el-form-item>
</template>
</div>
</div>
</el-form>
</section>
@@ -467,6 +475,10 @@ const contractProductRows = computed<PlanFormProductRow[]>(() =>
mapContractProducts(contractSnapshot.value?.products)
)
const selectedPlanProductIds = computed(() =>
formData.products.map(item => String(item.productId))
)
function enrichProductsFromContract(products: PlanFormProductRow[], contract?: ContractVO | null) {
if (!contract?.products?.length) return products
const contractMap = new Map(
@@ -845,12 +857,24 @@ async function handleContractConfirm(contract: ContractVO) {
contractSnapshot.value = contract
Object.assign(formData, {
...mapped,
products: [],
followUserId: resolveCurrentUserId(),
followUserName: resolveCurrentUserName()
})
formData.products = await enrichPlanProductsHazardFromCrm(
enrichProductsFromContract(formData.products, contract)
freightCalc.resetFreightCalc()
}
async function handleSelectContractProduct(row: PlanFormProductRow) {
const productId = String(row.productId)
if (formData.products.some(item => String(item.productId) === productId)) {
ElMessage.warning('该产品已添加到运输计划中')
return
}
const [enriched] = await enrichPlanProductsHazardFromCrm(
enrichProductsFromContract([{ ...row }], contractSnapshot.value)
)
formData.products.push(enriched)
freightCalc.resetFreightCalc()
}
@@ -994,11 +1018,22 @@ async function handleSubmit() {
color: #1e293b;
}
.pl-plan-freight-section {
.pl-plan-subtitle {
margin-top: 16px;
}
:deep(.el-form-item__label) {
width: 140px !important;
max-width: 140px;
}
}
.pl-plan-form-empty-hint,
.text-muted {
margin: 0;
margin: 0 0 8px;
font-size: 13px;
color: #1e293b;
color: #64748b;
}
.pl-plan-contract-kv-rows {
@@ -1166,7 +1201,7 @@ async function handleSubmit() {
<style lang="scss">
.el-dialog.pl-plan-form-dialog.dialog-form-layout {
width: min(1680px, calc(100vw - 80px)) !important;
max-height: calc(100vh - 48px);
max-height: var(--dialog-max-height, calc(100vh - 40px));
}
.el-dialog.pl-plan-form-dialog.dialog-form-layout .el-dialog__body {
@@ -0,0 +1,88 @@
<template>
<div class="pl-plan-hazard-cert-view-table">
<p v-if="!products.length" class="pl-plan-hazard-cert-view-table__empty">暂无易制毒/易制爆产品</p>
<div v-else class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
:data="products"
class="list-table"
stripe
empty-text="暂无易制毒/易制爆产品"
>
<el-table-column
type="index"
label="序号"
min-width="60"
align="center"
header-align="center"
:index="index => index + 1"
/>
<el-table-column label="产品名称" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<PlProductNameHazardCell :product="row" />
</template>
</el-table-column>
<el-table-column label="购买证" min-width="160" align="center" header-align="center">
<template #default="{ row }">
<div v-if="row.purchaseCertUrl" class="pl-plan-hazard-cert-view-table__attachment">
<AttachmentUploadField :model-value="row.purchaseCertUrl" disabled compact />
</div>
<span v-else class="pl-plan-hazard-cert-view-table__missing">未上传</span>
</template>
</el-table-column>
<el-table-column label="合法使用证明" min-width="160" align="center" header-align="center">
<template #default="{ row }">
<div v-if="row.transportCertUrl" class="pl-plan-hazard-cert-view-table__attachment">
<AttachmentUploadField :model-value="row.transportCertUrl" disabled compact />
</div>
<span v-else class="pl-plan-hazard-cert-view-table__missing">未上传</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { PlanProduct } from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlProductNameHazardCell from '@/components/business/pl-product-name-hazard-cell/index.vue'
withDefaults(
defineProps<{
products?: PlanProduct[]
}>(),
{
products: () => []
}
)
</script>
<style scoped lang="scss">
.pl-plan-hazard-cert-view-table__empty {
margin: 0;
font-size: 13px;
color: var(--mint-text-muted);
}
.pl-plan-hazard-cert-view-table__attachment {
display: inline-flex;
justify-content: center;
width: 100%;
:deep(.attachment-upload) {
width: auto;
}
:deep(.attachment-list) {
justify-content: center;
}
}
.pl-plan-hazard-cert-view-table__missing {
color: var(--mint-text-muted);
font-size: 13px;
}
</style>
@@ -1,5 +1,9 @@
<template>
<div v-if="hazardProducts.length" class="pl-plan-list-cert-cell">
<div
v-if="hazardProducts.length"
class="pl-plan-list-cert-cell"
:class="{ 'pl-plan-list-cert-cell--compact': compact }"
>
<div
v-for="(product, index) in hazardProducts"
:key="product.id || `${product.productNo}-${index}`"
@@ -7,10 +11,22 @@
>
<div class="pl-plan-list-cert-cell__product">
<span class="pl-plan-list-cert-cell__name">{{ product.productName || '—' }}</span>
<el-tag v-if="product.isToxic" type="warning" size="small">易制毒</el-tag>
<el-tag v-if="product.isExplosive" type="danger" size="small">易制爆</el-tag>
<el-tag v-if="product.isToxic" type="warning" size="small">
{{ useShortHazardTag ? '毒' : '易制毒' }}
</el-tag>
<el-tag v-if="product.isExplosive" type="danger" size="small">
{{ useShortHazardTag ? '爆' : '易制爆' }}
</el-tag>
</div>
<div class="pl-plan-list-cert-cell__certs">
<div v-if="compact" class="pl-plan-list-cert-cell__certs pl-plan-list-cert-cell__certs--text">
<span class="pl-plan-list-cert-cell__cert-text">
购买证({{ product.purchaseCertUrl ? '有' : '无' }})
</span>
<span class="pl-plan-list-cert-cell__cert-text">
合法使用证明({{ product.transportCertUrl ? '有' : '无' }})
</span>
</div>
<div v-else class="pl-plan-list-cert-cell__certs">
<div class="pl-plan-list-cert-cell__cert-item">
<AttachmentUploadField
v-if="product.purchaseCertUrl"
@@ -44,12 +60,20 @@ import { isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
const props = withDefaults(
defineProps<{
products?: PlanProduct[]
/** 列表紧凑模式:文字展示证书有无,不显示缩略图 */
compact?: boolean
/** 危化标签短文案:毒 / 爆 */
shortHazardTag?: boolean
}>(),
{
products: () => []
products: () => [],
compact: false,
shortHazardTag: false
}
)
const useShortHazardTag = computed(() => props.compact || props.shortHazardTag)
const hazardProducts = computed(() => (props.products || []).filter(product => isHazardProduct(product)))
</script>
@@ -137,4 +161,29 @@ const hazardProducts = computed(() => (props.products || []).filter(product => i
:deep(.attachment-footer) {
display: none;
}
.pl-plan-list-cert-cell--compact {
gap: 6px;
.pl-plan-list-cert-cell__group {
gap: 4px;
}
.pl-plan-list-cert-cell__name {
font-size: 12px;
}
.pl-plan-list-cert-cell__certs--text {
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.pl-plan-list-cert-cell__cert-text {
color: var(--mint-text);
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
}
}
</style>
@@ -5,20 +5,40 @@
:key="product.id || `${product.productNo}-${index}`"
class="pl-plan-list-product-cell__row"
>
<span class="pl-plan-list-product-cell__line">
<span class="pl-plan-list-product-cell__text">{{ formatProductLine(product) }}</span>
<el-tag v-if="getHazardFlags(product).isToxic" type="warning" size="small" class="pl-plan-list-product-cell__mark">
毒
</el-tag>
<el-tag
v-if="getHazardFlags(product).isExplosive"
type="danger"
size="small"
class="pl-plan-list-product-cell__mark"
<span class="pl-plan-list-product-cell__index">{{ index + 1 }}.</span>
<div class="pl-plan-list-product-cell__content">
<span class="pl-plan-list-product-cell__line">
<span class="pl-plan-list-product-cell__text">{{ formatProductLine(product) }}</span>
<el-tag v-if="getHazardFlags(product).isToxic" type="warning" size="small" class="pl-plan-list-product-cell__mark">
毒
</el-tag>
<el-tag
v-if="getHazardFlags(product).isExplosive"
type="danger"
size="small"
class="pl-plan-list-product-cell__mark"
>
爆
</el-tag>
</span>
<div
v-if="showCertStatus && isHazardProduct(product)"
class="pl-plan-list-product-cell__certs"
>
爆
</el-tag>
</span>
<span
class="pl-plan-list-product-cell__cert-text"
:class="{ 'pl-plan-list-product-cell__cert-text--missing': !product.purchaseCertUrl }"
>
购买证({{ product.purchaseCertUrl ? '有' : '无' }})
</span>
<span
class="pl-plan-list-product-cell__cert-text"
:class="{ 'pl-plan-list-product-cell__cert-text--missing': !product.transportCertUrl }"
>
合法使用证明({{ product.transportCertUrl ? '有' : '无' }})
</span>
</div>
</div>
</div>
</div>
<span v-else class="pl-plan-list-product-cell__empty">—</span>
@@ -26,16 +46,19 @@
<script setup lang="ts">
import type { PlanProduct } from '@/api/pl'
import { resolveProductHazardFlags } from '@/composables/usePlToxicProductCertCheck'
import { isHazardProduct, resolveProductHazardFlags } from '@/composables/usePlToxicProductCertCheck'
import { useDictStore } from '@/store/modules/dict'
import { formatPlanProductListText } from '@/utils/pl-display'
withDefaults(
defineProps<{
products?: PlanProduct[]
/** 危化品行下方展示购买证/合法使用证明有无 */
showCertStatus?: boolean
}>(),
{
products: () => []
products: () => [],
showCertStatus: false
}
)
@@ -58,22 +81,47 @@ function getHazardFlags(product: PlanProduct) {
.pl-plan-list-product-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
width: 100%;
text-align: left;
}
.pl-plan-list-product-cell__row {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 4px;
width: 100%;
min-width: 0;
}
.pl-plan-list-product-cell__index {
flex-shrink: 0;
min-width: 1.25em;
font-size: 13px;
line-height: 1.5;
color: var(--mint-text);
text-align: right;
}
.pl-plan-list-product-cell__content {
flex: 1;
min-width: 0;
}
.pl-plan-list-product-cell__line {
display: inline-flex;
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 4px;
width: 100%;
max-width: 100%;
font-size: 13px;
line-height: 1.5;
color: var(--text, #1e293b);
text-align: left;
}
.pl-plan-list-product-cell__text {
@@ -85,6 +133,30 @@ function getHazardFlags(product: PlanProduct) {
font-weight: 600;
}
.pl-plan-list-product-cell__certs {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 0;
width: 100%;
margin-top: 2px;
text-align: left;
}
.pl-plan-list-product-cell__cert-text {
color: var(--mint-text);
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
&--missing {
color: var(--danger, #dc2626);
font-weight: 500;
}
}
.pl-plan-list-product-cell__empty {
color: var(--mint-text);
}
@@ -13,50 +13,32 @@
@closed="handleClosed"
@opened="handleOpened"
>
<section v-if="showArrangeActions" class="pl-plan-arrange-summary">
<h3 class="pl-plan-arrange-summary-title">运输安排结果</h3>
<div class="pl-arrange-link-row">
<span class="pl-arrange-link-label">司机链接</span>
<el-input :model-value="driverUrl" readonly />
<el-button @click="copyDriverUrl()">
<el-icon><CopyDocument /></el-icon>复制链接
<div
v-if="$slots['hero-actions'] || showArrangeActions"
class="pl-plan-view-hero-actions"
>
<template v-if="showArrangeActions">
<el-button @click="copyPlanSummary()">
<el-icon><CopyDocument /></el-icon>复制给司机
</el-button>
<el-button v-if="hasSiteInventoryRecords" @click="copySiteSummary()">
<el-icon><CopyDocument /></el-icon>复制给现场
</el-button>
<el-button v-if="hasDibangInventoryRecords" @click="copyPoundSlipQrcode()">
<el-icon><CopyDocument /></el-icon>复制磅单二维码
</el-button>
</div>
<p v-if="!hasSiteInventoryRecords && planSupportsInventory" class="pl-plan-arrange-summary-hint">
暂无现场链接
</p>
<template v-if="hasSiteInventoryRecords">
<h4 class="pl-plan-arrange-summary-subtitle">现场链接</h4>
<div v-if="siteRecords.length === 1" class="pl-arrange-link-row">
<span class="pl-arrange-link-label">现场链接</span>
<el-input :model-value="siteRecords[0].siteUrl || ''" readonly />
<el-button @click="copySiteUrl(siteRecords[0].siteUrl)">
<el-icon><CopyDocument /></el-icon>复制链接
</el-button>
</div>
<template v-else>
<div
v-for="record in siteRecords"
:key="record.siteLinkId"
class="pl-arrange-link-row"
>
<span class="pl-arrange-link-label">{{ record.productName || '现场链接' }}</span>
<el-input :model-value="record.siteUrl || ''" readonly />
<el-button @click="copySiteUrl(record.siteUrl)">
<el-icon><CopyDocument /></el-icon>复制本条
</el-button>
</div>
</template>
</template>
</section>
<div v-if="$slots['hero-actions']" class="pl-plan-view-hero-actions">
<slot name="hero-actions" />
</div>
<PlPlanViewTabs v-if="planId" ref="tabsRef" :plan-id="planId" />
<PlPlanViewTabs
v-if="planId"
ref="tabsRef"
:plan-id="planId"
:show-arrange-links="showArrangeActions"
:arrange-driver-url="driverUrl"
:arrange-site-records="siteRecords"
/>
<DbPoundSlipQrcodeDialog
v-model:visible="qrcodeVisible"
@@ -65,39 +47,11 @@
ref="qrcodeDialogRef"
/>
<template #footer>
<div class="dialog-footer pl-plan-view-footer">
<template v-if="showArrangeActions">
<el-button @click="copyPlanSummary()">
<el-icon><CopyDocument /></el-icon>复制给司机
</el-button>
<el-button v-if="hasSiteInventoryRecords" @click="copySiteSummary()">
<el-icon><CopyDocument /></el-icon>复制给现场
</el-button>
<el-button
v-if="hasSiteInventoryRecords && siteRecords.length > 1"
@click="copyAllSiteUrls()"
>
<el-icon><CopyDocument /></el-icon>复制全部现场链接
</el-button>
<el-button
v-if="hasDibangInventoryRecords"
type="primary"
@click="copyPoundSlipQrcode()"
>
<el-icon><CopyDocument /></el-icon>复制磅单二维码
</el-button>
</template>
<el-button @click="innerVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Close, CopyDocument } from '@element-plus/icons-vue'
import { CopyDocument } from '@element-plus/icons-vue'
import { planApi } from '@/api/pl'
import DbPoundSlipQrcodeDialog from '@/components/business/db/DbPoundSlipQrcodeDialog.vue'
import PlPlanViewTabs from '@/components/business/pl-plan-view-tabs/index.vue'
@@ -126,27 +80,17 @@ const {
hasDibangInventoryRecords,
driverUrl,
siteRecords,
inventoryRecords,
plan,
qrcodeVisible,
selectedInventoryId,
selectedInventoryUnit,
qrcodeDialogRef,
resetArrangeActions,
loadArrangeActions,
copyDriverUrl,
copySiteUrl,
copyAllSiteUrls,
copySiteSummary,
copyPlanSummary,
copyPoundSlipQrcode
} = usePlPlanArrangeActions()
const planSupportsInventory = computed(() => {
const planType = plan.value?.planType
return planType === 'inbound' || planType === 'outbound'
})
const dialogTitle = computed(() =>
planNo.value ? `查看运输计划 · ${planNo.value}` : '查看运输计划'
)
@@ -168,6 +112,21 @@ function handleClosed() {
planNo.value = ''
resetArrangeActions()
}
async function reload() {
if (!props.visible || !props.planId) return
try {
const res = await planApi.get(props.planId)
const data = res.data ?? res
planNo.value = data?.planNo || ''
await loadArrangeActions(props.planId)
await tabsRef.value?.reload()
} catch {
/* 查看弹窗仍打开时静默失败,避免打断用户 */
}
}
defineExpose({ reload })
</script>
<style scoped lang="scss">
@@ -186,52 +145,42 @@ function handleClosed() {
gap: 8px;
margin-bottom: 12px;
}
</style>
.pl-plan-arrange-summary {
margin-bottom: 12px;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #f8fafc;
<style lang="scss">
.el-dialog.pl-plan-view-dialog.dialog-form-layout {
width: min(1680px, calc(100vw - 80px)) !important;
height: var(--dialog-max-height, calc(100vh - 40px));
max-height: var(--dialog-max-height, calc(100vh - 40px));
}
.pl-plan-arrange-summary-title {
margin: 0 0 10px;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
border-left: 3px solid #1ca851;
line-height: 1;
.el-dialog.pl-plan-view-dialog.dialog-form-layout .el-dialog__body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.pl-plan-arrange-summary-subtitle {
margin: 12px 0 10px;
font-size: 13px;
font-weight: 600;
color: #475569;
.el-dialog.pl-plan-view-dialog.dialog-form-layout .pl-plan-view-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.pl-arrange-link-row {
display: grid;
grid-template-columns: 72px 1fr auto;
gap: 8px;
align-items: center;
& + & {
margin-top: 8px;
}
.el-dialog.pl-plan-view-dialog.dialog-form-layout .pl-plan-view-tabs__nav.el-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.pl-arrange-link-label {
font-size: 13px;
color: #64748b;
}
.pl-plan-arrange-summary-hint {
margin: 10px 0 0;
font-size: 12px;
color: #64748b;
line-height: 1.5;
.el-dialog.pl-plan-view-dialog.dialog-form-layout
.pl-plan-view-tabs__nav.el-tabs
> .el-tabs__content {
flex: 1;
min-height: 0;
max-height: none;
overflow-y: auto;
}
</style>
@@ -5,7 +5,7 @@
<el-tab-pane label="计划概况" name="overview">
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">基础信息</h3>
<ViewKvTable :fields="overviewBasicViewFields" mode="quad" />
<ViewKvTable :fields="overviewBasicViewFields" mode="quint" />
</section>
<section class="pl-plan-form-section">
@@ -16,40 +16,7 @@
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">危化品证书信息</h3>
<p v-if="!hazardProducts.length && !loading" class="pl-plan-form-empty-hint"
>暂无易制毒/易制爆产品</p
>
<div v-for="item in hazardProducts" :key="item.id" class="pl-plan-cert-block">
<div class="pl-plan-cert-head">
<span class="pl-plan-cert-name">{{ item.productName }}</span>
<el-tag v-if="item.isToxic" type="warning" size="small">易制毒</el-tag>
<el-tag v-if="item.isExplosive" type="danger" size="small">易制爆</el-tag>
</div>
<dl class="pl-plan-kv-grid pl-plan-kv-grid-2 pl-plan-cert-grid">
<div class="pl-plan-kv">
<dt>购买证</dt>
<dd>
<AttachmentUploadField
v-if="item.purchaseCertUrl"
:model-value="item.purchaseCertUrl"
disabled
/>
<span v-else>未上传</span>
</dd>
</div>
<div class="pl-plan-kv">
<dt>合法使用证明</dt>
<dd>
<AttachmentUploadField
v-if="item.transportCertUrl"
:model-value="item.transportCertUrl"
disabled
/>
<span v-else>未上传</span>
</dd>
</div>
</dl>
</div>
<PlPlanHazardCertViewTable :products="hazardProducts" />
</section>
<section class="pl-plan-form-section">
@@ -59,6 +26,45 @@
</el-tab-pane>
<el-tab-pane label="运输信息" name="transport">
<section v-if="showArrangeLinks" class="pl-plan-form-section pl-plan-arrange-links">
<h3 class="pl-plan-form-section-title">运输链接</h3>
<div class="pl-arrange-link-row">
<span class="pl-arrange-link-label">司机链接</span>
<el-input :model-value="arrangeDriverUrl" readonly />
<el-button @click="copyArrangeDriverUrl">
<el-icon><CopyDocument /></el-icon>复制链接
</el-button>
</div>
<template v-if="hasArrangeSiteLinks">
<div v-if="arrangeSiteRecords.length === 1" class="pl-arrange-link-row">
<span class="pl-arrange-link-label">现场链接</span>
<el-input :model-value="arrangeSiteRecords[0].siteUrl || ''" readonly />
<el-button @click="copyArrangeSiteUrl(arrangeSiteRecords[0].siteUrl)">
<el-icon><CopyDocument /></el-icon>复制链接
</el-button>
</div>
<template v-else>
<div
v-for="record in arrangeSiteRecords"
:key="record.siteLinkId"
class="pl-arrange-link-row"
>
<span class="pl-arrange-link-label">{{ record.productName || '现场链接' }}</span>
<el-input :model-value="record.siteUrl || ''" readonly />
<el-button @click="copyArrangeSiteUrl(record.siteUrl)">
<el-icon><CopyDocument /></el-icon>复制链接
</el-button>
</div>
</template>
</template>
<p
v-else-if="showArrangeLinks && planSupportsInventory"
class="pl-plan-form-empty-hint pl-plan-arrange-links-hint"
>
暂无现场链接
</p>
</section>
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">车辆与承运</h3>
<PlVehicleSummaryStrip
@@ -110,13 +116,47 @@
<div class="pl-plan-feedback-region">
<h4 class="pl-plan-feedback-region__title">司机回传信息</h4>
<p v-if="!hasDriverFeedback" class="pl-plan-form-empty-hint">暂无司机回传</p>
<template v-else>
<ViewKvTable :fields="driverFeedbackViewFields" mode="quad" />
<div v-if="feedbackImageCsv" class="pl-plan-feedback-attachments">
<span class="pl-plan-feedback-attachments__label">回传附件</span>
<AttachmentUploadField :model-value="feedbackImageCsv" disabled />
</div>
</template>
<div v-else class="view-subtable-wrap">
<el-table :data="driverFeedbackTableRows" class="view-subtable list-table" stripe>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
prop="feedbackTime"
label="回传时间"
min-width="160"
align="center"
/>
<el-table-column
prop="feedbackQty"
label="回传数量"
min-width="100"
align="center"
>
<template #default="{ row }">
{{ row.feedbackQty ?? '—' }}
</template>
</el-table-column>
<el-table-column
prop="feedbackRemark"
label="回传备注"
min-width="180"
show-overflow-tooltip
/>
<el-table-column label="回传附件" min-width="140" align="center" header-align="center">
<template #default="{ row }">
<div v-if="row.feedbackImages" class="pl-plan-feedback-table__attachment">
<AttachmentUploadField :model-value="row.feedbackImages" disabled compact />
</div>
<span v-else class="pl-plan-feedback-table__empty-cell">—</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
<div class="pl-plan-feedback-region">
@@ -124,38 +164,69 @@
<p v-if="!siteInventoryRows.length && !loading" class="pl-plan-form-empty-hint">
暂无现场链接
</p>
<template v-else>
<div
v-for="(row, idx) in siteInventoryRows"
:key="`site-feedback-${row.link.siteLinkId || idx}`"
class="pl-plan-site-feedback-block"
>
<div class="pl-plan-site-feedback-head">
<span class="pl-plan-dibang-product">{{ row.link.productName || '—' }}</span>
<div class="pl-plan-dibang-head-actions">
<el-button class="btn-op-item" @click="copySiteSummaryForRow(row)">
<div v-else class="view-subtable-wrap">
<el-table :data="siteFeedbackTableRows" class="view-subtable list-table" stripe>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
prop="productName"
label="产品"
min-width="140"
show-overflow-tooltip
/>
<el-table-column
prop="siteFeedbackTime"
label="保存时间"
min-width="160"
align="center"
/>
<el-table-column label="业务类型" min-width="100" align="center">
<template #default="{ row }">
<el-tag
v-if="row.businessType && row.businessType !== '—'"
:type="businessTypeTagType(row.businessType)"
size="small"
>
{{ row.businessType }}
</el-tag>
<span v-else class="pl-plan-feedback-table__empty-cell">—</span>
</template>
</el-table-column>
<el-table-column
prop="siteFeedbackRemark"
label="现场备注"
min-width="160"
show-overflow-tooltip
/>
<el-table-column label="现场附件" min-width="140" align="center" header-align="center">
<template #default="{ row }">
<div v-if="row.siteFeedbackImages" class="pl-plan-feedback-table__attachment">
<AttachmentUploadField :model-value="row.siteFeedbackImages" disabled compact />
</div>
<span v-else class="pl-plan-feedback-table__empty-cell">—</span>
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="120"
align="center"
fixed="right"
class-name="col-actions"
>
<template #default="{ row }">
<el-button class="btn-op-item" @click="copySiteSummaryForRow(row.sourceRow)">
<el-icon><CopyDocument /></el-icon>
复制给现场
</el-button>
<el-button class="btn-op-item" @click="copySiteLinkForRow(row)">
<el-icon><CopyDocument /></el-icon>
复制现场链接
</el-button>
</div>
</div>
<template v-if="hasSiteFeedback(row)">
<ViewKvTable :fields="siteFeedbackViewFields(row)" mode="quad" />
<div
v-if="siteFeedbackImageCsv(row)"
class="pl-plan-feedback-attachments pl-plan-site-feedback-attachments"
>
<span class="pl-plan-feedback-attachments__label">现场附件</span>
<AttachmentUploadField :model-value="siteFeedbackImageCsv(row)" disabled />
</div>
</template>
<p v-else class="pl-plan-form-empty-hint pl-plan-site-feedback-empty">暂无现场回传</p>
</div>
</template>
</template>
</el-table-column>
</el-table>
</div>
</div>
<h4 class="pl-plan-subtitle">地磅单信息</h4>
@@ -355,6 +426,7 @@
import { CopyDocument, View } from '@element-plus/icons-vue'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlPlanFormProductTable from '@/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue'
import PlPlanHazardCertViewTable from '@/components/business/pl-plan-hazard-cert-view-table/index.vue'
import PlVehicleSummaryStrip from '@/components/business/pl-vehicle-summary-strip/index.vue'
import PlVehicleViewDialog from '@/components/business/pl-vehicle-view-dialog/index.vue'
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
@@ -365,8 +437,10 @@
} from '@/components/business/db/db-related-view'
import type { ContractVO } from '@/api/crm/contract/types'
import { inventoryApi } from '@/api/db/inventory'
import type { TransportSiteLink } from '@/api/pl'
import { mapContractProducts, type PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { mapPlanProductToFormRow, usePlPlanViewDetail, type PlPlanTransportDibangRow, type PlPlanTransportSiteRow } from '@/composables/usePlPlanViewDetail'
import { resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
import { isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
import { useDictStore } from '@/store/modules/dict'
import { formatDate, formatDateTime } from '@/utils/format'
@@ -396,8 +470,16 @@
defineProps<{
planId: string
embedded?: boolean
showArrangeLinks?: boolean
arrangeDriverUrl?: string
arrangeSiteRecords?: TransportSiteLink[]
}>(),
{ embedded: false }
{
embedded: false,
showArrangeLinks: false,
arrangeDriverUrl: '',
arrangeSiteRecords: () => []
}
)
const activeTab = ref('overview')
@@ -436,22 +518,28 @@
return Array.isArray(imgs) ? imgs.filter(Boolean).join(',') : ''
}
function hasSiteFeedback(row: PlPlanTransportSiteRow) {
return Boolean(
row.link.siteFeedbackRemark ||
siteFeedbackImageCsv(row) ||
row.link.siteFeedbackTime
)
}
function siteFeedbackViewFields(row: PlPlanTransportSiteRow): ViewField[] {
const driverFeedbackTableRows = computed(() => {
if (!hasDriverFeedback.value) return []
return [
{ label: '保存时间', value: formatDateTime(row.link.siteFeedbackTime) || '—' },
{ label: '业务类型', value: row.link.businessType || '—' },
{ label: '现场备注', value: row.link.siteFeedbackRemark, span: 'full' }
{
feedbackTime: displayFeedbackTime.value,
feedbackQty: transport.value?.feedbackQty,
feedbackRemark: transport.value?.feedbackRemark || '—',
feedbackImages: feedbackImageCsv.value
}
]
}
})
const siteFeedbackTableRows = computed(() =>
siteInventoryRows.value.map((row) => ({
sourceRow: row,
productName: row.link.productName || '—',
siteFeedbackTime: formatDateTime(row.link.siteFeedbackTime) || '—',
businessType: row.link.businessType || '—',
siteFeedbackRemark: row.link.siteFeedbackRemark || '—',
siteFeedbackImages: siteFeedbackImageCsv(row)
}))
)
function dibangBasicViewFields(row: PlPlanTransportDibangRow): ViewField[] {
const businessType = row.inventory?.businessType
@@ -478,13 +566,32 @@
const siteInventoryRows = computed(() => siteRows.value)
async function copySiteLinkForRow(row: PlPlanTransportSiteRow) {
const url = row.link.siteUrl?.trim()
const hasArrangeSiteLinks = computed(
() => props.showArrangeLinks && (props.arrangeSiteRecords?.length ?? 0) > 0
)
const planSupportsInventory = computed(() => {
const planType = plan.value?.planType
return planType === 'inbound' || planType === 'outbound'
})
async function copyArrangeDriverUrl() {
const url = props.arrangeDriverUrl?.trim()
if (!url) {
ElMessage.warning('暂无现场链接')
ElMessage.warning('暂无司机链接')
return
}
await navigator.clipboard.writeText(url)
ElMessage.success('已复制司机链接')
}
async function copyArrangeSiteUrl(url?: string) {
const target = url?.trim()
if (!target) {
ElMessage.warning('暂无现场链接')
return
}
await navigator.clipboard.writeText(target)
ElMessage.success('已复制现场链接')
}
@@ -548,6 +655,18 @@
return amount ? formatMoneyDisplay(Number(amount)) : '—'
})
const freightUnitLabel = computed(() => {
const unitCode = resolveFreightUnitLabel(productRows.value)
return dictStore.getLabel('crm_product_unit', unitCode) || unitCode || '吨'
})
const displayHasInvoiceLabel = computed(() => {
if (transport.value?.hasInvoice != null) {
return getPlYesNoLabel(transport.value.hasInvoice)
}
return getPlHasInvoiceLabelFromSettlement(freightSettlement.value)
})
function mergeTaxRatesFromContract(
rows: PlanFormProductRow[],
contract: ContractVO | null
@@ -576,8 +695,8 @@
const pickupViewFields = computed<ViewField[]>(() => [
{ label: '取货地址', value: plan.value?.pickupAddress, span: 'full' },
{ label: '取货时间', value: formatDate(plan.value?.pickupTime) || '—' },
{ label: '注意事项', value: plan.value?.pickupNotes, span: 'full' }
{ label: '取货时间', value: formatDate(plan.value?.pickupTime) || '—', long: false },
{ label: '注意事项', value: plan.value?.pickupNotes, long: false, fullSpan: false }
])
const deliveryViewFields = computed<ViewField[]>(() => [
@@ -586,40 +705,38 @@
value: plan.value?.destinationAddress || plan.value?.destinationName,
span: 'full'
},
{ label: '送达时间', value: formatDate(plan.value?.deliveryTime) || '—' },
{ label: '注意事项', value: plan.value?.destinationNotes, span: 'full' }
{ label: '送达时间', value: formatDate(plan.value?.deliveryTime) || '—', long: false },
{ label: '注意事项', value: plan.value?.destinationNotes, long: false, fullSpan: false }
])
const freightViewFields = computed<ViewField[]>(() => {
const fields: ViewField[] = [
{ label: '是否有运输费用', value: getPlYesNoLabel(plan.value?.hasFreight) }
const hasFreight = plan.value?.hasFreight === 1
const unit = freightUnitLabel.value
const isByWeight = hasFreight && displayFreightType.value === 'by_weight'
return [
{ label: '是否有运输费用', value: getPlYesNoLabel(plan.value?.hasFreight) },
{ label: '运输公里数', value: hasFreight ? displayDistanceKm.value : '—' },
{
label: '运费计算方式',
value: hasFreight ? getPlFreightTypeLabel(displayFreightType.value) : '—'
},
{
label: `运输量(${unit})`,
value: isByWeight ? displayTransportQty.value : '—'
},
{
label: `运输单价(元/${unit})`,
value: isByWeight ? displayFreightPrice.value : '—',
money: isByWeight
},
{
label: '运费金额(元)',
value: hasFreight ? displayFreightAmount.value : '—',
money: hasFreight
},
{ label: '是否开票', value: hasFreight ? displayHasInvoiceLabel.value : '—' }
]
if (plan.value?.hasFreight !== 1) return fields
fields.push(
{ label: '运输公里数', value: displayDistanceKm.value },
{ label: '运费计算方式', value: getPlFreightTypeLabel(displayFreightType.value) }
)
if (displayFreightType.value === 'by_weight') {
fields.push(
{ label: '运输量(吨)', value: displayTransportQty.value },
{ label: '运输单价(元/吨)', value: displayFreightPrice.value, money: true }
)
}
fields.push(
{ label: '运费金额(元)', value: displayFreightAmount.value, money: true },
{ label: '是否开票', value: getPlHasInvoiceLabelFromSettlement(freightSettlement.value) }
)
return fields
})
const driverFeedbackViewFields = computed<ViewField[]>(() => {
const fields: ViewField[] = [{ label: '回传时间', value: displayFeedbackTime.value }]
if (transport.value?.feedbackQty != null) {
fields.push({ label: '回传数量', value: transport.value.feedbackQty })
}
fields.push({ label: '回传备注', value: transport.value?.feedbackRemark, span: 'full' })
return fields
})
const contractBasicViewFields = computed<ViewField[]>(() => {
@@ -712,7 +829,11 @@
if (props.planId) return loadDetail(props.planId)
}
defineExpose({ reload })
function setActiveTab(name: string) {
activeTab.value = name
}
defineExpose({ reload, setActiveTab })
</script>
<style scoped lang="scss">
@@ -785,6 +906,32 @@
color: var(--mint-text-muted);
}
.pl-plan-arrange-links {
display: flex;
flex-direction: column;
gap: 10px;
}
.pl-plan-arrange-links-hint {
margin-top: 2px;
}
.pl-arrange-link-row {
display: grid;
grid-template-columns: 72px 1fr auto;
gap: 8px;
align-items: center;
&--readonly {
grid-template-columns: 72px 1fr;
}
}
.pl-arrange-link-label {
font-size: 13px;
color: #64748b;
}
.pl-plan-view-remark {
margin: 0;
font-size: 13px;
@@ -931,43 +1078,52 @@
line-height: 1;
}
.pl-plan-feedback-attachments {
margin-top: 12px;
padding: 12px;
.pl-plan-feedback-region .view-subtable-wrap {
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
overflow: hidden;
background: #fff;
}
.pl-plan-feedback-attachments__label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: var(--mint-text-muted);
}
.pl-plan-feedback-region .view-subtable-wrap :deep(.el-table.view-subtable) {
--el-table-header-bg-color: #f8fafc;
--el-table-header-text-color: #1e293b;
--el-table-border-color: #e2e8f0;
width: 100%;
.pl-plan-cert-block {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
.el-table__header th.el-table__cell,
.el-table__body td.el-table__cell {
font-size: 14px;
padding: 8px 0;
}
& + & {
margin-top: 10px;
.el-table__header th.el-table__cell .cell {
font-weight: 500;
color: #1e293b;
}
.el-table__body td.el-table__cell .cell {
color: #1e293b;
}
}
.pl-plan-cert-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
.pl-plan-feedback-table__attachment {
display: inline-flex;
justify-content: center;
width: 100%;
:deep(.attachment-upload) {
width: auto;
}
:deep(.attachment-list) {
justify-content: center;
}
}
.pl-plan-cert-name {
.pl-plan-feedback-table__empty-cell {
color: var(--mint-text-muted);
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
.money-display {
@@ -986,38 +1142,6 @@
}
}
.pl-plan-site-feedback-block {
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
& + & {
margin-top: 10px;
}
}
.pl-plan-site-feedback-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.pl-plan-site-feedback-empty {
margin: 0;
}
.pl-plan-site-feedback-batch {
font-size: 12px;
color: var(--mint-text-muted);
}
.pl-plan-site-feedback-attachments {
margin-top: 10px;
}
.pl-plan-dibang-head {
display: flex;
align-items: center;
@@ -111,18 +111,11 @@
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="innerVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Close, RefreshLeft, Search, Select } from '@element-plus/icons-vue'
import { RefreshLeft, Search, Select } from '@element-plus/icons-vue'
import { transportAddressApi, type TransportAddress } from '@/api/pl/index'
import ListPagination from '@/components/ListPagination/index.vue'
import { pickBxPageResult } from '@/utils/http/bx'
@@ -1,6 +1,23 @@
<template>
<div class="vehicle-cert-part-tabs">
<el-tabs v-if="hasTrailer" v-model="activePart" class="vehicle-cert-part-tabs__tabs">
<PlCertFormList
v-if="layout === 'table'"
ref="unifiedCertFormRef"
v-model="certList"
:title="title"
:upload-folder="uploadFolder"
:cert-name-options="certNameOptions"
layout="table"
show-vehicle-part-column
:license-plate="licensePlate"
:trailer-no="trailerNo"
/>
<el-tabs
v-else-if="hasTrailer"
v-model="activePart"
class="vehicle-cert-part-tabs__tabs"
>
<el-tab-pane :label="headTabLabel" name="head">
<PlCertFormList
ref="headCertFormRef"
@@ -8,6 +25,7 @@
:title="headTabLabel"
:upload-folder="uploadFolder"
:cert-name-options="certNameOptions"
:layout="layout"
/>
</el-tab-pane>
<el-tab-pane :label="trailerTabLabel" name="trailer">
@@ -17,6 +35,7 @@
:title="trailerTabLabel"
:upload-folder="uploadFolder"
:cert-name-options="certNameOptions"
:layout="layout"
/>
</el-tab-pane>
</el-tabs>
@@ -28,6 +47,7 @@
:title="title"
:upload-folder="uploadFolder"
:cert-name-options="certNameOptions"
layout="card"
/>
</div>
</template>
@@ -52,18 +72,21 @@ const props = withDefaults(
uploadFolder: string
certNameOptions?: string[]
title?: string
layout?: 'card' | 'table'
}>(),
{
licensePlate: '',
trailerNo: '',
certNameOptions: () => [],
title: '车辆证书'
title: '车辆证书',
layout: 'card'
}
)
const certList = defineModel<CertRowWithKey[]>('modelValue', { default: () => [] })
const activePart = ref(PL_VEHICLE_CERT_PART_HEAD)
const unifiedCertFormRef = ref<CertFormListRef>()
const headCertFormRef = ref<CertFormListRef>()
const trailerCertFormRef = ref<CertFormListRef>()
@@ -102,6 +125,7 @@ watch(hasTrailer, value => {
[],
false
)
unifiedCertFormRef.value?.clearUploadRefs()
trailerCertFormRef.value?.clearUploadRefs()
return
}
@@ -111,12 +135,16 @@ watch(hasTrailer, value => {
})
function hasUploading() {
if (props.layout === 'table') {
return Boolean(unifiedCertFormRef.value?.hasUploading?.())
}
return Boolean(
headCertFormRef.value?.hasUploading?.() || trailerCertFormRef.value?.hasUploading?.()
)
}
function clearUploadRefs() {
unifiedCertFormRef.value?.clearUploadRefs()
headCertFormRef.value?.clearUploadRefs()
trailerCertFormRef.value?.clearUploadRefs()
}
@@ -1,23 +1,52 @@
<template>
<div class="vehicle-cert-part-view">
<el-tabs v-if="hasTrailer" v-model="activePart" class="vehicle-cert-part-view__tabs">
<el-tab-pane :label="headTabLabel" name="head">
<PlCertViewList :cert-list="headCertList" />
</el-tab-pane>
<el-tab-pane :label="trailerTabLabel" name="trailer">
<PlCertViewList :cert-list="trailerCertList" />
</el-tab-pane>
</el-tabs>
<PlCertViewList
v-if="certLayout === 'table'"
:cert-list="scopedCertList"
:table-mode="tableMode"
layout="table"
:show-vehicle-part-column="hasTrailer"
:head-label="headTabLabel"
:trailer-label="trailerTabLabel"
/>
<PlCertViewList v-else :cert-list="headCertList" />
<template v-else>
<el-tabs
v-if="hasTrailer"
:key="tabsInstanceKey"
v-model="activePart"
class="vehicle-cert-part-view__tabs"
>
<el-tab-pane :label="headTabLabelWithHint" :name="headPartName">
<PlCertViewList :cert-list="headCertList" :table-mode="tableMode" :layout="certLayout" />
</el-tab-pane>
<el-tab-pane :label="trailerTabLabelWithHint" :name="trailerPartName">
<PlCertViewList
:cert-list="trailerCertList"
:table-mode="tableMode"
:layout="certLayout"
/>
</el-tab-pane>
</el-tabs>
<PlCertViewList
v-else
:cert-list="headCertList"
:table-mode="tableMode"
:layout="certLayout"
/>
</template>
</div>
</template>
<script setup lang="ts">
import PlCertViewList from '@/components/business/pl-cert-view-list/index.vue'
import type { CertRead } from '@/api/pl'
import type { ViewTableMode } from '@/components/business/system-view-dialog/types'
import {
PL_VEHICLE_CERT_PART_HEAD,
PL_VEHICLE_CERT_PART_TRAILER,
getSelfVehiclePartCertMissingSuffix,
splitVehicleCertsByPart
} from '@/utils/pl-vehicle-cert'
@@ -26,22 +55,69 @@ const props = withDefaults(
certList?: CertRead[]
licensePlate?: string
trailerNo?: string
vehicleId?: string
checkSelfVehicleCerts?: boolean
tableMode?: ViewTableMode
certLayout?: 'card' | 'table'
}>(),
{
certList: () => [],
licensePlate: '',
trailerNo: ''
trailerNo: '',
vehicleId: '',
checkSelfVehicleCerts: false,
tableMode: 'triple',
certLayout: 'card'
}
)
const activePart = ref(PL_VEHICLE_CERT_PART_HEAD)
const activePart = ref('')
const tabsInstanceKey = computed(
() => `${props.vehicleId || props.licensePlate || 'vehicle'}-${props.trailerNo || 'head'}`
)
const headPartName = computed(
() => `${PL_VEHICLE_CERT_PART_HEAD}-${props.vehicleId || props.licensePlate || 'vehicle'}`
)
const trailerPartName = computed(
() =>
`${PL_VEHICLE_CERT_PART_TRAILER}-${props.vehicleId || props.trailerNo || props.licensePlate || 'vehicle'}`
)
const hasTrailer = computed(() => Boolean(props.trailerNo?.trim()))
const headTabLabel = computed(() => props.licensePlate?.trim() || '主车')
const trailerTabLabel = computed(() => props.trailerNo?.trim() || '挂车')
const headCertList = computed(() => splitVehicleCertsByPart(props.certList).headCerts)
const trailerCertList = computed(() => splitVehicleCertsByPart(props.certList).trailerCerts)
const scopedCertList = computed(() => {
const list = props.certList || []
if (!props.vehicleId) return list
return list.filter(cert => !cert.ownerId || cert.ownerId === props.vehicleId)
})
const headCertList = computed(() => splitVehicleCertsByPart(scopedCertList.value).headCerts)
const trailerCertList = computed(() => splitVehicleCertsByPart(scopedCertList.value).trailerCerts)
const headTabLabelWithHint = computed(() => {
if (!props.checkSelfVehicleCerts) return headTabLabel.value
return headTabLabel.value + getSelfVehiclePartCertMissingSuffix(headCertList.value, PL_VEHICLE_CERT_PART_HEAD)
})
const trailerTabLabelWithHint = computed(() => {
if (!props.checkSelfVehicleCerts) return trailerTabLabel.value
return (
trailerTabLabel.value +
getSelfVehiclePartCertMissingSuffix(trailerCertList.value, PL_VEHICLE_CERT_PART_TRAILER)
)
})
watch(
tabsInstanceKey,
() => {
activePart.value = headPartName.value
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
@@ -1,10 +1,15 @@
<template>
<el-tabs v-model="activeTab" class="vehicle-detail-tabs">
<el-tab-pane label="车辆证书" name="vehicle-cert">
<el-tabs :key="tabsInstanceKey" v-model="activeTab" class="vehicle-detail-tabs">
<el-tab-pane :label="vehicleCertTabLabel" :name="vehicleCertTabName">
<PlVehicleCertPartView
:key="`${vehicleId || licensePlate}-cert`"
:cert-list="certList"
:license-plate="licensePlate"
:trailer-no="trailerNo"
:vehicle-id="vehicleId"
:check-self-vehicle-certs="checkSelfVehicleCerts"
:table-mode="tableMode"
:cert-layout="certLayout"
/>
</el-tab-pane>
<el-tab-pane
@@ -13,7 +18,12 @@
:label="getPersonTabLabel(person)"
:name="getPersonKey(person, index)"
>
<PersonnelViewPanel :person="person" />
<PersonnelViewPanel
:person="person"
:check-self-vehicle-certs="checkSelfVehicleCerts"
:table-mode="tableMode"
:cert-layout="certLayout"
/>
</el-tab-pane>
</el-tabs>
</template>
@@ -23,26 +33,57 @@ import PlVehicleCertPartView from '@/components/business/pl-vehicle-cert-part-vi
import PersonnelViewPanel from '@/components/business/pl-vehicle-personnel-view-list/PersonnelViewPanel.vue'
import type { CertRead } from '@/api/pl'
import type { VehiclePersonnelViewRow } from '@/components/business/pl-vehicle-personnel-view-list/index.vue'
import type { ViewTableMode } from '@/components/business/system-view-dialog/types'
import { getSelfVehicleCertMissingSuffix } from '@/utils/pl-vehicle-cert'
import { getPersonnelCertMissingSuffix } from '@/utils/pl-personnel-cert'
const props = withDefaults(
defineProps<{
certList?: CertRead[]
licensePlate?: string
trailerNo?: string
vehicleId?: string
personnelList?: VehiclePersonnelViewRow[]
checkSelfVehicleCerts?: boolean
tableMode?: ViewTableMode
certLayout?: 'card' | 'table'
}>(),
{
certList: () => [],
licensePlate: '',
trailerNo: '',
personnelList: () => []
vehicleId: '',
personnelList: () => [],
checkSelfVehicleCerts: false,
tableMode: 'triple',
certLayout: 'card'
}
)
const activeTab = ref('vehicle-cert')
const vehicleCertTabName = computed(
() => `vehicle-cert-${props.vehicleId || props.licensePlate || 'default'}`
)
const tabsInstanceKey = computed(
() => `${props.vehicleId || props.licensePlate || 'vehicle'}-${props.trailerNo || 'none'}`
)
const vehicleCertTabLabel = computed(() => {
const label = '车辆证书'
if (!props.checkSelfVehicleCerts) return label
return (
label +
getSelfVehicleCertMissingSuffix({
certList: props.certList,
trailerNo: props.trailerNo
})
)
})
function getPersonKey(person: VehiclePersonnelViewRow, index: number) {
return `person-${person.personnelId || `${person.personnelName}-${index}`}`
return `person-${props.vehicleId || props.licensePlate || 'vehicle'}-${person.personnelId || `${person.personnelName}-${index}`}`
}
function getRoleLabel(role: string) {
@@ -56,23 +97,31 @@ function getRoleLabel(role: string) {
function getPersonTabLabel(person: VehiclePersonnelViewRow) {
const name = person.personnelName || '—'
const role = getRoleLabel(person.role)
return `${name}(${role})`
const suffix =
props.checkSelfVehicleCerts && person.role
? getPersonnelCertMissingSuffix(person.certList || [], person.role as 'driver' | 'escort')
: ''
return `${name}(${role})${suffix}`
}
watch(
tabsInstanceKey,
() => {
activeTab.value = vehicleCertTabName.value
},
{ immediate: true }
)
watch(
() => props.personnelList,
list => {
if (!list.length) {
activeTab.value = 'vehicle-cert'
return
}
if (activeTab.value === 'vehicle-cert') return
if (activeTab.value === vehicleCertTabName.value) return
const keys = list.map((person, index) => getPersonKey(person, index))
if (!keys.includes(activeTab.value)) {
activeTab.value = 'vehicle-cert'
activeTab.value = vehicleCertTabName.value
}
},
{ immediate: true, deep: true }
{ deep: true }
)
</script>
@@ -1,7 +1,7 @@
<template>
<div class="personnel-view-panel">
<h4 v-if="showTitle" class="personnel-view-card-title">
{{ person.personnelName || '—' }}
{{ person.personnelName || '—' }}{{ personnelCertMissingSuffix }}
<el-tag :type="getRoleTagType(person.role)" size="small">
{{ getRoleLabel(person.role) }}
</el-tag>
@@ -10,11 +10,15 @@
</el-tag>
</h4>
<ViewKvTable :fields="personnelViewFields" mode="triple" />
<ViewKvTable :fields="personnelViewFields" :mode="tableMode" />
<div class="personnel-certs-block">
<p class="personnel-certs-title">人员证书</p>
<PlCertViewList :cert-list="person.certList || []" />
<PlCertViewList
:cert-list="person.certList || []"
:table-mode="tableMode"
:layout="certLayout"
/>
</div>
</div>
</template>
@@ -22,8 +26,9 @@
<script setup lang="ts">
import PlCertViewList from '@/components/business/pl-cert-view-list/index.vue'
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import type { ViewField, ViewTableMode } from '@/components/business/system-view-dialog/types'
import type { CertRead, VehiclePersonnelRow } from '@/api/pl'
import { getPersonnelCertMissingSuffix } from '@/utils/pl-personnel-cert'
type VehiclePersonnelViewRow = VehiclePersonnelRow & { certList?: CertRead[] }
@@ -31,12 +36,26 @@ const props = withDefaults(
defineProps<{
person: VehiclePersonnelViewRow
showTitle?: boolean
checkSelfVehicleCerts?: boolean
tableMode?: ViewTableMode
certLayout?: 'card' | 'table'
}>(),
{
showTitle: false
showTitle: false,
checkSelfVehicleCerts: false,
tableMode: 'triple',
certLayout: 'card'
}
)
const personnelCertMissingSuffix = computed(() => {
if (!props.checkSelfVehicleCerts || !props.person.role) return ''
return getPersonnelCertMissingSuffix(
props.person.certList || [],
props.person.role as 'driver' | 'escort'
)
})
const personnelViewFields = computed<ViewField[]>(() => {
const person = props.person
const fields: ViewField[] = [
@@ -58,7 +77,7 @@ const personnelViewFields = computed<ViewField[]>(() => {
})
}
fields.push({ label: '备注', value: person.remark || '—', span: 'full' })
fields.push({ label: '备注', value: person.remark || '—', long: false })
return fields
})
@@ -13,7 +13,7 @@
:label="getTabLabel(person)"
:name="getPersonKey(person, index)"
>
<PersonnelViewPanel :person="person" />
<PersonnelViewPanel :person="person" :check-self-vehicle-certs="checkSelfVehicleCerts" />
</el-tab-pane>
</el-tabs>
@@ -23,7 +23,7 @@
:key="getPersonKey(person, index)"
class="personnel-view-card"
>
<PersonnelViewPanel :person="person" show-title />
<PersonnelViewPanel :person="person" show-title :check-self-vehicle-certs="checkSelfVehicleCerts" />
</div>
</div>
</div>
@@ -32,6 +32,7 @@
<script setup lang="ts">
import PersonnelViewPanel from './PersonnelViewPanel.vue'
import type { CertRead, VehiclePersonnelRow } from '@/api/pl'
import { getPersonnelCertMissingSuffix } from '@/utils/pl-personnel-cert'
export interface VehiclePersonnelViewRow extends VehiclePersonnelRow {
certList?: CertRead[]
@@ -41,10 +42,12 @@ const props = withDefaults(
defineProps<{
personnelList?: VehiclePersonnelViewRow[]
layout?: 'list' | 'tabs'
checkSelfVehicleCerts?: boolean
}>(),
{
personnelList: () => [],
layout: 'list'
layout: 'list',
checkSelfVehicleCerts: false
}
)
@@ -65,7 +68,11 @@ function getRoleLabel(role: string) {
function getTabLabel(person: VehiclePersonnelViewRow) {
const name = person.personnelName || '—'
const role = getRoleLabel(person.role)
return `${name}(${role})`
const suffix =
props.checkSelfVehicleCerts && person.role
? getPersonnelCertMissingSuffix(person.certList || [], person.role as 'driver' | 'escort')
: ''
return `${name}(${role})${suffix}`
}
watch(
@@ -14,7 +14,12 @@
<template #vehicleDetailTabs>
<PlVehicleCertPersonnelTabs
:cert-list="detail?.certList || []"
:license-plate="detail?.licensePlate"
:trailer-no="detail?.trailerNo"
:vehicle-id="detail?.id"
:personnel-list="personnelList"
:check-self-vehicle-certs="detail?.vehicleType === 'self'"
cert-layout="table"
/>
</template>
</SystemViewDialog>
@@ -19,13 +19,19 @@
<div v-show="!collapsible || expanded" class="vehicle-view-panel-body">
<section class="vehicle-view-section">
<h4 v-if="!collapsible" class="vehicle-view-section-title">车辆信息</h4>
<ViewKvTable :fields="vehicleViewFields" mode="triple" />
<ViewKvTable :fields="vehicleViewFields" :mode="tableMode" />
</section>
<section class="vehicle-view-section">
<PlVehicleCertPersonnelTabs
:key="detail.id"
:cert-list="detail.certList || []"
:license-plate="detail.licensePlate"
:trailer-no="detail.trailerNo"
:vehicle-id="detail.id"
:personnel-list="personnelList"
:table-mode="tableMode"
:cert-layout="certLayout"
/>
</section>
</div>
@@ -36,7 +42,7 @@
import { ArrowDown } from '@element-plus/icons-vue'
import PlVehicleCertPersonnelTabs from '@/components/business/pl-vehicle-cert-personnel-tabs/index.vue'
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import type { ViewField, ViewTableMode } from '@/components/business/system-view-dialog/types'
import type { VehiclePersonnelViewRow } from '@/components/business/pl-vehicle-personnel-view-list/index.vue'
import type { VehicleDetail } from '@/api/pl'
import { formatDateTime } from '@/utils/format'
@@ -53,11 +59,15 @@ const props = withDefaults(
personnelList?: VehiclePersonnelViewRow[]
collapsible?: boolean
defaultExpanded?: boolean
tableMode?: ViewTableMode
certLayout?: 'card' | 'table'
}>(),
{
personnelList: () => [],
collapsible: false,
defaultExpanded: false
defaultExpanded: false,
tableMode: 'triple',
certLayout: 'card'
}
)
@@ -78,11 +88,12 @@ const vehicleViewFields = computed<ViewField[]>(() => {
{
label: '车辆状态',
value: getPlVehicleStatusLabel(d.vehicleStatus),
tagType: getPlVehicleStatusTagType(d.vehicleStatus)
tagType: getPlVehicleStatusTagType(d.vehicleStatus),
long: false
},
{ label: '关联人员摘要', value: d.personnelSummary || '—', span: true },
{ label: '创建时间', value: formatDateTime(d.createTime) },
{ label: '备注', value: d.remark || '—', span: 'full' }
{ label: '创建时间', value: formatDateTime(d.createTime), long: false },
{ label: '关联人员摘要', value: d.personnelSummary || '—', long: false },
{ label: '备注', value: d.remark || '—', long: false }
]
})
@@ -258,12 +258,6 @@
</div>
</div>
<template #footer>
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
<el-icon><Close /></el-icon>
关闭
</el-button>
</template>
<el-dialog
v-model="previewVisible"
@@ -299,7 +293,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { Close, Document, Money, Picture, Setting, VideoPlay } from '@element-plus/icons-vue'
import { Document, Money, Picture, Setting, VideoPlay } from '@element-plus/icons-vue'
import { buildTreePathLabelFromFlat } from '@/utils/tree-path'
import {
fetchProductDetail,
@@ -0,0 +1,77 @@
<template>
<div v-if="items.length" class="view-highlights">
<span
v-for="(item, index) in items"
:key="item.label"
class="view-highlight-item"
:class="{
'is-emphasis': item.emphasis,
'is-warn': item.warn
}"
>
<span class="view-highlight-sep" v-if="index > 0">·</span>
<span class="view-highlight-label">{{ item.label }}</span>
<span class="view-highlight-value">
<el-tag v-if="item.tagType" :type="item.tagType" size="small">{{
formatViewValue(item.value)
}}</el-tag>
<span v-else :class="{ 'money-display': item.money }">{{
formatViewValue(item.value)
}}</span>
</span>
</span>
</div>
</template>
<script setup lang="ts">
import type { ViewHighlight } from './types'
import { formatViewValue } from './types'
defineProps<{
items: ViewHighlight[]
}>()
</script>
<style scoped lang="scss">
.view-highlights {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 0;
padding: 0 16px 10px;
font-size: 13px;
line-height: 1.45;
}
.view-highlight-item {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.view-highlight-sep {
margin: 0 10px;
color: #cbd5e1;
user-select: none;
}
.view-highlight-label {
color: var(--mint-text-muted, #64748b);
font-size: 12px;
}
.view-highlight-value {
font-weight: 600;
color: var(--text, #1e293b);
}
.view-highlight-item.is-emphasis .view-highlight-value {
color: var(--primary-dark, #059669);
font-weight: 700;
}
.view-highlight-item.is-warn .view-highlight-value {
color: #d97706;
}
</style>
@@ -28,7 +28,7 @@
</template>
<template v-else>
<th>{{ row.left.label }}</th>
<td>
<td :colspan="row.right ? undefined : 3">
<ViewKvTableCell :field="row.left" />
</td>
<template v-if="row.right">
@@ -37,10 +37,6 @@
<ViewKvTableCell :field="row.right" />
</td>
</template>
<template v-else>
<th></th>
<td></td>
</template>
</template>
</tr>
</template>
@@ -57,22 +53,22 @@
</td>
</template>
<template v-else>
<template v-for="slot in 3" :key="slot">
<template v-if="row.fields[slot - 1]">
<th>{{ row.fields[slot - 1].label }}</th>
<td>
<ViewKvTableCell :field="row.fields[slot - 1]" />
</td>
</template>
<template v-else>
<th></th>
<td></td>
</template>
<template v-for="(field, fi) in row.fields" :key="field.label">
<th>{{ field.label }}</th>
<td
:colspan="
fi === row.fields.length - 1 && row.fields.length < 3
? (3 - row.fields.length) * 2 + 1
: undefined
"
>
<ViewKvTableCell :field="field" />
</td>
</template>
</template>
</tr>
</template>
<template v-else>
<template v-else-if="mode === 'quad'">
<tr
v-for="(row, idx) in quadRows"
:key="quadRowKey(row, idx)"
@@ -85,17 +81,45 @@
</td>
</template>
<template v-else>
<template v-for="slot in 4" :key="slot">
<template v-if="row.fields[slot - 1]">
<th>{{ row.fields[slot - 1].label }}</th>
<td>
<ViewKvTableCell :field="row.fields[slot - 1]" />
</td>
</template>
<template v-else>
<th></th>
<td></td>
</template>
<template v-for="(field, fi) in row.fields" :key="field.label">
<th>{{ field.label }}</th>
<td
:colspan="
fi === row.fields.length - 1 && row.fields.length < 4
? (4 - row.fields.length) * 2 + 1
: undefined
"
>
<ViewKvTableCell :field="field" />
</td>
</template>
</template>
</tr>
</template>
<template v-else>
<tr
v-for="(row, idx) in quintRows"
:key="quintRowKey(row, idx)"
:class="{ 'row-long': row.type === 'long' }"
>
<template v-if="row.type === 'long'">
<th>{{ row.field.label }}</th>
<td colspan="9">
<ViewKvTableCell :field="row.field" />
</td>
</template>
<template v-else>
<template v-for="(field, fi) in row.fields" :key="field.label">
<th>{{ field.label }}</th>
<td
:colspan="
fi === row.fields.length - 1 && row.fields.length < 5
? (5 - row.fields.length) * 2 + 1
: undefined
"
>
<ViewKvTableCell :field="field" />
</td>
</template>
</template>
</tr>
@@ -113,11 +137,13 @@
import {
buildViewKvTableDualRows,
buildViewKvTableQuadRows,
buildViewKvTableQuintRows,
buildViewKvTableTripleRows,
getViewFieldDisplayText,
isViewFieldFullSpan,
isViewFieldLong,
type ViewKvTableQuadRow,
type ViewKvTableQuintRow,
type ViewKvTableRow,
type ViewKvTableTripleRow
} from '@/utils/view-field-layout'
@@ -125,8 +151,8 @@
const props = withDefaults(
defineProps<{
fields: ViewField[]
/** pair:一行一组;dual:一行两组(四列);triple:一行三组(六列);quad:一行四组(八列) */
mode?: 'pair' | 'dual' | 'triple' | 'quad'
/** pair:一行一组;dual:一行两组(四列);triple:一行三组(六列);quad:一行四组(八列);quint:一行五组(十列) */
mode?: 'pair' | 'dual' | 'triple' | 'quad' | 'quint'
}>(),
{
mode: 'pair'
@@ -136,6 +162,7 @@
const dualRows = computed(() => buildViewKvTableDualRows(props.fields, formatViewValue))
const tripleRows = computed(() => buildViewKvTableTripleRows(props.fields, formatViewValue))
const quadRows = computed(() => buildViewKvTableQuadRows(props.fields, formatViewValue))
const quintRows = computed(() => buildViewKvTableQuintRows(props.fields, formatViewValue))
const emit = defineEmits<{
(e: 'field-view', field: ViewField): void
@@ -162,6 +189,11 @@
if (row.type === 'long') return row.field.label
return row.fields.map((f) => f.label).join('-') || String(idx)
}
function quintRowKey(row: ViewKvTableQuintRow, idx: number) {
if (row.type === 'long') return row.field.label
return row.fields.map((f) => f.label).join('-') || String(idx)
}
</script>
<style scoped lang="scss">
@@ -261,4 +293,20 @@
.view-kv-table--quad tr.row-long td {
width: auto;
}
.view-kv-table--quint th {
width: 6.5%;
}
.view-kv-table--quint td {
width: 13%;
}
.view-kv-table--quint tr.row-long th {
width: 6.5%;
}
.view-kv-table--quint tr.row-long td {
width: auto;
}
</style>
@@ -9,6 +9,17 @@
<span v-else :class="{ 'money-display': field.money, 'muted-display': isMuted }">{{
formatViewValue(field.value)
}}</span>
<el-button
v-if="showMapBtn"
link
type="primary"
size="small"
class="view-kv-field-link"
title="在高德地图查看"
@click="openAmapLocation"
>
<el-icon><Location /></el-icon>
</el-button>
<el-button
v-if="showViewBtn"
link
@@ -24,9 +35,10 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import { View } from '@element-plus/icons-vue'
import { Location, View } from '@element-plus/icons-vue'
import type { ViewField } from './types'
import { formatViewValue } from './types'
import { buildAmapLocationUrl } from '@/utils/amap/navigation'
const VIEW_KV_FIELD_VIEW_KEY = 'viewKvFieldView'
@@ -48,9 +60,27 @@
return true
})
const amapLocationUrl = computed(() => {
if (!props.field.showMapLink) return null
return buildAmapLocationUrl({
lng: props.field.mapLng,
lat: props.field.mapLat,
name: props.field.mapName,
address: formatViewValue(props.field.value) === '—' ? undefined : String(props.field.value ?? '')
})
})
const showMapBtn = computed(() => Boolean(amapLocationUrl.value))
function handleView() {
onFieldView?.(props.field)
}
function openAmapLocation() {
const url = amapLocationUrl.value
if (!url) return
window.open(url, '_blank', 'noopener,noreferrer')
}
</script>
<style scoped lang="scss">
@@ -3,12 +3,16 @@
<template>
<el-dialog
v-model="innerVisible"
:title="title"
:width="width"
class="system-view-dialog"
:class="[dialogClass, { 'system-view-dialog--compact': compact }]"
:class="[
dialogClass,
{ 'system-view-dialog--compact': compact },
{ 'system-view-dialog--dense': dense }
]"
align-center
destroy-on-close
:show-close="false"
:append-to-body="appendToBody"
:modal-class="modalClass"
style="backdrop-filter: none"
@@ -34,7 +38,11 @@
:key="sectionKey(section, si)"
class="view-section"
>
<h3 v-if="section.title && section.titleStyle !== 'field'">{{ section.title }}</h3>
<h3
v-if="section.title && section.titleStyle !== 'field' && !(dense && section.hideTitleInDense)"
>
{{ section.title }}
</h3>
<div v-else-if="section.title && section.titleStyle === 'field'" class="view-field-label">
{{ section.title }}
</div>
@@ -108,9 +116,21 @@
</div>
</div>
<ViewHighlights v-if="highlights?.length" :items="highlights" />
<div :class="dense ? 'view-dense-body' : 'view-sections-stack'">
<!-- sections -->
<div v-for="(section, si) in sections" :key="sectionKey(section, si)" class="view-section">
<h3 v-if="section.title && section.titleStyle !== 'field'">{{ section.title }}</h3>
<div
v-for="(section, si) in sections"
:key="sectionKey(section, si)"
class="view-section"
:class="{ 'view-section--muted': dense && section.variant === 'muted' }"
>
<h3
v-if="section.title && section.titleStyle !== 'field' && !(dense && section.hideTitleInDense)"
>
{{ section.title }}
</h3>
<div v-else-if="section.title && section.titleStyle === 'field'" class="view-field-label">
{{ section.title }}
</div>
@@ -162,23 +182,20 @@
<slot :name="section.slot" />
</div>
</div>
</div>
<slot name="append" />
</template>
</div>
<template #footer>
<el-button class="btn-ghost" @click="innerVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, useSlots, type Component } from 'vue'
import { Close, Document } from '@element-plus/icons-vue'
import { Document } from '@element-plus/icons-vue'
import ViewKvGrid from './ViewKvGrid.vue'
import ViewKvTable from './ViewKvTable.vue'
import ViewHighlights from './ViewHighlights.vue'
import {
isIconComponent,
type TagType,
@@ -186,15 +203,26 @@
type ViewFieldGroup,
type ViewFieldLayout,
type ViewGridColumns,
type ViewHighlight,
type ViewSection,
type ViewTableMode
} from './types'
export type { TagType, ViewField, ViewFieldGroup, ViewFieldLayout, ViewSection, ViewTableMode }
export type {
TagType,
ViewField,
ViewFieldGroup,
ViewFieldLayout,
ViewHighlight,
ViewSection,
ViewTableMode
}
const props = withDefaults(
defineProps<{
visible: boolean
/** 推荐:v-model:visible;兼容旧写法 v-model(modelValue) */
visible?: boolean
modelValue?: boolean
title: string
icon?: Component
meta?: string
@@ -215,6 +243,13 @@
dialogClass?: string
/** 紧凑布局:缩小头部/区块间距与内容区最大高度 */
compact?: boolean
/**
* 聚拢布局:核心指标条 + 单面板分区,信息密度更高。
* 各业务页仅需配置 highlights / sections,无需单独写样式。
*/
dense?: boolean
/** dense 模式下 hero 下方的核心指标(2~6 项) */
highlights?: ViewHighlight[]
/** 详情异步加载中 */
loading?: boolean
/** default 单列滚动;split 左右分栏(需 split-right 插槽) */
@@ -229,6 +264,8 @@
modalClass: '',
dialogClass: '',
compact: false,
dense: false,
highlights: () => [],
loading: false,
layout: 'default',
splitGridColumns: 1
@@ -241,18 +278,24 @@
() => props.layout === 'split' || Boolean(slots['split-right'])
)
/** 紧凑模式降为 2 列,避免 4 列过密 */
const effectiveGridColumns = computed<ViewGridColumns>(() =>
props.compact ? 2 : props.gridColumns
)
/** 紧凑(非 dense)降为 2 列;聚拢模式保持 4 列密度 */
const effectiveGridColumns = computed<ViewGridColumns>(() => {
if (props.dense) return props.gridColumns
if (props.compact) return 2
return props.gridColumns
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'update:modelValue', val: boolean): void
}>()
const innerVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
get: () => props.visible ?? props.modelValue ?? false,
set: (val) => {
emit('update:visible', val)
emit('update:modelValue', val)
}
})
/** 须为 @element-plus/icons-vue 组件;字符串或未传时使用默认 Document */
@@ -272,8 +315,9 @@
<style scoped lang="scss">
.dialog-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
max-height: calc(100vh - 120px);
&--split {
overflow: hidden;
@@ -286,7 +330,6 @@
display: flex;
flex: 1;
min-height: 0;
max-height: calc(100vh - 120px);
}
.view-split-left {
@@ -464,10 +507,6 @@
}
.system-view-dialog--compact {
.dialog-scroll {
max-height: min(68vh, 560px);
}
.view-hero {
gap: 12px;
padding: 12px 18px;
@@ -489,7 +528,7 @@
}
.view-hero-meta {
font-size: 12px;
font-size: 13px;
}
.view-section {
@@ -511,7 +550,7 @@
.view-field-label {
margin-bottom: 6px;
font-size: 12px;
font-size: 13px;
}
.view-field-group--spaced {
@@ -519,7 +558,93 @@
}
.view-field-group .view-empty-hint {
font-size: 12px;
font-size: 13px;
}
}
.system-view-dialog--dense {
:deep(.view-kv-grid) {
gap: 10px 14px;
}
:deep(.view-kv) {
display: flex;
flex-direction: column;
gap: 3px;
align-items: stretch;
dt {
margin: 0;
font-size: 13px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
white-space: normal;
}
dd {
margin: 0;
font-size: 13px;
font-weight: 600;
min-width: 0;
word-break: break-word;
line-height: 1.4;
}
}
:deep(.view-kv-span-2) {
grid-column: span 2;
}
:deep(.view-kv-span-full) {
grid-column: 1 / -1;
dd {
font-weight: 400;
line-height: 1.5;
}
}
:deep(.view-kv-table-wrap) {
border: none;
border-radius: 0;
}
:deep(.view-kv-table) {
border: none;
th {
padding: 5px 8px;
font-size: 13px;
background: transparent;
border-bottom: 1px solid #eef4f1;
border-right: none;
width: auto;
min-width: 72px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
}
td {
padding: 5px 10px 5px 0;
font-size: 13px;
font-weight: 600;
border-bottom: 1px solid #eef4f1;
}
tr:last-child th,
tr:last-child td {
border-bottom: none;
}
}
.view-field-label {
margin-bottom: 6px;
font-size: 13px;
}
.view-section-slot .view-empty-hint {
font-size: 13px;
}
}
</style>
@@ -531,14 +656,71 @@
</style>
<style lang="scss">
.system-view-dialog {
.el-dialog__header {
display: none;
/* dense 布局间距:非 scoped,确保 append-to-body 时 hero 与信息区间距与 Demo 一致 */
.system-view-dialog.system-view-dialog--dense,
.el-dialog.system-view-dialog--dense {
.view-hero {
align-items: flex-start;
gap: 10px;
padding: 12px 16px 8px;
background: #fff !important;
border-bottom: 1px solid #f1f5f9;
}
.view-hero-icon {
display: none !important;
}
.view-hero-main h2 {
margin-bottom: 2px;
font-size: 16px;
}
.view-hero-meta {
font-size: 13px;
}
.view-dense-body {
margin: 0;
padding: 16px 16px 12px;
border: none;
border-radius: 0;
background: transparent;
}
.view-dense-body > .view-section {
padding: 0 !important;
margin: 0;
border-bottom: none !important;
&.view-section--muted {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e2ebe6;
}
> h3 {
display: none;
}
}
.view-dense-body .view-field-label {
margin: 10px 0 6px;
font-size: 13px;
}
.view-dense-body .view-section:first-child .view-field-label {
margin-top: 0;
}
}
.system-view-dialog {
.el-dialog__body {
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
.el-dialog__footer {
@@ -601,7 +783,7 @@
}
.view-section-slot .view-empty-hint {
font-size: 12px;
font-size: 13px;
}
}
</style>
@@ -14,17 +14,22 @@ export interface ViewField {
span?: boolean | 'full'
/** 显式长值,等同 span:true */
long?: boolean
/** 显式占满整行,等同 span:'full' */
/** 显式占满整行,等同 span:'full';false 时禁止按超长文本自动占满 */
fullSpan?: boolean
tagType?: TagType
money?: boolean
/** 值后展示「查看」按钮,点击时 ViewKvTable 抛出 field-view */
viewTarget?: 'customer' | 'opportunity' | 'quote'
viewId?: string | number | null
/** 为 true 时,值后展示地图图标跳转高德查看位置(须配合 mapLng/mapLat 或地址文案) */
showMapLink?: boolean
mapLng?: number | null
mapLat?: number | null
mapName?: string
}
export type ViewFieldLayout = 'grid' | 'table'
export type ViewTableMode = 'pair' | 'dual' | 'triple' | 'quad'
export type ViewTableMode = 'pair' | 'dual' | 'triple' | 'quad' | 'quint'
export interface ViewFieldGroup {
title?: string
@@ -40,6 +45,10 @@ export interface ViewSection {
title: string
fields: ViewField[]
fieldGroups?: ViewFieldGroup[]
/** dense 模式下:muted = 底部分隔区(描述/备注等),默认 default */
variant?: ViewSectionVariant
/** dense 模式下隐藏区块大标题(信息并入单表) */
hideTitleInDense?: boolean
titleStyle?: 'section' | 'field'
/** 默认 grid;table 时使用 ViewKvTable(如 triple = 一行三组 / 2 行 3 列) */
fieldLayout?: ViewFieldLayout
@@ -51,6 +60,19 @@ export interface ViewSection {
export type ViewGridColumns = 1 | 2 | 3 | 4
/** 查看弹窗顶部核心指标条(各业务页按场景配置 2~6 项) */
export interface ViewHighlight {
label: string
value?: string | number | boolean | null | undefined
money?: boolean
/** 主指标:字号略大、保持主色强调 */
emphasis?: boolean
warn?: boolean
tagType?: TagType
}
export type ViewSectionVariant = 'default' | 'muted'
export function isIconComponent(val: unknown): val is Component {
return val != null && (typeof val === 'object' || typeof val === 'function')
}
@@ -0,0 +1,42 @@
<template>
<div class="dlg-view-section dlg-view-section-tabs">
<el-tabs v-model="activeTab" class="dlg-view-flow-tabs">
<el-tab-pane label="审批进度" name="flow">
<div class="flow-scroll">
<ApprovalFlowView :nodes="nodes" :loading="loading" />
</div>
</el-tab-pane>
<el-tab-pane label="审批历史" name="history">
<div class="flow-scroll">
<ApprovalHistoryView :items="history" :loading="loading" />
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import ApprovalFlowView from './ApprovalFlowView.vue'
import ApprovalHistoryView from './ApprovalHistoryView.vue'
const props = withDefaults(
defineProps<{
nodes: unknown[]
history: unknown[]
loading?: boolean
}>(),
{
loading: false
}
)
const activeTab = ref('flow')
watch(
() => [props.nodes, props.history],
() => {
activeTab.value = 'flow'
}
)
</script>
@@ -1,22 +1,11 @@
<script setup lang="ts">
import { Refresh, Search } from '@element-plus/icons-vue'
withDefaults(
defineProps<{
/** 是否与「展开更多条件」联动(收起/展开文案);无更多筛选项时可不传 */
showMore?: boolean
/** 为 true 时才显示「展开更多条件 / 收起更多条件」按钮 */
hasExpandableFilters?: boolean
loading?: boolean
}>(),
{
showMore: false,
hasExpandableFilters: false,
}
)
defineProps<{
loading?: boolean
}>()
const emit = defineEmits<{
toggleMore: []
reset: []
search: []
}>()
@@ -28,14 +17,7 @@ const emit = defineEmits<{
<slot />
</div>
<div class="filter-actions">
<el-button
v-if="hasExpandableFilters"
link
class="btn-link"
@click="emit('toggleMore')"
>
{{ showMore ? '收起更多条件' : '展开更多条件' }}
</el-button>
<div class="filter-actions-start" />
<div class="filter-actions-end">
<el-button class="filter-reset" :loading="loading" @click="emit('reset')">
<el-icon class="ic"><Refresh /></el-icon>
@@ -8,9 +8,6 @@ import BaseTable from '../base/BaseTable.vue'
const props = withDefaults(
defineProps<{
panelTitle: string
showMore?: boolean
/** 为 true 时显示「展开更多条件」;筛选项全部在主区域时不要传或传 false */
hasExpandableFilters?: boolean
selectedCount: number
rows: any[]
rangeText: string
@@ -28,13 +25,10 @@ const props = withDefaults(
{
pageSizes: () => [10, 20, 30],
showSummary: false,
showMore: false,
hasExpandableFilters: false,
}
)
const emit = defineEmits<{
toggleMore: []
reset: []
search: []
'update:currentPage': [value: number]
@@ -56,14 +50,7 @@ const pageSizeModel = computed({
<template>
<DataTablePanel :title="panelTitle">
<FilterBlock
:show-more="showMore"
:has-expandable-filters="hasExpandableFilters"
:loading="loading"
@toggle-more="emit('toggleMore')"
@reset="emit('reset')"
@search="emit('search')"
>
<FilterBlock :loading="loading" @reset="emit('reset')" @search="emit('search')">
<slot name="filters" />
</FilterBlock>
@@ -74,9 +61,9 @@ const pageSizeModel = computed({
<div class="table-scroll" :class="tableScrollClass">
<BaseTable
:data="rows"
:show-summary="showSummary"
:summary-method="summaryMethod"
:max-height="tableMaxHeight"
:show-summary="showSummary"
:summary-method="summaryMethod"
:max-height="tableMaxHeight"
@selection-change="(rows: any[]) => emit('selectionChange', rows)"
>
<slot name="table-columns" />
@@ -384,12 +384,6 @@
* 监听窗口尺寸变化,自动处理移动端菜单
*/
watch(width, (newWidth) => {
if (isPortalMenu.value) {
settingStore.setMenuOpen(true)
showMobileModal.value = false
return
}
if (newWidth < MOBILE_BREAKPOINT) {
settingStore.setMenuOpen(false)
if (!menuOpen.value) {
@@ -1,26 +1,34 @@
<!-- 一体化门户顶栏 -->
<template>
<header class="portal-header">
<div class="portal-header-brand" @click="toHome">
<ArtLogo class="portal-header-logo" src="/logo-sx.svg" :size="38" />
<p class="portal-header-title">{{ AppConfig.systemInfo.name }}</p>
<div class="portal-header-start">
<div class="portal-header-brand" @click="toHome">
<ArtLogo class="portal-header-logo" src="/logo-sx.svg" :size="38" />
<p class="portal-header-title">{{ AppConfig.systemInfo.name }}</p>
</div>
<div class="portal-header-nav">
<ArtIconButton
icon="fold"
class="portal-header-icon-btn portal-header-fold-btn"
@click="toggleSidebar"
/>
<ArtPortalModuleSelect />
</div>
</div>
<div class="portal-header-nav">
<ArtPortalModuleSelect />
<ArtPortalSubNav class="portal-header-sub-nav" />
<div class="portal-header-center" aria-hidden="false">
<p v-if="pageTitle" class="portal-header-page-title">{{ pageTitle }}</p>
</div>
<div class="portal-header-actions">
<ArtPortalMobileMenu />
<ArtIconButton
v-if="shouldShowFullscreen"
icon="fullscreen"
:class="[
'portal-header-icon-btn',
'portal-header-icon-btn max-md:!hidden',
!isFullscreen ? 'full-screen-btn' : 'exit-full-screen-btn'
]"
class="max-md:!hidden"
@click="toggleFullScreen"
/>
<ArtUserMenu />
@@ -29,19 +37,34 @@
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { useFullscreen } from '@vueuse/core'
import AppConfig from '@/config'
import { useCommon } from '@/hooks/core/useCommon'
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
import { useSettingStore } from '@/store/modules/setting'
import { formatMenuTitle } from '@/utils/router'
import ArtUserMenu from '../art-header-bar/widget/ArtUserMenu.vue'
defineOptions({ name: 'ArtPortalHeader' })
const route = useRoute()
const router = useRouter()
const { homePath } = useCommon()
const { shouldShowFullscreen } = useHeaderBar()
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
const settingStore = useSettingStore()
const { menuOpen } = storeToRefs(settingStore)
const pageTitle = computed(() => {
const matched = route.matched
if (!matched.length) return ''
const current = matched[matched.length - 1]
const title = current.meta?.title
return title ? formatMenuTitle(String(title)) : ''
})
const toHome = (): void => {
router.push(homePath.value)
@@ -50,14 +73,20 @@
const toggleFullScreen = (): void => {
toggleFullscreen()
}
const toggleSidebar = (): void => {
settingStore.setMenuOpen(!menuOpen.value)
}
</script>
<style lang="scss" scoped>
.portal-header {
position: relative;
box-sizing: border-box;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
width: 100%;
height: 56px;
padding: 0 var(--mint-content-padding, 24px);
@@ -66,6 +95,15 @@
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.portal-header-start {
display: flex;
flex-shrink: 0;
gap: 12px;
align-items: center;
min-width: 0;
z-index: 1;
}
.portal-header-brand {
display: flex;
flex-shrink: 0;
@@ -93,15 +131,33 @@
.portal-header-nav {
display: flex;
flex: 1;
gap: 12px;
align-items: center;
min-width: 0;
}
.portal-header-sub-nav {
flex: 1;
min-width: 0;
.portal-header-center {
position: absolute;
left: 50%;
z-index: 0;
display: flex;
align-items: center;
justify-content: center;
max-width: min(520px, calc(100% - 420px));
pointer-events: none;
transform: translateX(-50%);
}
.portal-header-page-title {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 16px;
font-weight: 600;
line-height: 1.35;
color: #fff;
text-align: center;
white-space: nowrap;
}
.portal-header-actions {
@@ -109,11 +165,11 @@
flex-shrink: 0;
gap: 8px;
align-items: center;
z-index: 1;
}
:deep(.portal-module-select-trigger) {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.28);
background: transparent;
.portal-module-select-trigger__icon,
.portal-module-select-trigger__label,
@@ -122,58 +178,7 @@
}
&:hover:not(.is-disabled) {
background: rgba(255, 255, 255, 0.18);
border-color: rgba(255, 255, 255, 0.45);
}
}
:deep(.portal-sub-nav-card__icon),
:deep(.portal-sub-nav-card__label) {
color: rgba(255, 255, 255, 0.88);
}
:deep(.portal-sub-nav-menu > .el-sub-menu .el-sub-menu__icon-arrow) {
color: rgba(255, 255, 255, 0.88);
}
:deep(.portal-sub-nav-menu > .el-sub-menu.el-sub-menu__hide-arrow .el-sub-menu__title) {
color: #fff !important;
}
:deep(.portal-sub-nav-menu > .el-sub-menu.el-sub-menu__hide-arrow .el-sub-menu__icon-more),
:deep(.portal-sub-nav-menu > .el-sub-menu.el-sub-menu__hide-arrow .el-icon) {
color: #fff !important;
}
:deep(.portal-sub-nav-menu > .el-sub-menu.el-sub-menu__hide-arrow:hover .el-sub-menu__title) {
background: rgba(255, 255, 255, 0.14) !important;
border-radius: 8px;
}
:deep(.portal-sub-nav-menu > .el-menu-item:hover .portal-sub-nav-card),
:deep(.portal-sub-nav-menu > .el-sub-menu:hover .portal-sub-nav-card) {
background: rgba(255, 255, 255, 0.14);
}
:deep(.portal-sub-nav-menu > .el-menu-item.is-active .portal-sub-nav-card),
:deep(.portal-sub-nav-menu > .el-sub-menu.is-active .portal-sub-nav-card) {
background: #fff;
.portal-sub-nav-card__icon,
.portal-sub-nav-card__label {
color: var(--mint-primary, #1ca851);
}
}
:deep(.portal-mobile-menu-trigger) {
color: #fff;
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.28);
&:hover {
color: #fff;
background: rgba(255, 255, 255, 0.18);
border-color: rgba(255, 255, 255, 0.45);
background: transparent;
}
}
@@ -190,8 +195,12 @@
display: none;
}
.portal-header-sub-nav {
display: none !important;
.portal-header-center {
max-width: min(240px, calc(100% - 220px));
}
.portal-header-page-title {
font-size: 15px;
}
}
</style>
@@ -119,12 +119,9 @@
height: 36px;
padding: 0 12px;
cursor: pointer;
background: var(--primary-bg, #e6f8ed);
border: 1px solid var(--mint-border, #d9e8e2);
background: transparent;
border: none;
border-radius: 8px;
transition:
background-color 0.2s ease,
border-color 0.2s ease;
&__icon {
flex-shrink: 0;
@@ -149,12 +146,13 @@
}
&:hover:not(.is-disabled) {
border-color: var(--primary, #1ca851);
background: transparent;
}
&.is-disabled {
cursor: not-allowed;
background: var(--mint-bg-page, #f8fafc);
background: transparent;
opacity: 0.72;
}
}
</style>