Web 与 H5 详情将关联购买证/运输证移到装送货后并以附件卡片展示;用证接口回填证件附件 URL。 Co-authored-by: Cursor <cursoragent@cursor.com>
1707 lines
52 KiB
Vue
1707 lines
52 KiB
Vue
<template>
|
||
<MpWebviewShell @ready="onShellReady">
|
||
<div
|
||
class="mp-h5-detail-page mp-plan-detail-page"
|
||
:style="{
|
||
'--mp-h5-sticky-top': `${stickyOffset}px`,
|
||
'--mp-h5-anchor-offset': `${scrollMargin}px`
|
||
}"
|
||
>
|
||
<MpBusinessHeader title="运输计划详情" back-label="返回运输计划" @back="goList" />
|
||
|
||
<MpStructSkeleton v-if="loading" kind="detail" caption="正在加载计划详情" />
|
||
<div v-else-if="errorText" class="mp-wv-empty" role="alert">
|
||
{{ errorText }}
|
||
<button class="mp-wv-btn" type="button" @click="loadDetail">重试</button>
|
||
</div>
|
||
<template v-else-if="plan">
|
||
<van-tabs
|
||
v-model:active="activeTab"
|
||
class="mp-h5-detail-tabs mp-customer-view-tabs"
|
||
shrink
|
||
:ellipsis="false"
|
||
aria-label="运输计划分区"
|
||
@click-tab="onAnchorTab"
|
||
>
|
||
<van-tab v-for="tab in detailTabs" :key="tab.key" :name="tab.key" :title="tab.label" />
|
||
</van-tabs>
|
||
<div class="mp-wv-detail-body">
|
||
<div v-if="partialErrors.length" class="mp-wv-section" role="alert"
|
||
>部分信息加载失败:{{ partialErrors.join('、') }}。已加载内容仍可查看。<button
|
||
class="mp-wv-btn"
|
||
type="button"
|
||
@click="loadDetail"
|
||
>重试</button
|
||
></div
|
||
>
|
||
<section id="plan-sec-base" class="mp-wv-section mp-h5-anchor">
|
||
<h3>基础信息</h3>
|
||
<div class="detail-field-grid">
|
||
<div class="mp-wv-kv">
|
||
<span>状态</span>
|
||
<span
|
||
class="status-tag pl-plan-follow-status"
|
||
:data-follow-status="plan.followStatus"
|
||
:class="`status-tag--${statusTagType}`"
|
||
>{{
|
||
statusLabel
|
||
}}</span>
|
||
</div>
|
||
<div class="mp-wv-kv"
|
||
><span>计划编号</span><span>{{ plan.planNo || '—' }}</span></div
|
||
>
|
||
<div class="mp-wv-kv"
|
||
><span>计划日期</span><span>{{ planDateText }}</span></div
|
||
>
|
||
<div class="mp-wv-kv">
|
||
<span>归属公司</span><span>{{ getPlPlanCompanyLabel(plan.belongsCompany) }}</span>
|
||
</div>
|
||
<div class="mp-wv-kv"
|
||
><span>单据类型</span><span>{{ typeLabel }}</span></div
|
||
>
|
||
<div class="mp-wv-kv"
|
||
><span>跟进人</span><span>{{ plan.followUserName || '—' }}</span></div
|
||
>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="plan-sec-transport" class="mp-wv-section mp-h5-anchor">
|
||
<h3>运输信息</h3>
|
||
|
||
<PlanVehicleInfo v-if="hasVehicle" :plate="plate" :trailer="vehicleDetail?.trailerNo" :type-label="vehicleTypeLabel" :capacity-text="vehicleLoadCapacityText" :carrier-name="carrierDisplayName" :people-summary="vehiclePeopleSummaryText" />
|
||
<p v-else class="plan-empty-hint">暂无车辆信息</p>
|
||
|
||
<div v-if="showArrangeActions || hasVehicle" class="plan-chip-row">
|
||
<button
|
||
v-if="showArrangeActions"
|
||
class="plan-chip plan-chip--primary"
|
||
type="button"
|
||
:disabled="acting"
|
||
@click="runAction('copy-driver')"
|
||
>
|
||
复制给司机
|
||
</button>
|
||
<button
|
||
v-if="showArrangeActions && hasSiteInventoryRecords"
|
||
class="plan-chip"
|
||
type="button"
|
||
:disabled="acting"
|
||
@click="runAction('copy-site')"
|
||
>
|
||
复制给现场
|
||
</button>
|
||
<button
|
||
v-if="showArrangeActions && hasDibangInventoryRecords"
|
||
class="plan-chip"
|
||
type="button"
|
||
:disabled="acting"
|
||
@click="runAction('copy-pound-qrcode')"
|
||
>
|
||
复制磅单二维码
|
||
</button>
|
||
<button
|
||
v-if="hasVehicle"
|
||
class="plan-chip"
|
||
type="button"
|
||
@click="vehicleOpen = true"
|
||
>
|
||
查看车辆
|
||
</button>
|
||
</div>
|
||
|
||
<template v-if="isArranged">
|
||
<div v-if="arrangeDriverUrl" class="plan-subcard">
|
||
<div class="plan-subcard__label">司机链接</div>
|
||
<div class="mp-wv-link-row">
|
||
<input class="mp-wv-link-input" :value="arrangeDriverUrl" readonly />
|
||
<button
|
||
class="mp-wv-btn"
|
||
type="button"
|
||
@click="copyText(arrangeDriverUrl, '司机链接')"
|
||
>
|
||
复制
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="siteLinks.length" class="plan-subcard">
|
||
<div class="plan-subcard__label">现场链接</div>
|
||
<div v-for="link in siteLinks" :key="link.siteLinkId" class="mp-wv-site-link">
|
||
<div v-if="siteLinkSubtitle(link)" class="plan-vehicle-card__meta">{{
|
||
siteLinkSubtitle(link)
|
||
}}</div>
|
||
<div class="mp-wv-link-row">
|
||
<input class="mp-wv-link-input" :value="link.siteUrl || ''" readonly />
|
||
<button
|
||
class="mp-wv-btn"
|
||
type="button"
|
||
@click="copyText(link.siteUrl || '', siteLinkSubtitle(link) || '现场链接')"
|
||
>
|
||
复制
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p v-else-if="requiresSiteLink" class="plan-empty-hint">暂无现场链接</p>
|
||
</template>
|
||
|
||
<div
|
||
v-for="link in siteLinks.filter((link) => link.siteFeedbackImages?.length)"
|
||
:key="link.siteLinkId"
|
||
class="plan-subcard"
|
||
>
|
||
<div class="plan-subcard__label"
|
||
>现场回传(历史未分段) · {{ link.productName }} ·
|
||
{{ link.siteFeedbackTime || '—' }}</div
|
||
>
|
||
<AttachmentUploadField
|
||
:model-value="link.siteFeedbackImages?.join(',') || ''"
|
||
disabled
|
||
/>
|
||
</div>
|
||
<article v-if="readDetail?.ioOrder" class="plan-subcard">
|
||
<div class="plan-subcard__label">出入库单 {{ readDetail.ioOrder.orderNo }}</div>
|
||
<p class="plan-vehicle-card__meta"
|
||
>{{ readDetail.ioOrder.businessType || '—' }} · 实际合计
|
||
{{ readDetail.ioOrder.actualQtyTotal ?? '—' }}</p
|
||
>
|
||
<p class="plan-vehicle-card__meta">{{
|
||
readDetail.ioOrder.siteFeedbackTime || '—'
|
||
}}</p>
|
||
<p class="plan-vehicle-card__meta">{{
|
||
readDetail.ioOrder.siteFeedbackRemark || '—'
|
||
}}</p>
|
||
<AttachmentUploadField
|
||
v-if="readDetail.ioOrder.siteFeedbackImages?.length"
|
||
:model-value="readDetail.ioOrder.siteFeedbackImages.join(',')"
|
||
disabled
|
||
/>
|
||
</article>
|
||
<div v-if="feedbackLines.length" class="plan-feedback-stack">
|
||
<div v-for="(line, idx) in feedbackLines" :key="idx" class="plan-subcard">
|
||
<div class="plan-subcard__label">{{ line.title }}</div>
|
||
<div>{{ line.text }}</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="plan-sec-load" class="mp-wv-section mp-h5-anchor">
|
||
<h3>装送货信息</h3>
|
||
<PlanStopReadCard
|
||
v-if="load"
|
||
:stop="load"
|
||
stop-type="load"
|
||
:address="loadViewAddress"
|
||
:products="plan.products || []"
|
||
:transport="readDetail?.transport || null"
|
||
:dibang-rows="readDetail?.dibangRows || []"
|
||
:incomplete="!!partialErrors.length"
|
||
/>
|
||
<van-tabs
|
||
v-if="deliveries.length > 1"
|
||
v-model:active="deliveryIndex"
|
||
class="mp-plan-delivery-tabs"
|
||
shrink
|
||
:ellipsis="false"
|
||
line-width="28px"
|
||
line-height="2px"
|
||
title-active-color="#087f57"
|
||
title-inactive-color="#6b7b74"
|
||
aria-label="送货段次"
|
||
>
|
||
<van-tab
|
||
v-for="(d, idx) in deliveries"
|
||
:key="d.id || idx"
|
||
:name="idx"
|
||
:title="deliveryTabTitle(d, idx)"
|
||
/>
|
||
</van-tabs>
|
||
<PlanStopReadCard
|
||
v-if="activeDelivery"
|
||
:key="activeDelivery.id || deliveryIndex"
|
||
:stop="activeDelivery"
|
||
stop-type="delivery"
|
||
:sequence="deliveryIndex + 1"
|
||
:address="activeDeliveryViewAddress"
|
||
:products="plan.products || []"
|
||
:transport="readDetail?.transport || null"
|
||
:dibang-rows="readDetail?.dibangRows || []"
|
||
:incomplete="!!partialErrors.length"
|
||
/>
|
||
<p v-else>暂无送货段</p>
|
||
<div class="plan-qty-summary">
|
||
<div
|
||
>计划总量 <b>{{ planTotalQtyText }}</b></div
|
||
>
|
||
<div
|
||
>装货合计 <b>{{ loadQty }}</b></div
|
||
>
|
||
<div
|
||
>送货合计 <b>{{ deliveryQty }}</b></div
|
||
>
|
||
<div v-if="deliveryQtyBreakdown" class="plan-vehicle-card__meta detail-wide"
|
||
>送货拆分 {{ deliveryQtyBreakdown }}</div
|
||
>
|
||
<div :class="qtyBalanced ? 'is-ok' : 'is-warn'">
|
||
{{ qtyBalanced ? '✓ 数量平衡' : '⚠ 装送数量不一致' }}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="plan-sec-cert" class="mp-wv-section mp-h5-anchor">
|
||
<h3>关联购买证 / 运输证</h3>
|
||
<p v-if="plan.quotaCertPendingFulfill" class="plan-empty-hint">
|
||
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
|
||
</p>
|
||
<div v-else-if="linkedCertRows.length" class="plan-linked-certs">
|
||
<div v-for="item in linkedCertRows" :key="item.key" class="plan-linked-cert-block">
|
||
<div class="plan-linked-cert-block__meta">
|
||
<span
|
||
class="status-tag"
|
||
:class="
|
||
item.kind === 'purchase' ? 'status-tag--success' : 'status-tag--warning'
|
||
"
|
||
>{{ item.kind === 'purchase' ? '购买证' : '运输证' }}</span
|
||
>
|
||
<span class="plan-linked-cert-block__no">{{ item.certNo || item.certId }}</span>
|
||
<span class="plan-vehicle-card__meta"
|
||
>预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span
|
||
>
|
||
</div>
|
||
<div class="plan-linked-cert-block__slot">
|
||
<div class="plan-subcard__label">证件附件</div>
|
||
<AttachmentUploadField
|
||
v-if="item.attachmentUrls"
|
||
:model-value="item.attachmentUrls"
|
||
disabled
|
||
/>
|
||
<p v-else class="plan-linked-cert-block__missing">未上传</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p v-else class="plan-empty-hint">暂无关联证件</p>
|
||
<div class="plan-cert-actions">
|
||
<van-button block plain type="primary" @click="quotaOpen = true">查看用证</van-button>
|
||
<van-button
|
||
v-if="needsLegalUseDocument"
|
||
block
|
||
plain
|
||
type="primary"
|
||
@click="router.push('/m/pl/legal-use-document')"
|
||
>维护合法使用证明</van-button
|
||
>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="plan-sec-freight" class="mp-wv-section mp-h5-anchor">
|
||
<h3>运费信息</h3>
|
||
<div class="detail-field-grid">
|
||
<div
|
||
v-for="(field, index) in displayFreightFields"
|
||
:key="field.label"
|
||
class="mp-wv-kv"
|
||
:class="{ 'detail-wide': index === 7 }"
|
||
><span>{{ field.label }}</span
|
||
><span>{{ field.value }}</span></div
|
||
>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="plan-sec-remark" class="mp-wv-section mp-h5-anchor">
|
||
<h3>备注</h3>
|
||
<p class="mp-wv-remark">{{ plan.remark?.trim() || '—' }}</p>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="mp-wv-fab-bar">
|
||
<button
|
||
v-if="actionSplit.more.length"
|
||
class="mp-wv-btn mp-wv-btn-more"
|
||
type="button"
|
||
aria-label="更多"
|
||
@click="moreOpen = true"
|
||
>
|
||
⋯
|
||
</button>
|
||
<button
|
||
v-for="(action, idx) in actionSplit.primary"
|
||
:key="action.kind"
|
||
class="mp-wv-btn"
|
||
:class="{
|
||
'mp-wv-btn--primary': idx === 0 || actionSplit.primary.length === 1,
|
||
'is-danger': action.danger
|
||
}"
|
||
type="button"
|
||
:disabled="acting"
|
||
@click="runAction(action.kind)"
|
||
>
|
||
{{ action.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<van-popup
|
||
v-model:show="quotaOpen"
|
||
position="bottom"
|
||
round
|
||
teleport="body"
|
||
class="mp-plan-quota-sheet"
|
||
safe-area-inset-bottom
|
||
>
|
||
<van-nav-bar :title="quotaTitle" left-text="关闭" @click-left="quotaOpen = false" />
|
||
<div class="mp-plan-quota-sheet__body mp-h5-detail-page">
|
||
<CertFacePanel v-if="quotaOpen" :plan-id="plan.id" />
|
||
</div>
|
||
</van-popup>
|
||
|
||
<van-popup
|
||
v-model:show="moreOpen"
|
||
position="bottom"
|
||
teleport="body"
|
||
round
|
||
class="mp-customer-vant-popup plan-detail-sheet"
|
||
>
|
||
<div class="mp-customer-filter-vant">
|
||
<van-nav-bar title="更多操作" left-text="关闭" @click-left="moreOpen = false" />
|
||
<div class="mp-customer-filter-vant__body plan-detail-sheet__opts">
|
||
<button
|
||
v-for="action in actionSplit.more"
|
||
:key="action.kind"
|
||
class="plan-sheet-opt"
|
||
:class="{ 'is-danger': action.danger }"
|
||
type="button"
|
||
:disabled="acting"
|
||
@click="runAction(action.kind)"
|
||
>
|
||
{{ action.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</van-popup>
|
||
|
||
<van-popup
|
||
v-model:show="voidOpen"
|
||
position="bottom"
|
||
teleport="body"
|
||
round
|
||
class="mp-customer-vant-popup plan-detail-sheet"
|
||
>
|
||
<div class="mp-customer-filter-vant">
|
||
<van-nav-bar
|
||
:title="voidForce ? '强制作废运输计划' : '作废运输计划'"
|
||
left-text="关闭"
|
||
@click-left="voidOpen = false"
|
||
/>
|
||
<div class="mp-customer-filter-vant__body">
|
||
<p class="mp-h5-sheet-note">{{ voidNote }}</p>
|
||
<van-field
|
||
v-model="voidReason"
|
||
type="textarea"
|
||
rows="3"
|
||
maxlength="200"
|
||
label="作废原因"
|
||
:placeholder="voidForce ? '必填' : '选填'"
|
||
/>
|
||
</div>
|
||
<div class="mp-customer-filter-vant__foot plan-detail-sheet__foot">
|
||
<button class="mp-wv-btn" type="button" :disabled="acting" @click="voidOpen = false">
|
||
取消
|
||
</button>
|
||
<button
|
||
class="mp-wv-btn mp-wv-btn--danger"
|
||
type="button"
|
||
:disabled="acting"
|
||
@click="confirmVoidPlan"
|
||
>
|
||
{{ voidForce ? '确认强制作废' : '确认作废' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</van-popup>
|
||
|
||
<PlanVehicleDetailSheet v-model="vehicleOpen" :vehicle-detail="vehicleDetail" :vehicle-people="vehiclePeople" :plate="plate" :vehicle-type-label="vehicleTypeLabel" :vehicle-load-capacity-text="vehicleLoadCapacityText" :carrier-display-name="carrierDisplayName" :vehicle-people-summary-text="vehiclePeopleSummaryText" :vehicle-personnel-summary-text="vehiclePersonnelSummaryText" />
|
||
|
||
<DbPoundSlipQrcodeDialog
|
||
ref="qrcodeDialogRef"
|
||
v-model:visible="qrcodeVisible"
|
||
:inventory-id="selectedInventoryId"
|
||
:quantity-unit="selectedInventoryUnit"
|
||
/>
|
||
<PlPlanPoundQrcodePickDialog
|
||
v-model:visible="poundQrcodePickVisible"
|
||
:records="poundQrcodePickRecords"
|
||
:plan="plan"
|
||
@copy="handlePoundQrcodePickCopy"
|
||
@preview="handlePoundQrcodePickPreview"
|
||
/>
|
||
</template>
|
||
</div>
|
||
</MpWebviewShell>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import PlanVehicleInfo from './PlanVehicleInfo.vue'
|
||
import PlanVehicleDetailSheet from './PlanVehicleDetailSheet.vue'
|
||
import { useUserStore } from '@/store/modules/user'
|
||
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
|
||
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
|
||
import MpStructSkeleton from '@/components/mp/MpStructSkeleton.vue'
|
||
import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
|
||
import DbPoundSlipQrcodeDialog from '@/components/business/db/DbPoundSlipQrcodeDialog.vue'
|
||
import PlPlanPoundQrcodePickDialog from '@/components/business/pl-plan-pound-qrcode-pick-dialog/index.vue'
|
||
import {
|
||
planApi,
|
||
transportApi,
|
||
vehicleApi,
|
||
personnelApi,
|
||
freightApi,
|
||
inboundOrderApi,
|
||
outboundOrderApi,
|
||
type Plan,
|
||
type TransportSiteLink
|
||
} from '@/api/pl'
|
||
import { fetchGetMenuCodes } from '@/api/auth'
|
||
import { buildPlanCard } from '@/utils/mp-pl-plan-card'
|
||
import { MP_PLAN_LIST_PATH } from '@/utils/mp-webview'
|
||
import { copyTextToClipboard } from '@/utils/clipboard'
|
||
import '@/styles/mp-webview.scss'
|
||
import '../../crm/customer/customer.scss'
|
||
import { formatMpPlanYmd } from '@/utils/mp-pl-plan-display'
|
||
import {
|
||
splitMpPlanDetailActions,
|
||
type MpDetailActionKind
|
||
} from '@/utils/mp-pl-plan-detail-actions'
|
||
import {
|
||
arrangeBlockedMessage,
|
||
arrangeTransportLabel,
|
||
canArrangeTransport,
|
||
canCancelWaitNotice,
|
||
canEditPlan,
|
||
canVoidPlan,
|
||
canWaitNotice,
|
||
isExecutionStarted,
|
||
showArrangeTransport,
|
||
showVoidPlan,
|
||
voidBlockedMessage
|
||
} from '@/utils/pl-plan-action-guards'
|
||
import {
|
||
getPlPlanCompanyLabel,
|
||
getPlVehicleTypeLabel,
|
||
normalizePlPersonnelSummaryDisplay
|
||
} from '@/utils/pl-display'
|
||
|
||
import { usePlPlanArrangeActions } from '@/composables/usePlPlanArrangeActions'
|
||
import { planRequiresSiteLink } from '@/utils/pl-internal-customer'
|
||
import {
|
||
resolveLoadViewAddress,
|
||
resolveDeliveryViewAddress
|
||
} from '@/utils/pl-plan-stop-form'
|
||
import { resolveMpProductUnitLabel } from '@/utils/mp-pl-plan-display'
|
||
|
||
import PlanStopReadCard from './PlanStopReadCard.vue'
|
||
import CertFacePanel from '../plan-cert/CertFacePanel.vue'
|
||
import { resolveProductHazardFlags } from '@/composables/usePlToxicProductCertCheck'
|
||
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
||
import { freightFields, loadMobileDetailReads } from './mobileDetailDisplay'
|
||
import { inventoryApi } from '@/api/db/inventory'
|
||
const readDetail = ref<Awaited<ReturnType<typeof loadMobileDetailReads>> | null>(null)
|
||
const partialErrors = ref<string[]>([])
|
||
const displayFreightFields = computed(() =>
|
||
plan.value
|
||
? freightFields(
|
||
plan.value,
|
||
readDetail.value?.transport || null,
|
||
readDetail.value?.settlement || null
|
||
)
|
||
: []
|
||
)
|
||
const vehicleDetail = computed(
|
||
() => readDetail.value?.snapshot || readDetail.value?.vehicle?.detail || plan.value
|
||
)
|
||
const vehiclePeople = computed(
|
||
() =>
|
||
readDetail.value?.snapshot?.personnelList || readDetail.value?.vehicle?.personnelList || []
|
||
)
|
||
function personnelRoleLabel(role?: string | null) {
|
||
if (role === 'driver') return '司机'
|
||
if (role === 'escort') return '押运员'
|
||
const raw = String(role || '').trim()
|
||
if (!raw) return '—'
|
||
if (/^(driver|escort)$/i.test(raw)) {
|
||
return raw.toLowerCase() === 'driver' ? '司机' : '押运员'
|
||
}
|
||
return raw
|
||
}
|
||
|
||
|
||
function localizePersonnelText(text?: string | null) {
|
||
const normalized = normalizePlPersonnelSummaryDisplay(text) || ''
|
||
return normalized
|
||
.replace(/\bdrivers?\b/gi, '司机')
|
||
.replace(/\bescorts?\b/gi, '押运员')
|
||
.replace(/\bdriver\b/gi, '司机')
|
||
.replace(/\bescort\b/gi, '押运员')
|
||
}
|
||
const vehiclePeopleSummaryText = computed(() => {
|
||
if (vehiclePeople.value.length) {
|
||
return vehiclePeople.value
|
||
.map((p) =>
|
||
`${personnelRoleLabel(p.role)} ${p.personnelName || '—'}${
|
||
p.phoneNo ? ` ${p.phoneNo}` : ''
|
||
}`.trim()
|
||
)
|
||
.join(' · ')
|
||
}
|
||
const p = plan.value
|
||
return `司机 ${p?.driverName || '—'} · 押运员 ${p?.escortName || '—'}`
|
||
})
|
||
const vehiclePersonnelSummaryText = computed(() => {
|
||
const raw =
|
||
vehicleDetail.value?.personnelSummary || plan.value?.personnelSummary || ''
|
||
return localizePersonnelText(raw) || '—'
|
||
})
|
||
const userStore = useUserStore()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const loading = ref(true)
|
||
const acting = ref(false)
|
||
const errorText = ref('')
|
||
const plan = ref<Plan | null>(null)
|
||
const quotaOpen = ref(false)
|
||
const linkedCertRows = ref<
|
||
Array<{
|
||
key: string
|
||
kind: string
|
||
certId: string
|
||
certNo?: string
|
||
reservedQty: string | number
|
||
usedQty: string | number
|
||
attachmentUrls: string
|
||
}>
|
||
>([])
|
||
const needsLegalUseDocument = computed(() =>
|
||
(plan.value?.products || []).some((row) => resolveProductHazardFlags(row).isExplosive)
|
||
)
|
||
const quotaTitle = computed(() => `计划用证 · ${plan.value?.planNo || ''}`)
|
||
|
||
async function loadLinkedCerts(planId: string) {
|
||
linkedCertRows.value = []
|
||
try {
|
||
const { planQuotaApi } = await import('@/api/pl/quota-cert')
|
||
const data = await planQuotaApi.get(planId)
|
||
linkedCertRows.value = (data?.usages || []).map((u: any, index: number) => ({
|
||
key: `${u.certType || u.cert_type}-${u.certId || u.cert_id}-${index}`,
|
||
kind: String(u.certType || u.cert_type || ''),
|
||
certId: String(u.certId || u.cert_id || ''),
|
||
certNo: u.certNo || u.cert_no || u.certNoSnapshot || u.cert_no_snapshot,
|
||
reservedQty: u.reservedQty ?? u.reserved_qty ?? '—',
|
||
usedQty: u.usedQty ?? u.used_qty ?? '—',
|
||
attachmentUrls: String(u.attachmentUrls || u.attachment_urls || '').trim()
|
||
}))
|
||
} catch {
|
||
linkedCertRows.value = []
|
||
}
|
||
}
|
||
const deliveryIndex = ref(0)
|
||
const moreOpen = ref(false)
|
||
const voidOpen = ref(false)
|
||
const voidReason = ref('')
|
||
const voidForce = ref(false)
|
||
const voidNote = ref('确认作废该运输计划?作废原因选填,此操作不可恢复。')
|
||
const canForceVoid = ref(false)
|
||
|
||
void loadForceVoidPermission()
|
||
|
||
async function loadForceVoidPermission() {
|
||
try {
|
||
const perm = await fetchGetMenuCodes()
|
||
canForceVoid.value =
|
||
!!perm?.all || !!perm?.isSuperAdmin || (perm?.menuCodes || []).includes('pl:plan:force-void')
|
||
} catch {
|
||
canForceVoid.value = false
|
||
}
|
||
}
|
||
const activeTab = ref('base')
|
||
const stickyOffset = ref(56)
|
||
const TAB_BAR_HEIGHT = 44
|
||
const scrollMargin = computed(() => stickyOffset.value + TAB_BAR_HEIGHT)
|
||
let scrollLockUntil = 0
|
||
const detailTabs = [
|
||
{ key: 'base', label: '基础信息', anchor: 'plan-sec-base' },
|
||
{ key: 'transport', label: '运输信息', anchor: 'plan-sec-transport' },
|
||
{ key: 'load', label: '装送货', anchor: 'plan-sec-load' },
|
||
{ key: 'cert', label: '关联证件', anchor: 'plan-sec-cert' },
|
||
{ key: 'freight', label: '运费', anchor: 'plan-sec-freight' },
|
||
{ key: 'remark', label: '备注', anchor: 'plan-sec-remark' }
|
||
]
|
||
const vehicleOpen = ref(false)
|
||
|
||
const {
|
||
driverUrl,
|
||
siteRecords,
|
||
inventoryRecords,
|
||
showArrangeActions,
|
||
hasSiteInventoryRecords,
|
||
hasDibangInventoryRecords,
|
||
qrcodeVisible,
|
||
selectedInventoryId,
|
||
selectedInventoryUnit,
|
||
qrcodeDialogRef,
|
||
poundQrcodePickVisible,
|
||
poundQrcodePickRecords,
|
||
loadArrangeActions,
|
||
resetArrangeActions,
|
||
copyPlanSummary,
|
||
copySiteSummary,
|
||
copyPoundSlipQrcode,
|
||
handlePoundQrcodePickCopy,
|
||
handlePoundQrcodePickPreview
|
||
} = usePlPlanArrangeActions({ isolateRecord: true })
|
||
|
||
const card = computed(() => (plan.value ? buildPlanCard(plan.value) : null))
|
||
const statusLabel = computed(() => card.value?.statusLabel || '—')
|
||
const statusTagType = computed(() => card.value?.statusTagType || 'info')
|
||
const typeLabel = computed(() => card.value?.typeLabel || '—')
|
||
const plate = computed(() => vehicleDetail.value?.licensePlate || card.value?.plate || '—')
|
||
const planDateText = computed(() => formatMpPlanYmd(plan.value?.planDate))
|
||
const isArranged = computed(() => plan.value?.transportStatus === 'arranged')
|
||
const hasVehicle = computed(() => {
|
||
const p = plan.value
|
||
return !!(p?.licensePlate || p?.vehicleId)
|
||
})
|
||
const vehicleTypeLabel = computed(() =>
|
||
getPlVehicleTypeLabel(String(vehicleDetail.value?.vehicleType || ''))
|
||
)
|
||
const vehicleLoadCapacityText = computed(() => {
|
||
const value = vehicleDetail.value?.loadCapacity
|
||
if (value == null || value === '') return '— 吨'
|
||
const num = Number(value)
|
||
if (Number.isNaN(num)) return `${value} 吨`
|
||
return `${num.toFixed(2)} 吨`
|
||
})
|
||
const carrierDisplayName = computed(
|
||
() =>
|
||
String(
|
||
readDetail.value?.transport?.carrierName ||
|
||
vehicleDetail.value?.carrierName ||
|
||
''
|
||
).trim()
|
||
)
|
||
const requiresSiteLink = computed(() => planRequiresSiteLink(plan.value))
|
||
const load = computed(() => plan.value?.load || null)
|
||
const deliveries = computed(() => plan.value?.deliveries || [])
|
||
const activeDelivery = computed(() => deliveries.value[deliveryIndex.value] || null)
|
||
const loadViewAddress = computed(() => resolveLoadViewAddress(plan.value, load.value))
|
||
const activeDeliveryViewAddress = computed(() =>
|
||
resolveDeliveryViewAddress(plan.value, activeDelivery.value, deliveryIndex.value)
|
||
)
|
||
const arrangeDriverUrl = computed(() => driverUrl.value || '')
|
||
const siteLinks = computed<TransportSiteLink[]>(() => siteRecords.value || [])
|
||
|
||
function sumProducts(products?: { planQty?: number }[]) {
|
||
return (products || []).reduce((sum, row) => sum + Number(row.planQty || 0), 0)
|
||
}
|
||
const loadQty = computed(() => sumProducts(load.value?.products))
|
||
const deliveryQty = computed(() =>
|
||
deliveries.value.reduce((sum, d) => sum + sumProducts(d.products), 0)
|
||
)
|
||
const qtyBalanced = computed(() => Math.abs(loadQty.value - deliveryQty.value) < 1e-6)
|
||
const qtyUnitLabel = computed(() => {
|
||
const units = (plan.value?.products || [])
|
||
.map((p) => resolveMpProductUnitLabel(p.unit))
|
||
.filter((u) => u && u !== '—')
|
||
const uniq = [...new Set(units)]
|
||
return uniq.length === 1 ? uniq[0] : ''
|
||
})
|
||
const planTotalQtyText = computed(() => {
|
||
const total = (plan.value?.products || []).reduce((sum, row) => sum + Number(row.quantity || 0), 0)
|
||
const unit = qtyUnitLabel.value
|
||
const text = String(parseFloat(total.toFixed(3)))
|
||
return unit ? `${text}${unit}` : text
|
||
})
|
||
const deliveryQtyBreakdown = computed(() => {
|
||
const parts = deliveries.value
|
||
.map((d) => parseFloat(sumProducts(d.products).toFixed(3)))
|
||
.filter((qty) => qty > 0)
|
||
if (parts.length < 2) return ''
|
||
const total = parseFloat(parts.reduce((a, b) => a + b, 0).toFixed(3))
|
||
return `${parts.join(' + ')} = ${total}`
|
||
})
|
||
function deliveryTabQtyLabel(delivery: { products?: { planQty?: number }[] }) {
|
||
const qty = sumProducts(delivery.products)
|
||
if (qty <= 0) return ''
|
||
const unit = qtyUnitLabel.value
|
||
const text = String(parseFloat(qty.toFixed(3)))
|
||
return unit ? `${text}${unit}` : text
|
||
}
|
||
function deliveryTabTitle(
|
||
delivery: { customerName?: string; products?: { planQty?: number }[] },
|
||
idx: number
|
||
) {
|
||
const name = delivery.customerName || `送货段 ${idx + 1}`
|
||
const qty = deliveryTabQtyLabel(delivery)
|
||
return qty ? `#${idx + 1} ${name} ${qty}` : `#${idx + 1} ${name}`
|
||
}
|
||
|
||
const feedbackLines = computed(() => {
|
||
const lines: { title: string; text: string }[] = []
|
||
for (const link of siteLinks.value) {
|
||
const remark = String(link.siteFeedbackRemark || '').trim()
|
||
if (remark) {
|
||
lines.push({
|
||
title: `现场回传 · ${link.productName || '现场'}`,
|
||
text: remark
|
||
})
|
||
}
|
||
}
|
||
return lines
|
||
})
|
||
|
||
const actionSplit = computed(() => {
|
||
const p = plan.value
|
||
if (!p) return { primary: [], more: [] }
|
||
const split = splitMpPlanDetailActions({
|
||
canEdit: canEditPlan(p),
|
||
canArrange: showArrangeTransport(p),
|
||
arrangeLabel: arrangeTransportLabel(p),
|
||
canCopyDriver: showArrangeActions.value,
|
||
canCopySite: showArrangeActions.value && hasSiteInventoryRecords.value,
|
||
canCopyPoundQrcode: showArrangeActions.value && hasDibangInventoryRecords.value,
|
||
canWait: canWaitNotice(p),
|
||
canCancelWait: canCancelWaitNotice(p),
|
||
canVoid: canVoidPlan(p)
|
||
})
|
||
if (showVoidPlan(p) && isExecutionStarted(p)) {
|
||
split.more.push(
|
||
canForceVoid.value
|
||
? { kind: 'force-void', label: '强制作废', danger: true }
|
||
: { kind: 'void-blocked', label: '作废', danger: true }
|
||
)
|
||
}
|
||
return split
|
||
})
|
||
|
||
function siteLinkSubtitle(link: TransportSiteLink) {
|
||
const name = String(link.productName || '').trim()
|
||
if (!name) return ''
|
||
const planNo = String(plan.value?.planNo || '').trim()
|
||
if (planNo && name === planNo) return ''
|
||
if (/^PO\d+/i.test(name)) return ''
|
||
return name
|
||
}
|
||
|
||
async function copyText(text: string, label: string) {
|
||
const value = text.trim()
|
||
if (!value) {
|
||
ElMessage.warning('无可复制内容')
|
||
return
|
||
}
|
||
const version = actionVersion
|
||
const ok = await copyTextToClipboard(value)
|
||
if (disposed || version !== actionVersion) return
|
||
if (ok) ElMessage.success(`已复制${label}`)
|
||
else ElMessage.error('复制失败')
|
||
}
|
||
|
||
const recordId = computed(() => String(route.params.id || ''))
|
||
let shellReady = false
|
||
let disposed = false
|
||
let loadVersion = 0
|
||
let actionVersion = 0
|
||
|
||
function invalidateRecord() {
|
||
loadVersion++
|
||
actionVersion++
|
||
plan.value = null
|
||
linkedCertRows.value = []
|
||
readDetail.value = null
|
||
partialErrors.value = []
|
||
errorText.value = ''
|
||
loading.value = true
|
||
acting.value = false
|
||
deliveryIndex.value = 0
|
||
activeTab.value = 'base'
|
||
moreOpen.value = false
|
||
voidOpen.value = false
|
||
voidReason.value = ''
|
||
vehicleOpen.value = false
|
||
resetArrangeActions()
|
||
}
|
||
|
||
async function loadDetail() {
|
||
if (!shellReady || disposed) return
|
||
invalidateRecord()
|
||
const id = recordId.value
|
||
const version = loadVersion
|
||
const current = () => !disposed && version === loadVersion && id === recordId.value
|
||
if (!id) {
|
||
errorText.value = '缺少计划 ID'
|
||
loading.value = false
|
||
return
|
||
}
|
||
try {
|
||
const res = await planApi.get(id, true)
|
||
if (!current()) return
|
||
const data = ((res as { data?: Plan }).data ?? res) as Plan
|
||
if (!data || String(data.id || '') !== id) throw new Error('计划不存在或已失效')
|
||
let arrangeFailed = false
|
||
try {
|
||
await loadArrangeActions(id, data)
|
||
} catch {
|
||
arrangeFailed = true
|
||
}
|
||
if (!current()) return
|
||
const detail = await loadMobileDetailReads(
|
||
data,
|
||
{
|
||
transport: transportApi.get,
|
||
freight: freightApi.getByTransport,
|
||
arrange: planApi.getArrangeInfo,
|
||
inventory: inventoryApi.get,
|
||
related: inventoryApi.getRelated,
|
||
io: (p) =>
|
||
(p.planType === 'inbound' ? inboundOrderApi : outboundOrderApi).getByPlan(p.id),
|
||
vehicle: vehicleApi.get,
|
||
personnel: personnelApi.get
|
||
},
|
||
{ siteRecords: siteRecords.value, inventoryRecords: inventoryRecords.value }
|
||
)
|
||
if (!current()) return
|
||
readDetail.value = detail
|
||
partialErrors.value = [...detail.errors, ...(arrangeFailed ? ['安排信息'] : [])]
|
||
plan.value = data
|
||
await loadLinkedCerts(id)
|
||
if (!current()) return
|
||
} catch {
|
||
if (!current()) return
|
||
plan.value = null
|
||
linkedCertRows.value = []
|
||
resetArrangeActions()
|
||
errorText.value = '计划详情加载失败或记录不存在,请重试'
|
||
} finally {
|
||
if (current()) loading.value = false
|
||
}
|
||
}
|
||
|
||
watch(
|
||
recordId,
|
||
() => {
|
||
invalidateRecord()
|
||
if (shellReady) void loadDetail()
|
||
},
|
||
{ flush: 'sync' }
|
||
)
|
||
|
||
watch(
|
||
() => JSON.stringify([userStore.accessToken, userStore.info]),
|
||
() => {
|
||
invalidateRecord()
|
||
if (!shellReady || disposed) return
|
||
if (userStore.accessToken) void loadDetail()
|
||
else {
|
||
loading.value = false
|
||
errorText.value = '登录状态已变化,请重新打开页面'
|
||
}
|
||
},
|
||
{ flush: 'sync' }
|
||
)
|
||
|
||
function goList() {
|
||
void router.replace(MP_PLAN_LIST_PATH)
|
||
}
|
||
|
||
function actionablePlan() {
|
||
const p = plan.value
|
||
return !disposed && !loading.value && !acting.value && p?.id === recordId.value ? p : null
|
||
}
|
||
|
||
async function runAction(kind: MpDetailActionKind) {
|
||
const p = actionablePlan()
|
||
if (!p) return
|
||
moreOpen.value = false
|
||
if (kind === 'edit' && canEditPlan(p)) {
|
||
void router.push(`/m/pl/plan/${p.id}/edit`)
|
||
} else if (kind === 'arrange') {
|
||
if (isExecutionStarted(p)) {
|
||
ElMessage.warning(arrangeBlockedMessage(p))
|
||
return
|
||
}
|
||
if (canArrangeTransport(p)) void router.push(`/m/pl/plan/${p.id}/arrange`)
|
||
} else if (kind === 'void-blocked') {
|
||
ElMessage.warning(voidBlockedMessage(p))
|
||
} else if (kind === 'copy') {
|
||
void router.push({ path: '/m/pl/plan/new', query: { copyFrom: p.id } })
|
||
} else if (kind === 'void' && canVoidPlan(p)) {
|
||
voidReason.value = ''
|
||
voidForce.value = false
|
||
voidNote.value = '确认作废该运输计划?作废原因选填,此操作不可恢复。'
|
||
voidOpen.value = true
|
||
} else if (kind === 'force-void' && canForceVoid.value && isExecutionStarted(p)) {
|
||
voidReason.value = ''
|
||
voidForce.value = true
|
||
voidNote.value = '正在读取强制作废影响…'
|
||
voidOpen.value = true
|
||
void loadForceVoidNote(p.id)
|
||
} else if (
|
||
(kind === 'wait' && canWaitNotice(p)) ||
|
||
(kind === 'cancel-wait' && canCancelWaitNotice(p))
|
||
) {
|
||
await changePlanStatus(kind, p)
|
||
} else if (
|
||
showArrangeActions.value &&
|
||
(kind === 'copy-driver' ||
|
||
(kind === 'copy-site' && hasSiteInventoryRecords.value) ||
|
||
(kind === 'copy-pound-qrcode' && hasDibangInventoryRecords.value))
|
||
) {
|
||
const version = ++actionVersion
|
||
acting.value = true
|
||
try {
|
||
if (kind === 'copy-driver') await copyPlanSummary()
|
||
else if (kind === 'copy-site') await copySiteSummary()
|
||
else await copyPoundSlipQrcode()
|
||
} catch {
|
||
if (version === actionVersion && !disposed) ElMessage.error('复制失败,请重试')
|
||
} finally {
|
||
if (version === actionVersion && !disposed) acting.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
async function changePlanStatus(kind: 'wait' | 'cancel-wait' | 'void', p: Plan) {
|
||
const id = p.id
|
||
const version = ++actionVersion
|
||
const current = () => !disposed && version === actionVersion && id === recordId.value
|
||
const reason = voidReason.value.trim() || undefined
|
||
const title =
|
||
kind === 'wait' ? '等客户通知' : kind === 'cancel-wait' ? '取消等通知' : '作废运输计划'
|
||
acting.value = true
|
||
try {
|
||
try {
|
||
await showCrmConfirmDialog({
|
||
kind: kind === 'void' ? 'danger' : 'default',
|
||
title,
|
||
message:
|
||
kind === 'wait'
|
||
? '确认将该计划标记为等客户通知?'
|
||
: kind === 'cancel-wait'
|
||
? '确认取消等通知,恢复为待安排?'
|
||
: '确认作废该运输计划?此操作不可恢复。',
|
||
confirmButtonText: kind === 'void' ? '确认作废' : '确认'
|
||
})
|
||
} catch {
|
||
return
|
||
}
|
||
if (!current()) return
|
||
// Status endpoints return scalar PlanRead; nested rows require the mobile detail GET.
|
||
if (kind === 'wait') await planApi.waitNotice(id)
|
||
else if (kind === 'cancel-wait') await planApi.cancelWaitNotice(id)
|
||
else await planApi.cancel(id, { cancelReason: reason })
|
||
if (!current()) return
|
||
if (kind === 'void') voidOpen.value = false
|
||
const refresh = loadDetail()
|
||
const refreshVersion = loadVersion
|
||
await refresh
|
||
if (disposed || recordId.value !== id || refreshVersion !== loadVersion) return
|
||
if (errorText.value) {
|
||
errorText.value = '状态已保存,但详情刷新失败,请重试加载详情,无需再次提交'
|
||
ElMessage.warning(errorText.value)
|
||
return
|
||
}
|
||
if (partialErrors.value.length) {
|
||
ElMessage.warning('状态已保存,部分详情刷新失败,请重试加载详情')
|
||
return
|
||
}
|
||
ElMessage.success(
|
||
kind === 'wait' ? '已标记为等客户通知' : kind === 'cancel-wait' ? '已取消等通知' : '已作废'
|
||
)
|
||
} catch {
|
||
if (current()) ElMessage.error('操作失败,请重试')
|
||
} finally {
|
||
if (current()) acting.value = false
|
||
}
|
||
}
|
||
|
||
async function loadForceVoidNote(id: string) {
|
||
try {
|
||
const preview = await planApi.forceVoidPreview(id)
|
||
if (plan.value?.id === id && voidForce.value) {
|
||
voidNote.value =
|
||
preview?.notice || '强制作废会冲销已开始的地磅单和出入库单,并退回数量。作废原因必填。'
|
||
}
|
||
} catch {
|
||
if (plan.value?.id === id && voidForce.value) {
|
||
voidNote.value = '强制作废会冲销已开始的地磅单和出入库单,并退回数量。作废原因必填。'
|
||
}
|
||
}
|
||
}
|
||
|
||
async function confirmVoidPlan() {
|
||
const p = actionablePlan()
|
||
if (!p) return
|
||
if (voidForce.value) {
|
||
if (!canForceVoid.value || !isExecutionStarted(p)) return
|
||
if (!voidReason.value.trim()) {
|
||
ElMessage.warning('请填写作废原因')
|
||
return
|
||
}
|
||
const id = p.id
|
||
const version = ++actionVersion
|
||
const current = () => !disposed && version === actionVersion && id === recordId.value
|
||
acting.value = true
|
||
try {
|
||
try {
|
||
await showCrmConfirmDialog({
|
||
kind: 'danger',
|
||
title: '强制作废运输计划',
|
||
message: '确认强制作废?已开始的地磅单将作废,数量退回,此操作不可恢复。',
|
||
confirmButtonText: '确认强制作废'
|
||
})
|
||
} catch {
|
||
return
|
||
}
|
||
if (!current()) return
|
||
await planApi.forceVoid(id, { cancelReason: voidReason.value.trim() })
|
||
if (!current()) return
|
||
voidOpen.value = false
|
||
await loadDetail()
|
||
if (current()) ElMessage.success('已强制作废')
|
||
} catch {
|
||
if (current()) ElMessage.error('强制作废失败,请重试')
|
||
} finally {
|
||
if (current()) acting.value = false
|
||
}
|
||
return
|
||
}
|
||
if (!canVoidPlan(p)) return
|
||
await changePlanStatus('void', p)
|
||
}
|
||
|
||
function syncStickyOffset() {
|
||
const header = document.querySelector(
|
||
'.mp-plan-detail-page > .mp-wv-header'
|
||
) as HTMLElement | null
|
||
stickyOffset.value = Math.ceil(header?.getBoundingClientRect().height || 56)
|
||
}
|
||
|
||
function syncActiveTabFromScroll() {
|
||
if (Date.now() < scrollLockUntil || !plan.value) return
|
||
const scroller = document.scrollingElement || document.documentElement
|
||
const viewportHeight = window.innerHeight
|
||
const atBottom =
|
||
scroller.scrollHeight > viewportHeight &&
|
||
window.scrollY > 0 &&
|
||
window.scrollY + viewportHeight >= scroller.scrollHeight - 2
|
||
const lastTab = detailTabs[detailTabs.length - 1]
|
||
const lastSection = lastTab && document.getElementById(lastTab.anchor)
|
||
// A short final section cannot reach the top probe once the document reaches its end.
|
||
if (atBottom && lastSection && lastSection.getBoundingClientRect().top < viewportHeight) {
|
||
activeTab.value = lastTab.key
|
||
return
|
||
}
|
||
const probe = scrollMargin.value + 4
|
||
let current = detailTabs[0]?.key ?? 'base'
|
||
let closestTop = Number.NEGATIVE_INFINITY
|
||
for (const tab of detailTabs) {
|
||
const el = document.getElementById(tab.anchor)
|
||
if (!el) continue
|
||
const top = el.getBoundingClientRect().top
|
||
if (top <= probe && top > closestTop) {
|
||
current = tab.key
|
||
closestTop = top
|
||
}
|
||
}
|
||
if (activeTab.value !== current) activeTab.value = current
|
||
}
|
||
|
||
function onAnchorTab({ name }: { name: string | number }) {
|
||
const key = String(name)
|
||
activeTab.value = key
|
||
scrollLockUntil = Date.now() + 700
|
||
const tab = detailTabs.find((item) => item.key === key)
|
||
const el = tab ? document.getElementById(tab.anchor) : null
|
||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
|
||
function onDetailScroll() {
|
||
syncActiveTabFromScroll()
|
||
}
|
||
|
||
function onShellReady() {
|
||
if (shellReady || disposed) return
|
||
shellReady = true
|
||
nextTick(() => {
|
||
if (!disposed) syncStickyOffset()
|
||
})
|
||
void loadDetail()
|
||
}
|
||
|
||
onMounted(() => {
|
||
syncStickyOffset()
|
||
window.addEventListener('resize', syncStickyOffset)
|
||
window.addEventListener('scroll', onDetailScroll, { passive: true })
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
disposed = true
|
||
invalidateRecord()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('resize', syncStickyOffset)
|
||
window.removeEventListener('scroll', onDetailScroll)
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.mp-plan-detail-page .mp-wv-btn-more {
|
||
width: 44px;
|
||
min-width: 44px;
|
||
flex-basis: 44px;
|
||
}
|
||
|
||
.mp-plan-detail-page .plan-chip {
|
||
min-height: 36px;
|
||
}
|
||
|
||
.mp-plan-detail-page.mp-h5-detail-page .mp-h5-detail-tabs {
|
||
:deep(.van-tabs__wrap) {
|
||
max-width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
:deep(.van-tabs__nav) {
|
||
height: 44px;
|
||
box-sizing: border-box;
|
||
padding: 0 8px;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
:deep(.van-tab) {
|
||
flex: 0 0 auto !important;
|
||
min-width: 72px;
|
||
height: 44px;
|
||
padding: 0 14px;
|
||
white-space: nowrap;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
:deep(.van-tab__text) {
|
||
white-space: nowrap;
|
||
}
|
||
|
||
:deep(.van-tabs__line) {
|
||
bottom: 0;
|
||
}
|
||
}
|
||
|
||
/* 中度美化:锚点区卡片化 */
|
||
.mp-plan-detail-page .mp-wv-detail-body {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
padding-bottom: 12px;
|
||
overflow-x: hidden;
|
||
box-sizing: border-box;
|
||
color: #1f2a26;
|
||
}
|
||
|
||
.mp-plan-detail-page .mp-wv-section {
|
||
min-width: 0;
|
||
max-width: 100%;
|
||
margin-bottom: 10px;
|
||
padding: 12px 14px;
|
||
box-sizing: border-box;
|
||
background: #fff;
|
||
border: 1px solid #dcebe4;
|
||
border-radius: 14px;
|
||
box-shadow: none;
|
||
|
||
> h3 {
|
||
position: relative;
|
||
padding: 0 0 0 8px;
|
||
margin: 0 0 12px;
|
||
font-size: 15px;
|
||
font-weight: 650;
|
||
line-height: 1.2;
|
||
color: #1f2a26;
|
||
|
||
&::before {
|
||
display: block;
|
||
position: absolute;
|
||
top: 0.12em;
|
||
bottom: 0.12em;
|
||
left: 0;
|
||
width: 3px;
|
||
content: '';
|
||
background: #0a9f6d;
|
||
border-radius: 2px;
|
||
}
|
||
}
|
||
|
||
> h4 {
|
||
margin: 12px 0 8px;
|
||
font-size: 13px;
|
||
font-weight: 650;
|
||
color: #1f2a26;
|
||
}
|
||
}
|
||
|
||
.mp-plan-detail-page .detail-field-grid,
|
||
.vehicle-sheet-body .detail-field-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 0 16px;
|
||
}
|
||
|
||
.mp-plan-detail-page .detail-field-grid .detail-wide,
|
||
.mp-plan-detail-page .detail-field-grid .mp-wv-kv.detail-wide,
|
||
.vehicle-sheet-body .detail-wide,
|
||
.plan-qty-summary .detail-wide {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.mp-plan-detail-page .mp-wv-kv,
|
||
.vehicle-sheet-body .mp-wv-kv {
|
||
min-width: 0;
|
||
padding: 8px 0;
|
||
border-bottom: 1px solid #eef4f1;
|
||
|
||
> span {
|
||
min-width: 0;
|
||
overflow-wrap: anywhere;
|
||
word-break: normal;
|
||
}
|
||
> span:first-child {
|
||
color: #8a9a93;
|
||
}
|
||
> span:last-child:not(.status-tag) {
|
||
font-weight: 600;
|
||
}
|
||
|
||
> .status-tag {
|
||
align-self: flex-start;
|
||
width: fit-content;
|
||
max-width: 100%;
|
||
}
|
||
}
|
||
|
||
.plan-transport-fields {
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
@media (max-width: 359px) {
|
||
.mp-plan-detail-page .detail-field-grid,
|
||
.vehicle-sheet-body .detail-field-grid,
|
||
.mp-plan-detail-page .plan-qty-summary {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
}
|
||
|
||
.plan-vehicle-card {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.plan-vehicle-card--sheet {
|
||
margin-bottom: 12px;
|
||
padding: 12px;
|
||
background: #e9f9f2;
|
||
border: 1px solid #b7ebce;
|
||
border-radius: 12px;
|
||
|
||
.plan-vehicle-card__plate {
|
||
background: #fff;
|
||
}
|
||
}
|
||
|
||
.plan-vehicle-sheet-block {
|
||
margin-top: 12px;
|
||
|
||
> .plan-subcard__label {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
> .plan-subcard + .plan-subcard {
|
||
margin-top: 8px;
|
||
}
|
||
}
|
||
|
||
.plan-person-head {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
align-items: center;
|
||
margin-bottom: 4px;
|
||
|
||
strong {
|
||
font-size: 14px;
|
||
font-weight: 650;
|
||
color: #1f2a26;
|
||
}
|
||
}
|
||
|
||
.plan-vehicle-card__plate {
|
||
flex: 0 0 auto;
|
||
min-width: 88px;
|
||
max-width: 42%;
|
||
padding: 10px 8px;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
line-height: 1.25;
|
||
color: #087f57;
|
||
text-align: center;
|
||
overflow-wrap: anywhere;
|
||
background: #e9f9f2;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.plan-vehicle-card__body {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.plan-vehicle-card__line {
|
||
font-size: 13px;
|
||
color: #1f2a26;
|
||
}
|
||
|
||
.plan-vehicle-card__meta {
|
||
margin: 4px 0 0;
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
color: #6b7b74;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.plan-empty-hint {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
color: #6b7b74;
|
||
}
|
||
|
||
.plan-chip-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin: 0 0 10px;
|
||
}
|
||
|
||
.plan-chip {
|
||
min-height: 36px;
|
||
padding: 6px 12px;
|
||
font-size: 12px;
|
||
color: #087f57;
|
||
background: #fff;
|
||
border: 1px solid #dcebe4;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.plan-chip--primary {
|
||
color: #fff;
|
||
background: #0a9f6d;
|
||
border-color: #0a9f6d;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.plan-chip:disabled {
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.plan-subcard {
|
||
margin-top: 8px;
|
||
padding: 10px 12px;
|
||
background: #f7faf8;
|
||
border: 1px solid #dcebe4;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.plan-subcard__label {
|
||
margin: 0 0 6px;
|
||
font-size: 12px;
|
||
font-weight: 650;
|
||
color: #087f57;
|
||
}
|
||
|
||
.plan-feedback-stack {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.mp-wv-link-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 8px;
|
||
|
||
.mp-wv-btn {
|
||
flex: 0 0 auto;
|
||
width: auto;
|
||
min-width: 56px;
|
||
min-height: 40px;
|
||
height: 40px;
|
||
padding: 0 12px;
|
||
}
|
||
}
|
||
|
||
.mp-wv-link-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
height: 40px;
|
||
padding: 0 10px;
|
||
font-size: 12px;
|
||
background: #fff;
|
||
border: 1px solid #dcebe4;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.mp-wv-site-link + .mp-wv-site-link {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.mp-plan-delivery-tabs {
|
||
margin: 4px -6px 8px;
|
||
background: transparent;
|
||
|
||
--van-tabs-line-height: 44px;
|
||
--van-tabs-nav-background: transparent;
|
||
--van-tab-font-size: 13px;
|
||
--van-tabs-bottom-bar-color: #0a9f6d;
|
||
--van-tabs-bottom-bar-height: 2px;
|
||
|
||
:deep(.van-tabs__wrap) {
|
||
height: 44px;
|
||
}
|
||
|
||
:deep(.van-tabs__nav) {
|
||
padding: 0 4px;
|
||
background: transparent;
|
||
}
|
||
|
||
:deep(.van-tab) {
|
||
flex: 0 0 auto;
|
||
min-width: 0;
|
||
padding: 0 12px;
|
||
}
|
||
|
||
:deep(.van-tabs__content) {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
.plan-qty-summary {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 6px 16px;
|
||
margin-top: 10px;
|
||
padding: 10px 12px;
|
||
font-size: 13px;
|
||
background: #f7faf8;
|
||
border: 1px solid #dcebe4;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.plan-qty-summary .is-ok {
|
||
color: #087f57;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.plan-qty-summary .is-warn {
|
||
color: #cf8a19;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.mp-wv-cert-summary {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
margin-bottom: 10px;
|
||
font-size: 12px;
|
||
color: #6b7b74;
|
||
}
|
||
|
||
.plan-cert-product {
|
||
margin-top: 8px;
|
||
padding: 10px 12px;
|
||
border: 1px solid #dcebe4;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.plan-cert-product h4 {
|
||
margin: 0 0 8px;
|
||
font-size: 14px;
|
||
font-weight: 650;
|
||
}
|
||
|
||
.plan-cert-slot + .plan-cert-slot {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.mp-wv-remark {
|
||
margin: 0;
|
||
padding: 0;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
line-height: 1.55;
|
||
color: #1f2a26;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.mp-plan-detail-page .mp-wv-fab-bar {
|
||
gap: 8px;
|
||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
||
background: rgba(255, 255, 255, 0.96);
|
||
border-top: 1px solid #dcebe4;
|
||
box-shadow: 0 -6px 20px rgb(34 64 52 / 8%);
|
||
}
|
||
|
||
.mp-plan-detail-page .mp-wv-fab-bar .mp-wv-btn {
|
||
height: 40px;
|
||
min-height: 40px;
|
||
font-size: 14px;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.mp-plan-detail-page .mp-wv-fab-bar .mp-wv-btn-more {
|
||
width: 44px;
|
||
min-width: 44px;
|
||
flex-basis: 44px;
|
||
}
|
||
|
||
.mp-wv-btn--danger {
|
||
color: #fff !important;
|
||
background: #dc2626 !important;
|
||
border-color: #dc2626 !important;
|
||
}
|
||
|
||
.plan-cert-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.plan-linked-certs {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.plan-linked-cert-block {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
padding: 12px;
|
||
border: 1px solid #d9e8e2;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
}
|
||
|
||
.plan-linked-cert-block__meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 8px 10px;
|
||
}
|
||
|
||
.plan-linked-cert-block__no {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.plan-linked-cert-block__slot {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.plan-linked-cert-block__missing {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 140px;
|
||
height: 96px;
|
||
margin: 0;
|
||
border: 1px dashed #d9e8e2;
|
||
border-radius: 8px;
|
||
background: #f8fafc;
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.mp-plan-quota-sheet {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 92dvh;
|
||
max-height: 92dvh;
|
||
overflow: hidden;
|
||
background: #f5f8f7;
|
||
--van-primary-color: #0a9f6d;
|
||
--van-nav-bar-text-color: #0a9f6d;
|
||
--van-nav-bar-icon-color: #0a9f6d;
|
||
}
|
||
|
||
.mp-plan-quota-sheet__body {
|
||
min-height: 0;
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 10px 12px 16px;
|
||
}
|
||
|
||
.plan-detail-sheet {
|
||
:deep(.van-nav-bar__left) {
|
||
min-width: 44px;
|
||
min-height: 44px;
|
||
}
|
||
}
|
||
|
||
.plan-detail-sheet__opts {
|
||
padding: 0 0 8px;
|
||
}
|
||
|
||
.plan-sheet-opt {
|
||
display: block;
|
||
width: 100%;
|
||
min-height: 48px;
|
||
padding: 12px 16px;
|
||
font: inherit;
|
||
color: #1f2a26;
|
||
text-align: left;
|
||
background: transparent;
|
||
border: 0;
|
||
border-bottom: 1px solid #eef4f1;
|
||
}
|
||
|
||
.plan-sheet-opt.is-danger {
|
||
color: #dc2626;
|
||
}
|
||
|
||
.plan-detail-sheet__foot {
|
||
display: flex !important;
|
||
gap: 8px;
|
||
align-items: stretch;
|
||
|
||
.mp-wv-btn {
|
||
flex: 1 1 0;
|
||
height: 40px;
|
||
min-height: 40px;
|
||
border-radius: 10px;
|
||
}
|
||
}
|
||
|
||
.vehicle-sheet-body {
|
||
box-sizing: border-box;
|
||
max-height: 70vh;
|
||
padding: 12px 16px 16px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.is-danger {
|
||
color: #dc2626 !important;
|
||
}
|
||
</style>
|