This commit is contained in:
zlwu committed 2026-06-21 09:19:07 +08:00
1 parent c3be9d1dce
commit 299609706c
328 files changed
+144899 -13873

No files matched your search

@@ -228,7 +228,7 @@ const realHeight = computed(() => normalizeCssSize(props.height as string | numb
align-items: center;
width: 100%;
height: 100%;
color: #909399;
color: #1e293b;
font-size: 30px;
.error-text {
@@ -311,7 +311,7 @@ defineExpose({ hasUploading })
.upload-hint {
display: block;
font-size: 11px;
color: #94a3b8;
color: #1e293b;
margin-top: 8px;
line-height: 1;
}
@@ -383,7 +383,7 @@ defineExpose({ hasUploading })
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 11px;
color: #334155;
color: #1e293b;
line-height: 1.4;
word-break: break-all;
}
@@ -400,7 +400,7 @@ defineExpose({ hasUploading })
height: 22px;
border-radius: 4px;
background: #f1f5f9;
color: #64748b;
color: #1e293b;
cursor: pointer;
&:hover {
@@ -0,0 +1,396 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<el-dialog
v-model="innerVisible"
title="选择合同"
width="1440px"
align-center
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
append-to-body
destroy-on-close
style="backdrop-filter: none"
@open="handleOpen"
>
<div class="filter-block contract-selector-filter">
<el-form ref="queryFormRef" :model="queryParams" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="合同编号" prop="contractNo">
<el-input
v-model="queryParams.contractNo"
placeholder="搜索合同编号"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="合同名称" prop="contractName">
<el-input
v-model="queryParams.contractName"
placeholder="搜索合同名称"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="客户名称" prop="customerName">
<el-input
v-model="queryParams.customerName"
placeholder="搜索客户名称"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-start" />
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="resetQuery">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleQuery">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
ref="tableRef"
v-loading="loading"
:data="contractList"
class="list-table contract-selector-table"
stripe
highlight-current-row
row-key="id"
:max-height="420"
@row-click="handleRowClick"
@current-change="handleCurrentChange"
>
<el-table-column label="" min-width="48" align="center" header-align="center">
<template #default="{ row }">
<el-radio
v-model="selectedId"
:label="String(row.id)"
@click.stop
@change="handleRadioChange(row)"
>
<span class="sr-only">选择</span>
</el-radio>
</template>
</el-table-column>
<el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
<el-table-column
prop="contractNo"
label="合同编号"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="contractName"
label="合同名称"
min-width="180"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="customerName"
label="客户名称"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column prop="contractType" label="合同类型" min-width="110" align="center" header-align="center">
<template #default="{ row }">
<DictTag dict-type="crm_contract_type" :value="row.contractType" />
</template>
</el-table-column>
<el-table-column prop="status" label="状态" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<DictTag dict-type="crm_contract_status" :value="row.status" />
</template>
</el-table-column>
<el-table-column prop="amount" label="合同金额" min-width="120" align="right" header-align="center">
<template #default="{ row }">
<span class="money-display">{{ formatMoneyDisplay(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column prop="signDate" label="签订日期" min-width="120" align="center" header-align="center" />
<el-table-column label="已关联的运输计划数" min-width="140" align="center" header-align="center">
<template #default="{ row }">
{{ planCountMap[String(row.id)] ?? 0 }}
</template>
</el-table-column>
</el-table>
<ListPagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button class="btn-ghost" @click="innerVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button
class="btn-main"
type="primary"
:disabled="!currentRow"
:loading="detailLoading"
@click="handleConfirm"
>
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close, RefreshLeft, Search } from '@element-plus/icons-vue'
import type { FormInstance, TableInstance } from 'element-plus'
import { getContract, listContract } from '@/api/crm/contract'
import type { ContractQuery, ContractVO } from '@/api/crm/contract/types'
import { planApi } from '@/api/pl'
import DictTag from '@/components/DictTag/index.vue'
import ListPagination from '@/components/ListPagination/index.vue'
import { formatMoneyDisplay } from '@/utils/money-format'
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
confirm: [contract: ContractVO]
}>()
const innerVisible = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const queryFormRef = ref<FormInstance>()
const tableRef = ref<TableInstance>()
const loading = ref(false)
const detailLoading = ref(false)
const total = ref(0)
const contractList = ref<ContractVO[]>([])
const currentRow = ref<ContractVO | null>(null)
const selectedId = ref('')
const planCountMap = ref<Record<string, number>>({})
const queryParams = reactive<ContractQuery>({
pageNum: 1,
pageSize: 10,
contractNo: '',
contractName: '',
customerName: '',
excludeStatus: 'invalid'
})
async function getList() {
loading.value = true
try {
const res = await listContract(queryParams)
contractList.value = res.rows || []
total.value = res.total || 0
await loadPlanCounts()
await nextTick()
syncTableSelection()
} finally {
loading.value = false
}
}
function syncTableSelection() {
const table = tableRef.value
if (!table) return
if (!selectedId.value) {
table.setCurrentRow(undefined)
return
}
const row = contractList.value.find(item => String(item.id) === selectedId.value)
if (!row) {
selectedId.value = ''
currentRow.value = null
table.setCurrentRow(undefined)
return
}
currentRow.value = row
table.setCurrentRow(row)
}
function selectContractRow(row: ContractVO | null | undefined) {
if (!row?.id) {
selectedId.value = ''
currentRow.value = null
syncTableSelection()
return
}
selectedId.value = String(row.id)
currentRow.value = row
syncTableSelection()
}
function handleRowClick(row: ContractVO) {
selectContractRow(row)
}
function handleRadioChange(row: ContractVO) {
selectContractRow(row)
}
async function loadPlanCounts() {
const ids = contractList.value.map(item => String(item.id)).filter(Boolean)
if (!ids.length) {
planCountMap.value = {}
return
}
try {
const res = await planApi.countByContract(ids)
const data = (res.data ?? res) as { counts?: Record<string, number> }
planCountMap.value = data.counts || {}
} catch {
planCountMap.value = {}
}
}
function handleQuery() {
queryParams.pageNum = 1
getList()
}
function resetQuery() {
queryFormRef.value?.resetFields()
queryParams.pageNum = 1
getList()
}
watchDebounced(
() => ({
contractNo: queryParams.contractNo,
contractName: queryParams.contractName,
customerName: queryParams.customerName
}),
() => {
queryParams.pageNum = 1
getList()
},
{ debounce: 300, deep: true }
)
function handleCurrentChange(row: ContractVO | undefined) {
if (row?.id && String(row.id) === selectedId.value) {
currentRow.value = row
return
}
selectContractRow(row)
}
function handleOpen() {
selectedId.value = ''
currentRow.value = null
planCountMap.value = {}
queryParams.pageNum = 1
nextTick(() => {
tableRef.value?.setCurrentRow(undefined)
})
getList()
}
async function handleConfirm() {
if (!currentRow.value?.id) return
detailLoading.value = true
try {
const res = await getContract(currentRow.value.id)
const detail = (res.data ?? res) as ContractVO
emit('confirm', detail)
innerVisible.value = false
} finally {
detailLoading.value = false
}
}
</script>
<style scoped lang="scss">
.contract-selector-filter {
padding: 12px 16px 10px;
background: var(--mint-surface);
border: 1px solid var(--mint-border);
border-radius: var(--mint-radius);
margin-bottom: 12px;
.filter-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px 14px;
}
.filter-actions {
margin-top: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.filter-actions-end {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.filter-actions-end .el-button + .el-button {
margin-left: 0 !important;
}
}
@media (max-width: 1100px) {
.contract-selector-filter .filter-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.contract-selector-filter .filter-grid {
grid-template-columns: 1fr;
}
}
.contract-selector-table {
:deep(.el-radio) {
margin-right: 0;
height: auto;
}
:deep(.el-radio__label) {
display: none;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
@@ -0,0 +1,153 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<el-dialog
v-model="innerVisible"
:title="dialogTitle"
width="400px"
align-center
class="db-qrcode-dialog"
modal-class="db-qrcode-overlay"
append-to-body
@close="handleClose"
>
<div v-loading="loading" class="db-qrcode-body pound-slip-capture-inline">
<template v-if="captureRow">
<div class="capture-card">
<div class="capture-row capture-row-plate">
<div class="vehicle-plate">
<span class="vehicle-plate-dot vehicle-plate-dot-lt"></span>
<span class="vehicle-plate-dot vehicle-plate-dot-lb"></span>
<span class="vehicle-plate-text">{{ captureRow.vehicleNo }}</span>
<span class="vehicle-plate-dot vehicle-plate-dot-rt"></span>
<span class="vehicle-plate-dot vehicle-plate-dot-rb"></span>
</div>
</div>
<div class="capture-row capture-row-large">
<span class="label">往来单位:</span>
<span class="value">{{ captureRow.partnerCompany }}</span>
</div>
<div class="capture-row capture-row-large">
<span class="label">物料名称:</span>
<span class="value">
{{ formatQrcodeProductLabel(captureRow) }}({{ captureRow.quantity }} {{ quantityUnitLabel }})
</span>
</div>
<div v-if="captureRow.remarks" class="capture-row">
<span class="label">备注:</span>
<span class="value">{{ captureRow.remarks }}</span>
</div>
</div>
<div class="capture-card capture-qrcode">
<div class="capture-id">
{{ captureRow.date }}
{{ captureRow.businessType === '出库' ? '出库单ID:' : '入库单ID:' }}{{ captureRow.id }}
</div>
<div class="capture-qr-wrap">
<img v-if="captureQrObjectUrl" :src="captureQrObjectUrl" alt="二维码" class="capture-qr-img" />
</div>
<div class="capture-desc">出入库单标识(扫码核验)</div>
</div>
</template>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { inventoryApi, type InventoryRecord } from '@/api/db/inventory'
const props = defineProps<{
visible: boolean
inventoryId?: string
quantityUnit?: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
const innerVisible = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const loading = ref(false)
const captureRow = ref<InventoryRecord | null>(null)
const captureQrObjectUrl = ref('')
const quantityUnitLabel = computed(() => (props.quantityUnit || '').trim() || '吨')
const dialogTitle = computed(() => {
if (!captureRow.value) return '磅单二维码'
return captureRow.value.businessType === '出库' ? '出库磅单二维码' : '入库磅单二维码'
})
function formatQrcodeProductLabel(row: InventoryRecord) {
const product = (row.product || '').trim()
const specification = (row.specification || '').trim()
if (!specification) return product
if (product.endsWith(specification)) {
const base = product.slice(0, -specification.length).trimEnd()
return base ? `${base} ${specification}` : specification
}
return `${product} ${specification}`
}
function getQrImageUrl(inventoryId: string) {
return `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(inventoryId)}`
}
async function loadQrcode(inventoryId: string) {
loading.value = true
captureRow.value = null
if (captureQrObjectUrl.value) {
URL.revokeObjectURL(captureQrObjectUrl.value)
captureQrObjectUrl.value = ''
}
try {
const res = await inventoryApi.get(inventoryId)
const row = (res.data ?? res) as InventoryRecord
captureRow.value = row
await nextTick()
const qrImageUrl = getQrImageUrl(row.id)
const fetchRes = await fetch(qrImageUrl, { mode: 'cors' })
if (!fetchRes.ok) throw new Error('二维码图片加载失败')
const blob = await fetchRes.blob()
captureQrObjectUrl.value = URL.createObjectURL(blob)
} catch (e: any) {
ElMessage.error(e?.message || '加载二维码失败')
innerVisible.value = false
} finally {
loading.value = false
}
}
function handleClose() {
if (captureQrObjectUrl.value) URL.revokeObjectURL(captureQrObjectUrl.value)
captureRow.value = null
captureQrObjectUrl.value = ''
}
async function copyQrcodeImage() {
if (!captureQrObjectUrl.value) return false
try {
const blob = await fetch(captureQrObjectUrl.value).then(r => r.blob())
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })])
ElMessage.success('已复制磅单二维码')
return true
} catch {
ElMessage.warning('当前浏览器不支持复制图片,请手动截图')
return false
}
}
watch(
() => [props.visible, props.inventoryId] as const,
async ([visible, inventoryId]) => {
if (visible && inventoryId) {
await loadQrcode(inventoryId)
}
}
)
defineExpose({ copyQrcodeImage, loadQrcode })
</script>
@@ -0,0 +1,208 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<el-dialog
v-model="innerVisible"
:title="carrier?.carrierName || '承运商详情'"
width="1080px"
align-center
destroy-on-close
class="dialog-form-layout carrier-view-dialog"
modal-class="order-module-dialog-mask"
append-to-body
style="backdrop-filter: none"
@closed="handleClosed"
>
<div class="carrier-view-body" v-loading="loading">
<section class="carrier-view-top">
<h3 class="carrier-view-block-title">承运商信息</h3>
<dl v-if="carrier" class="carrier-view-kv-grid">
<div class="carrier-view-kv">
<dt>承运商名称</dt>
<dd>{{ carrier.carrierName || '—' }}</dd>
</div>
<div class="carrier-view-kv">
<dt>联系人</dt>
<dd>{{ carrier.contactName || '—' }}</dd>
</div>
<div class="carrier-view-kv">
<dt>联系电话</dt>
<dd>{{ carrier.contactPhone || '—' }}</dd>
</div>
<div class="carrier-view-kv">
<dt>合作状态</dt>
<dd>
<el-tag :type="getPlCarrierStatusTagType(carrier.status)" size="small">
{{ getPlCarrierStatusLabel(carrier.status) }}
</el-tag>
</dd>
</div>
<div class="carrier-view-kv">
<dt>创建时间</dt>
<dd>{{ formatDateTime(carrier.createTime) }}</dd>
</div>
<div class="carrier-view-kv carrier-view-kv-span">
<dt>备注</dt>
<dd>{{ carrier.remark || '—' }}</dd>
</div>
</dl>
</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>
<script setup lang="ts">
import { Close } from '@element-plus/icons-vue'
import { carrierApi, vehicleApi, type Carrier } from '@/api/pl'
import PlVehicleViewPanel from '@/components/business/pl-vehicle-view-panel/index.vue'
import {
loadVehicleViewBundles,
type VehicleViewBundle
} from '@/composables/usePlVehicleViewDetail'
import { formatDateTime } from '@/utils/format'
import { getPlCarrierStatusLabel, getPlCarrierStatusTagType } from '@/utils/pl-display'
const props = defineProps<{
visible: boolean
carrierId?: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
const innerVisible = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const loading = ref(false)
const carrier = ref<Carrier | null>(null)
const vehicleBundles = ref<VehicleViewBundle[]>([])
const activeTab = ref('')
watch(
() => [props.visible, props.carrierId] as const,
async ([visible, carrierId]) => {
if (!visible || !carrierId) return
await loadDetail(carrierId)
},
{ immediate: true }
)
async function loadDetail(carrierId: string) {
loading.value = true
carrier.value = null
vehicleBundles.value = []
activeTab.value = ''
try {
const [carrierRes, vehicleListRes] = await Promise.all([
carrierApi.get(carrierId),
vehicleApi.list({ carrierId })
])
carrier.value = ((carrierRes.data ?? carrierRes) || null) as Carrier | null
const vehicles = ((vehicleListRes.data ?? vehicleListRes) || []) as { id: string }[]
vehicleBundles.value = await loadVehicleViewBundles(vehicles.map(item => item.id))
activeTab.value = vehicleBundles.value[0]?.detail.id || ''
} catch {
innerVisible.value = false
ElMessage.error('加载承运商详情失败')
} finally {
loading.value = false
}
}
function handleClosed() {
carrier.value = null
vehicleBundles.value = []
activeTab.value = ''
}
</script>
<style scoped lang="scss">
.carrier-view-body {
display: flex;
flex-direction: column;
gap: 20px;
min-height: 120px;
}
.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-kv-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px 20px;
}
.carrier-view-kv {
min-width: 0;
}
.carrier-view-kv-span {
grid-column: 1 / -1;
}
.carrier-view-empty {
margin: 0;
font-size: 13px;
color: #1e293b;
}
.carrier-vehicle-tabs {
:deep(.el-tabs__header) {
margin-bottom: 12px;
}
:deep(.el-tabs__content) {
padding-top: 4px;
}
}
@media (max-width: 900px) {
.carrier-view-kv-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.carrier-view-kv-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,215 @@
<template>
<div class="cert-form-section">
<div class="cert-form-header">
<span class="cert-form-title">{{ title }}</span>
<el-button type="primary" size="small" @click="handleAddCert">
<el-icon><Plus /></el-icon>添加证书
</el-button>
</div>
<div v-if="!certList.length" class="cert-empty">暂无证书,点击「添加证书」</div>
<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">
<span class="cert-card-index">证书 {{ index + 1 }}</span>
<el-button link type="danger" @click="handleRemoveCert(index)">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
<div class="form-grid-4 cert-card-grid">
<el-form-item label="证书名称" required>
<el-select
v-model="cert.certName"
placeholder="请选择或输入证书名称"
filterable
allow-create
default-first-option
>
<el-option v-for="name in certNameOptions" :key="name" :label="name" :value="name" />
</el-select>
</el-form-item>
<el-form-item label="证号">
<el-input v-model="cert.certNo" placeholder="请输入证号" />
</el-form-item>
<el-form-item label="有效期">
<el-date-picker
v-model="cert.expireDate"
type="date"
placeholder="选择有效期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</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"
:max="365"
:controls="false"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="附件" class="form-col-span-2">
<AttachmentUploadField
:ref="(el) => setCertUploadRef(index, el)"
v-model="cert.attachmentUrls"
:folder="uploadFolder"
/>
</el-form-item>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Delete, Plus } from '@element-plus/icons-vue'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import type { CertRow } from '@/api/pl'
type CertRowWithKey = CertRow & { _key: string }
type CertUploadRef = InstanceType<typeof AttachmentUploadField>
const props = withDefaults(
defineProps<{
title?: string
uploadFolder: string
certNameOptions?: string[]
}>(),
{
title: '证书',
certNameOptions: () => []
}
)
const certList = defineModel<CertRowWithKey[]>('modelValue', { default: () => [] })
const certUploadRefs = ref<Record<number, CertUploadRef | null>>({})
function createEmptyCert(): CertRowWithKey {
return {
_key: `cert-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
certName: '',
certNo: '',
expireDate: '',
remindDays: 30,
attachmentUrls: ''
}
}
function handleAddCert() {
certList.value = [...certList.value, createEmptyCert()]
}
function handleRemoveCert(index: number) {
const next = [...certList.value]
next.splice(index, 1)
certList.value = next
delete certUploadRefs.value[index]
}
function setCertUploadRef(index: number, el: unknown) {
if (el) {
certUploadRefs.value[index] = el as CertUploadRef
} else {
delete certUploadRefs.value[index]
}
}
function hasUploading() {
return Object.values(certUploadRefs.value).some(ref => ref?.hasUploading?.())
}
function clearUploadRefs() {
certUploadRefs.value = {}
}
defineExpose({ hasUploading, clearUploadRefs })
</script>
<style scoped lang="scss">
.cert-form-section {
margin-top: 16px;
grid-column: 1 / -1;
}
.cert-form-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.cert-form-title {
font-size: 14px;
font-weight: 500;
color: var(--text, #1e293b);
}
.cert-empty {
padding: 24px;
text-align: center;
font-size: 13px;
color: var(--mint-text);
border: 1px dashed var(--border, #d9e8e2);
border-radius: 8px;
background: #f8fafc;
}
.cert-card-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.cert-card {
border: 1px solid var(--border, #d9e8e2);
border-radius: 10px;
background: #fff;
padding: 14px 16px 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.cert-card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #eef2f6;
}
.cert-card-index {
font-size: 13px;
font-weight: 600;
color: var(--primary, #10b981);
}
.cert-card-index::before {
content: '';
display: inline-block;
width: 3px;
height: 14px;
margin-right: 8px;
vertical-align: -2px;
border-radius: 2px;
background: var(--primary, #10b981);
}
.cert-card-grid {
:deep(.el-form-item) {
margin-bottom: 12px;
}
:deep(.el-form-item__label) {
font-size: 11px;
color: var(--mint-text);
padding-bottom: 4px;
}
}
</style>
@@ -0,0 +1,141 @@
<template>
<div class="cert-view-section">
<p v-if="!certList.length" class="view-empty-hint">暂无证书</p>
<div v-else class="cert-view-list">
<div
v-for="(cert, index) in certList"
:key="cert.id || cert.certId || `cert-${index}`"
class="cert-view-card"
>
<h4 class="cert-view-card-title">证书 {{ index + 1 }}</h4>
<dl class="cert-view-kv-grid">
<div class="cert-view-kv">
<dt>证书名称</dt>
<dd>{{ cert.certName || '—' }}</dd>
</div>
<div class="cert-view-kv">
<dt>证号</dt>
<dd>{{ cert.certNo || '—' }}</dd>
</div>
<div class="cert-view-kv">
<dt>有效期</dt>
<dd>
<el-tag v-if="cert.expireDate" :type="getExpireTagType(cert.expireDate)" size="small">
{{ cert.expireDate }}
</el-tag>
<span v-else>—</span>
</dd>
</div>
<div class="cert-view-kv">
<dt>提醒天数</dt>
<dd>{{ cert.remindDays ?? 30 }} 天</dd>
</div>
<div class="cert-view-kv cert-view-kv-span">
<dt>附件</dt>
<dd>
<AttachmentUploadField
v-if="cert.attachmentUrls"
:model-value="cert.attachmentUrls"
disabled
/>
<span v-else class="view-empty-inline">—</span>
</dd>
</div>
</dl>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import type { CertRead } from '@/api/pl'
withDefaults(
defineProps<{
certList?: CertRead[]
}>(),
{
certList: () => []
}
)
function getExpireTagType(dateStr: string | undefined) {
if (!dateStr) return 'info'
const expDate = new Date(dateStr)
const today = new Date()
const diffDays = Math.ceil((expDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays <= 0) return 'danger'
if (diffDays <= 30) return 'warning'
return 'success'
}
</script>
<style scoped lang="scss">
.cert-view-section {
width: 100%;
}
.view-empty-hint {
margin: 0;
font-size: 13px;
color: #000;
line-height: 1.5;
}
.cert-view-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.cert-view-card {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
padding: 14px 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.cert-view-card-title {
margin: 0 0 12px;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
border-left: 3px solid #10b981;
line-height: 1;
}
.cert-view-kv-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px 20px;
}
.cert-view-kv {
min-width: 0;
}
.cert-view-kv-span {
grid-column: 1 / -1;
}
.view-empty-inline {
color: #000;
}
@media (max-width: 1200px) {
.cert-view-kv-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.cert-view-kv-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,661 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<el-dialog
v-model="innerVisible"
:title="dialogTitle"
align-center
destroy-on-close
append-to-body
class="dialog-form-layout pl-plan-arrange-dialog"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
style="backdrop-filter: none"
@closed="handleClosed"
@opened="handleOpened"
>
<div class="pl-arrange-layout">
<section class="pl-arrange-form-panel">
<div v-loading="loading" class="pl-arrange-form-scroll">
<section v-if="arrangeSuccess" class="pl-plan-form-section pl-arrange-success">
<h3 class="pl-plan-form-section-title">安排成功</h3>
<el-alert type="success" :closable="false" show-icon title="运输已安排,可复制司机链接或计划简述" />
<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>
</section>
<section v-else class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">运输安排</h3>
<el-form ref="formRef" :model="formData" :rules="formRules" label-position="top">
<div class="form-grid-4">
<el-form-item label="选择车辆" prop="vehicleId">
<el-select
v-model="formData.vehicleId"
placeholder="请选择车辆"
filterable
:loading="vehicleLoading"
@change="handleVehicleChange"
>
<el-option
v-for="v in vehicleList"
:key="v.id"
:label="`${v.licensePlate} (${v.loadCapacity || '-'}吨)`"
:value="v.id"
/>
</el-select>
</el-form-item>
<el-form-item label="选择司机" prop="driverId">
<el-select
v-model="formData.driverId"
placeholder="请先选择车辆"
filterable
:disabled="!formData.vehicleId || vehicleDriverList.length === 0"
>
<el-option
v-for="d in vehicleDriverList"
:key="d.personnelId"
:label="formatDriverLabel(d)"
:value="d.personnelId!"
/>
</el-select>
</el-form-item>
<el-form-item label="运输公司">
<el-input v-if="isSelfVehicle" :model-value="SELF_CARRIER_NAME" readonly />
<el-select
v-else
v-model="formData.carrierId"
placeholder="请选择运输公司"
filterable
clearable
>
<el-option v-for="c in carrierList" :key="c.id" :label="c.carrierName" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="运输起点" prop="startPoint">
<el-input v-model="formData.startPoint" placeholder="请输入运输起点" />
</el-form-item>
<el-form-item label="运输终点" prop="endPoint">
<el-input v-model="formData.endPoint" placeholder="请输入运输终点" />
</el-form-item>
<el-form-item label="运输介质">
<el-input :model-value="transportMediumText" readonly placeholder="—" />
</el-form-item>
<el-form-item label="备注" class="pl-arrange-remark-item">
<el-input v-model="formData.remark" placeholder="请输入备注" />
</el-form-item>
</div>
<el-form-item class="form-col-span-2 pl-arrange-dibang-item">
<template #label>
是否生成地磅计划
<span v-if="plan.planType === 'direct'" class="form-label-tip">直销单不涉及地磅</span>
</template>
<div class="pl-arrange-dibang-row">
<el-tooltip v-if="plan.planType === 'direct'" content="直销单不涉及地磅" placement="top">
<el-checkbox v-model="formData.generateDibangPlan" disabled>生成地磅计划与出入库单</el-checkbox>
</el-tooltip>
<el-checkbox v-else v-model="formData.generateDibangPlan">生成地磅计划与出入库单</el-checkbox>
<p v-if="showDibangHint" class="pl-arrange-dibang-hint">
<template v-if="isRearrangeWithDibang">
将作废本运输单此前待处理的出入库单,并按当前计划产品重新生成;已关联磅单或已完成的单据不会被自动作废,需先在地磅管理中处理。
</template>
<template v-else>
将按当前计划产品生成地磅月计划(当月已有则复用)及待处理出入库单。
</template>
</p>
</div>
</el-form-item>
</el-form>
</section>
</div>
</section>
<section class="pl-arrange-view-panel">
<h3 class="pl-arrange-view-title">计划单查看</h3>
<PlPlanViewTabs v-if="planId" ref="tabsRef" :plan-id="planId" embedded />
</section>
</div>
<DbPoundSlipQrcodeDialog
v-model:visible="qrcodeVisible"
:inventory-id="selectedInventoryId"
:quantity-unit="selectedInventoryUnit"
ref="qrcodeDialogRef"
/>
<template #footer>
<div class="dialog-footer pl-arrange-footer">
<template v-if="arrangeSuccess">
<el-button @click="innerVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
<el-button
@click="
copyPlanSummary({
startPoint: formData.startPoint,
endPoint: formData.endPoint,
licensePlate: vehicleList.find(v => v.id === formData.vehicleId)?.licensePlate,
driverUrl: arrangeResult?.driverUrl
})
"
>
<el-icon><CopyDocument /></el-icon>复制计划简述
</el-button>
<el-button
v-if="inventoryRecords.length > 0"
type="primary"
@click="copyPoundSlipQrcode()"
>
<el-icon><CopyDocument /></el-icon>复制磅单二维码
</el-button>
</template>
<template v-else>
<el-button @click="innerVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>确认安排
</el-button>
</template>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close, CopyDocument } from '@element-plus/icons-vue'
import {
carrierApi,
planApi,
transportApi,
vehicleApi,
type Carrier,
type Plan,
type PlanProduct,
type Transport,
type TransportArrangeResult,
type Vehicle,
type VehicleDetail,
type VehiclePersonnelRow
} from '@/api/pl'
import DbPoundSlipQrcodeDialog from '@/components/business/db/DbPoundSlipQrcodeDialog.vue'
import PlPlanViewTabs from '@/components/business/pl-plan-view-tabs/index.vue'
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { usePlFreightCalc } from '@/composables/usePlFreightCalc'
import { buildMissingHazardCertMessage } from '@/composables/usePlToxicProductCertCheck'
import { usePlPlanArrangeActions } from '@/composables/usePlPlanArrangeActions'
import { useDictStore } from '@/store/modules/dict'
import { pickBxData, pickBxList } from '@/utils/http/bx'
const SELF_CARRIER_NAME = '安庆市山峡化工有限公司'
const dictStore = useDictStore()
const props = defineProps<{
visible: boolean
planId: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
const innerVisible = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const loading = ref(false)
const submitLoading = ref(false)
const vehicleLoading = ref(false)
const arrangeSuccess = ref(false)
const arrangeResult = ref<TransportArrangeResult | null>(null)
const {
driverUrl,
inventoryRecords,
qrcodeVisible,
selectedInventoryId,
selectedInventoryUnit,
qrcodeDialogRef,
resetArrangeActions,
applyArrangeResult,
copyDriverUrl,
copyPlanSummary,
copyPoundSlipQrcode
} = usePlPlanArrangeActions()
const tabsRef = ref<InstanceType<typeof PlPlanViewTabs> | null>(null)
const formRef = ref()
const plan = ref<Partial<Plan>>({})
const products = ref<PlanProduct[]>([])
const vehicleList = ref<Vehicle[]>([])
const carrierList = ref<Carrier[]>([])
const vehicleDriverList = ref<VehiclePersonnelRow[]>([])
const prefilledDriverId = ref('')
const formData = reactive({
vehicleId: '',
driverId: '',
carrierId: '',
startPoint: '',
endPoint: '',
distanceKm: null as number | null,
freightType: 'by_weight',
hasInvoice: 0,
remark: '',
generateDibangPlan: false
})
const formRules = {
vehicleId: [{ required: true, message: '请选择车辆', trigger: 'change' }],
driverId: [{ required: true, message: '请选择司机', trigger: 'change' }]
}
const dialogTitle = computed(() =>
plan.value.planNo ? `安排运输 · ${plan.value.planNo}` : '安排运输'
)
function getUnitLabel(unit: string) {
return dictStore.getLabel('crm_product_unit', unit) || unit
}
function buildTransportMediumText(items: PlanProduct[]) {
return items
.map(p => {
const name = p.productName?.trim()
if (!name) return ''
const model = (p.model || p.packageSpec || '').trim()
const qty = p.quantity ?? p.inboundEstimateQty ?? p.outboundEstimateQty ?? ''
const unit = getUnitLabel(p.unit || '吨')
const detail = [model, qty, unit].filter(part => part !== '' && part != null).join('、')
return detail ? `${name}(${detail})` : name
})
.filter(Boolean)
.join(';')
}
const transportMediumText = computed(() => buildTransportMediumText(products.value))
const productRows = computed<PlanFormProductRow[]>(() =>
products.value.map((item, index) => ({
productId: String(item.productId || index),
productNo: item.productNo || String(item.productId || index),
productName: item.productName || '',
model: item.model,
categoryName: item.categoryName,
unit: item.unit || '吨',
quantity: Number(item.quantity ?? item.inboundEstimateQty ?? item.outboundEstimateQty ?? 0),
actualQty: Number(
item.actualQty ?? item.quantity ?? item.inboundEstimateQty ?? item.outboundEstimateQty ?? 0
),
packageSpec: item.packageSpec,
deliveryMethod: item.deliveryMethod,
remark: item.remark,
isExplosive: Number(item.isExplosive ?? 0),
isToxic: Number(item.isToxic ?? 0)
}))
)
const hasFreightRef = computed(() => plan.value.hasFreight ?? 0)
const freightTypeRef = computed(() => formData.freightType)
const freightCalc = usePlFreightCalc({
products: productRows,
hasFreight: hasFreightRef,
freightType: freightTypeRef
})
const selectedVehicle = computed(() => vehicleList.value.find(item => item.id === formData.vehicleId))
const isSelfVehicle = computed(() => selectedVehicle.value?.vehicleType === 'self')
const showDibangHint = computed(
() => formData.generateDibangPlan && plan.value.planType && plan.value.planType !== 'direct'
)
const isRearrangeWithDibang = computed(
() => showDibangHint.value && Boolean(plan.value.transportId)
)
function formatDriverLabel(d: VehiclePersonnelRow) {
const phone = d.phoneNo ? ` (${d.phoneNo})` : ''
return `${d.personnelName}${phone}`
}
function resolveSelfCarrierId() {
if (!Array.isArray(carrierList.value)) return
const matched = carrierList.value.find(item => item.carrierName === SELF_CARRIER_NAME)
formData.carrierId = matched?.id || formData.carrierId || ''
}
function applyVehiclePersonnel(personnelList: VehiclePersonnelRow[], preferredDriverId?: string) {
vehicleDriverList.value = personnelList.filter(p => p.role === 'driver' && p.personnelId)
if (preferredDriverId && vehicleDriverList.value.some(d => d.personnelId === preferredDriverId)) {
formData.driverId = preferredDriverId
return
}
const defaultDriver = personnelList.find(p => p.role === 'driver' && p.isDefault === 1 && p.personnelId)
if (defaultDriver?.personnelId) {
formData.driverId = defaultDriver.personnelId
} else if (vehicleDriverList.value.length === 1 && vehicleDriverList.value[0].personnelId) {
formData.driverId = vehicleDriverList.value[0].personnelId
} else {
formData.driverId = ''
}
}
async function applyPrefillFromPlan(data: Plan) {
formData.startPoint = data.pickupAddress || ''
formData.endPoint = data.destinationAddress || data.destinationName || ''
formData.remark = data.remark || ''
formData.vehicleId = data.vehicleId || ''
prefilledDriverId.value = data.driverId || ''
freightCalc.resetFreightCalc()
if (data.freightAmount != null && data.hasFreight === 1) {
freightCalc.freightAmount.value = Number(data.freightAmount)
freightCalc.freightAmountTouched.value = true
}
if (!data.transportId) return
try {
const transportRes = await transportApi.get(data.transportId)
const transport = pickBxData<Transport>(transportRes)
formData.vehicleId = transport.vehicleId || formData.vehicleId
formData.carrierId = transport.carrierId || ''
formData.startPoint = transport.startPoint || formData.startPoint
formData.endPoint = transport.endPoint || formData.endPoint
formData.distanceKm = transport.distanceKm != null ? Number(transport.distanceKm) : null
formData.freightType = transport.freightType || 'by_weight'
formData.hasInvoice = transport.hasInvoice ?? 0
formData.remark = transport.remark || formData.remark
prefilledDriverId.value = transport.driverId || prefilledDriverId.value
if (transport.transportQty != null) {
freightCalc.transportQty.value = Number(transport.transportQty)
freightCalc.transportQtyTouched.value = true
}
if (transport.freightPrice != null) {
freightCalc.freightPrice.value = Number(transport.freightPrice)
}
if (transport.freightAmount != null) {
freightCalc.freightAmount.value = Number(transport.freightAmount)
freightCalc.freightAmountTouched.value = true
}
} catch {
// 草稿运输单不存在时,回退到计划主表冗余字段
}
}
function resetState() {
arrangeSuccess.value = false
arrangeResult.value = null
resetArrangeActions()
plan.value = {}
products.value = []
vehicleDriverList.value = []
prefilledDriverId.value = ''
Object.assign(formData, {
vehicleId: '',
driverId: '',
carrierId: '',
startPoint: '',
endPoint: '',
distanceKm: null,
freightType: 'by_weight',
hasInvoice: 0,
remark: '',
generateDibangPlan: false
})
freightCalc.resetFreightCalc()
formRef.value?.clearValidate()
}
async function loadMasterData() {
const [vehicleRes, carrierRes] = await Promise.all([
vehicleApi.list({ vehicleStatus: 'available' }),
carrierApi.list({ status: '1' })
])
vehicleList.value = pickBxList<Vehicle>(vehicleRes)
carrierList.value = pickBxList<Carrier>(carrierRes)
}
async function handleVehicleChange(vehicleId: string, preferredDriverId?: string) {
if (!vehicleId) {
formData.driverId = ''
formData.carrierId = ''
vehicleDriverList.value = []
return
}
const vehicle = Array.isArray(vehicleList.value)
? vehicleList.value.find(v => v.id === vehicleId)
: undefined
if (vehicle?.vehicleType === 'self') {
resolveSelfCarrierId()
} else if (vehicle?.carrierId) {
formData.carrierId = vehicle.carrierId
}
if (!formData.startPoint && vehicle?.carrierName) {
formData.startPoint = vehicle.carrierName
}
vehicleLoading.value = true
try {
const res = await vehicleApi.get(vehicleId)
const detail = pickBxData<VehicleDetail>(res)
if (detail?.vehicleType === 'self') {
resolveSelfCarrierId()
}
applyVehiclePersonnel(detail?.personnelList || [], preferredDriverId)
} finally {
vehicleLoading.value = false
}
}
async function handleOpened() {
if (!props.planId) return
loading.value = true
resetState()
try {
await loadMasterData()
const res = await planApi.get(props.planId)
const data = pickBxData<Plan>(res)
if (!data?.id) {
throw new Error('运输计划数据无效')
}
plan.value = data
products.value = Array.isArray(data.products) ? data.products : []
await applyPrefillFromPlan(data)
if (formData.vehicleId) {
await handleVehicleChange(formData.vehicleId, prefilledDriverId.value || data.driverId)
}
} catch (error) {
innerVisible.value = false
const message = error instanceof Error ? error.message : '加载运输计划失败'
ElMessage.error(message.includes('加载') ? message : `加载运输计划失败:${message}`)
} finally {
loading.value = false
}
}
function handleClosed() {
resetState()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
const certMessage = buildMissingHazardCertMessage(products.value)
if (certMessage) {
await ElMessageBox.alert(`${certMessage}。请先上传购买证和运输证。`, '无法安排运输', {
type: 'warning'
})
return
}
submitLoading.value = true
try {
if (formData.generateDibangPlan && plan.value.transportId) {
await ElMessageBox.confirm(
'将作废本运输单此前待处理的出入库单,并按当前计划产品重新生成。已执行或已关联磅单的单据不会被自动作废。是否继续?',
'地磅联动确认',
{
type: 'warning',
confirmButtonText: '继续安排',
cancelButtonText: '取消'
}
)
}
const payload = {
vehicleId: formData.vehicleId,
driverId: formData.driverId,
carrierId: formData.carrierId || undefined,
startPoint: formData.startPoint || undefined,
endPoint: formData.endPoint || undefined,
transportMedium: transportMediumText.value || undefined,
distanceKm: formData.distanceKm ?? undefined,
freightType: formData.freightType,
freightPrice:
formData.freightType === 'by_weight' ? freightCalc.freightPrice.value ?? undefined : undefined,
freightAmount:
plan.value.hasFreight === 1
? freightCalc.calculatedFreight.value || freightCalc.freightAmount.value || undefined
: undefined,
hasInvoice: formData.hasInvoice,
remark: formData.remark || undefined,
generateDibangPlan: formData.generateDibangPlan
}
const res = await transportApi.arrange(props.planId, payload)
const result = pickBxData<TransportArrangeResult>(res)
arrangeResult.value = result
applyArrangeResult(result, plan.value, products.value)
arrangeSuccess.value = true
ElMessage.success('运输安排成功')
tabsRef.value?.reload()
emit('success')
} catch (e: unknown) {
if (e === 'cancel') return
const message = e instanceof Error ? e.message : '安排失败'
ElMessage.error(message)
} finally {
submitLoading.value = false
}
}
</script>
<style scoped lang="scss">
.pl-arrange-layout {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 480px;
}
.pl-arrange-form-panel {
flex: 0 0 auto;
max-height: 42vh;
overflow: hidden;
}
.pl-arrange-form-scroll {
max-height: 42vh;
overflow-y: auto;
padding-right: 4px;
}
.pl-arrange-view-panel {
flex: 1 1 auto;
min-height: 280px;
display: flex;
flex-direction: column;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
padding: 12px 16px 16px;
}
.pl-arrange-view-title {
margin: 0 0 12px;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
border-left: 3px solid #10b981;
line-height: 1;
}
.pl-plan-form-section {
padding: 16px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
}
.pl-plan-form-section-title {
margin: 0 0 12px;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
border-left: 3px solid #10b981;
line-height: 1;
}
.pl-arrange-dibang-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.pl-arrange-dibang-hint {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: #64748b;
}
.pl-arrange-remark-item {
grid-column: span 2;
}
.pl-arrange-success {
display: flex;
flex-direction: column;
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;
gap: 8px;
justify-content: flex-end;
}
</style>
<style lang="scss">
.el-dialog.pl-plan-arrange-dialog.dialog-form-layout {
width: min(1680px, calc(100vw - 80px)) !important;
max-height: calc(100vh - 48px);
}
</style>
@@ -0,0 +1,209 @@
<template>
<el-dialog
v-model="visibleProxy"
title="上传易制毒/易制爆证书"
width="min(960px, calc(100vw - 80px))"
align-center
destroy-on-close
append-to-body
class="dialog-form-layout pl-plan-cert-upload-dialog"
@closed="handleClosed"
>
<div v-loading="loading" class="pl-plan-cert-upload-body">
<p v-if="planBrief.planNo" class="plan-brief">
计划编号:<strong>{{ planBrief.planNo }}</strong>
<span v-if="planBrief.customerName"> · {{ planBrief.customerName }}</span>
</p>
<el-alert
type="info"
:closable="false"
show-icon
title="易制毒或易制爆产品须上传购买证、运输证;安排运输时将校验是否齐全。"
class="cert-tip"
/>
<el-empty v-if="!loading && !hazardProducts.length" description="该计划暂无易制毒/易制爆产品" />
<div v-for="item in hazardProducts" :key="item.id" class="product-cert-block">
<div class="product-cert-head">
<span class="product-name">{{ item.productName }}</span>
<el-tag v-if="item.isExplosive" type="danger" size="small">易制爆</el-tag>
<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>
<AttachmentUploadField
v-model="item.purchaseCertUrl"
folder="pl/plan-cert"
:limit="1"
hint="支持图片、PDF 等,上传购买证"
/>
</el-form-item>
<el-form-item label="运输证" required>
<AttachmentUploadField
v-model="item.transportCertUrl"
folder="pl/plan-cert"
:limit="1"
hint="支持图片、PDF 等,上传运输证"
/>
</el-form-item>
</div>
</div>
</div>
<template #footer>
<el-button @click="visibleProxy = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitLoading" :disabled="!hazardProducts.length" @click="handleSubmit">
<el-icon><Check /></el-icon>保存
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
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'
type HazardProductRow = PlanProduct & {
purchaseCertUrl?: string
transportCertUrl?: string
}
const props = defineProps<{
visible: boolean
planId: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
const visibleProxy = computed({
get: () => props.visible,
set: (val: boolean) => emit('update:visible', val)
})
const loading = ref(false)
const submitLoading = ref(false)
const planBrief = reactive({ planNo: '', customerName: '' })
const hazardProducts = ref<HazardProductRow[]>([])
watch(
() => [props.visible, props.planId] as const,
([open, planId]) => {
if (open && planId) {
loadPlan(planId)
}
},
{ immediate: true }
)
async function loadPlan(planId: string) {
loading.value = true
hazardProducts.value = []
planBrief.planNo = ''
planBrief.customerName = ''
try {
const res = await planApi.get(planId)
const data = (res.data ?? res) as Plan
planBrief.planNo = data.planNo || ''
planBrief.customerName = data.customerName || ''
hazardProducts.value = (data.products || [])
.filter(p => isHazardProduct(p))
.map(p => ({
...p,
purchaseCertUrl: p.purchaseCertUrl || '',
transportCertUrl: p.transportCertUrl || ''
}))
} finally {
loading.value = false
}
}
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(
props.planId,
hazardProducts.value.map(p => ({
id: p.id,
purchaseCertUrl: p.purchaseCertUrl?.trim() || '',
transportCertUrl: p.transportCertUrl?.trim() || ''
}))
)
ElMessage.success('证书保存成功')
visibleProxy.value = false
emit('success')
} finally {
submitLoading.value = false
}
}
function handleClosed() {
hazardProducts.value = []
planBrief.planNo = ''
planBrief.customerName = ''
}
</script>
<style scoped lang="scss">
.pl-plan-cert-upload-body {
min-height: 120px;
}
.plan-brief {
margin: 0 0 12px;
color: var(--mint-text);
font-size: 14px;
strong {
color: var(--text, #1e293b);
}
}
.cert-tip {
margin-bottom: 16px;
}
.product-cert-block {
padding: 16px;
margin-bottom: 12px;
border: 1px solid var(--border, #d9e8e2);
border-radius: 10px;
background: #fff;
}
.product-cert-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.product-name {
font-weight: 600;
color: var(--text, #1e293b);
}
.product-cert-grid {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -0,0 +1,112 @@
<template>
<div class="panel dialog-list-panel pl-plan-product-table-wrap">
<div class="list-body list-wrap dialog-list-body-only">
<el-table :data="products" stripe class="list-table pl-plan-product-table" empty-text="暂无产品">
<el-table-column label="序号" type="index" min-width="60" align="center" header-align="center" />
<el-table-column label="产品名称" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<PlProductNameHazardCell :product="row" />
</template>
</el-table-column>
<el-table-column label="型号/规格" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ row.model || row.productModel || '—' }}
</template>
</el-table-column>
<el-table-column prop="categoryName" label="产品分类" min-width="110" show-overflow-tooltip />
<el-table-column label="税率(%)" min-width="90" align="center">
<template #default="{ row }">{{ row.taxRate ?? 0 }}%</template>
</el-table-column>
<el-table-column label="单位" min-width="80" align="center">
<template #default="{ row }">
<DictTag dict-type="crm_product_unit" :value="row.unit" />
</template>
</el-table-column>
<el-table-column label="计划数量" min-width="110" align="right">
<template #default="{ row }">
<el-input-number
v-if="editableQty"
v-model="row.quantity"
:min="0"
:precision="3"
:controls="false"
style="width: 100%"
/>
<span v-else>{{ row.quantity ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="实际数量" min-width="110" align="right">
<template #default="{ row }">
<el-input-number
v-if="editableQty"
v-model="row.actualQty"
:min="0"
:precision="3"
:controls="false"
style="width: 100%"
/>
<span v-else>{{ row.actualQty ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="含税单价(元)" min-width="120" align="right">
<template #default="{ row }">{{ formatMoneyDisplay(row.taxPrice) }}</template>
</el-table-column>
<el-table-column label="含税总价(元)" min-width="120" align="right">
<template #default="{ row }">{{ formatMoneyDisplay(row.taxSubtotal) }}</template>
</el-table-column>
<el-table-column label="税费(元)" min-width="100" align="right">
<template #default="{ row }">{{ formatMoneyDisplay(row.taxAmount) }}</template>
</el-table-column>
<el-table-column prop="packageSpec" label="包装规格" min-width="110" show-overflow-tooltip />
<el-table-column label="送货方式" min-width="100" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ getDeliveryMethodLabel(row.deliveryMethod) }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="editableRemark" label="备注" min-width="140">
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" />
</template>
</el-table-column>
<el-table-column v-else prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<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)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
import { Delete } 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'
import { formatMoneyDisplay } from '@/utils/money-format'
defineProps<{
products: PlanFormProductRow[]
showDelete?: boolean
editableRemark?: boolean
editableQty?: boolean
}>()
const emit = defineEmits<{
remove: [index: number]
}>()
</script>
<style scoped lang="scss">
.pl-plan-product-table-wrap {
margin-top: 8px;
}
.dialog-list-body-only {
margin-top: 0;
}
</style>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,140 @@
<template>
<div v-if="hazardProducts.length" class="pl-plan-list-cert-cell">
<div
v-for="(product, index) in hazardProducts"
:key="product.id || `${product.productNo}-${index}`"
class="pl-plan-list-cert-cell__group"
>
<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>
</div>
<div class="pl-plan-list-cert-cell__certs">
<div class="pl-plan-list-cert-cell__cert-item">
<AttachmentUploadField
v-if="product.purchaseCertUrl"
:model-value="product.purchaseCertUrl"
disabled
/>
<span v-else class="pl-plan-list-cert-cell__missing">未上传</span>
<span class="pl-plan-list-cert-cell__cert-label">购买证</span>
</div>
<div class="pl-plan-list-cert-cell__cert-item">
<AttachmentUploadField
v-if="product.transportCertUrl"
:model-value="product.transportCertUrl"
disabled
/>
<span v-else class="pl-plan-list-cert-cell__missing">未上传</span>
<span class="pl-plan-list-cert-cell__cert-label">运输证</span>
</div>
</div>
</div>
</div>
<span v-else class="pl-plan-list-cert-cell__empty">—</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { PlanProduct } from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
const props = withDefaults(
defineProps<{
products?: PlanProduct[]
}>(),
{
products: () => []
}
)
const hazardProducts = computed(() => (props.products || []).filter(product => isHazardProduct(product)))
</script>
<style scoped lang="scss">
.pl-plan-list-cert-cell {
display: flex;
flex-direction: column;
gap: 10px;
}
.pl-plan-list-cert-cell__group {
display: flex;
flex-direction: column;
gap: 6px;
}
.pl-plan-list-cert-cell__product {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.pl-plan-list-cert-cell__name {
font-size: 13px;
font-weight: 600;
color: var(--text, #1e293b);
}
.pl-plan-list-cert-cell__certs {
display: flex;
align-items: flex-start;
gap: 12px;
}
.pl-plan-list-cert-cell__cert-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 96px;
}
.pl-plan-list-cert-cell__cert-label {
color: var(--mint-text);
font-size: 12px;
line-height: 1.4;
text-align: center;
}
.pl-plan-list-cert-cell__missing {
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 56px;
border: 1px dashed var(--border, #d9e8e2);
border-radius: 8px;
background: var(--table-head, #f8fafc);
color: var(--mint-text);
font-size: 12px;
}
.pl-plan-list-cert-cell__empty {
color: var(--mint-text);
}
:deep(.attachment-upload) {
width: 100%;
}
:deep(.attachment-list) {
flex-wrap: nowrap;
}
:deep(.attachment-item) {
width: 96px;
margin-bottom: 0;
}
:deep(.attachment-preview) {
height: 56px;
}
:deep(.attachment-footer) {
display: none;
}
</style>
@@ -0,0 +1,91 @@
<template>
<div v-if="products.length" class="pl-plan-list-product-cell">
<div
v-for="(product, index) in products"
: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"
>
爆
</el-tag>
</span>
</div>
</div>
<span v-else class="pl-plan-list-product-cell__empty">—</span>
</template>
<script setup lang="ts">
import type { PlanProduct } from '@/api/pl'
import { resolveProductHazardFlags } from '@/composables/usePlToxicProductCertCheck'
import { useDictStore } from '@/store/modules/dict'
import { formatPlanProductListText } from '@/utils/pl-display'
withDefaults(
defineProps<{
products?: PlanProduct[]
}>(),
{
products: () => []
}
)
const dictStore = useDictStore()
function getUnitLabel(unit: string) {
return dictStore.getLabel('crm_product_unit', unit)
}
function formatProductLine(product: PlanProduct) {
return formatPlanProductListText(product, getUnitLabel)
}
function getHazardFlags(product: PlanProduct) {
return resolveProductHazardFlags(product)
}
</script>
<style scoped lang="scss">
.pl-plan-list-product-cell {
display: flex;
flex-direction: column;
gap: 8px;
}
.pl-plan-list-product-cell__row {
min-width: 0;
}
.pl-plan-list-product-cell__line {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
max-width: 100%;
font-size: 13px;
line-height: 1.5;
color: var(--text, #1e293b);
}
.pl-plan-list-product-cell__text {
min-width: 0;
}
.pl-plan-list-product-cell__mark {
flex-shrink: 0;
font-weight: 600;
}
.pl-plan-list-product-cell__empty {
color: var(--mint-text);
}
</style>
@@ -0,0 +1,157 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<el-dialog
v-model="innerVisible"
:title="dialogTitle"
width="1680px"
align-center
destroy-on-close
append-to-body
class="dialog-form-layout pl-plan-view-dialog"
modal-class="order-module-dialog-mask"
style="backdrop-filter: none"
@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>复制链接
</el-button>
</div>
</section>
<PlPlanViewTabs v-if="planId" ref="tabsRef" :plan-id="planId" />
<DbPoundSlipQrcodeDialog
v-model:visible="qrcodeVisible"
:inventory-id="selectedInventoryId"
:quantity-unit="selectedInventoryUnit"
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="inventoryRecords.length > 0"
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 { planApi } from '@/api/pl'
import DbPoundSlipQrcodeDialog from '@/components/business/db/DbPoundSlipQrcodeDialog.vue'
import PlPlanViewTabs from '@/components/business/pl-plan-view-tabs/index.vue'
import { usePlPlanArrangeActions } from '@/composables/usePlPlanArrangeActions'
const props = defineProps<{
visible: boolean
planId?: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
const innerVisible = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const tabsRef = ref<InstanceType<typeof PlPlanViewTabs> | null>(null)
const planNo = ref('')
const {
showArrangeActions,
driverUrl,
inventoryRecords,
qrcodeVisible,
selectedInventoryId,
selectedInventoryUnit,
qrcodeDialogRef,
resetArrangeActions,
loadArrangeActions,
copyDriverUrl,
copyPlanSummary,
copyPoundSlipQrcode
} = usePlPlanArrangeActions()
const dialogTitle = computed(() =>
planNo.value ? `查看运输计划 · ${planNo.value}` : '查看运输计划'
)
async function handleOpened() {
if (!props.planId) return
try {
const res = await planApi.get(props.planId)
const data = res.data ?? res
planNo.value = data?.planNo || ''
await loadArrangeActions(props.planId)
} catch {
innerVisible.value = false
ElMessage.error('加载运输计划详情失败')
}
}
function handleClosed() {
planNo.value = ''
resetArrangeActions()
}
</script>
<style scoped lang="scss">
.pl-plan-view-footer {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.pl-plan-arrange-summary {
margin-bottom: 12px;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #f8fafc;
}
.pl-plan-arrange-summary-title {
margin: 0 0 10px;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
border-left: 3px solid #10b981;
line-height: 1;
}
.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;
}
</style>
@@ -0,0 +1,802 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div v-loading="loading" class="pl-plan-view-tabs" :class="{ 'is-embedded': embedded }">
<el-tabs v-model="activeTab" class="pl-plan-view-tabs__nav">
<el-tab-pane label="计划概况" name="overview">
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">基础信息</h3>
<dl class="pl-plan-kv-grid">
<div class="pl-plan-kv">
<dt>计划编号</dt>
<dd>{{ plan?.planNo || '—' }}</dd>
</div>
<div class="pl-plan-kv">
<dt>计划日期</dt>
<dd>{{ plan?.planDate || '—' }}</dd>
</div>
<div class="pl-plan-kv">
<dt>归属公司</dt>
<dd>{{ getPlPlanCompanyLabel(plan?.belongsCompany) }}</dd>
</div>
<div class="pl-plan-kv">
<dt>单据类型</dt>
<dd>{{ getPlPlanTypeLabel(plan?.planType || '') }}</dd>
</div>
<div class="pl-plan-kv">
<dt>跟进人</dt>
<dd>{{ plan?.followUserName || '—' }}</dd>
</div>
</dl>
</section>
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">产品信息</h3>
<p v-if="!productRows.length && !loading" class="pl-plan-form-empty-hint">暂无产品明细</p>
<PlPlanFormProductTable v-else :products="productRows" />
</section>
<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>
</section>
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">备注</h3>
<p class="pl-plan-view-remark">{{ plan?.remark || '—' }}</p>
</section>
</el-tab-pane>
<el-tab-pane label="运输信息" name="transport">
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">运输信息</h3>
<dl class="pl-plan-kv-grid">
<div class="pl-plan-kv">
<dt>车牌号</dt>
<dd>{{ plan?.licensePlate || transport?.licensePlate || '—' }}</dd>
</div>
<div class="pl-plan-kv">
<dt>运输公司</dt>
<dd>{{ displayCarrierName || '—' }}</dd>
</div>
</dl>
<div v-if="vehicleBundle" class="pl-plan-vehicle-detail">
<PlVehicleViewPanel
collapsible
:default-expanded="false"
:detail="vehicleBundle.detail"
:personnel-list="vehicleBundle.personnelList"
/>
</div>
<div class="pl-plan-logistics-row">
<div class="pl-plan-logistics-block">
<h4 class="pl-plan-subtitle">取货信息</h4>
<dl class="pl-plan-kv-grid pl-plan-kv-grid-2">
<div class="pl-plan-kv pl-plan-kv-span">
<dt>取货地址</dt>
<dd>{{ plan?.pickupAddress || '—' }}</dd>
</div>
<div class="pl-plan-kv">
<dt>取货时间</dt>
<dd>{{ plan?.pickupTime || '—' }}</dd>
</div>
<div class="pl-plan-kv">
<dt>注意事项</dt>
<dd>{{ plan?.pickupNotes || '—' }}</dd>
</div>
</dl>
</div>
<div class="pl-plan-flow-bridge" aria-hidden="true">
<el-icon class="pl-plan-flow-bridge__arrow"><Right /></el-icon>
</div>
<div class="pl-plan-logistics-block">
<h4 class="pl-plan-subtitle">送货信息</h4>
<dl class="pl-plan-kv-grid pl-plan-kv-grid-2">
<div class="pl-plan-kv pl-plan-kv-span">
<dt>送货地址</dt>
<dd>{{ plan?.destinationAddress || plan?.destinationName || '—' }}</dd>
</div>
<div class="pl-plan-kv">
<dt>送达时间</dt>
<dd>{{ plan?.deliveryTime || '—' }}</dd>
</div>
<div class="pl-plan-kv">
<dt>注意事项</dt>
<dd>{{ plan?.destinationNotes || '—' }}</dd>
</div>
</dl>
</div>
</div>
<h4 class="pl-plan-subtitle">运费信息</h4>
<dl class="pl-plan-kv-grid">
<div class="pl-plan-kv">
<dt>是否有运输费用</dt>
<dd>{{ getPlYesNoLabel(plan?.hasFreight) }}</dd>
</div>
<template v-if="plan?.hasFreight === 1">
<div class="pl-plan-kv">
<dt>运输公里数</dt>
<dd>{{ displayDistanceKm }}</dd>
</div>
<div class="pl-plan-kv">
<dt>运费计算方式</dt>
<dd>{{ getPlFreightTypeLabel(displayFreightType) }}</dd>
</div>
<div v-if="displayFreightType === 'by_weight'" class="pl-plan-kv">
<dt>运输量(吨)</dt>
<dd>{{ displayTransportQty }}</dd>
</div>
<div v-if="displayFreightType === 'by_weight'" class="pl-plan-kv">
<dt>运输单价(元/吨)</dt>
<dd class="money-display">{{ displayFreightPrice }}</dd>
</div>
<div class="pl-plan-kv">
<dt>运费金额(元)</dt>
<dd class="money-display">{{ displayFreightAmount }}</dd>
</div>
<div class="pl-plan-kv">
<dt>是否开票</dt>
<dd>{{ getPlYesNoLabel(transport?.hasInvoice) }}</dd>
</div>
</template>
</dl>
<h4 class="pl-plan-subtitle">司机回传信息</h4>
<p v-if="!hasDriverFeedback" class="pl-plan-form-empty-hint">暂无司机回传</p>
<template v-else>
<dl class="pl-plan-kv-grid">
<div class="pl-plan-kv">
<dt>回传时间</dt>
<dd>{{ displayFeedbackTime }}</dd>
</div>
<div v-if="transport?.feedbackQty != null" class="pl-plan-kv">
<dt>回传数量</dt>
<dd>{{ transport?.feedbackQty }}</dd>
</div>
<div class="pl-plan-kv pl-plan-kv-span">
<dt>回传备注</dt>
<dd class="pl-plan-view-remark">{{ transport?.feedbackRemark || '—' }}</dd>
</div>
<div class="pl-plan-kv pl-plan-kv-span">
<dt>回传附件</dt>
<dd>
<AttachmentUploadField
v-if="feedbackImageCsv"
:model-value="feedbackImageCsv"
disabled
/>
<span v-else>—</span>
</dd>
</div>
</dl>
</template>
<h4 class="pl-plan-subtitle">地磅单信息</h4>
<p v-if="!dibangRows.length && !loading" class="pl-plan-form-empty-hint"
>暂无关联地磅单</p
>
<div
v-for="(row, idx) in dibangRows"
:key="row.link.dbInventoryId || idx"
class="pl-plan-dibang-block"
>
<div class="pl-plan-dibang-head">
<span class="pl-plan-dibang-product">{{
row.link.productName || row.inventory?.product || '—'
}}</span>
<el-tag
v-if="row.inventory?.status"
:type="inventoryStatusTagType(row.inventory.status)"
size="small"
>
{{ row.inventory.status }}
</el-tag>
</div>
<dl class="pl-plan-kv-grid pl-plan-kv-grid-2">
<div class="pl-plan-kv">
<dt>出入库单号</dt>
<dd>{{ row.inventory?.batchNo || row.link.batchNo || '—' }}</dd>
</div>
<div class="pl-plan-kv">
<dt>业务类型</dt>
<dd>
<el-tag
v-if="row.inventory?.businessType"
:type="businessTypeTagType(row.inventory.businessType)"
size="small"
>
{{ row.inventory.businessType }}
</el-tag>
<span v-else>—</span>
</dd>
</div>
<div class="pl-plan-kv">
<dt>往来单位</dt>
<dd>{{ row.inventory?.partnerCompany || '—' }}</dd>
</div>
<div class="pl-plan-kv">
<dt>车牌号</dt>
<dd>{{
row.inventory?.vehicleNo || plan?.licensePlate || transport?.licensePlate || '—'
}}</dd>
</div>
<div class="pl-plan-kv">
<dt>产品</dt>
<dd>{{ row.inventory?.product || row.link.productName || '—' }}</dd>
</div>
<div class="pl-plan-kv">
<dt>数量(吨)</dt>
<dd>{{ row.inventory?.quantity ?? '—' }}</dd>
</div>
</dl>
<template v-if="row.weighing">
<h5 class="pl-plan-dibang-weighing-title">地磅单</h5>
<div class="view-subtable-wrap">
<el-table :data="[row.weighing]" class="view-subtable list-table" stripe>
<el-table-column
prop="batchNo"
label="出入库单号"
min-width="130"
align="center"
/>
<el-table-column prop="vehicleNo" label="车牌号" min-width="110" align="center" />
<el-table-column
prop="cargoName"
label="货物名称"
min-width="120"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="businessType"
label="业务类型"
min-width="100"
align="center"
>
<template #default="{ row: weighingRow }">
<el-tag :type="businessTypeTagType(weighingRow.businessType)" size="small">
{{ weighingRow.businessType }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="supplier"
label="供货单位"
min-width="140"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="consignee"
label="收货单位"
min-width="140"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="grossWeight"
label="毛重(kg)"
min-width="100"
align="right"
/>
<el-table-column
prop="grossWeightTime"
label="毛重时间"
min-width="160"
align="center"
/>
<el-table-column
prop="tareWeight"
label="皮重(kg)"
min-width="100"
align="right"
/>
<el-table-column
prop="tareWeightTime"
label="皮重时间"
min-width="160"
align="center"
/>
<el-table-column prop="deduction" label="扣杂(kg)" min-width="100" align="right">
<template #default="{ row: weighingRow }">
{{ weighingRow.deduction ?? 0 }}
</template>
</el-table-column>
<el-table-column
prop="netWeight"
label="净重(kg)"
min-width="100"
align="right"
/>
<el-table-column
prop="remarks"
label="备注"
min-width="140"
align="center"
show-overflow-tooltip
/>
</el-table>
</div>
</template>
<p v-else class="pl-plan-form-empty-hint pl-plan-dibang-pending">尚未关联地磅单</p>
</div>
</section>
</el-tab-pane>
<el-tab-pane label="合同信息" name="contract">
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">合同信息</h3>
<p v-if="!contractSnapshot" class="pl-plan-form-empty-hint">暂无关联合同信息</p>
<template v-else>
<dl class="pl-plan-kv-grid">
<div class="pl-plan-kv">
<dt>合同编号</dt>
<dd>{{ contractSnapshot.contractNo || '—' }}</dd>
</div>
<div class="pl-plan-kv">
<dt>合同名称</dt>
<dd>{{ contractSnapshot.contractName || '—' }}</dd>
</div>
<div class="pl-plan-kv">
<dt>签订日期</dt>
<dd>{{ contractSnapshot.signDate || '—' }}</dd>
</div>
<div class="pl-plan-kv">
<dt>合同有效期</dt>
<dd
>{{ contractSnapshot.startDate || '—' }} ~
{{ contractSnapshot.endDate || '—' }}</dd
>
</div>
<div class="pl-plan-kv">
<dt>结算方式</dt>
<dd>
<DictTag
v-if="contractSnapshot.settlementMethod"
dict-type="crm_settlement_method"
:value="contractSnapshot.settlementMethod"
/>
<span v-else>—</span>
</dd>
</div>
<div class="pl-plan-kv">
<dt>合同金额</dt>
<dd class="money-display">{{ formatMoneyDisplay(contractSnapshot.amount) }}</dd>
</div>
</dl>
<div class="pl-plan-party-row">
<div class="pl-plan-party-block">
<h4 class="pl-plan-party-title">供方信息</h4>
<dl class="pl-plan-kv-grid pl-plan-kv-grid-party">
<div class="pl-plan-kv">
<dt>名称</dt>
<dd>{{ contractSnapshot.supplierName || contractSnapshot.partyA || '—' }}</dd>
</div>
<div class="pl-plan-kv">
<dt>联系人</dt>
<dd>{{ contractSnapshot.partyAContact || '—' }}</dd>
</div>
<div class="pl-plan-kv">
<dt>电话</dt>
<dd>{{ contractSnapshot.partyAMobile || '—' }}</dd>
</div>
<div class="pl-plan-kv pl-plan-kv-span">
<dt>地址</dt>
<dd>{{ contractSnapshot.partyAAddress || '—' }}</dd>
</div>
</dl>
</div>
<div class="pl-plan-flow-bridge" aria-hidden="true">
<el-icon class="pl-plan-flow-bridge__arrow"><Right /></el-icon>
</div>
<div class="pl-plan-party-block">
<h4 class="pl-plan-party-title">需方信息</h4>
<dl class="pl-plan-kv-grid pl-plan-kv-grid-party">
<div class="pl-plan-kv">
<dt>名称</dt>
<dd>{{ contractSnapshot.customerName || contractSnapshot.partyB || '—' }}</dd>
</div>
<div class="pl-plan-kv">
<dt>联系人</dt>
<dd>{{
contractSnapshot.partyBContact || contractSnapshot.contactName || '—'
}}</dd>
</div>
<div class="pl-plan-kv">
<dt>电话</dt>
<dd>{{
contractSnapshot.partyBMobile || contractSnapshot.contactMobile || '—'
}}</dd>
</div>
<div class="pl-plan-kv pl-plan-kv-span">
<dt>地址</dt>
<dd>{{ contractSnapshot.partyBAddress || '—' }}</dd>
</div>
</dl>
</div>
</div>
<h4 class="pl-plan-subtitle">合同产品预览</h4>
<PlPlanFormProductTable :products="productRows" />
</template>
</section>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { Right } 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 PlVehicleViewPanel from '@/components/business/pl-vehicle-view-panel/index.vue'
import DictTag from '@/components/DictTag/index.vue'
import {
businessTypeTagType,
inventoryStatusTagType
} from '@/components/business/db/db-related-view'
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { mapPlanProductToFormRow, usePlPlanViewDetail } from '@/composables/usePlPlanViewDetail'
import { isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
import { formatDateTime } from '@/utils/format'
import {
getPlFreightTypeLabel,
getPlPlanCompanyLabel,
getPlPlanTypeLabel,
getPlYesNoLabel,
parsePlFeedbackImages
} from '@/utils/pl-display'
import { formatMoneyDisplay } from '@/utils/money-format'
import type { Transport } from '@/api/pl'
const SELF_CARRIER_NAME = '安庆市山峡化工有限公司'
const props = withDefaults(
defineProps<{
planId: string
embedded?: boolean
}>(),
{ embedded: false }
)
const activeTab = ref('overview')
const {
loading,
plan,
products,
transport,
vehicleBundle,
contractSnapshot,
dibangRows,
loadDetail,
resetDetail
} = usePlPlanViewDetail()
const feedbackImageList = computed(() => parsePlFeedbackImages(transport.value?.feedbackImages))
const feedbackImageCsv = computed(() => feedbackImageList.value.join(','))
const hasDriverFeedback = computed(() =>
Boolean(
transport.value?.feedbackTime ||
transport.value?.feedbackRemark ||
feedbackImageList.value.length ||
transport.value?.feedbackQty != null
)
)
const displayFeedbackTime = computed(() => formatDateTime(transport.value?.feedbackTime) || '—')
const displayCarrierName = computed(() => {
if (transport.value?.carrierName) return transport.value.carrierName
const vehicle = vehicleBundle.value?.detail
if (vehicle?.carrierName) return vehicle.carrierName
if (vehicle?.vehicleType === 'self') return SELF_CARRIER_NAME
return ''
})
const displayFreightType = computed(() => transport.value?.freightType || 'by_weight')
const displayDistanceKm = computed(() => {
const km = transport.value?.distanceKm
return km != null ? String(km) : '—'
})
const displayTransportQty = computed(() => {
const qty = (transport.value as Transport & { transportQty?: number })?.transportQty
if (qty != null) return String(qty)
if (!productRows.value.length) return '—'
const sum = productRows.value.reduce(
(total, item) => total + Number(item.actualQty ?? item.quantity ?? 0),
0
)
return sum ? String(sum) : '—'
})
const displayFreightPrice = computed(() => {
const price = transport.value?.freightPrice
return price != null ? formatMoneyDisplay(Number(price)) : '—'
})
const displayFreightAmount = computed(() => {
const amount = plan.value?.freightAmount ?? transport.value?.freightAmount
return amount ? formatMoneyDisplay(Number(amount)) : '—'
})
const productRows = computed<PlanFormProductRow[]>(() =>
products.value.map((item, index) => mapPlanProductToFormRow(item, index))
)
const hazardProducts = computed(() => products.value.filter((item) => isHazardProduct(item)))
watch(
() => props.planId,
async (planId) => {
if (!planId) {
resetDetail()
return
}
await loadDetail(planId)
},
{ immediate: true }
)
onUnmounted(() => {
resetDetail()
})
function reload() {
if (props.planId) return loadDetail(props.planId)
}
defineExpose({ reload })
</script>
<style scoped lang="scss">
.pl-plan-view-tabs {
min-height: 200px;
&.is-embedded {
.pl-plan-form-section:first-child {
margin-top: 0;
}
}
}
.pl-plan-view-tabs__nav :deep(.el-tabs__content) {
max-height: min(52vh, 560px);
overflow-y: auto;
padding-right: 4px;
}
.pl-plan-form-section {
padding: 16px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
& + & {
margin-top: 12px;
}
}
.pl-plan-form-section-title {
margin: 0 0 12px;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
border-left: 3px solid #10b981;
line-height: 1;
}
.pl-plan-subtitle {
margin: 16px 0 10px;
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
.pl-plan-form-empty-hint {
margin: 0;
font-size: 13px;
color: #64748b;
}
.pl-plan-view-remark {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #000;
line-height: 1.5;
word-break: break-word;
white-space: pre-wrap;
}
.pl-plan-kv-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px 20px;
}
.pl-plan-kv-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pl-plan-kv {
min-width: 0;
dt {
margin: 0 0 4px;
font-size: 11px;
color: #64748b;
}
dd {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #1e293b;
word-break: break-word;
}
}
.pl-plan-kv-span {
grid-column: 1 / -1;
}
.pl-plan-party-row,
.pl-plan-logistics-row {
display: flex;
align-items: stretch;
gap: 0;
margin-top: 12px;
}
.pl-plan-party-row .pl-plan-party-block,
.pl-plan-logistics-row .pl-plan-logistics-block {
flex: 1 1 0;
min-width: 0;
}
.pl-plan-flow-bridge {
flex: 0 0 72px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px;
}
.pl-plan-flow-bridge__arrow {
font-size: 28px;
color: #10b981;
}
.pl-plan-kv-grid-party {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.pl-plan-logistics-block,
.pl-plan-party-block {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.pl-plan-party-title {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
.pl-plan-vehicle-detail {
margin-top: 12px;
}
.pl-plan-cert-block {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
& + & {
margin-top: 10px;
}
}
.pl-plan-cert-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.pl-plan-cert-name {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
.money-display {
color: #059669;
}
.pl-plan-dibang-block {
margin-top: 12px;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
& + & {
margin-top: 10px;
}
}
.pl-plan-dibang-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.pl-plan-dibang-product {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
.pl-plan-dibang-weighing-title {
margin: 12px 0 8px;
font-size: 12px;
font-weight: 600;
color: #64748b;
}
.pl-plan-dibang-pending {
margin: 8px 0 0;
}
</style>
@@ -0,0 +1,42 @@
<template>
<span class="pl-product-name-hazard">
<span class="pl-product-name-hazard__name">{{ product.productName || '—' }}</span>
<el-tag v-if="flags.isToxic" type="warning" size="small" class="pl-product-name-hazard__mark">毒</el-tag>
<el-tag v-if="flags.isExplosive" type="danger" size="small" class="pl-product-name-hazard__mark">爆</el-tag>
</span>
</template>
<script setup lang="ts">
import { resolveProductHazardFlags } from '@/composables/usePlToxicProductCertCheck'
const props = defineProps<{
product: {
productName?: string
isToxic?: number
isExplosive?: number
is_toxic?: number
is_explosive?: number
}
}>()
const flags = computed(() => resolveProductHazardFlags(props.product))
</script>
<style scoped lang="scss">
.pl-product-name-hazard {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
max-width: 100%;
}
.pl-product-name-hazard__name {
min-width: 0;
}
.pl-product-name-hazard__mark {
flex-shrink: 0;
font-weight: 600;
}
</style>
@@ -0,0 +1,81 @@
<template>
<el-tabs v-model="activeTab" class="vehicle-detail-tabs">
<el-tab-pane label="车辆证书" name="vehicle-cert">
<PlCertViewList :cert-list="certList" />
</el-tab-pane>
<el-tab-pane
v-for="(person, index) in personnelList"
:key="getPersonKey(person, index)"
:label="getPersonTabLabel(person)"
:name="getPersonKey(person, index)"
>
<PersonnelViewPanel :person="person" />
</el-tab-pane>
</el-tabs>
</template>
<script setup lang="ts">
import PlCertViewList from '@/components/business/pl-cert-view-list/index.vue'
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'
const props = withDefaults(
defineProps<{
certList?: CertRead[]
personnelList?: VehiclePersonnelViewRow[]
}>(),
{
certList: () => [],
personnelList: () => []
}
)
const activeTab = ref('vehicle-cert')
function getPersonKey(person: VehiclePersonnelViewRow, index: number) {
return `person-${person.personnelId || `${person.personnelName}-${index}`}`
}
function getRoleLabel(role: string) {
const map: Record<string, string> = {
driver: '司机',
escort: '押运员'
}
return map[role] || role
}
function getPersonTabLabel(person: VehiclePersonnelViewRow) {
const name = person.personnelName || '—'
const role = getRoleLabel(person.role)
return `${name}(${role})`
}
watch(
() => props.personnelList,
list => {
if (!list.length) {
activeTab.value = 'vehicle-cert'
return
}
if (activeTab.value === 'vehicle-cert') return
const keys = list.map((person, index) => getPersonKey(person, index))
if (!keys.includes(activeTab.value)) {
activeTab.value = 'vehicle-cert'
}
},
{ immediate: true, deep: true }
)
</script>
<style scoped lang="scss">
.vehicle-detail-tabs {
:deep(.el-tabs__header) {
margin-bottom: 12px;
}
:deep(.el-tabs__content) {
padding-top: 4px;
}
}
</style>
@@ -0,0 +1,111 @@
<template>
<div class="personnel-view-panel">
<h4 v-if="showTitle" class="personnel-view-card-title">
{{ person.personnelName || '—' }}
<el-tag :type="getRoleTagType(person.role)" size="small">
{{ getRoleLabel(person.role) }}
</el-tag>
<el-tag v-if="person.role === 'driver' && person.isDefault === 1" type="success" size="small">
默认司机
</el-tag>
</h4>
<dl class="personnel-view-kv-grid">
<div class="personnel-view-kv">
<dt>手机号</dt>
<dd>{{ person.phoneNo || '—' }}</dd>
</div>
<div class="personnel-view-kv">
<dt>年龄</dt>
<dd>{{ person.age ?? '—' }}</dd>
</div>
<div class="personnel-view-kv personnel-view-kv-span">
<dt>备注</dt>
<dd>{{ person.remark || '—' }}</dd>
</div>
</dl>
<div class="personnel-certs-block">
<p class="personnel-certs-title">人员证书</p>
<PlCertViewList :cert-list="person.certList || []" />
</div>
</div>
</template>
<script setup lang="ts">
import PlCertViewList from '@/components/business/pl-cert-view-list/index.vue'
import type { CertRead, VehiclePersonnelRow } from '@/api/pl'
type VehiclePersonnelViewRow = VehiclePersonnelRow & { certList?: CertRead[] }
withDefaults(
defineProps<{
person: VehiclePersonnelViewRow
showTitle?: boolean
}>(),
{
showTitle: false
}
)
function getRoleLabel(role: string) {
const map: Record<string, string> = {
driver: '司机',
escort: '押运员'
}
return map[role] || role
}
function getRoleTagType(role: string) {
return role === 'driver' ? 'primary' : 'warning'
}
</script>
<style scoped lang="scss">
.personnel-view-card-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 0 0 12px;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
border-left: 3px solid #10b981;
line-height: 1.4;
}
.personnel-view-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 20px;
margin: 0 0 12px;
}
.personnel-view-kv {
min-width: 0;
}
.personnel-view-kv-span {
grid-column: 1 / -1;
}
.personnel-certs-block {
padding-top: 12px;
border-top: 1px solid #e2e8f0;
}
.personnel-certs-title {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
@media (max-width: 640px) {
.personnel-view-kv-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,122 @@
<template>
<div class="personnel-view-section">
<p v-if="!personnelList.length" class="view-empty-hint">暂无关联人员</p>
<el-tabs
v-else-if="layout === 'tabs'"
v-model="activeTab"
class="personnel-view-tabs"
>
<el-tab-pane
v-for="(person, index) in personnelList"
:key="getPersonKey(person, index)"
:label="getTabLabel(person)"
:name="getPersonKey(person, index)"
>
<PersonnelViewPanel :person="person" />
</el-tab-pane>
</el-tabs>
<div v-else class="personnel-view-list">
<div
v-for="(person, index) in personnelList"
:key="getPersonKey(person, index)"
class="personnel-view-card"
>
<PersonnelViewPanel :person="person" show-title />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import PersonnelViewPanel from './PersonnelViewPanel.vue'
import type { CertRead, VehiclePersonnelRow } from '@/api/pl'
export interface VehiclePersonnelViewRow extends VehiclePersonnelRow {
certList?: CertRead[]
}
const props = withDefaults(
defineProps<{
personnelList?: VehiclePersonnelViewRow[]
layout?: 'list' | 'tabs'
}>(),
{
personnelList: () => [],
layout: 'list'
}
)
const activeTab = ref('')
function getPersonKey(person: VehiclePersonnelViewRow, index: number) {
return person.personnelId || `${person.personnelName}-${index}`
}
function getRoleLabel(role: string) {
const map: Record<string, string> = {
driver: '司机',
escort: '押运员'
}
return map[role] || role
}
function getTabLabel(person: VehiclePersonnelViewRow) {
const name = person.personnelName || '—'
const role = getRoleLabel(person.role)
return `${name}(${role})`
}
watch(
() => props.personnelList,
list => {
if (props.layout !== 'tabs' || !list.length) {
activeTab.value = ''
return
}
const firstKey = getPersonKey(list[0], 0)
if (!list.some((person, index) => getPersonKey(person, index) === activeTab.value)) {
activeTab.value = firstKey
}
},
{ immediate: true, deep: true }
)
</script>
<style scoped lang="scss">
.personnel-view-section {
width: 100%;
}
.view-empty-hint {
margin: 0;
font-size: 13px;
color: #1e293b;
line-height: 1.5;
}
.personnel-view-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.personnel-view-card {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
padding: 14px 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.personnel-view-tabs {
:deep(.el-tabs__header) {
margin-bottom: 12px;
}
:deep(.el-tabs__content) {
padding-top: 4px;
}
}
</style>
@@ -0,0 +1,199 @@
<template>
<div class="vehicle-view-panel" :class="{ 'is-collapsible': collapsible }">
<button
v-if="collapsible"
type="button"
class="vehicle-view-collapse-trigger"
:aria-expanded="expanded"
@click="expanded = !expanded"
>
<span class="vehicle-view-collapse-title">车辆信息</span>
<span v-if="!expanded && detail.licensePlate" class="vehicle-view-collapse-summary">
{{ detail.licensePlate }}
</span>
<el-icon class="vehicle-view-collapse-icon" :class="{ 'is-expanded': expanded }">
<ArrowDown />
</el-icon>
</button>
<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>
<dl class="vehicle-view-kv-grid">
<div class="vehicle-view-kv">
<dt>车牌号</dt>
<dd>{{ detail.licensePlate || '—' }}</dd>
</div>
<div class="vehicle-view-kv">
<dt>车辆类型</dt>
<dd>
<el-tag :type="getPlVehicleTypeTagType(detail.vehicleType)" size="small">
{{ getPlVehicleTypeLabel(detail.vehicleType) }}
</el-tag>
</dd>
</div>
<div class="vehicle-view-kv">
<dt>承运商</dt>
<dd>{{ detail.carrierName || '—' }}</dd>
</div>
<div class="vehicle-view-kv">
<dt>载重(吨)</dt>
<dd>{{ detail.loadCapacity ?? '—' }}</dd>
</div>
<div class="vehicle-view-kv">
<dt>车辆状态</dt>
<dd>
<el-tag :type="getPlVehicleStatusTagType(detail.vehicleStatus)" size="small">
{{ getPlVehicleStatusLabel(detail.vehicleStatus) }}
</el-tag>
</dd>
</div>
<div class="vehicle-view-kv">
<dt>关联人员摘要</dt>
<dd>{{ detail.personnelSummary || '—' }}</dd>
</div>
<div class="vehicle-view-kv vehicle-view-kv-span">
<dt>备注</dt>
<dd>{{ detail.remark || '—' }}</dd>
</div>
<div class="vehicle-view-kv">
<dt>创建时间</dt>
<dd>{{ formatDateTime(detail.createTime) }}</dd>
</div>
</dl>
</section>
<section class="vehicle-view-section">
<PlVehicleCertPersonnelTabs
:cert-list="detail.certList || []"
:personnel-list="personnelList"
/>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ArrowDown } from '@element-plus/icons-vue'
import PlVehicleCertPersonnelTabs from '@/components/business/pl-vehicle-cert-personnel-tabs/index.vue'
import type { VehiclePersonnelViewRow } from '@/components/business/pl-vehicle-personnel-view-list/index.vue'
import type { VehicleDetail } from '@/api/pl'
import { formatDateTime } from '@/utils/format'
import {
getPlVehicleStatusLabel,
getPlVehicleStatusTagType,
getPlVehicleTypeLabel,
getPlVehicleTypeTagType
} from '@/utils/pl-display'
const props = withDefaults(
defineProps<{
detail: VehicleDetail
personnelList?: VehiclePersonnelViewRow[]
collapsible?: boolean
defaultExpanded?: boolean
}>(),
{
personnelList: () => [],
collapsible: false,
defaultExpanded: false
}
)
const expanded = ref(props.defaultExpanded)
watch(
() => props.defaultExpanded,
value => {
expanded.value = value
}
)
</script>
<style scoped lang="scss">
.vehicle-view-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.vehicle-view-collapse-trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
cursor: pointer;
text-align: left;
&:hover {
background: #f1f5f9;
}
}
.vehicle-view-collapse-title {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
.vehicle-view-collapse-summary {
font-size: 13px;
color: #1e293b;
}
.vehicle-view-collapse-icon {
margin-left: auto;
color: #1e293b;
transition: transform 0.2s ease;
&.is-expanded {
transform: rotate(180deg);
}
}
.vehicle-view-panel-body {
display: flex;
flex-direction: column;
gap: 16px;
}
.vehicle-view-section-title {
margin: 0 0 12px;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
border-left: 3px solid #10b981;
line-height: 1;
}
.vehicle-view-kv-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px 20px;
}
.vehicle-view-kv {
min-width: 0;
}
.vehicle-view-kv-span {
grid-column: 1 / -1;
}
@media (max-width: 1200px) {
.vehicle-view-kv-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.vehicle-view-kv-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,128 @@
<template>
<dl class="view-kv-grid" :class="`view-kv-grid--cols-${columns}`">
<div
v-for="field in fields"
:key="field.label"
class="view-kv"
:class="spanClass(field)"
>
<dt>{{ field.label }}</dt>
<dd>
<el-tag v-if="field.tagType" :type="field.tagType" size="small">{{
formatViewValue(field.value)
}}</el-tag>
<ul v-else-if="field.items?.length" class="view-plain-list">
<li v-for="(item, idx) in field.items" :key="idx">{{ item }}</li>
</ul>
<template v-else>
<span :class="{ 'money-display': field.money }">{{
formatViewValue(field.value)
}}</span>
</template>
</dd>
</div>
</dl>
</template>
<script setup lang="ts">
import type { ViewField } from './types'
import { formatViewValue } from './types'
import { getViewKvSpanClass } from '@/utils/view-field-layout'
const props = defineProps<{
fields: ViewField[]
columns: number
}>()
function spanClass(field: ViewField) {
return getViewKvSpanClass(field, props.columns, formatViewValue)
}
</script>
<style scoped lang="scss">
.view-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 18px;
&--cols-1 {
grid-template-columns: 1fr;
}
&--cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
&--cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.view-kv {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
font-size: 13px;
align-items: stretch;
dt {
margin: 0;
font-size: 11px;
font-weight: 400;
color: #000;
line-height: 1.4;
}
dd {
margin: 0;
font-weight: 600;
color: #000;
word-break: break-word;
line-height: 1.5;
}
}
.view-kv-span-2 {
grid-column: span 2;
}
.view-kv-span-full {
grid-column: 1 / -1;
}
.view-plain-list {
margin: 0;
padding-left: 1.15em;
list-style: disc;
li + li {
margin-top: 4px;
}
}
@media (max-width: 1100px) {
.view-kv-grid--cols-4 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.view-kv-grid--cols-3,
.view-kv-grid--cols-4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.view-kv-grid,
.view-kv-grid--cols-3,
.view-kv-grid--cols-4 {
grid-template-columns: 1fr;
}
.view-kv-span-2 {
grid-column: 1 / -1;
}
}
</style>
@@ -0,0 +1,206 @@
<template>
<div class="view-kv-table-wrap">
<table class="view-kv-table" :class="`view-kv-table--${mode}`">
<tbody>
<template v-if="mode === 'pair'">
<tr
v-for="field in fields"
:key="field.label"
:class="{ 'row-long': isLong(field) }"
>
<th>{{ field.label }}</th>
<td>
<ViewKvTableCell :field="field" />
</td>
</tr>
</template>
<template v-else-if="mode === 'dual'">
<tr
v-for="(row, idx) in dualRows"
:key="dualRowKey(row, idx)"
:class="{ 'row-long': row.type === 'long' }"
>
<template v-if="row.type === 'long'">
<th>{{ row.field.label }}</th>
<td colspan="3">
<ViewKvTableCell :field="row.field" />
</td>
</template>
<template v-else>
<th>{{ row.left.label }}</th>
<td>
<ViewKvTableCell :field="row.left" />
</td>
<template v-if="row.right">
<th>{{ row.right.label }}</th>
<td>
<ViewKvTableCell :field="row.right" />
</td>
</template>
<template v-else>
<th></th>
<td></td>
</template>
</template>
</tr>
</template>
<template v-else>
<tr
v-for="(row, idx) in tripleRows"
:key="tripleRowKey(row, idx)"
:class="{ 'row-long': row.type === 'long' }"
>
<template v-if="row.type === 'long'">
<th>{{ row.field.label }}</th>
<td colspan="5">
<ViewKvTableCell :field="row.field" />
</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>
</template>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ViewField } from './types'
import { formatViewValue } from './types'
import ViewKvTableCell from './ViewKvTableCell.vue'
import {
buildViewKvTableDualRows,
buildViewKvTableTripleRows,
getViewFieldDisplayText,
isViewFieldFullSpan,
isViewFieldLong,
type ViewKvTableRow,
type ViewKvTableTripleRow
} from '@/utils/view-field-layout'
const props = withDefaults(
defineProps<{
fields: ViewField[]
/** pair:一行一组;dual:一行两组(四列);triple:一行三组(六列) */
mode?: 'pair' | 'dual' | 'triple'
}>(),
{
mode: 'pair'
}
)
const dualRows = computed(() => buildViewKvTableDualRows(props.fields, formatViewValue))
const tripleRows = computed(() => buildViewKvTableTripleRows(props.fields, formatViewValue))
function isLong(field: ViewField) {
const text = getViewFieldDisplayText(field, formatViewValue)
return isViewFieldLong(field, text) || isViewFieldFullSpan(field, text)
}
function dualRowKey(row: ViewKvTableRow, idx: number) {
if (row.type === 'long') return row.field.label
return `${row.left.label}-${row.right?.label ?? idx}`
}
function tripleRowKey(row: ViewKvTableTripleRow, 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">
.view-kv-table-wrap {
border: 1px solid var(--border, #d9e8e2);
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.view-kv-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
table-layout: fixed;
th {
padding: 10px 12px;
background: var(--table-head, #f8fafc);
color: var(--text-muted, #64748b);
font-size: 11px;
font-weight: 500;
text-align: left;
vertical-align: top;
border-bottom: 1px solid #eef2f6;
border-right: 1px solid #eef2f6;
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;
line-height: 1.4;
}
td {
padding: 10px 14px;
font-weight: 600;
color: var(--text, #1e293b);
border-bottom: 1px solid #eef2f6;
vertical-align: top;
word-break: break-word;
line-height: 1.45;
}
tr:last-child th,
tr:last-child td {
border-bottom: none;
}
}
.view-kv-table--pair th {
width: 128px;
}
.view-kv-table--dual th {
width: 12%;
}
.view-kv-table--dual td {
width: 38%;
}
.view-kv-table--dual tr.row-long th {
width: 12%;
}
.view-kv-table--dual tr.row-long td {
width: auto;
}
.view-kv-table--triple th {
width: 10%;
}
.view-kv-table--triple td {
width: 23.33%;
}
.view-kv-table--triple tr.row-long th {
width: 10%;
}
.view-kv-table--triple tr.row-long td {
width: auto;
}
</style>
@@ -0,0 +1,42 @@
<template>
<el-tag v-if="field.tagType" :type="field.tagType" size="small">{{
formatViewValue(field.value)
}}</el-tag>
<ul v-else-if="field.items?.length" class="view-plain-list">
<li v-for="(item, idx) in field.items" :key="idx">{{ item }}</li>
</ul>
<span v-else :class="{ 'money-display': field.money, 'muted-display': isMuted }">{{
formatViewValue(field.value)
}}</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ViewField } from './types'
import { formatViewValue } from './types'
const props = defineProps<{
field: ViewField
}>()
const isMuted = computed(
() => !props.field.money && formatViewValue(props.field.value) === '—'
)
</script>
<style scoped lang="scss">
.view-plain-list {
margin: 0;
padding-left: 1.15em;
list-style: disc;
li + li {
margin-top: 4px;
}
}
.muted-display {
color: var(--text-muted, #64748b);
font-weight: 600;
}
</style>
@@ -52,63 +52,19 @@
>
{{ group.title }}
</div>
<dl
<ViewKvGrid
v-if="group.fields.length"
class="view-kv-grid"
:class="`view-kv-grid--cols-${splitGridColumns}`"
>
<div
v-for="field in group.fields"
:key="field.label"
class="view-kv"
:class="{ 'view-kv-span-2': field.span }"
>
<dt>{{ field.label }}</dt>
<dd>
<el-tag v-if="field.tagType" :type="field.tagType" size="small">{{
formatViewValue(field.value)
}}</el-tag>
<ul v-else-if="field.items?.length" class="view-plain-list">
<li v-for="(item, idx) in field.items" :key="idx">{{ item }}</li>
</ul>
<template v-else
><span :class="{ 'money-display': field.money }">{{
formatViewValue(field.value)
}}</span></template
>
</dd>
</div>
</dl>
:fields="group.fields"
:columns="splitGridColumns"
/>
<p v-else-if="group.title" class="view-empty-hint">{{ group.emptyHint || '—' }}</p>
</div>
</template>
<dl
<ViewKvGrid
v-else-if="section.fields.length"
class="view-kv-grid"
:class="`view-kv-grid--cols-${splitGridColumns}`"
>
<div
v-for="field in section.fields"
:key="field.label"
class="view-kv"
:class="{ 'view-kv-span-2': field.span }"
>
<dt>{{ field.label }}</dt>
<dd>
<el-tag v-if="field.tagType" :type="field.tagType" size="small">{{
formatViewValue(field.value)
}}</el-tag>
<ul v-else-if="field.items?.length" class="view-plain-list">
<li v-for="(item, idx) in field.items" :key="idx">{{ item }}</li>
</ul>
<template v-else
><span :class="{ 'money-display': field.money }">{{
formatViewValue(field.value)
}}</span></template
>
</dd>
</div>
</dl>
:fields="section.fields"
:columns="splitGridColumns"
/>
<div
v-if="section.slotTitle && section.slotTitleStyle === 'field'"
class="view-field-label"
@@ -162,63 +118,19 @@
>
{{ group.title }}
</div>
<dl
<ViewKvGrid
v-if="group.fields.length"
class="view-kv-grid"
:class="`view-kv-grid--cols-${gridColumns}`"
>
<div
v-for="field in group.fields"
:key="field.label"
class="view-kv"
:class="{ 'view-kv-span-2': field.span }"
>
<dt>{{ field.label }}</dt>
<dd>
<el-tag v-if="field.tagType" :type="field.tagType" size="small">{{
formatViewValue(field.value)
}}</el-tag>
<ul v-else-if="field.items?.length" class="view-plain-list">
<li v-for="(item, idx) in field.items" :key="idx">{{ item }}</li>
</ul>
<template v-else
><span :class="{ 'money-display': field.money }">{{
formatViewValue(field.value)
}}</span></template
>
</dd>
</div>
</dl>
:fields="group.fields"
:columns="effectiveGridColumns"
/>
<p v-else-if="group.title" class="view-empty-hint">{{ group.emptyHint || '—' }}</p>
</div>
</template>
<dl
<ViewKvGrid
v-else-if="section.fields.length"
class="view-kv-grid"
:class="`view-kv-grid--cols-${gridColumns}`"
>
<div
v-for="field in section.fields"
:key="field.label"
class="view-kv"
:class="{ 'view-kv-span-2': field.span }"
>
<dt>{{ field.label }}</dt>
<dd>
<el-tag v-if="field.tagType" :type="field.tagType" size="small">{{
formatViewValue(field.value)
}}</el-tag>
<ul v-else-if="field.items?.length" class="view-plain-list">
<li v-for="(item, idx) in field.items" :key="idx">{{ item }}</li>
</ul>
<template v-else
><span :class="{ 'money-display': field.money }">{{
formatViewValue(field.value)
}}</span></template
>
</dd>
</div>
</dl>
:fields="section.fields"
:columns="effectiveGridColumns"
/>
<div
v-if="section.slotTitle && section.slotTitleStyle === 'field'"
class="view-field-label"
@@ -245,54 +157,17 @@
<script setup lang="ts">
import { computed, useSlots, type Component } from 'vue'
import { Close, Document } from '@element-plus/icons-vue'
import ViewKvGrid from './ViewKvGrid.vue'
import {
isIconComponent,
type TagType,
type ViewField,
type ViewFieldGroup,
type ViewGridColumns,
type ViewSection
} from './types'
function isIconComponent(val: unknown): val is Component {
return val != null && (typeof val === 'object' || typeof val === 'function')
}
/** 查看弹窗字段展示:null/undefined/空字符串 → —;布尔转是/否 */
function formatViewValue(val: unknown): string {
if (val === null || val === undefined || val === '') return '—'
if (typeof val === 'boolean') return val ? '是' : '否'
return String(val)
}
export type TagType = 'success' | 'warning' | 'danger' | 'info' | '' | 'primary'
export interface ViewField {
label: string
value?: string | number | boolean | null | undefined
/** 无编号列表(ul),与 value / tagType 二选一 */
items?: string[]
span?: boolean
/** 传入后以 el-tag 渲染,值为 el-tag 的 type 属性 */
tagType?: TagType
/** 价格/单价/金额类只读数值,套用全局 .money-display 红色样式 */
money?: boolean
}
export interface ViewFieldGroup {
/** 分组小标题;field=与 KV 字段 label 一致;section=绿条区块标题 */
title?: string
titleStyle?: 'section' | 'field'
fields: ViewField[]
/** fields 为空且存在 title 时的占位文案 */
emptyHint?: string
}
export interface ViewSection {
title: string
fields: ViewField[]
/** 同区块内多组 KV(如任务信息 + 检测参数),与 fields 二选一 */
fieldGroups?: ViewFieldGroup[]
/** section=区块绿条标题;field=与 KV 字段 label(dt)一致 */
titleStyle?: 'section' | 'field'
/** 插槽上方标题(可与 fields 同区块,避免额外分割线) */
slotTitle?: string
slotTitleStyle?: 'section' | 'field'
/** 区块内追加内容(如附件列表),对应具名插槽名 */
slot?: string
}
export type { TagType, ViewField, ViewFieldGroup, ViewSection }
const props = withDefaults(
defineProps<{
@@ -305,10 +180,10 @@
/** 与 code 搭配的标签文案,默认「编码」 */
codeLabel?: string
sections: ViewSection[]
/** 弹窗宽度,默认 720px */
/** 弹窗宽度,默认 1040px(适配 4 列 KV) */
width?: string
/** 字段栅格列数,默认 2 */
gridColumns?: 2 | 3 | 4
/** 字段栅格列数,默认 4(短值 4 个/行,长值 2 个/行) */
gridColumns?: ViewGridColumns
/** 挂到 body,用于嵌套在其它弹窗内时叠层展示 */
appendToBody?: boolean
/** 遮罩类名(可配合全局样式提高 z-index) */
@@ -322,11 +197,11 @@
/** default 单列滚动;split 左右分栏(需 split-right 插槽) */
layout?: 'default' | 'split'
/** split 布局时左侧 KV 栅格列数,默认 1 */
splitGridColumns?: 1 | 2
splitGridColumns?: ViewGridColumns
}>(),
{
width: '720px',
gridColumns: 2,
width: '1040px',
gridColumns: 4,
appendToBody: false,
modalClass: '',
dialogClass: '',
@@ -343,6 +218,11 @@
() => props.layout === 'split' || Boolean(slots['split-right'])
)
/** 紧凑模式降为 2 列,避免 4 列过密 */
const effectiveGridColumns = computed<ViewGridColumns>(() =>
props.compact ? 2 : props.gridColumns
)
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
@@ -431,8 +311,8 @@
padding: 16px 20px;
}
.view-split-left .view-kv {
grid-template-columns: 84px 1fr;
.view-split-left :deep(.view-kv) {
gap: 4px;
}
@media (max-width: 1100px) {
@@ -500,7 +380,7 @@
.view-hero-meta {
margin: 0;
font-size: 13px;
color: #64748b;
color: #000;
line-height: 1.5;
}
@@ -523,64 +403,12 @@
}
}
.view-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 20px;
&--cols-1 {
grid-template-columns: 1fr;
}
&--cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
&--cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.view-kv {
display: grid;
grid-template-columns: 96px 1fr;
gap: 8px 12px;
font-size: 13px;
align-items: start;
dt {
margin: 0;
color: #64748b;
font-weight: 500;
}
dd {
margin: 0;
color: #1e293b;
word-break: break-word;
}
}
.view-kv-span-2 {
grid-column: 1 / -1;
}
.view-plain-list {
margin: 0;
padding-left: 1.15em;
list-style: disc;
li + li {
margin-top: 4px;
}
}
.view-field-label {
margin: 0 0 8px;
font-size: 13px;
font-weight: 500;
color: #64748b;
line-height: 1.5;
font-size: 11px;
font-weight: 400;
color: #000;
line-height: 1.4;
}
.view-section-slot {
@@ -592,12 +420,12 @@
}
.view-field-label + .view-section-slot,
.view-kv-grid + .view-field-label,
:deep(.view-kv-grid) + .view-field-label,
.view-field-group + .view-field-label {
margin-top: 0;
}
.view-kv-grid + .view-field-label {
:deep(.view-kv-grid) + .view-field-label {
margin-top: 12px;
}
@@ -608,36 +436,10 @@
.view-field-group .view-empty-hint {
margin: 0;
font-size: 13px;
color: #64748b;
color: #000;
line-height: 1.5;
}
@media (max-width: 1100px) {
.view-kv-grid--cols-4 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.view-kv-grid--cols-3,
.view-kv-grid--cols-4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.view-kv-grid,
.view-kv-grid--cols-3,
.view-kv-grid--cols-4 {
grid-template-columns: 1fr;
}
.view-kv {
grid-template-columns: 1fr;
gap: 4px;
}
}
.system-view-dialog--compact {
.dialog-scroll {
max-height: min(68vh, 560px);
@@ -676,13 +478,12 @@
}
}
.view-kv-grid {
:deep(.view-kv-grid) {
gap: 8px 16px;
}
.view-kv {
:deep(.view-kv) {
font-size: 12px;
grid-template-columns: 84px 1fr;
}
.view-field-label {
@@ -740,7 +541,7 @@
.view-section-slot .view-subtable-wrap .el-table.view-subtable {
--el-table-header-bg-color: #f8fafc;
--el-table-header-text-color: #64748b;
--el-table-header-text-color: #1e293b;
--el-table-border-color: #e2e8f0;
width: 100%;
@@ -752,7 +553,7 @@
.el-table__header th.el-table__cell .cell {
font-weight: 500;
color: #64748b;
color: #1e293b;
}
.el-table__body td.el-table__cell .cell {
@@ -763,7 +564,7 @@
.view-section-slot .view-empty-hint {
margin: 0;
font-size: 13px;
color: #64748b;
color: #1e293b;
line-height: 1.5;
}
}
@@ -0,0 +1,50 @@
import type { Component } from 'vue'
export type TagType = 'success' | 'warning' | 'danger' | 'info' | '' | 'primary'
export interface ViewField {
label: string
value?: string | number | boolean | null | undefined
/** 无编号列表(ul),与 value / tagType 二选一 */
items?: string[]
/**
* 跨列:true=长值(4 列下占 2 格,一行 2 个);
* 'full'=占满整行(超长备注等)
*/
span?: boolean | 'full'
/** 显式长值,等同 span:true */
long?: boolean
/** 显式占满整行,等同 span:'full' */
fullSpan?: boolean
tagType?: TagType
money?: boolean
}
export interface ViewFieldGroup {
title?: string
titleStyle?: 'section' | 'field'
fields: ViewField[]
emptyHint?: string
}
export interface ViewSection {
title: string
fields: ViewField[]
fieldGroups?: ViewFieldGroup[]
titleStyle?: 'section' | 'field'
slotTitle?: string
slotTitleStyle?: 'section' | 'field'
slot?: string
}
export type ViewGridColumns = 1 | 2 | 3 | 4
export function isIconComponent(val: unknown): val is Component {
return val != null && (typeof val === 'object' || typeof val === 'function')
}
export function formatViewValue(val: unknown): string {
if (val === null || val === undefined || val === '') return '—'
if (typeof val === 'boolean') return val ? '是' : '否'
return String(val)
}
@@ -139,7 +139,7 @@ function connectorClass(index: number) {
justify-content: center;
border: 2px solid #e2e8f0;
background: #f8fafc;
color: #94a3b8;
color: #1e293b;
}
.approval-flow-circle.is-approved {
@@ -135,7 +135,7 @@ const sortedItems = computed(() =>
justify-content: center;
border: 2px solid #e2e8f0;
background: #f8fafc;
color: #94a3b8;
color: #1e293b;
}
.approval-history-circle.is-approved {
@@ -0,0 +1,19 @@
<template>
<ViewKvGrid :fields="fields" :columns="columns" />
</template>
<script setup lang="ts">
import ViewKvGrid from '@/components/business/system-view-dialog/ViewKvGrid.vue'
import type { ViewField, ViewGridColumns } from '@/components/business/system-view-dialog/types'
withDefaults(
defineProps<{
fields: ViewField[]
/** 默认 4 列:短值 4 个/行,长值 2 个/行 */
columns?: ViewGridColumns
}>(),
{
columns: 4
}
)
</script>
@@ -0,0 +1,23 @@
<template>
<ViewKvTable :fields="fields" :mode="mode" />
</template>
<script setup lang="ts">
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
withDefaults(
defineProps<{
fields: ViewField[]
/**
* pair:标准两列表格;
* dual:双组四列表格;
* triple:三组六列表格(一行 3 组 label|value)
*/
mode?: 'pair' | 'dual' | 'triple'
}>(),
{
mode: 'pair'
}
)
</script>
@@ -353,7 +353,7 @@
i {
padding-left: 0;
font-size: 14px;
color: #999;
color: #1e293b;
}
.el-icon-circle-check {
@@ -10,19 +10,19 @@
<div
:class="
isClickable(item, index)
? 'c-p py-1 rounded tad-200 hover:bg-active-color hover:[&_span]:text-g-600'
? 'c-p py-1 rounded tad-200 hover:bg-active-color hover:[&_span]:text-[var(--mint-text)]'
: ''
"
@click="handleBreadcrumbClick(item, index)"
>
<span
class="block max-w-46 overflow-hidden text-ellipsis whitespace-nowrap px-1.5 text-sm text-g-600 dark:text-g-800"
class="block max-w-46 overflow-hidden text-ellipsis whitespace-nowrap px-1.5 text-sm text-[var(--mint-text)]"
>{{ formatMenuTitle(item.meta?.title as string) }}</span
>
</div>
<div
v-if="!isLastItem(index) && item.meta?.title"
class="mx-1 text-sm not-italic text-g-500"
class="mx-1 text-sm not-italic text-[var(--mint-text)]"
aria-hidden="true"
>
/
@@ -2,8 +2,8 @@
<template>
<div
class="layout-sidebar"
v-if="showLeftMenu || isDualMenu"
:class="{ 'no-border': menuList.length === 0 }"
:class="{ 'no-border': menuList.length === 0, 'layout-sidebar-portal': isPortalMenu }"
v-if="showLeftMenu || isDualMenu || isPortalMenu"
>
<!-- 双列菜单(左侧) -->
<div
@@ -36,16 +36,8 @@
}"
>
<ArtSvgIcon
v-if="menu.meta.icon"
class="menu-icon text-g-700 dark:text-g-800"
:icon="menu.meta.icon"
:style="{
marginBottom: dualMenuShowText ? '5px' : '0'
}"
/>
<div
v-else
class="dual-menu-icon-placeholder"
:icon="resolveSidebarMenuIcon(menu, Boolean(menu.children?.length))"
:style="{
marginBottom: dualMenuShowText ? '5px' : '0'
}"
@@ -70,8 +62,9 @@
:class="`menu-left-${getMenuTheme.theme} menu-left-${!menuOpen ? 'close' : 'open'}`"
:style="{ background: getMenuTheme.background }"
>
<!-- Logo、系统名称 -->
<!-- Logo、系统名称(门户布局顶栏已展示,侧栏隐藏) -->
<div
v-if="!isPortalMenu"
class="header"
@click="navigateToHome"
:style="{
@@ -109,6 +102,7 @@
<SidebarSubmenu
:list="menuList"
:isMobile="isMobileMode"
:level="isPortalMenu ? 1 : 0"
:theme="getMenuTheme"
@close="handleMenuClose"
/>
@@ -141,6 +135,7 @@
import { isIframe } from '@/utils/navigation'
import { handleMenuJump } from '@/utils/navigation'
import { filterSidebarRoutes } from '@/utils/router/sidebar-menu-filter'
import { resolveSidebarMenuIcon } from '@/utils/router/menu-icon'
import SidebarSubmenu from './widget/SidebarSubmenu.vue'
import { useCommon } from '@/hooks/core/useCommon'
import { useWindowSize, useTimeoutFn } from '@vueuse/core'
@@ -183,8 +178,12 @@
// 菜单类型判断
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT)
const isPortalMenu = computed(() => menuType.value === MenuTypeEnum.PORTAL)
const showLeftMenu = computed(
() => menuType.value === MenuTypeEnum.LEFT || menuType.value === MenuTypeEnum.TOP_LEFT
() =>
menuType.value === MenuTypeEnum.LEFT ||
menuType.value === MenuTypeEnum.TOP_LEFT ||
menuType.value === MenuTypeEnum.PORTAL
)
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
@@ -204,8 +203,8 @@
const menuStore = useMenuStore()
const allMenus = menuStore.menuList
// 如果不是顶部左侧菜单或双列菜单,直接返回完整菜单列表
if (!isTopLeftMenu.value && !isDualMenu.value) {
// 顶部混合菜单、门户布局或双列菜单:仅展示当前一级模块下的子菜单
if (!isTopLeftMenu.value && !isDualMenu.value && !isPortalMenu.value) {
return allMenus
}
@@ -284,6 +283,14 @@
// 双列菜单收起时的滚动条样式
const scrollbarStyle = computed(() => {
if (isPortalMenu.value) {
return {
height: '100%',
transform: 'translateY(0)',
transition: 'transform 0.3s ease'
}
}
const isCollapsed = isDualMenu.value && !menuOpen.value
return {
transform: isCollapsed ? 'translateY(-50px)' : 'translateY(0)',
@@ -377,6 +384,12 @@
* 监听窗口尺寸变化,自动处理移动端菜单
*/
watch(width, (newWidth) => {
if (isPortalMenu.value) {
settingStore.setMenuOpen(true)
showMobileModal.value = false
return
}
if (newWidth < MOBILE_BREAKPOINT) {
settingStore.setMenuOpen(false)
if (!menuOpen.value) {
@@ -1,4 +1,4 @@
.layout-sidebar {
.layout-sidebar {
display: flex;
height: 100vh;
user-select: none;
@@ -6,6 +6,10 @@
border-right: none;
background: var(--mint-surface, #fff);
&.layout-sidebar-portal {
height: 100%;
}
&.no-border {
border-right: none !important;
}
@@ -158,8 +158,8 @@ $popup-menu-radius: 6px;
flex-shrink: 0;
font-size: $menu-font-size !important;
font-weight: 500;
// Fresh Mint:默认菜单项为中性色(demo `.nav a`)
color: var(--mint-text-muted, #64748b);
// Fresh Mint:菜单文字使用主文本色
color: var(--mint-text, #1e293b);
text-rendering: geometricprecision;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -170,7 +170,7 @@ $popup-menu-radius: 6px;
.el-sub-menu > .el-sub-menu__title {
font-size: 12px !important;
font-weight: 700;
color: var(--mint-text-muted, #64748b) !important;
color: var(--mint-text, #1e293b) !important;
letter-spacing: 0.02em;
}
@@ -223,7 +223,7 @@ $popup-menu-radius: 6px;
}
.el-sub-menu__icon-arrow {
color: var(--mint-text-muted, #64748b);
color: var(--mint-text);
}
}
@@ -265,7 +265,7 @@ $popup-menu-radius: 6px;
@include menu-hover($hover-bg-color);
.el-sub-menu__icon-arrow {
color: var(--mint-text-muted, #64748b);
color: var(--mint-text);
}
}
}
@@ -2,17 +2,12 @@
<template v-for="(item, index) in filteredMenuItems" :key="getUniqueKey(item, index)">
<ElSubMenu v-if="hasChildren(item)" :index="item.path || item.meta.title" :level="level">
<template #title>
<div
v-if="level === 0 && (item.meta.icon || item.meta.virtualMenuIcon)"
class="menu-icon flex-cc"
>
<div class="menu-icon flex-cc">
<ArtSvgIcon
v-if="item.meta.icon"
:icon="item.meta.icon"
:icon="resolveSidebarMenuIcon(item, true)"
:color="theme?.iconColor"
:style="{ color: theme.iconColor }"
/>
<span v-else class="menu-icon__virtual" aria-hidden="true" />
</div>
<span class="menu-name">
{{ formatMenuTitle(item.meta.title) }}
@@ -35,17 +30,12 @@
:level-item="level + 1"
@click="goPage(item)"
>
<div
v-if="level === 0 && (item.meta.icon || item.meta.virtualMenuIcon)"
class="menu-icon flex-cc"
>
<div class="menu-icon flex-cc">
<ArtSvgIcon
v-if="item.meta.icon"
:icon="item.meta.icon"
:icon="resolveSidebarMenuIcon(item, false)"
:color="theme?.iconColor"
:style="{ color: theme.iconColor }"
/>
<span v-else class="menu-icon__virtual" aria-hidden="true" />
</div>
<div
v-show="item.meta.showBadge && level === 0 && !menuOpen"
@@ -71,6 +61,7 @@
import type { AppRouteRecord } from '@/types/router'
import { formatMenuTitle } from '@/utils/router'
import { filterSidebarRoutes } from '@/utils/router/sidebar-menu-filter'
import { resolveSidebarMenuIcon } from '@/utils/router/menu-icon'
import { handleMenuJump } from '@/utils/navigation'
import { useSettingStore } from '@/store/modules/setting'
@@ -0,0 +1,133 @@
<!-- 一体化门户顶栏 -->
<template>
<header class="portal-header">
<div class="portal-header-brand" @click="toHome">
<ArtLogo class="portal-header-logo" />
<p class="portal-header-title">{{ AppConfig.systemInfo.name }}</p>
</div>
<ArtPortalTopNav class="portal-header-nav" />
<div class="portal-header-actions">
<ArtIconButton
v-if="shouldShowFullscreen"
icon="fullscreen"
:class="[!isFullscreen ? 'full-screen-btn' : 'exit-full-screen-btn']"
class="max-md:!hidden"
@click="toggleFullScreen"
/>
<ArtUserMenu />
</div>
</header>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useFullscreen } from '@vueuse/core'
import AppConfig from '@/config'
import { useCommon } from '@/hooks/core/useCommon'
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
import ArtUserMenu from '../art-header-bar/widget/ArtUserMenu.vue'
defineOptions({ name: 'ArtPortalHeader' })
const router = useRouter()
const { homePath } = useCommon()
const { shouldShowFullscreen } = useHeaderBar()
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
const toHome = (): void => {
router.push(homePath.value)
}
const toggleFullScreen = (): void => {
toggleFullscreen()
}
</script>
<style lang="scss" scoped>
.portal-header {
box-sizing: border-box;
display: flex;
gap: 16px;
align-items: center;
width: 100%;
height: 72px;
padding: 0 20px;
user-select: none;
background: var(--default-bg-color, #fff);
border-bottom: 1px solid var(--art-card-border, #d9e8e2);
}
.portal-header-brand {
display: flex;
flex-shrink: 0;
gap: 10px;
align-items: center;
cursor: pointer;
}
.portal-header-logo {
flex-shrink: 0;
}
.portal-header-title {
margin: 0;
font-size: 18px;
font-weight: 600;
line-height: 1.2;
color: var(--mint-text, #1e293b);
white-space: nowrap;
}
.portal-header-nav {
flex: 1;
min-width: 0;
margin-left: 24px;
}
.portal-header-actions {
display: flex;
flex-shrink: 0;
gap: 8px;
align-items: center;
}
</style>
<style lang="scss" scoped>
.full-screen-btn:hover :deep(.art-svg-icon) {
animation: portal-expand 0.6s forwards;
}
.exit-full-screen-btn:hover :deep(.art-svg-icon) {
animation: portal-shrink 0.6s forwards;
}
@keyframes portal-expand {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
@keyframes portal-shrink {
0% {
transform: scale(1);
}
50% {
transform: scale(0.9);
}
100% {
transform: scale(1);
}
}
</style>
@@ -0,0 +1,155 @@
<!-- 门户顶栏一级模块卡片 -->
<template>
<div class="portal-top-nav">
<ElScrollbar v-if="moduleList.length" wrap-class="portal-top-nav-scroll">
<div class="portal-top-nav-list">
<button
v-for="item in moduleList"
:key="item.path"
type="button"
class="portal-top-nav-card"
:class="{ 'is-active': item.isActive }"
@click="handleModuleClick(item)"
>
<ArtSvgIcon
v-if="item.meta.icon"
:icon="item.meta.icon"
class="portal-top-nav-card__icon"
/>
<span class="portal-top-nav-card__label">{{ item.formattedTitle }}</span>
<div v-if="item.meta.showBadge" class="art-badge art-badge-mixed" />
</button>
</div>
</ElScrollbar>
<p v-else class="portal-top-nav-empty">暂无可用模块</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { formatMenuTitle } from '@/utils/router'
import { useMenuStore } from '@/store/modules/menu'
import {
filterPortalTopModules,
getFirstAccessiblePath,
isPortalModuleActive
} from '@/utils/router/portal-menu'
import type { AppRouteRecord } from '@/types/router'
defineOptions({ name: 'ArtPortalTopNav' })
interface PortalModuleItem extends AppRouteRecord {
isActive: boolean
formattedTitle: string
}
const route = useRoute()
const router = useRouter()
const menuStore = useMenuStore()
const { menuList } = storeToRefs(menuStore)
const currentRoutePath = computed(() =>
String(route.meta.activePath || route.path)
)
const moduleList = computed<PortalModuleItem[]>(() => {
return filterPortalTopModules(menuList.value).map((item) => ({
...item,
isActive: isPortalModuleActive(item, currentRoutePath.value),
formattedTitle: formatMenuTitle(item.meta.title)
}))
})
const handleModuleClick = (item: PortalModuleItem): void => {
if (item.isActive) {
return
}
const target = getFirstAccessiblePath(item)
if (target && target !== route.path) {
router.push(target)
}
}
</script>
<style lang="scss" scoped>
.portal-top-nav {
flex: 1;
min-width: 0;
}
.portal-top-nav-empty {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--mint-text-muted, #64748b);
}
:deep(.portal-top-nav-scroll) {
flex: 1;
min-width: 0;
}
.portal-top-nav-list {
display: flex;
flex-wrap: nowrap;
gap: 10px;
align-items: center;
min-height: 56px;
padding: 4px 0;
}
.portal-top-nav-card {
position: relative;
display: inline-flex;
flex-direction: column;
flex-shrink: 0;
gap: 4px;
align-items: center;
justify-content: center;
min-width: 72px;
padding: 8px 12px;
cursor: pointer;
background: transparent;
border: none;
border-radius: 8px;
transition:
background-color 0.2s ease,
color 0.2s ease;
&__icon {
font-size: 22px;
color: var(--mint-text-muted, #64748b);
}
&__label {
max-width: 88px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
line-height: 1.3;
color: var(--mint-text, #1e293b);
white-space: nowrap;
}
&:hover:not(.is-active) {
background: var(--primary-bg, #ecfdf5);
.portal-top-nav-card__icon,
.portal-top-nav-card__label {
color: var(--primary-dark, #059669);
}
}
&.is-active {
background: var(--primary, #10b981);
.portal-top-nav-card__icon,
.portal-top-nav-card__label {
color: #fff;
}
}
}
</style>
@@ -19,7 +19,11 @@ export function useSettingsState() {
// 菜单布局切换
const switchMenuLayouts = (type: MenuTypeEnum) => {
if (type === MenuTypeEnum.LEFT || type === MenuTypeEnum.TOP_LEFT) {
if (
type === MenuTypeEnum.LEFT ||
type === MenuTypeEnum.TOP_LEFT ||
type === MenuTypeEnum.PORTAL
) {
settingStore.setMenuOpen(true)
}
settingStore.switchMenuLayouts(type)
@@ -34,7 +34,8 @@
left: 'MenuTypeEnum.LEFT',
top: 'MenuTypeEnum.TOP',
'top-left': 'MenuTypeEnum.TOP_LEFT',
'dual-menu': 'MenuTypeEnum.DUAL_MENU'
'dual-menu': 'MenuTypeEnum.DUAL_MENU',
portal: 'MenuTypeEnum.PORTAL'
},
systemTheme: {
auto: 'SystemThemeEnum.AUTO',