提交
This commit is contained in:
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>
|
||||
+111
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-14
@@ -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>
|
||||
+5
-1
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user