This commit is contained in:
zlwu committed 2026-08-06 09:58:20 +08:00
1 parent 2716c2b6f8
commit f3693be913
140 files changed
+799199 -11262

No files matched your search

+14 -1
View File
@@ -450,11 +450,13 @@ export interface Plan {
pickupLng?: number | null
pickupLat?: number | null
pickupTime?: string
pickupContact?: string
pickupNotes?: string
destinationAddress?: string
destinationLng?: number | null
destinationLat?: number | null
deliveryTime?: string
deliveryContact?: string
destinationNotes?: string
vehicleId?: string
licensePlate?: string
@@ -488,8 +490,13 @@ export interface PlanQuery {
archiveStatus?: string
customerName?: string
productName?: string
licensePlate?: string
planDateFrom?: string
planDateTo?: string
/** 1=仅含易制毒/易制爆产品的计划 */
hasHazardProduct?: number
/** incomplete=至少一张必需危化证书未上传 */
certUploadStatus?: string
page?: number
pageSize?: number
}
@@ -538,11 +545,13 @@ export interface PlanCreateFullPayload {
pickupLng?: number | null
pickupLat?: number | null
pickupTime?: string
pickupContact?: string
pickupNotes?: string
destinationAddress?: string
destinationLng?: number | null
destinationLat?: number | null
deliveryTime?: string
deliveryContact?: string
destinationNotes?: string
followUserId?: string
followUserName?: string
@@ -730,18 +739,20 @@ export interface Transport {
export interface TransportArrange {
vehicleId: string
driverId: string
driverId?: string
carrierId?: string
transportMedium?: string
pickupAddress?: string
pickupLng?: number | null
pickupLat?: number | null
pickupTime?: string
pickupContact?: string
pickupNotes?: string
destinationAddress?: string
destinationLng?: number | null
destinationLat?: number | null
deliveryTime?: string
deliveryContact?: string
destinationNotes?: string
distanceKm?: number
unit?: string
@@ -842,12 +853,14 @@ export interface PlIoOrder {
pickupLng?: number | null
pickupLat?: number | null
pickupTime?: string
pickupContact?: string
pickupNotes?: string
destinationName?: string
destinationAddress?: string
destinationLng?: number | null
destinationLat?: number | null
deliveryTime?: string
deliveryContact?: string
destinationNotes?: string
siteAccessToken?: string
siteUrl?: string
+3
View File
@@ -38,17 +38,20 @@ export interface PublicIoOrderPage {
pickupLng?: number | null
pickupLat?: number | null
pickupTime?: string
pickupContact?: string
pickupNotes?: string
destinationName?: string
destinationAddress?: string
destinationLng?: number | null
destinationLat?: number | null
deliveryTime?: string
deliveryContact?: string
destinationNotes?: string
siteFeedbackRemark?: string
siteFeedbackImages?: string[]
siteFeedbackTime?: string
voided: boolean
archived?: boolean
items: PublicIoOrderItem[]
}
+3
View File
@@ -12,18 +12,21 @@ export interface PublicTransportSitePage {
pickupLng?: number | null
pickupLat?: number | null
pickupTime?: string
pickupContact?: string
pickupNotes?: string
destinationName?: string
destinationAddress?: string
destinationLng?: number | null
destinationLat?: number | null
deliveryTime?: string
deliveryContact?: string
destinationNotes?: string
products: PublicTransportProduct[]
siteFeedbackRemark?: string
siteFeedbackImages: string[]
siteFeedbackTime?: string
voided: boolean
archived?: boolean
}
export const publicPlSiteApi = {
+3
View File
@@ -37,12 +37,14 @@ export interface PublicTransportPage {
pickupLng?: number | null
pickupLat?: number | null
pickupTime?: string
pickupContact?: string
pickupNotes?: string
destinationName?: string
destinationAddress?: string
destinationLng?: number | null
destinationLat?: number | null
deliveryTime?: string
deliveryContact?: string
destinationNotes?: string
licensePlate?: string
driverName?: string
@@ -54,6 +56,7 @@ export interface PublicTransportPage {
feedbackQty?: number | null
feedbackTime?: string
planVoided?: boolean
planArchived?: boolean
}
export interface PublicUploadResult {
@@ -31,6 +31,7 @@ const props = defineProps<{
modelValue: string
lng?: number | null
lat?: number | null
contact?: string
placeholder?: string
disabled?: boolean
}>()
@@ -39,6 +40,7 @@ const emit = defineEmits<{
'update:modelValue': [value: string]
'update:lng': [value: number | null]
'update:lat': [value: number | null]
'update:contact': [value: string]
}>()
const libraryVisible = ref(false)
@@ -58,10 +60,21 @@ const innerLat = computed({
set: (value: number | null) => emit('update:lat', value)
})
function formatAddressContact(row: TransportAddress): string {
const name = (row.contactName || '').trim()
const phone = (row.contactPhone || '').trim()
if (name && phone) return `${name} ${phone}`
return name || phone
}
function handleLibrarySelect(row: TransportAddress) {
innerAddress.value = row.fullAddress
innerLng.value = row.lng != null ? Number(row.lng) : null
innerLat.value = row.lat != null ? Number(row.lat) : null
const contactText = formatAddressContact(row)
if (contactText) {
emit('update:contact', contactText)
}
}
</script>
@@ -0,0 +1,278 @@
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="960px"
align-center
append-to-body
destroy-on-close
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
style="backdrop-filter: none"
:close-on-click-modal="false"
@close="handleDialogClose"
>
<el-form
ref="formRef"
v-loading="dialogLoading"
:model="formData"
:rules="formRules"
label-position="right"
label-width="100px"
class="pl-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="承运商名称" prop="carrierName">
<el-input v-model="formData.carrierName" placeholder="请输入承运商名称" maxlength="200" />
</el-form-item>
<el-form-item label="合作状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择">
<el-option label="合作中" value="1" />
<el-option label="已终止" value="0" />
</el-select>
</el-form-item>
<el-form-item label="联系人" prop="contactName">
<el-input v-model="formData.contactName" placeholder="请输入联系人" maxlength="100" />
</el-form-item>
<el-form-item label="联系电话" prop="contactPhone">
<el-input v-model="formData.contactPhone" placeholder="请输入联系电话" maxlength="32" />
</el-form-item>
<el-form-item label="备注" class="form-col-span-2" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
maxlength="512"
/>
</el-form-item>
</div>
<el-divider content-position="left">资质附件</el-divider>
<div class="form-grid-2">
<el-form-item label="营业执照" prop="businessLicenseUrls" class="form-col-span-2">
<AttachmentUploadField
ref="businessLicenseUploadRef"
v-model="formData.businessLicenseUrls"
:limit="5"
accept="image/*,.pdf"
folder="pl/carrier/license"
hint="支持 JPG、PNG、PDF,最多 5 个;可拖拽或在此区域粘贴上传"
/>
</el-form-item>
<el-form-item label="道路经营许可证" prop="roadTransportLicenseUrls" class="form-col-span-2">
<AttachmentUploadField
ref="roadTransportLicenseUploadRef"
v-model="formData.roadTransportLicenseUrls"
:limit="5"
accept="image/*,.pdf"
folder="pl/carrier/permit"
hint="支持 JPG、PNG、PDF,最多 5 个;可拖拽或在此区域粘贴上传"
/>
</el-form-item>
<el-form-item label="运输协议" prop="transportAgreementUrls" class="form-col-span-2">
<AttachmentUploadField
ref="transportAgreementUploadRef"
v-model="formData.transportAgreementUrls"
:limit="5"
accept="image/*,.pdf"
folder="pl/carrier/agreement"
hint="支持 JPG、PNG、PDF,最多 5 个;可拖拽或在此区域粘贴上传"
/>
</el-form-item>
<el-form-item label="协议有效期" prop="transportAgreementExpireDate">
<el-date-picker
v-model="formData.transportAgreementExpireDate"
type="date"
placeholder="请选择协议有效期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { carrierApi, type Carrier } from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { pickBxData } from '@/utils/http/bx'
defineOptions({ name: 'PlCarrierFormDialog' })
const props = defineProps<{
modelValue: boolean
/** 有值则为编辑,无值则为新增 */
carrierId?: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: [payload: { mode: 'create' | 'edit'; carrier: Carrier }]
}>()
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const isEdit = computed(() => !!props.carrierId)
const dialogTitle = computed(() => (isEdit.value ? '编辑承运商' : '新增承运商'))
const formRef = ref<FormInstance>()
const dialogLoading = ref(false)
const submitLoading = ref(false)
const businessLicenseUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const roadTransportLicenseUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const transportAgreementUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const formData = reactive({
id: '',
carrierName: '',
contactName: '',
contactPhone: '',
status: '1',
remark: '',
businessLicenseUrls: '',
roadTransportLicenseUrls: '',
transportAgreementUrls: '',
transportAgreementExpireDate: ''
})
const formRules: FormRules = {
carrierName: [{ required: true, message: '请输入承运商名称', trigger: 'blur' }],
contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
contactPhone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }]
}
function hasAttachmentUploading() {
return [businessLicenseUploadRef, roadTransportLicenseUploadRef, transportAgreementUploadRef].some(
item => item.value?.hasUploading?.()
)
}
function resetForm() {
Object.assign(formData, {
id: '',
carrierName: '',
contactName: '',
contactPhone: '',
status: '1',
remark: '',
businessLicenseUrls: '',
roadTransportLicenseUrls: '',
transportAgreementUrls: '',
transportAgreementExpireDate: ''
})
formRef.value?.clearValidate()
}
async function openDialog() {
if (!props.carrierId) {
resetForm()
return
}
dialogLoading.value = true
try {
const res = await carrierApi.get(props.carrierId)
const detail = pickBxData<Carrier>(res) || ({} as Carrier)
Object.assign(formData, {
id: detail.id || '',
carrierName: detail.carrierName || '',
contactName: detail.contactName || '',
contactPhone: detail.contactPhone || '',
status: detail.status || '1',
remark: detail.remark || '',
businessLicenseUrls: detail.businessLicenseUrls || '',
roadTransportLicenseUrls: detail.roadTransportLicenseUrls || '',
transportAgreementUrls: detail.transportAgreementUrls || '',
transportAgreementExpireDate: detail.transportAgreementExpireDate || ''
})
} catch {
ElMessage.error('加载承运商详情失败')
visible.value = false
} finally {
dialogLoading.value = false
}
}
function handleDialogClose() {
formRef.value?.resetFields()
resetForm()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
if (hasAttachmentUploading()) {
ElMessage.warning('附件上传中,请稍候')
return
}
submitLoading.value = true
try {
const payload = {
carrierName: formData.carrierName,
contactName: formData.contactName,
contactPhone: formData.contactPhone,
status: formData.status,
remark: formData.remark,
businessLicenseUrls: formData.businessLicenseUrls,
roadTransportLicenseUrls: formData.roadTransportLicenseUrls,
transportAgreementUrls: formData.transportAgreementUrls,
...(formData.transportAgreementExpireDate
? { transportAgreementExpireDate: formData.transportAgreementExpireDate }
: {})
}
if (isEdit.value) {
await carrierApi.update(formData.id, payload)
const res = await carrierApi.get(formData.id)
const detail = pickBxData<Carrier>(res)
if (!detail?.id) {
ElMessage.error('编辑成功但未返回承运商信息')
return
}
ElMessage.success('编辑成功')
emit('saved', { mode: 'edit', carrier: detail })
visible.value = false
return
}
const res = await carrierApi.create(payload)
const created = pickBxData<Carrier>(res)
if (!created?.id) {
ElMessage.error('新增成功但未返回承运商信息')
return
}
ElMessage.success('新增成功')
emit('saved', { mode: 'create', carrier: created })
visible.value = false
} finally {
submitLoading.value = false
}
}
watch(
() => props.modelValue,
value => {
if (value) void openDialog()
}
)
</script>
@@ -0,0 +1,240 @@
<template>
<el-dialog
v-model="innerVisible"
title="从承运商库选择"
width="1200px"
align-center
destroy-on-close
append-to-body
class="dialog-form-layout pl-carrier-library-select-dialog"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
style="backdrop-filter: none"
@open="handleOpen"
>
<div class="panel dialog-list-panel">
<div class="list-toolbar">
<div class="list-toolbar-row pl-carrier-library-select-toolbar">
<el-input
v-model="keyword"
class="pl-carrier-library-select-search"
placeholder="搜索承运商名称、联系人或电话"
clearable
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button class="btn-main" type="primary" @click="handleOpenCreate">
<el-icon><Plus /></el-icon>新增承运商
</el-button>
</div>
</div>
<div class="list-body list-wrap dialog-list-body-only">
<ListTable
table-key="business-pl-carrier-library-select-dialog"
class="list-table pl-carrier-library-select-table"
:data="rows"
v-loading="loading"
stripe
row-key="id"
highlight-current-row
:max-height="560"
empty-text="暂无可用承运商"
@row-dblclick="handleConfirm"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="index => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="carrierName" label="承运商名称" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="handleConfirm(row)">{{ row.carrierName }}</el-link>
</template>
</el-table-column>
<el-table-column
prop="contactName"
label="联系人"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="contactPhone"
label="联系电话"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column label="合作状态" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="row.status === '1' ? 'success' : 'info'" size="small">
{{ row.status === '1' ? '合作中' : '已终止' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="160"
align="center"
header-align="center"
fixed="right"
class-name="col-actions"
>
<template #default="{ row }">
<el-button link type="primary" @click="handleOpenEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="primary" @click="handleConfirm(row)">
<el-icon><Select /></el-icon>选择
</el-button>
</template>
</el-table-column>
</ListTable>
<ListPagination
v-show="pagination.total > 0"
:total="pagination.total"
v-model:page="pagination.page"
v-model:limit="pagination.pageSize"
@pagination="fetchList"
/>
</div>
</div>
<PlCarrierFormDialog
v-model="formVisible"
:carrier-id="editingId"
@saved="handleFormSaved"
/>
</el-dialog>
</template>
<script setup lang="ts">
import { Edit, Plus, Search, Select } from '@element-plus/icons-vue'
import { carrierApi, type Carrier } from '@/api/pl/index'
import PlCarrierFormDialog from '@/components/business/pl-carrier-form-dialog/index.vue'
import ListPagination from '@/components/ListPagination/index.vue'
import { pickBxPageResult } from '@/utils/http/bx'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
defineOptions({ name: 'PlCarrierLibrarySelectDialog' })
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
select: [row: Carrier]
}>()
const innerVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const loading = ref(false)
const keyword = ref('')
const rows = ref<Carrier[]>([])
const pagination = reactive({
page: 1,
pageSize: DEFAULT_LIST_PAGE_SIZE,
total: 0
})
const formVisible = ref(false)
const editingId = ref<string | null>(null)
async function fetchList() {
loading.value = true
try {
const res = await carrierApi.list({
q: keyword.value.trim() || undefined,
page: pagination.page,
pageSize: pagination.pageSize
})
const { list, total } = pickBxPageResult<Carrier>(res)
rows.value = list
pagination.total = total
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
fetchList()
}
function handleOpen() {
keyword.value = ''
pagination.page = 1
pagination.pageSize = DEFAULT_LIST_PAGE_SIZE
pagination.total = 0
rows.value = []
formVisible.value = false
editingId.value = null
fetchList()
}
function handleOpenCreate() {
editingId.value = null
formVisible.value = true
}
function handleOpenEdit(row: Carrier) {
editingId.value = row.id
formVisible.value = true
}
function handleFormSaved(payload: { mode: 'create' | 'edit'; carrier: Carrier }) {
formVisible.value = false
editingId.value = null
if (payload.mode === 'create') {
handleConfirm(payload.carrier)
return
}
fetchList()
}
function handleConfirm(row: Carrier) {
emit('select', row)
innerVisible.value = false
}
</script>
<style scoped lang="scss">
.pl-carrier-library-select-dialog {
:deep(.el-dialog__body) {
min-height: 560px;
}
.dialog-list-panel {
.list-toolbar {
margin-bottom: 0;
padding: 10px 0 12px;
}
}
}
.pl-carrier-library-select-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pl-carrier-library-select-search {
width: 320px;
max-width: 100%;
}
</style>
@@ -39,7 +39,11 @@
v-model="carrierId"
placeholder="请选择承运商"
filterable
remote
clearable
:remote-method="searchCarriers"
:loading="carrierSelectLoading"
@focus="onCarrierSelectFocus"
style="width: 100%"
>
<el-option
@@ -130,7 +134,6 @@
<script setup lang="ts">
import { Check, Close, MagicStick } from '@element-plus/icons-vue'
import {
carrierApi,
pasteRecognizeApi,
personnelApi,
vehicleApi,
@@ -138,6 +141,7 @@
type PasteConflictAction,
type PasteConflictItem
} from '@/api/pl'
import { usePlRemoteCarrierOptions } from '@/composables/usePlRemoteOptions'
import { pickBxPageResult } from '@/utils/http/bx'
import {
PL_PASTE_SAMPLE_TEXT,
@@ -175,7 +179,14 @@
const carrierId = ref('')
const createVehicle = ref(true)
const submitLoading = ref(false)
const localCarriers = ref<Carrier[]>([])
const {
list: remoteCarriers,
loading: carrierSelectLoading,
search: searchCarriers,
onFocus: onCarrierSelectFocus
} = usePlRemoteCarrierOptions({
getSelectedId: () => carrierId.value
})
const vehicleForm = reactive({
licensePlate: '',
@@ -202,9 +213,10 @@
}
const conflictRows = ref<ConflictRow[]>([])
const carrierOptions = computed(() =>
props.carrierOptions.length ? props.carrierOptions : localCarriers.value
)
const carrierOptions = computed(() => {
if (remoteCarriers.value.length) return remoteCarriers.value
return props.carrierOptions
})
const hasPlate = computed(() => Boolean(vehicleForm.licensePlate.trim()))
const showVehicleFields = computed(() => {
@@ -213,11 +225,7 @@
})
async function ensureCarriers() {
if (props.carrierOptions.length) return
if (localCarriers.value.length) return
const res = await carrierApi.list({ page: 1, pageSize: 100 })
const page = pickBxPageResult<Carrier>(res)
localCarriers.value = page.list || []
await searchCarriers('')
}
async function handleParse() {
@@ -45,7 +45,11 @@
class="pl-arrange-vehicle-select"
placeholder="请选择车辆"
filterable
:loading="vehicleLoading"
remote
clearable
:remote-method="searchVehicles"
:loading="vehicleSelectLoading"
@focus="onVehicleSelectFocus"
@change="handleVehicleChange"
>
<el-option
@@ -69,8 +73,9 @@
<el-form-item label="选择司机" prop="driverId">
<el-select
v-model="formData.driverId"
placeholder="请先选择车辆"
placeholder="请先选择车辆(可选)"
filterable
clearable
:disabled="!formData.vehicleId || vehicleDriverList.length === 0"
>
<el-option
@@ -86,7 +91,11 @@
v-model="formData.carrierId"
placeholder="请选择运输公司"
filterable
remote
clearable
:remote-method="searchCarriers"
:loading="carrierSelectLoading"
@focus="onCarrierSelectFocus"
>
<el-option v-for="c in carrierList" :key="c.id" :label="c.carrierName" :value="c.id" />
</el-select>
@@ -104,6 +113,7 @@
v-model="formData.pickupAddress"
v-model:lng="formData.pickupLng"
v-model:lat="formData.pickupLat"
v-model:contact="formData.pickupContact"
placeholder="取货地址"
/>
</el-form-item>
@@ -117,10 +127,13 @@
style="width: 100%"
/>
</el-form-item>
<el-form-item label="注意事项">
<el-input v-model="formData.pickupNotes" placeholder="取货注意事项" />
<el-form-item label="取货联系方式">
<el-input v-model="formData.pickupContact" placeholder="取货联系方式" />
</el-form-item>
</div>
<el-form-item label="注意事项">
<el-input v-model="formData.pickupNotes" placeholder="取货注意事项" />
</el-form-item>
</div>
</div>
@@ -152,23 +165,27 @@
v-model="formData.destinationAddress"
v-model:lng="formData.destinationLng"
v-model:lat="formData.destinationLat"
v-model:contact="formData.deliveryContact"
placeholder="送货地址"
/>
</el-form-item>
<div class="form-grid-2 pl-logistics-meta-row">
<el-form-item label="送达时间">
<el-form-item label="送货时间">
<el-date-picker
v-model="formData.deliveryTime"
type="date"
placeholder="选择送达时间"
placeholder="选择送货时间"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="注意事项">
<el-input v-model="formData.destinationNotes" placeholder="送货注意事项" />
<el-form-item label="送货联系方式">
<el-input v-model="formData.deliveryContact" placeholder="送货联系方式" />
</el-form-item>
</div>
<el-form-item label="注意事项">
<el-input v-model="formData.destinationNotes" placeholder="送货注意事项" />
</el-form-item>
</div>
</div>
</div>
@@ -251,7 +268,9 @@
destinationAddress: formData.destinationAddress,
destinationLng: formData.destinationLng,
destinationLat: formData.destinationLat,
pickupContact: formData.pickupContact,
pickupNotes: formData.pickupNotes,
deliveryContact: formData.deliveryContact,
destinationNotes: formData.destinationNotes,
licensePlate: vehicleList.find(v => v.id === formData.vehicleId)?.licensePlate,
driverUrl: arrangeResult?.driverUrl
@@ -322,16 +341,12 @@
<script setup lang="ts">
import { ArrowDown, Check, Close, CopyDocument, Printer, View } from '@element-plus/icons-vue'
import {
carrierApi,
planApi,
transportApi,
vehicleApi,
type Carrier,
type Plan,
type PlanProduct,
type Transport,
type TransportArrangeResult,
type Vehicle,
type VehiclePersonnelRow
} from '@/api/pl'
import DbPoundSlipQrcodeDialog from '@/components/business/db/DbPoundSlipQrcodeDialog.vue'
@@ -343,13 +358,17 @@ import PlVehicleViewDialog from '@/components/business/pl-vehicle-view-dialog/in
import PlPlanViewTabs from '@/components/business/pl-plan-view-tabs/index.vue'
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { usePlFreightCalc } from '@/composables/usePlFreightCalc'
import {
usePlRemoteCarrierOptions,
usePlRemoteVehicleOptions
} from '@/composables/usePlRemoteOptions'
import { loadVehicleViewBundle, type VehicleViewBundle } from '@/composables/usePlVehicleViewDetail'
import { buildMissingHazardCertReadyMessage } from '@/composables/usePlToxicProductCertCheck'
import { usePlPlanArrangeActions } from '@/composables/usePlPlanArrangeActions'
import { useDictStore } from '@/store/modules/dict'
import { isTonProductUnit } from '@/utils/crm-product-display'
import { formatDate } from '@/utils/format'
import { pickBxData, pickBxList } from '@/utils/http/bx'
import { pickBxData } from '@/utils/http/bx'
import { canPrintSlipCommand, formatPlVehiclePlateDisplay, type PlPrintSlipCommand } from '@/utils/pl-display'
const dictStore = useDictStore()
@@ -399,8 +418,28 @@ const subpackSlipPrintRef = ref<InstanceType<typeof PlSubpackSlipPrintDialog> |
const formRef = ref()
const plan = ref<Partial<Plan>>({})
const products = ref<PlanProduct[]>([])
const vehicleList = ref<Vehicle[]>([])
const carrierList = ref<Carrier[]>([])
const {
list: vehicleList,
loading: vehicleSelectLoading,
search: searchVehicles,
onFocus: onVehicleSelectFocus,
reset: resetVehicleOptions,
ensureById: ensureSelectedVehicleOption
} = usePlRemoteVehicleOptions({
getSelectedId: () => formData.vehicleId,
listParams: { vehicleStatus: 'available' }
})
const {
list: carrierList,
loading: carrierSelectLoading,
search: searchCarriers,
onFocus: onCarrierSelectFocus,
reset: resetCarrierOptions,
ensureById: ensureSelectedCarrierOption
} = usePlRemoteCarrierOptions({
getSelectedId: () => formData.carrierId,
listParams: { status: '1' }
})
const vehicleDriverList = ref<VehiclePersonnelRow[]>([])
const prefilledDriverId = ref('')
const vehicleBundle = ref<VehicleViewBundle | null>(null)
@@ -416,11 +455,13 @@ const formData = reactive({
pickupLng: null as number | null,
pickupLat: null as number | null,
pickupTime: '',
pickupContact: '',
pickupNotes: '',
destinationAddress: '',
destinationLng: null as number | null,
destinationLat: null as number | null,
deliveryTime: '',
deliveryContact: '',
destinationNotes: '',
hasFreight: 0,
distanceKm: null as number | null,
@@ -545,11 +586,13 @@ async function applyPrefillFromPlan(data: Plan) {
formData.pickupLng = data.pickupLng != null ? Number(data.pickupLng) : null
formData.pickupLat = data.pickupLat != null ? Number(data.pickupLat) : null
formData.pickupTime = formatDate(data.pickupTime) || ''
formData.pickupContact = data.pickupContact || ''
formData.pickupNotes = data.pickupNotes || ''
formData.destinationAddress = data.destinationAddress || ''
formData.destinationLng = data.destinationLng != null ? Number(data.destinationLng) : null
formData.destinationLat = data.destinationLat != null ? Number(data.destinationLat) : null
formData.deliveryTime = formatDate(data.deliveryTime) || ''
formData.deliveryContact = data.deliveryContact || ''
formData.destinationNotes = data.destinationNotes || ''
formData.hasFreight = data.hasFreight ?? 0
formData.remark = data.remark || ''
@@ -610,6 +653,8 @@ function resetState() {
plan.value = {}
products.value = []
vehicleDriverList.value = []
resetVehicleOptions()
resetCarrierOptions()
prefilledDriverId.value = ''
vehicleBundle.value = null
vehicleViewVisible.value = false
@@ -623,11 +668,13 @@ function resetState() {
pickupLng: null,
pickupLat: null,
pickupTime: '',
pickupContact: '',
pickupNotes: '',
destinationAddress: '',
destinationLng: null,
destinationLat: null,
deliveryTime: '',
deliveryContact: '',
destinationNotes: '',
hasFreight: 0,
distanceKm: null,
@@ -640,12 +687,7 @@ function resetState() {
}
async function loadMasterData() {
const [vehicleRes, carrierRes] = await Promise.all([
vehicleApi.list({ vehicleStatus: 'available' }),
carrierApi.list({ status: '1', page: 1, pageSize: 100 })
])
vehicleList.value = pickBxList<Vehicle>(vehicleRes)
carrierList.value = pickBxList<Carrier>(carrierRes)
await Promise.all([searchVehicles(''), searchCarriers('')])
}
async function handleVehicleChange(vehicleId: string, preferredDriverId?: string) {
@@ -672,6 +714,9 @@ async function handleVehicleChange(vehicleId: string, preferredDriverId?: string
if (vehicleBundle.value.detail?.carrierId) {
formData.carrierId = vehicleBundle.value.detail.carrierId
}
if (formData.carrierId) {
await ensureSelectedCarrierOption(formData.carrierId)
}
applyVehiclePersonnel(vehicleBundle.value.personnelList || [], preferredDriverId)
} finally {
vehicleLoading.value = false
@@ -693,8 +738,12 @@ async function handleOpened() {
products.value = Array.isArray(data.products) ? data.products : []
await applyPrefillFromPlan(data)
if (formData.vehicleId) {
await ensureSelectedVehicleOption(formData.vehicleId)
await handleVehicleChange(formData.vehicleId, prefilledDriverId.value || data.driverId)
}
if (formData.carrierId) {
await ensureSelectedCarrierOption(formData.carrierId)
}
} catch (error) {
innerVisible.value = false
const message = error instanceof Error ? error.message : '加载运输计划失败'
@@ -754,17 +803,19 @@ async function handleSubmit() {
const payload = {
vehicleId: formData.vehicleId,
driverId: formData.driverId,
driverId: formData.driverId || undefined,
carrierId: formData.carrierId || undefined,
pickupAddress: formData.pickupAddress || undefined,
pickupLng: formData.pickupLng,
pickupLat: formData.pickupLat,
pickupTime: formData.pickupTime || undefined,
pickupContact: formData.pickupContact || undefined,
pickupNotes: formData.pickupNotes || undefined,
destinationAddress: formData.destinationAddress || undefined,
destinationLng: formData.destinationLng,
destinationLat: formData.destinationLat,
deliveryTime: formData.deliveryTime || undefined,
deliveryContact: formData.deliveryContact || undefined,
destinationNotes: formData.destinationNotes || undefined,
transportMedium: transportMediumText.value || undefined,
distanceKm: formData.distanceKm ?? undefined,
@@ -108,31 +108,61 @@
<el-form :model="formData" label-position="right" label-width="100px" class="pl-dialog-form">
<div class="form-grid-4">
<el-form-item label="车牌号">
<el-select
v-model="formData.vehicleId"
placeholder="请选择车牌号"
filterable
clearable
:loading="vehicleLoading"
@change="handleVehicleChange"
>
<el-option
v-for="item in vehicleList"
:key="item.id"
:label="item.licensePlate"
:value="item.id"
/>
</el-select>
<div class="pl-plan-vehicle-with-library">
<el-select
v-model="formData.vehicleId"
class="pl-plan-vehicle-with-library__select"
placeholder="请选择车牌号"
filterable
remote
clearable
:remote-method="searchVehicles"
:loading="vehicleSelectLoading || vehicleLoading"
@focus="onVehicleSelectFocus"
@change="handleVehicleChange"
>
<el-option
v-for="item in vehicleList"
:key="item.id"
:label="item.licensePlate"
:value="item.id"
/>
</el-select>
<el-button
class="pl-plan-vehicle-with-library__btn btn-ghost"
@click="vehicleLibraryVisible = true"
>
<el-icon><FolderOpened /></el-icon>从车辆库选择
</el-button>
</div>
</el-form-item>
<el-form-item label="运输公司">
<el-select
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>
<div class="pl-plan-carrier-with-library">
<el-select
v-model="formData.carrierId"
class="pl-plan-carrier-with-library__select"
placeholder="请选择运输公司"
filterable
remote
clearable
:remote-method="searchCarriers"
:loading="carrierSelectLoading"
@focus="onCarrierSelectFocus"
>
<el-option
v-for="c in carrierList"
:key="c.id"
:label="c.carrierName"
:value="c.id"
/>
</el-select>
<el-button
class="pl-plan-carrier-with-library__btn btn-ghost"
@click="carrierLibraryVisible = true"
>
<el-icon><FolderOpened /></el-icon>从承运商库选择
</el-button>
</div>
</el-form-item>
</div>
@@ -153,6 +183,7 @@
v-model="formData.pickupAddress"
v-model:lng="formData.pickupLng"
v-model:lat="formData.pickupLat"
v-model:contact="formData.pickupContact"
placeholder="取货地址"
/>
</el-form-item>
@@ -165,7 +196,10 @@
style="width: 100%"
/>
</el-form-item>
<el-form-item label="注意事项">
<el-form-item label="取货联系方式">
<el-input v-model="formData.pickupContact" placeholder="取货联系方式" />
</el-form-item>
<el-form-item label="注意事项" class="form-col-span-2">
<el-input v-model="formData.pickupNotes" placeholder="取货注意事项" />
</el-form-item>
</div>
@@ -200,19 +234,23 @@
v-model="formData.destinationAddress"
v-model:lng="formData.destinationLng"
v-model:lat="formData.destinationLat"
v-model:contact="formData.deliveryContact"
placeholder="送货地址"
/>
</el-form-item>
<el-form-item label="送达时间">
<el-form-item label="送货时间">
<el-date-picker
v-model="formData.deliveryTime"
type="date"
placeholder="选择送达时间"
placeholder="选择送货时间"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="注意事项">
<el-form-item label="送货联系方式">
<el-input v-model="formData.deliveryContact" placeholder="送货联系方式" />
</el-form-item>
<el-form-item label="注意事项" class="form-col-span-2">
<el-input v-model="formData.destinationNotes" placeholder="送货注意事项" />
</el-form-item>
</div>
@@ -263,19 +301,42 @@
:vehicle-id="formData.vehicleId"
:bundle="vehicleBundle"
/>
<PlVehicleLibrarySelectDialog
v-model="vehicleLibraryVisible"
@select="handleVehicleLibrarySelect"
/>
<PlCarrierLibrarySelectDialog
v-model="carrierLibraryVisible"
@select="handleCarrierLibrarySelect"
/>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close, Search, Van, View } from '@element-plus/icons-vue'
import { Check, Close, FolderOpened, Search, Van, View } from '@element-plus/icons-vue'
import { getContract } from '@/api/crm/contract'
import type { ContractVO } from '@/api/crm/contract/types'
import { carrierApi, planApi, transportApi, vehicleApi, type Carrier, type Plan, type PlanProduct, type PlanUpdatePayload, type Vehicle, type VehiclePersonnelRow } from '@/api/pl'
import {
planApi,
transportApi,
type Plan,
type PlanProduct,
type PlanUpdatePayload,
type Carrier,
type Vehicle,
type VehiclePersonnelRow
} from '@/api/pl'
import {
usePlRemoteCarrierOptions,
usePlRemoteVehicleOptions
} from '@/composables/usePlRemoteOptions'
import ContractSelector from '@/components/business/contract-selector/index.vue'
import ViewKvGrid from '@/components/business/system-view-dialog/ViewKvGrid.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import PlVehicleSummaryStrip from '@/components/business/pl-vehicle-summary-strip/index.vue'
import PlVehicleViewDialog from '@/components/business/pl-vehicle-view-dialog/index.vue'
import PlVehicleLibrarySelectDialog from '@/components/business/pl-vehicle-library-select-dialog/index.vue'
import PlCarrierLibrarySelectDialog from '@/components/business/pl-carrier-library-select-dialog/index.vue'
import PlAddressWithLibrary from '@/components/business/pl-address-with-library/index.vue'
import PlPlanFormProductTable from './PlPlanFormProductTable.vue'
import {
@@ -294,7 +355,7 @@ import { loadVehicleViewBundle, type VehicleViewBundle } from '@/composables/use
import { useDictStore } from '@/store/modules/dict'
import { useUserStore } from '@/store/modules/user'
import { formatDate } from '@/utils/format'
import { pickBxData, pickBxList } from '@/utils/http/bx'
import { pickBxData } from '@/utils/http/bx'
const ContractDetailDialog = defineAsyncComponent(
() => import('@/views/crm/contract/components/ContractDetailDialog.vue')
@@ -365,11 +426,31 @@ const contractSelectorVisible = ref(false)
const contractDetailDialogActive = ref(false)
const contractDetailRef = ref<InstanceType<typeof ContractDetailDialog>>()
const vehicleViewVisible = ref(false)
const vehicleLibraryVisible = ref(false)
const carrierLibraryVisible = ref(false)
const vehicleLoading = ref(false)
const basicFormRef = ref()
const contractSnapshot = ref<ContractVO | null>(null)
const vehicleList = ref<Vehicle[]>([])
const carrierList = ref<Carrier[]>([])
const {
list: vehicleList,
loading: vehicleSelectLoading,
search: searchVehicles,
onFocus: onVehicleSelectFocus,
ensureById: ensureSelectedVehicleOption
} = usePlRemoteVehicleOptions({
getSelectedId: () => formData.vehicleId,
listParams: { vehicleStatus: 'available' }
})
const {
list: carrierList,
loading: carrierSelectLoading,
search: searchCarriers,
onFocus: onCarrierSelectFocus,
ensureById: ensureSelectedCarrierOption
} = usePlRemoteCarrierOptions({
getSelectedId: () => formData.carrierId,
listParams: { status: '1' }
})
const vehicleBundle = ref<VehicleViewBundle | null>(null)
const selectedDriverId = ref('')
@@ -400,11 +481,13 @@ const formData = reactive({
pickupLng: null as number | null,
pickupLat: null as number | null,
pickupTime: '',
pickupContact: '',
pickupNotes: '',
destinationAddress: '',
destinationLng: null as number | null,
destinationLat: null as number | null,
deliveryTime: '',
deliveryContact: '',
destinationNotes: '',
vehicleId: '',
carrierId: '',
@@ -615,11 +698,13 @@ function buildPlanCommonPayload(options?: { allowClearTransport?: boolean }): Pl
pickupLng: formData.pickupLng,
pickupLat: formData.pickupLat,
pickupTime: formData.pickupTime || undefined,
pickupContact: formData.pickupContact || undefined,
pickupNotes: formData.pickupNotes || undefined,
destinationAddress: formData.destinationAddress || undefined,
destinationLng: formData.destinationLng,
destinationLat: formData.destinationLat,
deliveryTime: formData.deliveryTime || undefined,
deliveryContact: formData.deliveryContact || undefined,
destinationNotes: formData.destinationNotes || undefined,
followUserId: formData.followUserId || undefined,
followUserName: formData.followUserName || undefined,
@@ -702,11 +787,13 @@ function resetForm() {
pickupLng: null as number | null,
pickupLat: null as number | null,
pickupTime: '',
pickupContact: '',
pickupNotes: '',
destinationAddress: '',
destinationLng: null as number | null,
destinationLat: null as number | null,
deliveryTime: '',
deliveryContact: '',
destinationNotes: '',
vehicleId: '',
carrierId: '',
@@ -778,11 +865,13 @@ async function loadPlanForEdit(planId: string) {
pickupLng: data.pickupLng != null ? Number(data.pickupLng) : null,
pickupLat: data.pickupLat != null ? Number(data.pickupLat) : null,
pickupTime: formatDate(data.pickupTime) || '',
pickupContact: data.pickupContact || '',
pickupNotes: data.pickupNotes || '',
destinationAddress: data.destinationAddress || '',
destinationLng: data.destinationLng != null ? Number(data.destinationLng) : null,
destinationLat: data.destinationLat != null ? Number(data.destinationLat) : null,
deliveryTime: formatDate(data.deliveryTime) || '',
deliveryContact: data.deliveryContact || '',
destinationNotes: data.destinationNotes || '',
vehicleId: data.vehicleId || '',
remark: data.remark || '',
@@ -806,10 +895,14 @@ async function loadPlanForEdit(planId: string) {
await refreshContractProductRows()
if (data.vehicleId) {
await ensureSelectedVehicleOption(data.vehicleId)
await handleVehicleChange(data.vehicleId)
}
freightCalc.resetFreightCalc()
await restoreFreightFromTransport(data)
if (formData.carrierId) {
await ensureSelectedCarrierOption(formData.carrierId)
}
} finally {
detailLoading.value = false
}
@@ -866,17 +959,7 @@ function handleClosed() {
}
async function loadMasterData() {
try {
const [vehicleRes, carrierRes] = await Promise.all([
vehicleApi.list({ vehicleStatus: 'available' }),
carrierApi.list({ status: '1', page: 1, pageSize: 100 })
])
vehicleList.value = pickBxList<Vehicle>(vehicleRes)
carrierList.value = pickBxList<Carrier>(carrierRes)
} catch {
vehicleList.value = []
carrierList.value = []
}
await Promise.all([searchVehicles(''), searchCarriers('')])
}
async function handleViewContract() {
@@ -957,6 +1040,22 @@ async function handleVehicleChange(vehicleId: string) {
}
}
async function handleVehicleLibrarySelect(vehicle: Vehicle) {
if (!vehicle?.id) return
await ensureSelectedVehicleOption(vehicle.id)
formData.vehicleId = vehicle.id
await handleVehicleChange(vehicle.id)
if (vehicle.carrierId) {
await ensureSelectedCarrierOption(vehicle.carrierId)
}
}
async function handleCarrierLibrarySelect(carrier: Carrier) {
if (!carrier?.id) return
await ensureSelectedCarrierOption(carrier.id)
formData.carrierId = carrier.id
}
function validateBusinessRules() {
if (!formData.contractId) {
ElMessage.warning('请选择合同')
@@ -1046,6 +1145,26 @@ async function handleSubmit(mode: 'save' | 'saveAndArrange' = 'save') {
</script>
<style scoped lang="scss">
.pl-plan-vehicle-with-library,
.pl-plan-carrier-with-library {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.pl-plan-vehicle-with-library__select,
.pl-plan-carrier-with-library__select {
flex: 1 1 auto;
min-width: 0;
}
.pl-plan-vehicle-with-library__btn,
.pl-plan-carrier-with-library__btn {
flex: 0 0 auto;
white-space: nowrap;
}
.pl-plan-form-body {
display: flex;
flex-direction: column;
@@ -8,8 +8,10 @@
<span
class="pl-plan-list-product-cell__line"
:class="{
'pl-plan-list-product-cell__line--qty': field === 'qty',
'pl-plan-list-product-cell__line--center': field === 'spec' || field === 'unit'
'pl-plan-list-product-cell__line--qty':
field === 'qty' || field === 'planQty' || field === 'actualQty',
'pl-plan-list-product-cell__line--center':
field === 'spec' || field === 'model' || field === 'packageSpec' || field === 'unit'
}"
>
<template v-if="!field">
@@ -88,11 +90,23 @@ import {
import { useDictStore } from '@/store/modules/dict'
import {
formatPlanProductListText,
getPlanProductActualQuantity,
getPlanProductModel,
getPlanProductPackageSpec,
getPlanProductPlannedQuantity,
getPlanProductQuantity,
getPlanProductSpec
} from '@/utils/pl-display'
export type PlPlanListProductCellField = 'name' | 'spec' | 'qty' | 'unit'
export type PlPlanListProductCellField =
| 'name'
| 'spec'
| 'model'
| 'packageSpec'
| 'qty'
| 'planQty'
| 'actualQty'
| 'unit'
const props = withDefaults(
defineProps<{
@@ -122,9 +136,21 @@ function formatFieldValue(product: PlanProduct) {
if (props.field === 'spec') {
return getPlanProductSpec(product)
}
if (props.field === 'model') {
return getPlanProductModel(product)
}
if (props.field === 'packageSpec') {
return getPlanProductPackageSpec(product)
}
if (props.field === 'qty') {
return getPlanProductQuantity(product)
}
if (props.field === 'planQty') {
return getPlanProductPlannedQuantity(product)
}
if (props.field === 'actualQty') {
return getPlanProductActualQuantity(product)
}
if (props.field === 'unit') {
const unitRaw = product.unit || ''
if (!unitRaw) return '—'
@@ -5,7 +5,7 @@
@tab-change="handleTabChange"
>
<el-tab-pane
v-for="item in PL_PLAN_TYPE_TABS"
v-for="item in resolvedTabs"
:key="item.name"
:label="item.label"
:name="item.name"
@@ -14,19 +14,23 @@
</template>
<script setup lang="ts">
import { PL_PLAN_TYPE_TABS, type PlPlanTypeTab } from '@/utils/pl-display'
import { PL_PLAN_TYPE_TABS } from '@/utils/pl-display'
defineProps<{
modelValue: PlPlanTypeTab
const props = defineProps<{
modelValue: string
/** 覆盖默认单据类型 Tab(如危化品证上传页追加「未上传证书」) */
tabs?: { label: string; name: string }[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: PlPlanTypeTab]
change: [value: PlPlanTypeTab]
'update:modelValue': [value: string]
change: [value: string]
}>()
const resolvedTabs = computed(() => props.tabs ?? PL_PLAN_TYPE_TABS)
function handleTabChange(name: string | number) {
const value = String(name) as PlPlanTypeTab
const value = String(name)
emit('update:modelValue', value)
emit('change', value)
}
@@ -1131,7 +1131,8 @@
const pickupViewFields = computed<ViewField[]>(() => [
{ label: '取货地址', value: plan.value?.pickupAddress, span: 'full' },
{ label: '取货时间', value: formatDate(plan.value?.pickupTime) || '—', long: false },
{ label: '注意事项', value: plan.value?.pickupNotes, long: false, fullSpan: false }
{ label: '取货联系方式', value: plan.value?.pickupContact, long: false, fullSpan: false },
{ label: '注意事项', value: plan.value?.pickupNotes, span: 'full' }
])
const deliveryViewFields = computed<ViewField[]>(() => [
@@ -1140,8 +1141,9 @@
value: plan.value?.destinationAddress || plan.value?.destinationName,
span: 'full'
},
{ label: '送达时间', value: formatDate(plan.value?.deliveryTime) || '—', long: false },
{ label: '注意事项', value: plan.value?.destinationNotes, long: false, fullSpan: false }
{ label: '送货时间', value: formatDate(plan.value?.deliveryTime) || '—', long: false },
{ label: '送货联系方式', value: plan.value?.deliveryContact, long: false, fullSpan: false },
{ label: '注意事项', value: plan.value?.destinationNotes, span: 'full' }
])
const freightViewFields = computed<ViewField[]>(() => {
@@ -2,7 +2,7 @@
<el-dialog
v-model="innerVisible"
title="从地址库选择"
width="1000px"
width="1200px"
align-center
destroy-on-close
append-to-body
@@ -12,35 +12,21 @@
style="backdrop-filter: none"
@open="handleOpen"
>
<div class="filter-block pl-transport-address-select-filter">
<el-form class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="名称/地址">
<el-input
v-model="keyword"
placeholder="搜索地址名称、详细地址、联系人或电话"
clearable
@keyup.enter="handleSearch"
/>
</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="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="panel dialog-list-panel">
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-row pl-transport-address-select-toolbar">
<el-input
v-model="keyword"
class="pl-transport-address-select-search"
placeholder="搜索地址名称、详细地址、联系人或电话"
clearable
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button class="btn-main" type="primary" @click="handleOpenCreate">
<el-icon><Plus /></el-icon>新增地址
</el-button>
@@ -48,7 +34,8 @@
</div>
<div class="list-body list-wrap dialog-list-body-only">
<ListTable table-key="business-pl-transport-address-select-dialog"
<ListTable
table-key="business-pl-transport-address-select-dialog"
class="list-table pl-transport-address-select-table"
:data="rows"
v-loading="loading"
@@ -72,13 +59,7 @@
<el-link type="primary" @click="handleConfirm(row)">{{ row.addressName }}</el-link>
</template>
</el-table-column>
<el-table-column prop="fullAddress" label="详细地址" min-width="260" show-overflow-tooltip />
<el-table-column label="坐标" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.lng != null && row.lat != null">{{ row.lng }}, {{ row.lat }}</span>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column prop="fullAddress" label="详细地址" min-width="280" show-overflow-tooltip />
<el-table-column
prop="contactName"
label="联系人"
@@ -95,13 +76,16 @@
/>
<el-table-column
label="操作"
min-width="100"
min-width="160"
align="center"
header-align="center"
fixed="right"
class-name="col-actions"
>
<template #default="{ row }">
<el-button link type="primary" @click="handleOpenEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="primary" @click="handleConfirm(row)">
<el-icon><Select /></el-icon>选择
</el-button>
@@ -120,22 +104,22 @@
</div>
<el-dialog
v-model="createVisible"
title="新增地址"
v-model="formVisible"
:title="formDialogTitle"
width="640px"
align-center
destroy-on-close
append-to-body
class="dialog-form-layout pl-transport-address-create-dialog"
class="dialog-form-layout pl-transport-address-form-dialog"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
style="backdrop-filter: none"
@closed="handleCreateClosed"
@closed="handleFormClosed"
>
<el-form
ref="createFormRef"
:model="createForm"
:rules="createRules"
ref="formRef"
:model="addressForm"
:rules="formRules"
label-position="top"
class="pl-dialog-form"
scroll-to-error
@@ -143,33 +127,33 @@
>
<div class="form-grid-2">
<el-form-item label="地址名称" prop="addressName">
<el-input v-model="createForm.addressName" placeholder="如:安庆仓库、某某化工厂" />
<el-input v-model="addressForm.addressName" placeholder="如:安庆仓库、某某化工厂" />
</el-form-item>
<el-form-item label="详细地址" prop="fullAddress" class="form-col-span-2">
<AmapAddressPicker
v-model="createForm.fullAddress"
v-model:lng="createForm.lng"
v-model:lat="createForm.lat"
v-model="addressForm.fullAddress"
v-model:lng="addressForm.lng"
v-model:lat="addressForm.lat"
placeholder="请地图选点或搜索 POI"
/>
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="createForm.contactName" placeholder="联系人(可选)" />
<el-input v-model="addressForm.contactName" placeholder="联系人(可选)" />
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="createForm.contactPhone" placeholder="联系电话(可选)" />
<el-input v-model="addressForm.contactPhone" placeholder="联系电话(可选)" />
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="createForm.remark" type="textarea" :rows="2" placeholder="备注(可选)" />
<el-input v-model="addressForm.remark" type="textarea" :rows="2" placeholder="备注(可选)" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="createVisible = false">
<el-button @click="formVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="createLoading" @click="handleCreateSubmit">
<el-button type="primary" :loading="formLoading" @click="handleFormSubmit">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
@@ -179,7 +163,7 @@
</template>
<script setup lang="ts">
import { Check, Close, Plus, RefreshLeft, Search, Select } from '@element-plus/icons-vue'
import { Check, Close, Edit, Plus, Search, Select } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import {
transportAddressApi,
@@ -215,11 +199,12 @@ const pagination = reactive({
total: 0
})
const createVisible = ref(false)
const createLoading = ref(false)
const createFormRef = ref<FormInstance>()
const formVisible = ref(false)
const formLoading = ref(false)
const formRef = ref<FormInstance>()
const editingId = ref<string | null>(null)
const createForm = reactive({
const addressForm = reactive({
addressName: '',
fullAddress: '',
lng: null as number | null,
@@ -229,11 +214,13 @@ const createForm = reactive({
remark: ''
})
const createRules: FormRules = {
const formRules: FormRules = {
addressName: [{ required: true, message: '请输入地址名称', trigger: 'blur' }],
fullAddress: [{ required: true, message: '请填写详细地址', trigger: 'blur' }]
}
const formDialogTitle = computed(() => (editingId.value ? '编辑地址' : '新增地址'))
async function fetchList() {
loading.value = true
try {
@@ -256,24 +243,19 @@ function handleSearch() {
fetchList()
}
function handleReset() {
keyword.value = ''
pagination.page = 1
fetchList()
}
function handleOpen() {
keyword.value = ''
pagination.page = 1
pagination.pageSize = DEFAULT_LIST_PAGE_SIZE
pagination.total = 0
rows.value = []
createVisible.value = false
formVisible.value = false
editingId.value = null
fetchList()
}
function resetCreateForm() {
Object.assign(createForm, {
function resetAddressForm() {
Object.assign(addressForm, {
addressName: '',
fullAddress: '',
lng: null,
@@ -282,45 +264,78 @@ function resetCreateForm() {
contactPhone: '',
remark: ''
})
createFormRef.value?.resetFields()
formRef.value?.resetFields()
}
function fillAddressForm(row: TransportAddress) {
Object.assign(addressForm, {
addressName: row.addressName || '',
fullAddress: row.fullAddress || '',
lng: row.lng != null ? Number(row.lng) : null,
lat: row.lat != null ? Number(row.lat) : null,
contactName: row.contactName || '',
contactPhone: row.contactPhone || '',
remark: row.remark || ''
})
}
function handleOpenCreate() {
resetCreateForm()
createVisible.value = true
editingId.value = null
resetAddressForm()
formVisible.value = true
}
function handleCreateClosed() {
resetCreateForm()
function handleOpenEdit(row: TransportAddress) {
editingId.value = row.id
resetAddressForm()
fillAddressForm(row)
formVisible.value = true
}
async function handleCreateSubmit() {
const valid = await createFormRef.value?.validate().catch(() => false)
function handleFormClosed() {
editingId.value = null
resetAddressForm()
}
function buildPayload(): TransportAddressPayload {
return {
addressName: addressForm.addressName.trim(),
fullAddress: addressForm.fullAddress.trim(),
lng: addressForm.lng as number,
lat: addressForm.lat as number,
contactName: addressForm.contactName.trim() || undefined,
contactPhone: addressForm.contactPhone.trim() || undefined,
status: '1',
remark: addressForm.remark.trim() || undefined
}
}
async function handleFormSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
if (createForm.lng == null || createForm.lat == null) {
if (addressForm.lng == null || addressForm.lat == null) {
ElMessage.warning('请通过地图选点填写经纬度')
return
}
createLoading.value = true
formLoading.value = true
try {
const payload: TransportAddressPayload = {
addressName: createForm.addressName.trim(),
fullAddress: createForm.fullAddress.trim(),
lng: createForm.lng,
lat: createForm.lat,
contactName: createForm.contactName.trim() || undefined,
contactPhone: createForm.contactPhone.trim() || undefined,
status: '1',
remark: createForm.remark.trim() || undefined
const payload = buildPayload()
if (editingId.value) {
await transportAddressApi.update(editingId.value, payload)
ElMessage.success('编辑成功')
formVisible.value = false
await fetchList()
return
}
const res = await transportAddressApi.create(payload)
const created = (res.data ?? res) as TransportAddress
ElMessage.success('新增成功')
createVisible.value = false
formVisible.value = false
handleConfirm(created)
} finally {
createLoading.value = false
formLoading.value = false
}
}
@@ -331,43 +346,9 @@ function handleConfirm(row: TransportAddress) {
</script>
<style scoped lang="scss">
.pl-transport-address-select-filter {
padding: 12px 16px 10px;
background: var(--mint-surface, #fff);
border: 1px solid var(--mint-border, #d9e8e2);
border-radius: var(--mint-radius, 10px);
margin-bottom: 12px;
.filter-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px 14px;
max-width: 480px;
}
.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;
}
}
.pl-transport-address-select-dialog {
:deep(.el-dialog__body) {
min-height: 620px;
min-height: 560px;
}
.dialog-list-panel {
@@ -377,4 +358,16 @@ function handleConfirm(row: TransportAddress) {
}
}
}
.pl-transport-address-select-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pl-transport-address-select-search {
width: 360px;
max-width: 100%;
}
</style>
@@ -0,0 +1,708 @@
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="1360px"
align-center
append-to-body
destroy-on-close
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
style="backdrop-filter: none"
:close-on-click-modal="false"
@close="handleDialogClose"
>
<el-form
ref="formRef"
v-loading="dialogLoading"
:model="formData"
:rules="formRules"
label-position="right"
label-width="100px"
class="pl-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-3">
<el-form-item label="车牌号" prop="licensePlate">
<el-input v-model="formData.licensePlate" placeholder="请输入车牌号" maxlength="20" />
</el-form-item>
<el-form-item label="挂车号" prop="trailerNo">
<el-input
v-model="formData.trailerNo"
placeholder="请输入挂车号"
maxlength="20"
clearable
/>
</el-form-item>
<el-form-item label="载重(吨)" prop="loadCapacity">
<el-input-number
v-model="formData.loadCapacity"
:precision="2"
:min="0"
placeholder="请输入载重"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="承运商" prop="carrierId">
<el-select
v-model="formData.carrierId"
placeholder="请选择承运商"
filterable
remote
clearable
:remote-method="searchCarriers"
:loading="carrierSelectLoading"
@focus="onCarrierSelectFocus"
>
<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="vehicleStatus">
<el-select v-model="formData.vehicleStatus" placeholder="请选择">
<el-option label="可用" value="available" />
<el-option label="维修中" value="maintaining" />
<el-option label="报废" value="scrapped" />
</el-select>
</el-form-item>
<el-form-item
label="备注"
prop="remark"
:class="{ 'form-col-span-2': props.vehicleType === 'self' }"
>
<el-input
v-model="formData.remark"
type="textarea"
:rows="2"
placeholder="请输入备注"
maxlength="512"
/>
</el-form-item>
<el-form-item label="车辆照片" style="grid-column: 1 / -1">
<AttachmentUploadField
ref="photoUploadRef"
v-model="formData.photoUrls"
:limit="1"
accept="image/*"
folder="pl/vehicle-photo"
/>
</el-form-item>
</div>
<div class="sub-table-section">
<div class="sub-table-header">
<span class="sub-table-title">关联人员</span>
<el-button type="primary" size="small" @click="handleAddPersonnel">
<el-icon><Plus /></el-icon>添加人员
</el-button>
</div>
<ListTable
table-key="pl-vehicle-form-dialog-personnel"
:data="personnelList"
stripe
class="list-table sub-table"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column label="姓名" min-width="160">
<template #default="{ row }">
<el-select
v-model="row.personnelId"
placeholder="请选择人员"
filterable
remote
clearable
:remote-method="searchPersonnel"
:loading="personnelSelectLoading"
@focus="onPersonnelSelectFocus"
@change="(id) => handlePersonnelSelect(row, id)"
>
<el-option
v-for="p in getPersonnelOptionsForRow(row)"
:key="p.id"
:label="formatPersonnelLabel(p)"
:value="p.id"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="手机号" min-width="120">
<template #default="{ row }">
<span>{{ row.phoneNo || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="年龄" min-width="80" align="center">
<template #default="{ row }">
<span>{{ row.age ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="人员类型" min-width="100" align="center">
<template #default="{ row }">
<el-select v-model="row.role" placeholder="类型*" @change="handleRoleChange(row)">
<el-option label="司机" value="driver" />
<el-option label="押运员" value="escort" />
</el-select>
</template>
</el-table-column>
<el-table-column label="备注" min-width="100">
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" />
</template>
</el-table-column>
<el-table-column label="操作" min-width="60" align="center">
<template #default="{ $index }">
<el-button link type="danger" @click="handleRemovePersonnel($index)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</ListTable>
</div>
<PlVehicleCertPartTabs
ref="certFormListRef"
v-model="certList"
:license-plate="formData.licensePlate"
:trailer-no="formData.trailerNo"
title="车辆证书"
upload-folder="pl/vehicle-cert"
:cert-name-options="certNameOptions"
layout="table"
:enforce-self-vehicle-required="props.vehicleType === 'self'"
:enforce-carrier-vehicle-required="false"
/>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlVehicleCertPartTabs from '@/components/business/pl-vehicle-cert-part-tabs/index.vue'
import { Check, Close, Delete, Plus } from '@element-plus/icons-vue'
import {
vehicleApi,
type VehicleDetail,
type VehiclePersonnelRow,
type CertRow,
type CertRead,
type Personnel
} from '@/api/pl'
import {
usePlRemoteCarrierOptions,
usePlRemotePersonnelOptions
} from '@/composables/usePlRemoteOptions'
import { pickBxData } from '@/utils/http/bx'
import {
collectSelfVehicleRequiredCertErrors,
ensureSelfVehicleCertSlots,
ensureVehicleCertSlots,
formatRequiredCertErrors
} from '@/utils/pl-cert-slots'
import { PL_VEHICLE_CERT_NAME_OPTIONS } from '@/utils/pl-vehicle-cert'
import {
mapPersonnelTypeToVehicleRole,
validatePersonnelTypeRoleCompat,
validatePersonnelAffiliationVehicleCompat
} from '@/utils/pl-personnel-cert'
type CertListItem = CertRow & { _key?: string }
type CertFormListRef = InstanceType<typeof PlVehicleCertPartTabs>
const props = defineProps<{
modelValue: boolean
/** carrier=三方,self=自有;锁定类型,不可切换 */
vehicleType: 'carrier' | 'self'
/** 有值则为编辑,无值则为新增 */
vehicleId?: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
/** 新增成功:返回详情;编辑成功:返回详情(父级刷新列表用) */
saved: [payload: { mode: 'create' | 'edit'; vehicle: VehicleDetail }]
}>()
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const isEdit = computed(() => Boolean(props.vehicleId))
const dialogTitle = computed(() => {
if (isEdit.value) {
return props.vehicleType === 'self' ? '编辑自有车辆' : '编辑车辆'
}
return props.vehicleType === 'self' ? '新增自有车辆' : '新增车辆'
})
const submitLoading = ref(false)
const dialogLoading = ref(false)
const formRef = ref()
const photoUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const personnelList = ref<VehiclePersonnelRow[]>([])
const certList = ref<CertListItem[]>([])
const certFormListRef = ref<CertFormListRef>()
const certNameOptions = PL_VEHICLE_CERT_NAME_OPTIONS
const formData = reactive({
id: '',
licensePlate: '',
trailerNo: '',
vehicleType: props.vehicleType as 'carrier' | 'self',
carrierId: '',
carrierName: '',
loadCapacity: null as number | null,
vehicleStatus: 'available',
remark: '',
photoUrls: ''
})
const formRules = computed(() => {
const rules: Record<string, unknown[]> = {
licensePlate: [{ required: true, message: '请输入车牌号', trigger: 'blur' }]
}
if (props.vehicleType === 'self') {
rules.vehicleStatus = [{ required: true, message: '请选择车辆状态', trigger: 'change' }]
rules.carrierId = [{ required: true, message: '请选择承运商', trigger: 'change' }]
}
return rules
})
const {
list: carrierList,
loading: carrierSelectLoading,
search: searchCarriers,
onFocus: onCarrierSelectFocus,
ensureById: ensureSelectedCarrierOption
} = usePlRemoteCarrierOptions({
getSelectedId: () => formData.carrierId
})
const {
list: personnelOptions,
loading: personnelSelectLoading,
search: searchPersonnel,
onFocus: onPersonnelSelectFocus,
invalidate: invalidatePersonnelOptions
} = usePlRemotePersonnelOptions({
listParams: () => ({
status: '1',
personnelAffiliation: props.vehicleType === 'carrier' ? 'carrier' : 'self'
})
})
function createEmptyPersonnel(): VehiclePersonnelRow {
return {
personnelId: undefined,
personnelName: '',
phoneNo: '',
age: null,
role: 'driver',
isDefault: 0,
remark: ''
}
}
function formatPersonnelLabel(p: Personnel) {
return p.phoneNo ? `${p.personnelName}(${p.phoneNo})` : p.personnelName
}
function getPersonnelOptionsForRow(row: VehiclePersonnelRow) {
const selectedIds = new Set(
personnelList.value
.filter((item) => item !== row && item.personnelId)
.map((item) => item.personnelId as string)
)
const options = personnelOptions.value.filter(
(p) => !selectedIds.has(p.id) || p.id === row.personnelId
)
if (row.personnelId && !options.some((p) => p.id === row.personnelId) && row.personnelName) {
return [
{
id: row.personnelId,
personnelName: row.personnelName,
phoneNo: row.phoneNo,
age: row.age ?? undefined,
status: '0',
createTime: ''
} as Personnel,
...options
]
}
return options
}
function handlePersonnelSelect(row: VehiclePersonnelRow, personnelId?: string) {
if (!personnelId) {
row.personnelId = undefined
row.personnelName = ''
row.phoneNo = ''
row.age = null
row.role = ''
return
}
const person = personnelOptions.value.find((p) => p.id === personnelId)
if (!person) return
row.personnelId = person.id
row.personnelName = person.personnelName
row.phoneNo = person.phoneNo || ''
row.age = person.age ?? null
const mappedRole = mapPersonnelTypeToVehicleRole(person.personnelType)
if (mappedRole) {
row.role = mappedRole
handleRoleChange(row)
}
}
function resolvePersonnelRows(rows: VehiclePersonnelRow[]) {
return rows.map((row) => {
if (row.personnelId) {
handlePersonnelSelect(row, row.personnelId)
return row
}
const matched = personnelOptions.value.find(
(p) =>
p.personnelName === row.personnelName &&
(!row.phoneNo || !p.phoneNo || p.phoneNo === row.phoneNo)
)
if (matched) {
handlePersonnelSelect(row, matched.id)
}
return row
})
}
async function loadPersonnelOptions() {
invalidatePersonnelOptions()
await searchPersonnel('')
}
async function loadCarrierOptions() {
await searchCarriers('')
}
function handleAddPersonnel() {
const row = createEmptyPersonnel()
if (!personnelList.value.some((p) => p.role === 'driver' && p.isDefault === 1)) {
row.isDefault = 1
}
personnelList.value.push(row)
}
function handleRemovePersonnel(index: number) {
const wasDefault = personnelList.value[index]?.isDefault === 1
personnelList.value.splice(index, 1)
if (wasDefault) {
const firstDriver = personnelList.value.find((p) => p.role === 'driver')
if (firstDriver) firstDriver.isDefault = 1
}
}
function handleRoleChange(row: VehiclePersonnelRow) {
if (row.role !== 'driver') {
row.isDefault = 0
if (!personnelList.value.some((p) => p.role === 'driver' && p.isDefault === 1)) {
const firstDriver = personnelList.value.find((p) => p.role === 'driver')
if (firstDriver) firstDriver.isDefault = 1
}
} else if (!personnelList.value.some((p) => p.role === 'driver' && p.isDefault === 1)) {
row.isDefault = 1
}
}
function resetSubTables() {
personnelList.value = []
certList.value = []
certFormListRef.value?.clearUploadRefs()
}
function resetFormData() {
Object.assign(formData, {
id: '',
licensePlate: '',
trailerNo: '',
vehicleType: props.vehicleType,
carrierId: '',
carrierName: '',
loadCapacity: null,
vehicleStatus: 'available',
remark: '',
photoUrls: ''
})
}
function ensureEmptyCertSlots() {
if (props.vehicleType === 'self') {
certList.value = ensureSelfVehicleCertSlots([], formData.trailerNo)
} else {
certList.value = ensureVehicleCertSlots([], formData.trailerNo, 'carrier')
}
}
function mapCertRowsForForm(certs: CertRead[] | undefined | null) {
return (certs || []).map((c) => ({
_key: c.id || c.certId || `cert-${Math.random().toString(36).slice(2, 10)}`,
certId: c.id || c.certId,
vehiclePart: c.vehiclePart,
certName: c.certName,
certNo: c.certNo,
expireDate: c.expireDate ? String(c.expireDate).slice(0, 10) : '',
remindDays: c.remindDays ?? 30,
attachmentUrls: c.attachmentUrls || ''
}))
}
function validateSubTables(): boolean {
for (const row of personnelList.value) {
if (!row.personnelId) {
ElMessage.warning('请选择关联人员')
return false
}
if (!row.role) {
ElMessage.warning('请选择人员类型')
return false
}
const person = personnelOptions.value.find((p) => p.id === row.personnelId)
if (
!validatePersonnelTypeRoleCompat(person?.personnelType, row.role, (message) =>
ElMessage.warning(message)
)
) {
return false
}
if (
!validatePersonnelAffiliationVehicleCompat(
person?.personnelAffiliation,
props.vehicleType,
(message) => ElMessage.warning(message)
)
) {
return false
}
}
if (props.vehicleType === 'self') {
const certErrors = collectSelfVehicleRequiredCertErrors(certList.value, formData.trailerNo)
if (certErrors.length) {
ElMessage.warning(formatRequiredCertErrors(certErrors))
return false
}
}
return true
}
async function openCreate() {
resetFormData()
resetSubTables()
ensureEmptyCertSlots()
await Promise.all([loadPersonnelOptions(), loadCarrierOptions()])
}
async function openEdit() {
const vehicleId = props.vehicleId
if (!vehicleId) return
dialogLoading.value = true
try {
await loadPersonnelOptions()
const res = await vehicleApi.get(vehicleId)
const detail = pickBxData<VehicleDetail>(res) || ({} as VehicleDetail)
if (props.vehicleType === 'self' && detail.vehicleType !== 'self') {
ElMessage.warning('仅可编辑自有车辆')
visible.value = false
return
}
Object.assign(formData, {
id: detail.id,
licensePlate: detail.licensePlate,
trailerNo: detail.trailerNo || '',
vehicleType: props.vehicleType,
carrierId: detail.carrierId || '',
carrierName: detail.carrierName || '',
loadCapacity: detail.loadCapacity ?? null,
vehicleStatus: detail.vehicleStatus,
remark: detail.remark,
photoUrls: detail.photoUrls || ''
})
if (formData.carrierId) {
await ensureSelectedCarrierOption(formData.carrierId)
} else {
await loadCarrierOptions()
}
personnelList.value = resolvePersonnelRows(
(detail.personnelList || []).map((p) => ({
...p,
age: p.age ?? null
}))
)
// 等 trailerNo 相关 watch 先消化,再回填证书,避免被 hasTrailer 逻辑误清空
await nextTick()
const sourceCerts = detail.certList || []
if (props.vehicleType === 'self') {
certList.value = ensureSelfVehicleCertSlots(
mapCertRowsForForm(sourceCerts),
formData.trailerNo
)
} else {
certList.value = ensureVehicleCertSlots(
mapCertRowsForForm(sourceCerts),
formData.trailerNo,
'carrier'
)
}
await nextTick()
certFormListRef.value?.refreshRequiredSlots?.()
} catch {
visible.value = false
ElMessage.error('加载车辆详情失败')
} finally {
dialogLoading.value = false
}
}
async function openDialog() {
if (isEdit.value) {
await openEdit()
} else {
await openCreate()
}
}
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid || !validateSubTables()) return
if (certFormListRef.value?.hasUploading?.()) {
ElMessage.warning('证书附件上传中,请稍候')
return
}
if (photoUploadRef.value?.hasUploading?.()) {
ElMessage.warning('附件上传中,请稍候')
return
}
if (formData.carrierId) {
const carrier = carrierList.value.find((c) => c.id === formData.carrierId)
formData.carrierName = carrier?.carrierName || ''
} else {
formData.carrierName = ''
}
submitLoading.value = true
try {
const payload = {
licensePlate: formData.licensePlate,
trailerNo: formData.trailerNo || undefined,
vehicleType: props.vehicleType,
carrierId: formData.carrierId || undefined,
carrierName: formData.carrierName || undefined,
loadCapacity: formData.loadCapacity,
vehicleStatus: formData.vehicleStatus,
remark: formData.remark,
photoUrls: formData.photoUrls ?? '',
personnelList: personnelList.value.map((p) => ({
personnelId: p.personnelId,
personnelName: p.personnelName.trim(),
phoneNo: p.phoneNo || undefined,
age: p.age ?? undefined,
role: p.role,
isDefault: p.role === 'driver' ? (p.isDefault ?? 0) : 0,
remark: p.remark || undefined
})),
certList: certList.value.map((c) => ({
certId: c.certId,
vehiclePart: c.vehiclePart,
certName: c.certName.trim(),
certNo: c.certNo || undefined,
expireDate: c.expireDate || undefined,
remindDays: c.remindDays ?? 30,
attachmentUrls: c.attachmentUrls || undefined
}))
}
if (isEdit.value) {
await vehicleApi.update(formData.id, payload)
const res = await vehicleApi.get(formData.id)
const detail = pickBxData<VehicleDetail>(res)
if (!detail?.id) {
ElMessage.error('编辑成功但未返回车辆信息')
return
}
ElMessage.success('编辑成功')
emit('saved', { mode: 'edit', vehicle: detail })
} else {
const res = await vehicleApi.create(payload)
const detail = pickBxData<VehicleDetail>(res)
if (!detail?.id) {
ElMessage.error('新增成功但未返回车辆信息')
return
}
ElMessage.success('新增成功')
emit('saved', { mode: 'create', vehicle: detail })
}
visible.value = false
} finally {
submitLoading.value = false
}
}
function handleDialogClose() {
formRef.value?.resetFields()
resetSubTables()
}
watch(
() => props.modelValue,
async (open) => {
if (open) {
await openDialog()
}
}
)
</script>
<style scoped lang="scss">
.sub-table-section {
margin-top: 16px;
grid-column: 1 / -1;
}
.sub-table-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.sub-table-title {
font-size: 14px;
font-weight: 500;
color: var(--text, #1e293b);
}
.sub-table {
margin-bottom: 8px;
}
</style>
@@ -0,0 +1,294 @@
<template>
<el-dialog
v-model="innerVisible"
title="从车辆库选择"
width="1360px"
align-center
destroy-on-close
append-to-body
class="dialog-form-layout pl-vehicle-library-select-dialog"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
style="backdrop-filter: none"
@open="handleOpen"
>
<div class="panel dialog-list-panel">
<el-tabs v-model="activeTab" class="pl-vehicle-library-select-tabs" @tab-change="handleTabChange">
<el-tab-pane label="三方车辆" name="carrier" />
<el-tab-pane label="自有车辆" name="self" />
</el-tabs>
<div class="list-toolbar">
<div class="list-toolbar-row pl-vehicle-library-select-toolbar">
<el-input
v-model="keyword"
class="pl-vehicle-library-select-search"
placeholder="搜索车牌号"
clearable
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button class="btn-main" type="primary" @click="handleOpenCreate">
<el-icon><Plus /></el-icon>新增车辆
</el-button>
</div>
</div>
<div class="list-body list-wrap dialog-list-body-only">
<ListTable
table-key="business-pl-vehicle-library-select-dialog"
class="list-table pl-vehicle-library-select-table"
:data="rows"
v-loading="loading"
stripe
row-key="id"
highlight-current-row
:max-height="560"
empty-text="暂无可用车辆"
@row-dblclick="handleConfirm"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="index => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="licensePlate" label="车牌号" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="handleConfirm(row)">{{ row.licensePlate }}</el-link>
</template>
</el-table-column>
<el-table-column
prop="trailerNo"
label="挂车号"
min-width="110"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
label="车辆状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="getPlVehicleStatusTagType(row.vehicleStatus)" size="small">
{{ getPlVehicleStatusLabel(row.vehicleStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="loadCapacity"
label="载重(吨)"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
v-if="activeTab === 'carrier'"
prop="carrierName"
label="承运商"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="personnelSummary"
label="关联人员摘要"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
{{ normalizePlPersonnelSummaryDisplay(row.personnelSummary) }}
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="160"
align="center"
header-align="center"
fixed="right"
class-name="col-actions"
>
<template #default="{ row }">
<el-button link type="primary" @click="handleOpenEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="primary" @click="handleConfirm(row)">
<el-icon><Select /></el-icon>选择
</el-button>
</template>
</el-table-column>
</ListTable>
<ListPagination
v-show="pagination.total > 0"
:total="pagination.total"
v-model:page="pagination.page"
v-model:limit="pagination.pageSize"
@pagination="fetchList"
/>
</div>
</div>
<PlVehicleFormDialog
v-model="formVisible"
:vehicle-type="activeTab"
:vehicle-id="editingId"
@saved="handleFormSaved"
/>
</el-dialog>
</template>
<script setup lang="ts">
import { Edit, Plus, Search, Select } from '@element-plus/icons-vue'
import { vehicleApi, type Vehicle, type VehicleDetail } from '@/api/pl/index'
import PlVehicleFormDialog from '@/components/business/pl-vehicle-form-dialog/index.vue'
import ListPagination from '@/components/ListPagination/index.vue'
import { pickBxPageResult } from '@/utils/http/bx'
import {
getPlVehicleStatusLabel,
getPlVehicleStatusTagType,
normalizePlPersonnelSummaryDisplay
} from '@/utils/pl-display'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
type VehicleTab = 'carrier' | 'self'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
select: [row: Vehicle]
}>()
const innerVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const loading = ref(false)
const keyword = ref('')
const activeTab = ref<VehicleTab>('carrier')
const rows = ref<Vehicle[]>([])
const pagination = reactive({
page: 1,
pageSize: DEFAULT_LIST_PAGE_SIZE,
total: 0
})
const formVisible = ref(false)
const editingId = ref<string | null>(null)
async function fetchList() {
loading.value = true
try {
const res = await vehicleApi.list({
vehicleType: activeTab.value,
q: keyword.value.trim() || undefined,
page: pagination.page,
pageSize: pagination.pageSize
})
const { list, total } = pickBxPageResult<Vehicle>(res)
rows.value = list
pagination.total = total
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
fetchList()
}
function handleTabChange() {
pagination.page = 1
formVisible.value = false
editingId.value = null
fetchList()
}
function handleOpen() {
keyword.value = ''
activeTab.value = 'carrier'
pagination.page = 1
pagination.pageSize = DEFAULT_LIST_PAGE_SIZE
pagination.total = 0
rows.value = []
formVisible.value = false
editingId.value = null
fetchList()
}
function handleOpenCreate() {
editingId.value = null
formVisible.value = true
}
function handleOpenEdit(row: Vehicle) {
editingId.value = row.id
formVisible.value = true
}
function handleFormSaved(payload: { mode: 'create' | 'edit'; vehicle: VehicleDetail }) {
formVisible.value = false
editingId.value = null
if (payload.mode === 'create') {
handleConfirm(payload.vehicle)
return
}
fetchList()
}
function handleConfirm(row: Vehicle) {
emit('select', row)
innerVisible.value = false
}
</script>
<style scoped lang="scss">
.pl-vehicle-library-select-dialog {
:deep(.el-dialog__body) {
min-height: 560px;
}
.dialog-list-panel {
.list-toolbar {
margin-bottom: 0;
padding: 10px 0 12px;
}
}
}
.pl-vehicle-library-select-tabs {
:deep(.el-tabs__header) {
margin-bottom: 0;
}
}
.pl-vehicle-library-select-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pl-vehicle-library-select-search {
width: 280px;
max-width: 100%;
}
</style>
@@ -9,6 +9,7 @@
clearable
style="width: 100%"
@focus="handleFocus"
@change="emit('change', $event)"
>
<el-option
v-for="item in displayOptions"
@@ -34,9 +35,15 @@
const model = defineModel<string>({ default: '' })
withDefaults(
const emit = defineEmits<{
change: [value: string]
}>()
const props = withDefaults(
defineProps<{
placeholder?: string
/** 透传车辆列表筛选,如 available */
vehicleStatus?: string
}>(),
{
placeholder: '请选择车牌号(三方/自有车辆)'
@@ -58,6 +65,7 @@
loading.value = true
try {
const res = await vehicleApi.list({
...(props.vehicleStatus ? { vehicleStatus: props.vehicleStatus } : {}),
...(keyword.trim() ? { q: keyword.trim() } : {}),
page: 1,
pageSize: 100
@@ -115,7 +115,9 @@ export function usePlPlanArrangeActions() {
destinationAddress?: string
destinationLng?: number | null
destinationLat?: number | null
pickupContact?: string
pickupNotes?: string
deliveryContact?: string
destinationNotes?: string
licensePlate?: string
driverUrl?: string
@@ -160,7 +162,13 @@ export function usePlPlanArrangeActions() {
)
)
lines.push(
`取货联系方式:${options?.pickupContact || plan.value?.pickupContact || '—'}`
)
lines.push(`取货注意事项:${options?.pickupNotes || plan.value?.pickupNotes || '—'}`)
lines.push(
`送货联系方式:${options?.deliveryContact || plan.value?.deliveryContact || '—'}`
)
lines.push(`送货注意事项:${options?.destinationNotes || plan.value?.destinationNotes || '—'}`)
lines.push(`司机链接:${options?.driverUrl || driverUrl.value || '—'}`)
return lines.join('\n')
@@ -212,7 +220,9 @@ export function usePlPlanArrangeActions() {
destinationAddress?: string
destinationLng?: number | null
destinationLat?: number | null
pickupContact?: string
pickupNotes?: string
deliveryContact?: string
destinationNotes?: string
licensePlate?: string
driverUrl?: string
@@ -0,0 +1,204 @@
import {
carrierApi,
personnelApi,
vehicleApi,
type Carrier,
type Personnel,
type PersonnelQuery,
type Vehicle,
type VehicleDetail,
type VehicleQuery
} from '@/api/pl'
import { pickBxData, pickBxList } from '@/utils/http/bx'
const REMOTE_PAGE_SIZE = 100
type IdItem = { id: string }
function keepSelected<T extends IdItem>(
rows: T[],
selectedId: string | undefined,
currentList: T[]
): T[] {
if (!selectedId) return rows
if (rows.some(item => item.id === selectedId)) return rows
const kept = currentList.find(item => item.id === selectedId)
return kept ? [kept, ...rows] : rows
}
function keepSelectedByPlate(
rows: Vehicle[],
selectedPlate: string | undefined,
currentList: Vehicle[]
): Vehicle[] {
const plate = selectedPlate?.trim()
if (!plate) return rows
if (rows.some(item => item.licensePlate === plate)) return rows
const kept = currentList.find(item => item.licensePlate === plate)
return kept ? [kept, ...rows] : rows
}
/** 车辆下拉:服务端按车牌 q 检索 */
export function usePlRemoteVehicleOptions(options?: {
getSelectedId?: () => string | undefined
getSelectedPlate?: () => string | undefined
/** 默认按 id 保留已选项;选车牌字符串时传 licensePlate */
valueMode?: 'id' | 'licensePlate'
listParams?: Omit<VehicleQuery, 'q' | 'page' | 'pageSize'>
}) {
const list = ref<Vehicle[]>([])
const loading = ref(false)
const loaded = ref(false)
const valueMode = options?.valueMode ?? 'id'
async function search(keyword: string) {
loading.value = true
try {
const res = await vehicleApi.list({
...(options?.listParams || {}),
...(keyword.trim() ? { q: keyword.trim() } : {}),
page: 1,
pageSize: REMOTE_PAGE_SIZE
})
const rows = pickBxList<Vehicle>(res)
list.value =
valueMode === 'licensePlate'
? keepSelectedByPlate(rows, options?.getSelectedPlate?.(), list.value)
: keepSelected(rows, options?.getSelectedId?.(), list.value)
loaded.value = true
} catch (error) {
console.error('加载车辆选项失败', error)
list.value = []
} finally {
loading.value = false
}
}
function onFocus() {
if (!loaded.value) void search('')
}
function reset() {
list.value = []
loaded.value = false
}
async function ensureById(vehicleId: string) {
if (!vehicleId || list.value.some(item => item.id === vehicleId)) return
try {
const res = await vehicleApi.get(vehicleId)
const detail = pickBxData<VehicleDetail>(res)
if (detail?.id) list.value = [detail, ...list.value]
} catch {
// ignore
}
}
return { list, loading, loaded, search, onFocus, reset, ensureById }
}
/** 承运商下拉:服务端按 q 检索 */
export function usePlRemoteCarrierOptions(options?: {
getSelectedId?: () => string | undefined
listParams?: { status?: string; auditStatus?: string }
}) {
const list = ref<Carrier[]>([])
const loading = ref(false)
const loaded = ref(false)
async function search(keyword: string) {
loading.value = true
try {
const res = await carrierApi.list({
...(options?.listParams || {}),
...(keyword.trim() ? { q: keyword.trim() } : {}),
page: 1,
pageSize: REMOTE_PAGE_SIZE
})
list.value = keepSelected(
pickBxList<Carrier>(res),
options?.getSelectedId?.(),
list.value
)
loaded.value = true
} catch (error) {
console.error('加载承运商选项失败', error)
list.value = []
} finally {
loading.value = false
}
}
function onFocus() {
if (!loaded.value) void search('')
}
function reset() {
list.value = []
loaded.value = false
}
async function ensureById(carrierId: string) {
if (!carrierId || list.value.some(item => item.id === carrierId)) return
try {
const res = await carrierApi.get(carrierId)
const detail = pickBxData<Carrier>(res)
if (detail?.id) list.value = [detail, ...list.value]
} catch {
// ignore
}
}
return { list, loading, loaded, search, onFocus, reset, ensureById }
}
/** 人员下拉:服务端按姓名/手机号 q 检索 */
export function usePlRemotePersonnelOptions(options?: {
getSelectedId?: () => string | undefined
listParams?: Omit<PersonnelQuery, 'q' | 'page' | 'pageSize'> | (() => Omit<PersonnelQuery, 'q' | 'page' | 'pageSize'>)
}) {
const list = ref<Personnel[]>([])
const loading = ref(false)
const loaded = ref(false)
async function search(keyword: string) {
loading.value = true
try {
const extra =
typeof options?.listParams === 'function' ? options.listParams() : options?.listParams || {}
const res = await personnelApi.list({
...extra,
...(keyword.trim() ? { q: keyword.trim() } : {}),
page: 1,
pageSize: REMOTE_PAGE_SIZE
})
list.value = keepSelected(
pickBxList<Personnel>(res),
options?.getSelectedId?.(),
list.value
)
loaded.value = true
} catch (error) {
console.error('加载人员选项失败', error)
list.value = []
} finally {
loading.value = false
}
}
function onFocus() {
if (!loaded.value) void search('')
}
function reset() {
list.value = []
loaded.value = false
}
/** 切换归属等过滤条件后需重新拉取 */
function invalidate() {
loaded.value = false
}
return { list, loading, loaded, search, onFocus, reset, invalidate }
}
+36
View File
@@ -88,6 +88,15 @@ export function getPlanProductSpec(product: { model?: string; packageSpec?: stri
return product.model || product.packageSpec || '—'
}
/** 列表「型号/规格」列:仅型号,不用包装规格兜底 */
export function getPlanProductModel(product: { model?: string | null }) {
return product.model || '—'
}
export function getPlanProductPackageSpec(product: { packageSpec?: string | null }) {
return product.packageSpec || '—'
}
export function getPlanProductQuantity(product: {
quantity?: number | string | null
actualQty?: number | string | null
@@ -112,6 +121,15 @@ export function getPlanProductPlannedQuantity(product: {
return String(qty)
}
/** 列表「实际数量」列:仅实际数量,无回退 */
export function getPlanProductActualQuantity(product: {
actualQty?: number | string | null
}) {
const qty = product.actualQty
if (qty === undefined || qty === null || qty === '') return '—'
return String(qty)
}
export function formatPlanProductListText(
product: {
productName?: string
@@ -306,6 +324,9 @@ export function canPrintSlipCommand(planType: string | undefined, command: PlPri
export type PlPlanTypeTab = 'all' | 'inbound' | 'outbound' | 'direct'
/** 危化品证上传页 Tab(在单据类型后增加「未上传证书」) */
export type PlPlanCertTab = PlPlanTypeTab | 'cert_incomplete'
export const PL_PLAN_TYPE_TABS: { label: string; name: PlPlanTypeTab }[] = [
{ label: '全部', name: 'all' },
{ label: '入库单', name: 'inbound' },
@@ -313,10 +334,25 @@ export const PL_PLAN_TYPE_TABS: { label: string; name: PlPlanTypeTab }[] = [
{ label: '直销单', name: 'direct' }
]
export const PL_PLAN_CERT_TABS: { label: string; name: PlPlanCertTab }[] = [
...PL_PLAN_TYPE_TABS,
{ label: '未上传证书', name: 'cert_incomplete' }
]
export function plPlanTypeTabToFilter(tab: PlPlanTypeTab): string {
return tab === 'all' ? '' : tab
}
export function plPlanCertTabToFilters(tab: PlPlanCertTab): {
planType: string
certUploadStatus?: string
} {
if (tab === 'cert_incomplete') {
return { planType: '', certUploadStatus: 'incomplete' }
}
return { planType: plPlanTypeTabToFilter(tab) }
}
export function plPlanTypeFilterToTab(planType?: string): PlPlanTypeTab {
if (planType === 'inbound' || planType === 'outbound' || planType === 'direct') {
return planType
@@ -31,7 +31,7 @@
</div>
<div class="detail-actions">
<el-button
v-if="detailContract?.status === 'draft'"
v-if="isContractEditableStatus(detailContract?.status)"
class="btn-ghost"
@click="handleDetailEdit"
>
@@ -850,7 +850,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, defineAsyncComponent, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { isFrameworkContractType } from '../contractType'
import { isFrameworkContractType, isContractEditableStatus, getContractReeditConfirmMessage } from '../contractType'
import {
CircleCheck,
CircleClose,
@@ -1244,12 +1244,20 @@ const handleDetailDrawerClose = () => {
};
/** 详情抽屉 - 编辑 */
const handleDetailEdit = () => {
const handleDetailEdit = async () => {
if (!detailContract.value?.id) return
if (detailContract.value.status !== 'draft') {
$modal.msgWarning('只能修改草稿状态的合同')
if (!isContractEditableStatus(detailContract.value.status)) {
$modal.msgWarning('仅草稿、审批中或已审批(未执行)的合同才能修改')
return
}
const confirmMsg = getContractReeditConfirmMessage(detailContract.value.status)
if (confirmMsg) {
try {
await $modal.confirm(confirmMsg)
} catch {
return
}
}
visible.value = false
formDialogEditId.value = detailContract.value.id
formDialogVisible.value = true
@@ -10,3 +10,26 @@ export type FrameworkContractType = (typeof FRAMEWORK_CONTRACT_TYPES)[number]
export function isFrameworkContractType(type?: string | null): boolean {
return FRAMEWORK_CONTRACT_TYPES.includes((type || '').trim() as FrameworkContractType)
}
/** 允许打开编辑的合同状态(保存后回草稿) */
export const CONTRACT_EDITABLE_STATUSES = ['draft', 'approving', 'approved'] as const
export type ContractEditableStatus = (typeof CONTRACT_EDITABLE_STATUSES)[number]
export function isContractEditableStatus(status?: string | null): boolean {
return CONTRACT_EDITABLE_STATUSES.includes(
(status || '').trim() as ContractEditableStatus
)
}
/** 审批中 / 已审批打开编辑前的确认文案;草稿返回 null(无需确认) */
export function getContractReeditConfirmMessage(status?: string | null): string | null {
const s = (status || '').trim()
if (s === 'approving') {
return '编辑保存后将作废当前审批流,合同变回草稿,需重新提交审批'
}
if (s === 'approved') {
return '编辑保存后合同将变回草稿,需重新提交审批'
}
return null
}
+17 -5
View File
@@ -24,7 +24,7 @@
<el-button
type="primary"
@click="handleEdit"
v-if="contract?.status === 'draft'"
v-if="isContractEditableStatus(contract?.status)"
>
<el-icon><Edit /></el-icon>修改
</el-button>
@@ -1033,7 +1033,7 @@ import ImagePreview from '@/components/ImagePreview/index.vue';
import TinyMceEditor from '@/components/TinyMceEditor/index.vue';
import { getOpportunity } from '@/api/crm/opportunity';
import { getQuote } from '@/api/crm/quote';
import { isFrameworkContractType } from './contractType';
import { isFrameworkContractType, isContractEditableStatus, getContractReeditConfirmMessage } from './contractType';
import {
parseCrmAttachmentField,
resolveCrmAttachmentFiles,
@@ -1296,12 +1296,24 @@ const goBack = () => {
};
/** 编辑 */
const handleEdit = () => {
const handleEdit = async () => {
if (!isContractEditableStatus(contract.value?.status)) {
$modal.msgWarning('仅草稿、审批中或已审批(未执行)的合同才能修改')
return
}
const confirmMsg = getContractReeditConfirmMessage(contract.value?.status)
if (confirmMsg) {
try {
await $modal.confirm(confirmMsg)
} catch {
return
}
}
router.push({
path: '/crm/contract/form',
query: { id: contract.value?.id }
});
};
})
}
/** 删除合同 */
const handleDelete = async () => {
+21 -12
View File
@@ -112,7 +112,7 @@
</el-button>
<el-button
class="btn-ghost"
:disabled="single || selectedRows[0]?.status !== 'draft'"
:disabled="single || !isContractEditableStatus(selectedRows[0]?.status)"
@click="handleUpdate()"
>
<el-icon><Edit /></el-icon>编辑
@@ -516,6 +516,7 @@ import { $modal } from '@/utils/modal'
import { parseTime } from '@/utils/format'
import { useUserStore } from '@/store/modules/user'
import { filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { isContractEditableStatus, getContractReeditConfirmMessage } from './contractType'
import ApprovalDialog from './components/ApprovalDialog.vue'
import ExecuteDialog from './components/ExecuteDialog.vue'
import InvalidDialog from './components/InvalidDialog.vue'
@@ -1003,24 +1004,32 @@ const handleAdd = () => {
};
/** 修改按钮操作 */
const handleUpdate = (row?: ContractVO) => {
const handleUpdate = async (row?: ContractVO) => {
if (!row && ids.value.length !== 1) {
$modal.msgWarning('请选择一条数据进行修改');
return;
$modal.msgWarning('请选择一条数据进行修改')
return
}
const targetRow = row || contractList.value[contractList.value.findIndex((item) => item.id === ids.value[0])];
const targetRow =
row || contractList.value[contractList.value.findIndex((item) => item.id === ids.value[0])]
if (!targetRow) {
$modal.msgWarning('未找到选中的数据');
return;
$modal.msgWarning('未找到选中的数据')
return
}
// 检查状态,只能修改草稿状态的合同
if (targetRow.status !== 'draft') {
$modal.msgWarning('只能修改草稿状态的合同');
return;
if (!isContractEditableStatus(targetRow.status)) {
$modal.msgWarning('仅草稿、审批中或已审批(未执行)的合同才能修改')
return
}
const confirmMsg = getContractReeditConfirmMessage(targetRow.status)
if (confirmMsg) {
try {
await $modal.confirm(confirmMsg)
} catch {
return
}
}
formDialogEditId.value = targetRow.id
formDialogVisible.value = true
};
}
/** 表单对话框保存/提交成功回调 */
const handleFormSuccess = () => {
@@ -546,11 +546,12 @@
fullSpan: false
},
{
label: '注意事项',
value: editForm.value?.pickupNotes || '—',
label: '取货联系方式',
value: editForm.value?.pickupContact || '—',
long: false,
fullSpan: false
}
},
{ label: '注意事项', value: editForm.value?.pickupNotes || '—', span: 'full' }
])
const deliveryViewFields = computed<ViewField[]>(() => [
@@ -560,17 +561,18 @@
span: 'full'
},
{
label: '送达时间',
label: '送货时间',
value: formatDisplayDate(editForm.value?.deliveryTime),
long: false,
fullSpan: false
},
{
label: '注意事项',
value: editForm.value?.destinationNotes || '—',
label: '送货联系方式',
value: editForm.value?.deliveryContact || '—',
long: false,
fullSpan: false
}
},
{ label: '注意事项', value: editForm.value?.destinationNotes || '—', span: 'full' }
])
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const searchForm = reactive<PlIoOrderQuery>({
@@ -4,7 +4,16 @@
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="运输公司">
<el-select v-model="searchForm.carrierId" placeholder="全部" clearable filterable>
<el-select
v-model="searchForm.carrierId"
placeholder="全部"
clearable
filterable
remote
:remote-method="searchCarriers"
:loading="carrierSelectLoading"
@focus="onCarrierSelectFocus"
>
<el-option
v-for="c in carrierList"
:key="c.id"
@@ -309,8 +318,8 @@ import {
RefreshLeft,
Search
} from '@element-plus/icons-vue'
import { carrierApi, freightApi, type Carrier, type FreightSettlement } from '@/api/pl'
import { pickBxList } from '@/utils/http/bx'
import { freightApi, type FreightSettlement } from '@/api/pl'
import { usePlRemoteCarrierOptions } from '@/composables/usePlRemoteOptions'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import PlFreightApprovalDialog from '@/components/business/pl-freight-approval-dialog/index.vue'
import PlFreightSettlementFormDialog from '@/components/business/pl-freight-settlement-form-dialog/index.vue'
@@ -345,7 +354,15 @@ const {
const loading = ref(false)
const cancelLoadingMap = reactive<Record<string, boolean>>({})
const carrierList = ref<Carrier[]>([])
const {
list: carrierList,
loading: carrierSelectLoading,
search: searchCarriers,
onFocus: onCarrierSelectFocus
} = usePlRemoteCarrierOptions({
getSelectedId: () => searchForm.carrierId,
listParams: { status: '1' }
})
const settlementList = ref<FreightSettlement[]>([])
const selectedRows = ref<FreightSettlement[]>([])
const viewingRow = ref<FreightSettlement | null>(null)
@@ -421,12 +438,7 @@ function handleQuickSearchClear() {
}
async function loadCarrierList() {
try {
const res = await carrierApi.list({ status: '1', page: 1, pageSize: 100 })
carrierList.value = pickBxList(res)
} catch {
carrierList.value = []
}
await searchCarriers('')
}
function handlePageChange() {
@@ -4,7 +4,16 @@
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="运输公司">
<el-select v-model="searchForm.carrierId" placeholder="全部" clearable filterable>
<el-select
v-model="searchForm.carrierId"
placeholder="全部"
clearable
filterable
remote
:remote-method="searchCarriers"
:loading="carrierSelectLoading"
@focus="onCarrierSelectFocus"
>
<el-option
v-for="c in carrierList"
:key="c.id"
@@ -49,7 +58,7 @@
<div class="list-toolbar-row">
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="计划编号/客户/车牌"
placeholder="计划编号/客户/车牌/承运商"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
@@ -144,7 +153,7 @@
<el-table-column
v-if="isColumnVisible('transportCompany')"
prop="transportCompany"
label="运输公司"
label="承运商"
min-width="140"
show-overflow-tooltip
/>
@@ -197,9 +206,10 @@ import ListToolbarEndActions from '@/components/business/list-toolbar-end-action
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { DocumentAdd, RefreshLeft, Search } from '@element-plus/icons-vue'
import { carrierApi, freightApi, vehicleApi, type Carrier, type UnsettledPlanItem, type Vehicle } from '@/api/pl'
import { freightApi, vehicleApi, type UnsettledPlanItem, type Vehicle } from '@/api/pl'
import { usePlRemoteCarrierOptions } from '@/composables/usePlRemoteOptions'
import { fetchAllPagedRecords } from '@/utils/fetch-paged-list'
import { pickBxList, pickBxPageResult } from '@/utils/http/bx'
import { pickBxPageResult } from '@/utils/http/bx'
import PlPlanViewDialog from '@/components/business/pl-plan-view-dialog/index.vue'
import { getPlanTypeLabel, getPlanTypeTagType } from '@/views/pl/freight/freight-status'
import { buildVehicleTrailerLookup, formatPlVehiclePlateDisplay } from '@/utils/pl-display'
@@ -213,7 +223,7 @@ const listColumnDefs = [
{ prop: 'planType', label: '单据类型' },
{ prop: 'partnerName', label: '客户/供应商' },
{ prop: 'licensePlate', label: '车牌号' },
{ prop: 'transportCompany', label: '运输公司' },
{ prop: 'transportCompany', label: '承运商' },
{ prop: 'freightAmount', label: '运费(元)' },
{ prop: 'archiveTime', label: '归档时间' }
]
@@ -228,7 +238,15 @@ const {
const loading = ref(false)
const createLoading = ref(false)
const carrierList = ref<Carrier[]>([])
const {
list: carrierList,
loading: carrierSelectLoading,
search: searchCarriers,
onFocus: onCarrierSelectFocus
} = usePlRemoteCarrierOptions({
getSelectedId: () => searchForm.carrierId,
listParams: { status: '1' }
})
const vehicleOptions = ref<Vehicle[]>([])
const vehicleTrailerLookup = computed(() => buildVehicleTrailerLookup(vehicleOptions.value))
const planList = ref<UnsettledPlanItem[]>([])
@@ -302,12 +320,7 @@ function handleQuickSearchClear() {
}
async function loadCarrierList() {
try {
const res = await carrierApi.list({ status: '1', page: 1, pageSize: 100 })
carrierList.value = pickBxList(res)
} catch {
carrierList.value = []
}
await searchCarriers('')
}
async function loadVehicleOptions() {
+57 -45
View File
@@ -6,7 +6,11 @@
<h2 class="title-bar-heading">危化品证上传</h2>
</div>
<PlPlanTypeTabs v-model="activePlanTypeTab" @change="handlePlanTypeTabChange" />
<PlPlanTypeTabs
v-model="activePlanTypeTab"
:tabs="PL_PLAN_CERT_TABS"
@change="handlePlanTypeTabChange"
/>
<!-- 筛选区 -->
<div class="filter-block">
@@ -179,6 +183,41 @@
min-width="120"
align="center"
/>
<el-table-column
v-if="isColumnVisible('customerName')"
prop="customerName"
label="客户名称"
min-width="240"
align="left"
header-align="left"
class-name="col-name-wrap"
label-class-name="col-name-wrap"
/>
<el-table-column
v-if="isColumnVisible('planType')"
prop="planType"
label="单据类型"
min-width="100"
align="center"
>
<template #default="{ row }">
<el-tag :type="getPlanTypeTagType(row.planType)" size="small">
{{ getPlanTypeLabel(row.planType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('hazardCertReady')"
label="证书齐备"
min-width="100"
align="center"
>
<template #default="{ row }">
<el-tag :type="getHazardCertReadyTagType(row)" size="small">
{{ getHazardCertReadyLabel(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('productName')"
label="产品名称"
@@ -238,41 +277,6 @@
/>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('customerName')"
prop="customerName"
label="客户名称"
min-width="240"
align="left"
header-align="left"
class-name="col-name-wrap"
label-class-name="col-name-wrap"
/>
<el-table-column
v-if="isColumnVisible('planType')"
prop="planType"
label="单据类型"
min-width="100"
align="center"
>
<template #default="{ row }">
<el-tag :type="getPlanTypeTagType(row.planType)" size="small">
{{ getPlanTypeLabel(row.planType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('hazardCertReady')"
label="证书齐备"
min-width="100"
align="center"
>
<template #default="{ row }">
<el-tag :type="getHazardCertReadyTagType(row)" size="small">
{{ getHazardCertReadyLabel(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('followStatus')"
prop="followStatus"
@@ -409,9 +413,10 @@ import {
getPlPlanFollowStatusLabel,
getPlPlanFollowStatusTagType,
normalizePlPersonnelSummaryDisplay,
PL_PLAN_CERT_TABS,
PL_PLAN_FOLLOW_STATUS_OPTIONS,
plPlanTypeTabToFilter,
type PlPlanTypeTab
plPlanCertTabToFilters,
type PlPlanCertTab
} from '@/utils/pl-display'
import { syncViewRowFromFlatList } from '@/utils/view-dialog-refresh'
import { refreshMenuBadges } from '@/utils/menu-badge-registry'
@@ -430,14 +435,14 @@ const planList = ref<Plan[]>([])
const listColumnDefs = [
{ prop: 'planDate', label: '计划日期' },
{ prop: 'customerName', label: '客户名称' },
{ prop: 'planType', label: '单据类型' },
{ prop: 'hazardCertReady', label: '证书齐备' },
{ prop: 'productName', label: '产品名称' },
{ prop: 'productSpec', label: '型号/规格' },
{ prop: 'productQty', label: '数量' },
{ prop: 'productUnit', label: '单位' },
{ prop: 'planAttachments', label: '计划附件' },
{ prop: 'customerName', label: '客户名称' },
{ prop: 'planType', label: '单据类型' },
{ prop: 'hazardCertReady', label: '证书齐备' },
{ prop: 'followStatus', label: '跟进状态' },
{ prop: 'licensePlate', label: '车牌号' },
{ prop: 'personnelSummary', label: '人员' },
@@ -457,13 +462,14 @@ const viewingPlan = ref<Plan | null>(null)
const selectedPlan = computed(() => (selectedRows.value.length === 1 ? selectedRows.value[0] : null))
const dateRange = ref<string[]>([])
const quickSearch = ref('')
const activePlanTypeTab = ref<PlPlanTypeTab>('all')
const activePlanTypeTab = ref<PlPlanCertTab>('all')
const searchForm = reactive({
planNo: '',
planType: '',
followStatus: '',
customerName: ''
customerName: '',
certUploadStatus: '' as string
})
const pagination = reactive({
@@ -494,6 +500,8 @@ async function fetchData() {
customerName: keyword ? undefined : searchForm.customerName || undefined,
planDateFrom: dateRange.value?.[0] || undefined,
planDateTo: dateRange.value?.[1] || undefined,
hasHazardProduct: 1,
certUploadStatus: searchForm.certUploadStatus || undefined,
page: pagination.page,
pageSize: pagination.pageSize
})
@@ -510,8 +518,10 @@ function handleSearch() {
fetchData()
}
function handlePlanTypeTabChange(tab: PlPlanTypeTab) {
searchForm.planType = plPlanTypeTabToFilter(tab)
function handlePlanTypeTabChange(tab: PlPlanCertTab | string) {
const filters = plPlanCertTabToFilters(tab as PlPlanCertTab)
searchForm.planType = filters.planType
searchForm.certUploadStatus = filters.certUploadStatus || ''
pagination.page = 1
fetchData()
}
@@ -522,6 +532,7 @@ function handleReset() {
searchForm.planType = ''
searchForm.followStatus = ''
searchForm.customerName = ''
searchForm.certUploadStatus = ''
quickSearch.value = ''
resetDateShortcut()
pagination.page = 1
@@ -665,6 +676,7 @@ useDbInstantSearch(
planType: searchForm.planType,
followStatus: searchForm.followStatus,
customerName: searchForm.customerName,
certUploadStatus: searchForm.certUploadStatus,
dateRange: dateRange.value
}),
() => {
+63 -11
View File
@@ -35,6 +35,23 @@
v-model="searchForm.customerName"
placeholder="搜索客户名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="产品名称">
<el-input
v-model="searchForm.productName"
placeholder="搜索产品名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="车牌号">
<el-input
v-model="searchForm.licensePlate"
placeholder="搜索车牌号"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="计划日期">
@@ -74,7 +91,7 @@
/>
<ListQuickSearchInput
v-model="quickSearch"
placeholder="计划编号/客户名称"
placeholder="计划编号/客户名称/产品名称/车牌号"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
@@ -241,18 +258,19 @@
show-overflow-tooltip
>
<template #default="{ row }">
<PlPlanListProductCell :products="row.products" field="spec" />
<PlPlanListProductCell :products="row.products" field="model" />
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('productQty')"
label="数量"
min-width="90"
align="right"
header-align="right"
v-if="isColumnVisible('productPackageSpec')"
label="包装规格"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<PlPlanListProductCell :products="row.products" field="qty" />
<PlPlanListProductCell :products="row.products" field="packageSpec" />
</template>
</el-table-column>
<el-table-column
@@ -266,6 +284,28 @@
<PlPlanListProductCell :products="row.products" field="unit" />
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('productPlanQty')"
label="计划数量"
min-width="100"
align="right"
header-align="right"
>
<template #default="{ row }">
<PlPlanListProductCell :products="row.products" field="planQty" />
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('productActualQty')"
label="实际数量"
min-width="100"
align="right"
header-align="right"
>
<template #default="{ row }">
<PlPlanListProductCell :products="row.products" field="actualQty" />
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('planAttachments')"
label="计划附件"
@@ -502,8 +542,10 @@ const listColumnDefs = [
{ prop: 'customerName', label: '客户名称' },
{ prop: 'productName', label: '产品名称' },
{ prop: 'productSpec', label: '型号/规格' },
{ prop: 'productQty', label: '数量' },
{ prop: 'productPackageSpec', label: '包装规格' },
{ prop: 'productUnit', label: '单位' },
{ prop: 'productPlanQty', label: '计划数量' },
{ prop: 'productActualQty', label: '实际数量' },
{ prop: 'planAttachments', label: '计划附件' },
{ prop: 'licensePlate', label: '车牌号' },
{ prop: 'personnelSummary', label: '人员' },
@@ -516,7 +558,7 @@ const {
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('pl-plan-list-columns', listColumnDefs)
} = usePersistedListColumns('pl-plan-list-columns-v2', listColumnDefs)
const selectedRows = ref<Plan[]>([])
const viewingPlan = ref<Plan | null>(null)
@@ -597,7 +639,9 @@ const searchForm = reactive({
planNo: '',
planType: '',
followStatus: '',
customerName: ''
customerName: '',
productName: '',
licensePlate: ''
})
const pagination = reactive({
@@ -627,6 +671,8 @@ async function fetchData() {
planType: searchForm.planType || undefined,
followStatus: searchForm.followStatus || undefined,
customerName: keyword ? undefined : searchForm.customerName || undefined,
productName: keyword ? undefined : searchForm.productName || undefined,
licensePlate: keyword ? undefined : searchForm.licensePlate || undefined,
planDateFrom: dateRange.value?.[0] || undefined,
planDateTo: dateRange.value?.[1] || undefined,
page: pagination.page,
@@ -657,6 +703,8 @@ function handleReset() {
searchForm.planType = ''
searchForm.followStatus = ''
searchForm.customerName = ''
searchForm.productName = ''
searchForm.licensePlate = ''
quickSearch.value = ''
resetDateShortcut()
pagination.page = 1
@@ -666,6 +714,8 @@ function handleReset() {
function handleQuickSearch() {
searchForm.planNo = ''
searchForm.customerName = ''
searchForm.productName = ''
searchForm.licensePlate = ''
pagination.page = 1
fetchData()
}
@@ -908,6 +958,8 @@ useDbInstantSearch(
planType: searchForm.planType,
followStatus: searchForm.followStatus,
customerName: searchForm.customerName,
productName: searchForm.productName,
licensePlate: searchForm.licensePlate,
dateRange: dateRange.value
}),
() => {
+56 -11
View File
@@ -25,7 +25,16 @@
</el-select>
</el-form-item>
<el-form-item label="承运商">
<el-select v-model="searchForm.carrierId" placeholder="全部" clearable filterable>
<el-select
v-model="searchForm.carrierId"
placeholder="全部"
clearable
filterable
remote
:remote-method="searchCarriers"
:loading="carrierSelectLoading"
@focus="onCarrierSelectFocus"
>
<el-option
v-for="c in carrierList"
:key="c.id"
@@ -258,7 +267,16 @@
/>
</el-form-item>
<el-form-item label="承运商" prop="carrierId">
<el-select v-model="formData.carrierId" placeholder="请选择承运商" filterable>
<el-select
v-model="formData.carrierId"
placeholder="请选择承运商"
filterable
remote
clearable
:remote-method="searchCarriers"
:loading="carrierSelectLoading"
@focus="onCarrierSelectFocus"
>
<el-option
v-for="c in carrierList"
:key="c.id"
@@ -309,7 +327,11 @@
v-model="row.personnelId"
placeholder="请选择人员"
filterable
remote
clearable
:remote-method="searchPersonnel"
:loading="personnelSelectLoading"
@focus="onPersonnelSelectFocus"
@change="(id: string | undefined) => handlePersonnelSelect(row, id)"
>
<el-option
@@ -441,8 +463,6 @@ import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { Check, Close, Delete, Edit, Plus, RefreshLeft, Search, Van } from '@element-plus/icons-vue'
import {
vehicleApi,
personnelApi,
carrierApi,
type Vehicle,
type VehicleDetail,
type VehiclePersonnelRow,
@@ -451,6 +471,10 @@ import {
type Personnel,
type Carrier
} from '@/api/pl'
import {
usePlRemoteCarrierOptions,
usePlRemotePersonnelOptions
} from '@/composables/usePlRemoteOptions'
import PlVehicleCertPartTabs from '@/components/business/pl-vehicle-cert-part-tabs/index.vue'
import PlVehicleCertPersonnelTabs from '@/components/business/pl-vehicle-cert-personnel-tabs/index.vue'
import PlVehicleCertBadges from '@/components/business/pl-vehicle-cert-badges/index.vue'
@@ -462,7 +486,7 @@ import SystemViewDialog, {
import { loadPersonnelWithCerts } from '@/composables/usePlVehicleViewDetail'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { useListMediaActive } from '@/composables/useListMediaActive'
import { pickBxData, pickBxList, pickBxPageResult } from '@/utils/http/bx'
import { pickBxData, pickBxPageResult } from '@/utils/http/bx'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
import { formatDateTime } from '@/utils/format'
import {
@@ -500,7 +524,15 @@ const quickSearchKeyword = ref('')
const submitLoading = ref(false)
const dialogLoading = ref(false)
const vehicleList = ref<Vehicle[]>([])
const carrierList = ref<Carrier[]>([])
const {
list: carrierList,
loading: carrierSelectLoading,
search: searchCarriers,
onFocus: onCarrierSelectFocus,
ensureById: ensureSelectedCarrierOption
} = usePlRemoteCarrierOptions({
getSelectedId: () => formData.carrierId || searchForm.carrierId
})
const listColumnDefs = [
{ prop: 'photoUrls', label: '照片' },
@@ -521,7 +553,18 @@ const {
resetToDefault
} = usePersistedListColumns('pl-self-vehicle-list-columns-v3', listColumnDefs)
const personnelOptions = ref<Personnel[]>([])
const {
list: personnelOptions,
loading: personnelSelectLoading,
search: searchPersonnel,
onFocus: onPersonnelSelectFocus,
invalidate: invalidatePersonnelOptions
} = usePlRemotePersonnelOptions({
listParams: () => ({
status: '1',
personnelAffiliation: 'self'
})
})
const selectedRows = ref<Vehicle[]>([])
const dialogVisible = ref(false)
const dialogTitle = ref('')
@@ -728,13 +771,12 @@ function resolvePersonnelRows(rows: VehiclePersonnelRow[]) {
}
async function loadPersonnelOptions() {
const res = await personnelApi.list({ status: '1', pageSize: 100, personnelAffiliation: 'self' })
personnelOptions.value = pickBxList<Personnel>(res)
invalidatePersonnelOptions()
await searchPersonnel('')
}
async function loadCarrierOptions() {
const res = await carrierApi.list({ page: 1, pageSize: 100 })
carrierList.value = pickBxList<Carrier>(res)
await searchCarriers('')
}
function handleAddPersonnel() {
@@ -971,6 +1013,9 @@ async function handleEdit(row: Vehicle) {
remark: detail.remark,
photoUrls: detail.photoUrls || ''
})
if (formData.carrierId) {
await ensureSelectedCarrierOption(formData.carrierId)
}
personnelList.value = resolvePersonnelRows(
(detail.personnelList || []).map(p => ({
...p,
@@ -259,20 +259,11 @@
</el-select>
</el-form-item>
<el-form-item label="车牌号" prop="licensePlate">
<el-select
<PlVehicleSelect
v-model="formData.licensePlate"
filterable
placeholder="请选择车牌"
style="width: 100%"
@change="handleLicensePlateChange"
>
<el-option
v-for="item in vehicleOptions"
:key="item.id"
:label="item.licensePlate"
:value="item.licensePlate"
/>
</el-select>
/>
</el-form-item>
<el-form-item label="维保类型" prop="maintenanceType">
<el-select
@@ -389,9 +380,11 @@ import {
type Vehicle,
type VehicleMaintenanceApply
} from '@/api/pl'
import { pickBxList } from '@/utils/http/bx'
import { pickBxPageResult } from '@/utils/http/bx'
import { fetchAllPagedRecords } from '@/utils/fetch-paged-list'
import { fetchUserOptions } from '@/api/admin'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlVehicleSelect from '@/components/business/pl/PlVehicleSelect.vue'
import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { useUserStore } from '@/store/modules/user'
import {
@@ -549,8 +542,14 @@ async function loadUserOptions() {
}
async function loadVehicleOptions() {
const res = await vehicleApi.list({ page: 1, pageSize: 100 })
vehicleOptions.value = pickBxList(res)
try {
vehicleOptions.value = await fetchAllPagedRecords<Vehicle>(async (page, pageSize) => {
const res = await vehicleApi.list({ page, pageSize })
return pickBxPageResult<Vehicle>(res)
})
} catch {
vehicleOptions.value = []
}
}
async function showMaintenanceReminders(licensePlate: string) {
@@ -322,20 +322,11 @@
</el-form-item>
<template v-if="dialogMode === 'supplement'">
<el-form-item label="车牌号" prop="licensePlate">
<el-select
<PlVehicleSelect
v-model="formData.licensePlate"
filterable
placeholder="请选择车牌"
style="width: 100%"
@change="handleLicensePlateChange"
>
<el-option
v-for="item in vehicleOptions"
:key="item.id"
:label="item.licensePlate"
:value="item.licensePlate"
/>
</el-select>
/>
</el-form-item>
</template>
<el-form-item label="处理日期" prop="processDate">
@@ -427,8 +418,10 @@ import {
type Vehicle,
type VehicleMaintenanceProcessBoardItem
} from '@/api/pl'
import { pickBxList } from '@/utils/http/bx'
import { pickBxPageResult } from '@/utils/http/bx'
import { fetchAllPagedRecords } from '@/utils/fetch-paged-list'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlVehicleSelect from '@/components/business/pl/PlVehicleSelect.vue'
import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { useUserStore } from '@/store/modules/user'
import {
@@ -620,8 +613,14 @@ async function loadUserOptions() {
}
async function loadVehicleOptions() {
const res = await vehicleApi.list({ page: 1, pageSize: 100 })
vehicleOptions.value = pickBxList(res)
try {
vehicleOptions.value = await fetchAllPagedRecords<Vehicle>(async (page, pageSize) => {
const res = await vehicleApi.list({ page, pageSize })
return pickBxPageResult<Vehicle>(res)
})
} catch {
vehicleOptions.value = []
}
}
async function showMaintenanceReminders(licensePlate: string) {
@@ -240,9 +240,10 @@
</el-select>
</el-form-item>
<el-form-item label="车牌号" prop="licensePlate">
<el-select v-model="formData.licensePlate" filterable placeholder="请选择车牌" style="width: 100%">
<el-option v-for="item in vehicleOptions" :key="item.id" :label="item.licensePlate" :value="item.licensePlate" />
</el-select>
<PlVehicleSelect
v-model="formData.licensePlate"
placeholder="请选择车牌"
/>
</el-form-item>
<el-form-item label="充值类型" prop="rechargeType">
<el-select v-model="formData.rechargeType" placeholder="请选择" style="width: 100%">
@@ -328,9 +329,11 @@ import {
type Vehicle,
type VehicleRechargeApply
} from '@/api/pl'
import { pickBxList } from '@/utils/http/bx'
import { pickBxPageResult } from '@/utils/http/bx'
import { fetchAllPagedRecords } from '@/utils/fetch-paged-list'
import { fetchUserOptions } from '@/api/admin'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlVehicleSelect from '@/components/business/pl/PlVehicleSelect.vue'
import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { useUserStore } from '@/store/modules/user'
import {
@@ -479,8 +482,14 @@ async function loadUserOptions() {
}
async function loadVehicleOptions() {
const res = await vehicleApi.list({ page: 1, pageSize: 100 })
vehicleOptions.value = pickBxList(res)
try {
vehicleOptions.value = await fetchAllPagedRecords<Vehicle>(async (page, pageSize) => {
const res = await vehicleApi.list({ page, pageSize })
return pickBxPageResult<Vehicle>(res)
})
} catch {
vehicleOptions.value = []
}
}
async function fetchData() {
@@ -324,19 +324,10 @@
</el-form-item>
<template v-if="dialogMode === 'supplement'">
<el-form-item label="车牌号" prop="licensePlate">
<el-select
<PlVehicleSelect
v-model="formData.licensePlate"
filterable
placeholder="请选择车牌"
style="width: 100%"
>
<el-option
v-for="item in vehicleOptions"
:key="item.id"
:label="item.licensePlate"
:value="item.licensePlate"
/>
</el-select>
/>
</el-form-item>
<el-form-item label="充值类型" prop="rechargeType">
<el-select v-model="formData.rechargeType" placeholder="请选择" style="width: 100%">
@@ -439,8 +430,10 @@ import {
type Vehicle,
type VehicleRechargeDisburseBoardItem
} from '@/api/pl'
import { pickBxList } from '@/utils/http/bx'
import { pickBxPageResult } from '@/utils/http/bx'
import { fetchAllPagedRecords } from '@/utils/fetch-paged-list'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlVehicleSelect from '@/components/business/pl/PlVehicleSelect.vue'
import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { useUserStore } from '@/store/modules/user'
import {
@@ -599,8 +592,14 @@ const viewSections = computed<ViewSection[]>(() => {
})
async function loadVehicleOptions() {
const vehicles = await vehicleApi.list({ page: 1, pageSize: 100 })
vehicleOptions.value = pickBxList(vehicles)
try {
vehicleOptions.value = await fetchAllPagedRecords<Vehicle>(async (page, pageSize) => {
const vehicles = await vehicleApi.list({ page, pageSize })
return pickBxPageResult<Vehicle>(vehicles)
})
} catch {
vehicleOptions.value = []
}
}
async function fetchData() {
@@ -244,9 +244,10 @@
</el-select>
</el-form-item>
<el-form-item label="车牌号" prop="licensePlate">
<el-select v-model="formData.licensePlate" filterable placeholder="请选择车牌" style="width: 100%">
<el-option v-for="item in vehicleOptions" :key="item.id" :label="item.licensePlate" :value="item.licensePlate" />
</el-select>
<PlVehicleSelect
v-model="formData.licensePlate"
placeholder="请选择车牌"
/>
</el-form-item>
<el-form-item label="申请时间" prop="applyDate">
<el-date-picker v-model="formData.applyDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
@@ -344,9 +345,11 @@ import {
type Vehicle,
type VehicleUreaApply
} from '@/api/pl'
import { pickBxList } from '@/utils/http/bx'
import { pickBxPageResult } from '@/utils/http/bx'
import { fetchAllPagedRecords } from '@/utils/fetch-paged-list'
import { fetchUserOptions } from '@/api/admin'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlVehicleSelect from '@/components/business/pl/PlVehicleSelect.vue'
import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { useUserStore } from '@/store/modules/user'
import {
@@ -499,8 +502,14 @@ async function loadUserOptions() {
}
async function loadVehicleOptions() {
const res = await vehicleApi.list({ page: 1, pageSize: 100 })
vehicleOptions.value = pickBxList(res)
try {
vehicleOptions.value = await fetchAllPagedRecords<Vehicle>(async (page, pageSize) => {
const res = await vehicleApi.list({ page, pageSize })
return pickBxPageResult<Vehicle>(res)
})
} catch {
vehicleOptions.value = []
}
}
async function fetchData() {
@@ -300,19 +300,10 @@
</el-form-item>
<template v-if="dialogMode === 'supplement'">
<el-form-item label="车牌号" prop="licensePlate">
<el-select
<PlVehicleSelect
v-model="formData.licensePlate"
filterable
placeholder="请选择车牌"
style="width: 100%"
>
<el-option
v-for="item in vehicleOptions"
:key="item.id"
:label="item.licensePlate"
:value="item.licensePlate"
/>
</el-select>
/>
</el-form-item>
</template>
<el-form-item label="发放时间" prop="disburseDate">
@@ -396,9 +387,11 @@ import {
type Vehicle,
type VehicleUreaDisburseBoardItem
} from '@/api/pl'
import { pickBxList } from '@/utils/http/bx'
import { pickBxPageResult } from '@/utils/http/bx'
import { fetchAllPagedRecords } from '@/utils/fetch-paged-list'
import { fetchUserOptions } from '@/api/admin'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlVehicleSelect from '@/components/business/pl/PlVehicleSelect.vue'
import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { useUserStore } from '@/store/modules/user'
import {
@@ -583,8 +576,14 @@ async function loadUserOptions() {
}
async function loadVehicleOptions() {
const vehicles = await vehicleApi.list({ page: 1, pageSize: 100 })
vehicleOptions.value = pickBxList(vehicles)
try {
vehicleOptions.value = await fetchAllPagedRecords<Vehicle>(async (page, pageSize) => {
const vehicles = await vehicleApi.list({ page, pageSize })
return pickBxPageResult<Vehicle>(vehicles)
})
} catch {
vehicleOptions.value = []
}
}
async function fetchData() {
+61 -16
View File
@@ -19,7 +19,16 @@
/>
</el-form-item>
<el-form-item label="承运商">
<el-select v-model="searchForm.carrierId" placeholder="全部" clearable filterable>
<el-select
v-model="searchForm.carrierId"
placeholder="全部"
clearable
filterable
remote
:remote-method="searchCarriers"
:loading="carrierSelectLoading"
@focus="onCarrierSelectFocus"
>
<el-option
v-for="c in carrierList"
:key="c.id"
@@ -266,7 +275,16 @@
/>
</el-form-item>
<el-form-item label="承运商" prop="carrierId">
<el-select v-model="formData.carrierId" placeholder="请选择承运商" filterable clearable>
<el-select
v-model="formData.carrierId"
placeholder="请选择承运商"
filterable
remote
clearable
:remote-method="searchCarriers"
:loading="carrierSelectLoading"
@focus="onCarrierSelectFocus"
>
<el-option
v-for="c in carrierList"
:key="c.id"
@@ -318,7 +336,11 @@
v-model="row.personnelId"
placeholder="请选择人员"
filterable
remote
clearable
:remote-method="searchPersonnel"
:loading="personnelSelectLoading"
@focus="onPersonnelSelectFocus"
@change="(id: string | undefined) => handlePersonnelSelect(row, id)"
>
<el-option
@@ -459,7 +481,6 @@ import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { Check, Close, Delete, DocumentCopy, Edit, Plus, RefreshLeft, Search, Van } from '@element-plus/icons-vue'
import {
vehicleApi,
carrierApi,
personnelApi,
type Vehicle,
type VehicleDetail,
@@ -469,6 +490,10 @@ import {
type Carrier,
type Personnel
} from '@/api/pl'
import {
usePlRemoteCarrierOptions,
usePlRemotePersonnelOptions
} from '@/composables/usePlRemoteOptions'
import PlVehicleCertPartTabs from '@/components/business/pl-vehicle-cert-part-tabs/index.vue'
import PlVehicleCertPersonnelTabs from '@/components/business/pl-vehicle-cert-personnel-tabs/index.vue'
import PlVehicleCertBadges from '@/components/business/pl-vehicle-cert-badges/index.vue'
@@ -480,7 +505,7 @@ import SystemViewDialog, {
} from '@/components/business/system-view-dialog/index.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { useListMediaActive } from '@/composables/useListMediaActive'
import { pickBxData, pickBxList, pickBxPageResult } from '@/utils/http/bx'
import { pickBxData, pickBxPageResult } from '@/utils/http/bx'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
import { formatDateTime } from '@/utils/format'
import { formatPlVehiclePlateDisplay, normalizePlPersonnelSummaryDisplay } from '@/utils/pl-display'
@@ -511,7 +536,15 @@ const dialogLoading = ref(false)
/** 编辑回填中:跳过 vehicleType watch,避免证书被 ensure 覆盖清空 */
const hydratingEdit = ref(false)
const vehicleList = ref<Vehicle[]>([])
const carrierList = ref<Carrier[]>([])
const {
list: carrierList,
loading: carrierSelectLoading,
search: searchCarriers,
onFocus: onCarrierSelectFocus,
ensureById: ensureSelectedCarrierOption
} = usePlRemoteCarrierOptions({
getSelectedId: () => formData.carrierId || searchForm.carrierId
})
const listColumnDefs = [
{ prop: 'photoUrls', label: '照片' },
@@ -532,7 +565,23 @@ const {
resetToDefault
} = usePersistedListColumns('pl-vehicle-list-columns-v3', listColumnDefs)
const personnelOptions = ref<Personnel[]>([])
const {
list: personnelOptions,
loading: personnelSelectLoading,
search: searchPersonnel,
onFocus: onPersonnelSelectFocus,
invalidate: invalidatePersonnelOptions
} = usePlRemotePersonnelOptions({
listParams: () => ({
status: '1',
personnelAffiliation:
formData.vehicleType === 'carrier'
? 'carrier'
: formData.vehicleType === 'self'
? 'self'
: undefined
})
})
const selectedRows = ref<Vehicle[]>([])
const dialogVisible = ref(false)
const pasteVisible = ref(false)
@@ -772,19 +821,12 @@ function resolvePersonnelRows(rows: VehiclePersonnelRow[]) {
}
async function loadPersonnelOptions() {
const affiliation =
formData.vehicleType === 'carrier' ? 'carrier' : formData.vehicleType === 'self' ? 'self' : undefined
const res = await personnelApi.list({
status: '1',
pageSize: 100,
personnelAffiliation: affiliation
})
personnelOptions.value = pickBxList<Personnel>(res)
invalidatePersonnelOptions()
await searchPersonnel('')
}
async function loadCarrierOptions() {
const res = await carrierApi.list({ page: 1, pageSize: 100 })
carrierList.value = pickBxList<Carrier>(res)
await searchCarriers('')
}
function handleAddPersonnel() {
@@ -1019,6 +1061,9 @@ async function handleEdit(row: Vehicle) {
remark: detail.remark,
photoUrls: detail.photoUrls || ''
})
if (formData.carrierId) {
await ensureSelectedCarrierOption(formData.carrierId)
}
personnelList.value = resolvePersonnelRows(
(detail.personnelList || []).map(p => ({
...p,
@@ -5,6 +5,7 @@
<van-loading v-if="loading" vertical class="page-loading">加载中...</van-loading>
<van-empty v-else-if="errorMessage" :description="errorMessage" />
<van-empty v-else-if="page?.archived" description="运输计划已归档完成" />
<template v-else-if="page">
<van-notice-bar
v-if="isReadOnly"
@@ -48,6 +49,7 @@
</template>
</van-cell>
<van-cell title="取货时间" :value="formatDateTime(page.pickupTime)" />
<van-cell title="取货联系方式" :value="page.pickupContact || '—'" class="multiline-cell" />
<van-cell title="注意事项" :value="page.pickupNotes || '—'" class="multiline-cell" />
</van-cell-group>
@@ -69,7 +71,8 @@
</div>
</template>
</van-cell>
<van-cell title="送达时间" :value="formatDateTime(page.deliveryTime)" />
<van-cell title="送货时间" :value="formatDateTime(page.deliveryTime)" />
<van-cell title="送货联系方式" :value="page.deliveryContact || '—'" class="multiline-cell" />
<van-cell title="注意事项" :value="page.destinationNotes || '—'" class="multiline-cell" />
</van-cell-group>
+12 -1
View File
@@ -6,6 +6,7 @@
<van-loading v-if="loading" vertical class="page-loading">加载中...</van-loading>
<van-empty v-else-if="errorMessage" :description="errorMessage" />
<van-empty v-else-if="page?.voided" description="运输计划已作废" />
<van-empty v-else-if="page?.archived" description="运输计划已归档完成" />
<template v-else-if="page">
<van-cell-group inset class="section-card">
<van-cell title="业务类型" :value="page.businessType || '—'" />
@@ -35,6 +36,11 @@
</template>
</van-cell>
<van-cell title="取货时间" :value="formatDateTime(page.pickupTime)" />
<van-cell
title="取货联系方式"
:value="page.pickupContact || '—'"
class="multiline-cell"
/>
<van-cell
title="注意事项"
:value="page.pickupNotes || '—'"
@@ -60,7 +66,12 @@
</div>
</template>
</van-cell>
<van-cell title="送达时间" :value="formatDateTime(page.deliveryTime)" />
<van-cell title="送货时间" :value="formatDateTime(page.deliveryTime)" />
<van-cell
title="送货联系方式"
:value="page.deliveryContact || '—'"
class="multiline-cell"
/>
<van-cell
title="注意事项"
:value="page.destinationNotes || '—'"
@@ -6,6 +6,7 @@
<van-loading v-if="loading" vertical class="page-loading">加载中...</van-loading>
<van-empty v-else-if="errorMessage" :description="errorMessage" />
<van-empty v-else-if="page?.planVoided" description="运输计划已作废" />
<van-empty v-else-if="page?.planArchived" description="运输计划已归档完成" />
<template v-else-if="page">
<van-cell-group inset class="section-card">
<van-cell title="计划编号" :value="page.planNo" />
@@ -47,6 +48,11 @@
</template>
</van-cell>
<van-cell title="取货时间" :value="formatDateTime(page.pickupTime)" />
<van-cell
title="取货联系方式"
:value="page.pickupContact || '—'"
class="multiline-cell"
/>
<van-cell
title="注意事项"
:value="page.pickupNotes || '—'"
@@ -72,7 +78,12 @@
</div>
</template>
</van-cell>
<van-cell title="送达时间" :value="formatDateTime(page.deliveryTime)" />
<van-cell title="送货时间" :value="formatDateTime(page.deliveryTime)" />
<van-cell
title="送货联系方式"
:value="page.deliveryContact || '—'"
class="multiline-cell"
/>
<van-cell
title="注意事项"
:value="page.destinationNotes || '—'"