Files
shanxia2.0/web-admin/src/views/m/pl/plan/detail.vue
T
wuzilongandCursor 95ecb7236e fix(pl): 计划详情用关联证件附件替换危化品证书区。
Web 与 H5 详情将关联购买证/运输证移到装送货后并以附件卡片展示;用证接口回填证件附件 URL。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 14:46:46 +08:00

1707 lines
52 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>