front/src/views/h5/customer-center/components/H5PlaceOrderSheet.vue
zzh b011a5caee
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m33s
feat: 下单申报表头配置与产品线路排序,完善自动触发、店铺订单与权限相关能力
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 14:03:30 +08:00

4389 lines
135 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>
<a-drawer
:visible="visible"
placement="bottom"
height="auto"
:footer="false"
:header="false"
unmount-on-close
:mask-closable="!submitting"
:drawer-style="drawerStyle"
popup-container="body"
class="h5-po-drawer"
@cancel="close"
>
<div class="h5-po">
<div class="h5-po__handle" aria-hidden="true" />
<header class="h5-po__head">
<h3>{{ isEditMode ? t('h5.placeOrder.editTitle') : t('h5.placeOrder.title') }}</h3>
<button type="button" class="h5-po__close" :disabled="submitting" @click="close">
<icon-close />
</button>
</header>
<div class="h5-po__steps">
<button
v-for="(s, idx) in steps"
:key="s.key"
type="button"
class="h5-po__step"
:class="{ 'is-active': step === idx, 'is-done': idx < step }"
@click="goToStep(idx)"
>
{{ idx + 1 }}.{{ s.label }}
</button>
</div>
<p v-if="handoffTip" class="h5-po__tip">
<span>{{ handoffTip }}</span>
<button type="button" @click="handoffTip = ''">×</button>
</p>
<a-spin :loading="optionsLoading" class="h5-po__spin">
<section v-show="step === 0" class="h5-po__card">
<div class="h5-po__sender-head">
<label class="h5-po__label">{{ t('h5.placeOrder.sender') }}</label>
<button type="button" class="h5-po__link" @click="showSenderForm = !showSenderForm">
{{ showSenderForm ? t('h5.placeOrder.cancelAddSender') : t('h5.placeOrder.addSender') }}
</button>
</div>
<div v-if="showSenderForm" class="h5-po__sender-form">
<div class="h5-po__row">
<a-input v-model="senderForm.senderName" :placeholder="t('h5.placeOrder.senderNamePh')" />
<a-input v-model="senderForm.phone" :placeholder="t('h5.placeOrder.senderPhonePh')" />
</div>
<div class="h5-po__row">
<a-input v-model="senderForm.province" :placeholder="t('h5.placeOrder.senderProvincePh')" />
<a-input v-model="senderForm.city" :placeholder="t('h5.placeOrder.senderCityPh')" />
</div>
<a-textarea
v-model="senderForm.streetAddress1"
:placeholder="t('h5.placeOrder.senderAddressPh')"
:auto-size="{ minRows: 2, maxRows: 3 }"
/>
<button
type="button"
class="h5-po__btn is-primary h5-po__btn--block"
:disabled="savingSender"
@click="saveSender"
>
{{ savingSender ? t('h5.placeOrder.savingSender') : t('h5.placeOrder.saveSender') }}
</button>
</div>
<template v-else>
<p v-if="!senderLoading && !senderOptions.length" class="h5-po__sender-empty">
{{ t('h5.placeOrder.senderEmpty') }}
</p>
<a-select
:model-value="selectedSenderId"
allow-clear
:loading="senderLoading"
:error="fieldHasError('sender')"
:placeholder="t('h5.placeOrder.senderPh')"
@change="onSenderChange"
>
<a-option v-for="opt in senderOptions" :key="opt.id" :value="opt.id">
{{ opt.label }}
</a-option>
</a-select>
<p v-if="fieldError('sender')" class="h5-po__field-err">{{ fieldError('sender') }}</p>
</template>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.clientNo') }}</label>
<a-input
v-model="form.clientNo"
allow-clear
:error="fieldHasError('clientNo')"
:placeholder="t('h5.placeOrder.clientNoPh')"
/>
<p v-if="fieldError('clientNo')" class="h5-po__field-err">{{ fieldError('clientNo') }}</p>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.product') }}</label>
<a-select
v-model="form.productCode"
allow-search
allow-clear
:disabled="isEditMode"
:error="fieldHasError('productCode')"
:placeholder="t('h5.placeOrder.productPh')"
>
<a-option v-for="opt in productOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-option>
</a-select>
<p v-if="fieldError('productCode')" class="h5-po__field-err">{{ fieldError('productCode') }}</p>
</div>
<div v-if="productAdditionalServiceOptions.length" class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.additionalServices') }}</label>
<a-checkbox-group
v-model="selectedAdditionalServices"
class="h5-po__services"
:disabled="isEditMode"
>
<a-checkbox
v-for="opt in productAdditionalServiceOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-checkbox>
</a-checkbox-group>
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.cargo') }}</label>
<a-select v-model="form.cargoAttribute" allow-clear :error="fieldHasError('cargoAttribute')">
<a-option v-for="opt in cargoOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-option>
</a-select>
<p v-if="fieldError('cargoAttribute')" class="h5-po__field-err">
{{ fieldError('cargoAttribute') }}
</p>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.warehouse') }}</label>
<a-select
v-model="form.warehouseId"
allow-clear
:disabled="isEditMode"
:error="fieldHasError('warehouseId')"
>
<a-option v-for="opt in warehouseOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-option>
</a-select>
<p v-if="fieldError('warehouseId')" class="h5-po__field-err">
{{ fieldError('warehouseId') }}
</p>
</div>
</div>
<div class="h5-po__field">
<label class="h5-po__label">
{{ t('h5.placeOrder.weight') }} / {{ t('h5.placeOrder.dimensions') }}
</label>
<div class="h5-po__dims h5-po__dims--with-weight">
<a-input
v-model="form.actualWeight"
allow-clear
:disabled="isEditMode"
:error="fieldHasError('actualWeight')"
:placeholder="t('h5.placeOrder.weightPh')"
/>
<a-input
v-model="form.length"
:disabled="isEditMode"
:error="fieldHasError('length')"
:placeholder="t('h5.placeOrder.length')"
/>
<a-input
v-model="form.width"
:disabled="isEditMode"
:error="fieldHasError('width')"
:placeholder="t('h5.placeOrder.width')"
/>
<a-input
v-model="form.height"
:disabled="isEditMode"
:error="fieldHasError('height')"
:placeholder="t('h5.placeOrder.height')"
/>
</div>
<p
v-if="
fieldError('actualWeight') ||
fieldError('length') ||
fieldError('width') ||
fieldError('height')
"
class="h5-po__field-err"
>
{{
fieldError('actualWeight') ||
fieldError('length') ||
fieldError('width') ||
fieldError('height')
}}
</p>
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.packageQty') }}</label>
<a-input
v-model="form.quantity"
allow-clear
:disabled="isEditMode"
:error="fieldHasError('quantity')"
:placeholder="t('h5.placeOrder.packageQtyPh')"
/>
<p v-if="fieldError('quantity')" class="h5-po__field-err">
{{ fieldError('quantity') }}
</p>
<p v-else class="h5-po__hint">{{ t('h5.placeOrder.packageQtyHint') }}</p>
</div>
<div class="h5-po__field">
<label class="h5-po__label h5-po__label--with-help">
<span>{{ t('h5.placeOrder.exportCustomsMode') }}</span>
<button
type="button"
class="h5-po__help"
:aria-label="t('h5.placeOrder.customsGuideTitle')"
@click="exportCustomsGuideVisible = true"
>
<icon-question-circle />
</button>
</label>
<a-select
v-model="form.exportCustomsMode"
allow-search
allow-clear
:disabled="isEditMode"
:placeholder="t('h5.placeOrder.exportCustomsModePh')"
>
<a-option
v-for="opt in exportCustomsModeOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-option>
</a-select>
</div>
</div>
<template v-if="needExportQualification">
<div class="h5-po__summary-banner">
<strong>{{ t('h5.placeOrder.summaryNeedTitle') }}</strong>
<p>{{ t('h5.placeOrder.summaryNeedDesc') }}</p>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.exportQualification') }}</label>
<a-select
v-model="form.exportQualificationId"
allow-search
allow-clear
:loading="exportQualificationLoading"
:disabled="isEditMode"
:error="fieldHasError('exportQualificationId')"
:placeholder="t('h5.placeOrder.exportQualificationPh')"
>
<a-option
v-for="opt in exportQualificationOptions"
:key="opt.id"
:value="String(opt.id)"
>
{{ opt.label }}
</a-option>
</a-select>
<p v-if="fieldError('exportQualificationId')" class="h5-po__field-err">
{{ fieldError('exportQualificationId') }}
</p>
<p v-else-if="!exportQualificationOptions.length" class="h5-po__hint">
{{ t('h5.placeOrder.exportQualificationEmpty') }}
</p>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.platformCode') }}</label>
<a-input
v-model="form.platformCode"
allow-clear
:max-length="16"
:disabled="isEditMode"
:error="fieldHasError('platformCode')"
:placeholder="t('h5.placeOrder.platformCodePh')"
/>
<p v-if="fieldError('platformCode')" class="h5-po__field-err">
{{ fieldError('platformCode') }}
</p>
</div>
</template>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.inboundMethod') }}</label>
<a-radio-group
v-model="form.inboundMethod"
type="button"
size="small"
class="h5-po__inbound"
:disabled="isEditMode"
>
<a-radio value="selfShip">{{ t('h5.placeOrder.inboundSelfShip') }}</a-radio>
<a-radio value="doorPickup">{{ t('h5.placeOrder.inboundDoorPickup') }}</a-radio>
</a-radio-group>
<p v-if="form.inboundMethod === 'doorPickup'" class="h5-po__hint">
{{ t('h5.placeOrder.inboundDoorPickupHint') }}
</p>
<div v-if="form.inboundMethod === 'selfShip'" class="h5-po__wh-card">
<div class="h5-po__wh-head">
<strong>{{ t('h5.placeOrder.selfShipAddressTitle') }}</strong>
</div>
<p v-if="!warehouseOptions.length" class="h5-po__hint">
{{ t('h5.placeOrder.selfShipNoWarehouse') }}
</p>
<p v-else-if="!warehousesWithShipAddress.length" class="h5-po__hint">
{{ t('h5.placeOrder.selfShipNoAddressConfigured') }}
</p>
<template v-else>
<div class="h5-po__wh-list">
<div
v-for="opt in warehouseOptions"
:key="opt.value"
class="h5-po__wh-item"
:class="{
'is-selected': String(opt.value) === String(form.warehouseId ?? ''),
'is-empty': !warehouseHasAddress(opt),
}"
>
<div class="h5-po__wh-item-head">
<strong>
{{ opt.label || opt.value }}
<span
v-if="String(opt.value) === String(form.warehouseId ?? '')"
class="h5-po__wh-badge"
>
{{ t('h5.placeOrder.selfShipCurrent') }}
</span>
</strong>
<button
v-if="warehouseHasAddress(opt)"
type="button"
class="h5-po__wh-copy"
@click="copyWarehouseShipAddress(opt)"
>
{{ t('h5.placeOrder.selfShipCopy') }}
</button>
</div>
<p v-if="!warehouseHasAddress(opt)" class="h5-po__hint">
{{ t('h5.placeOrder.selfShipAddressEmpty') }}
</p>
<template v-else>
<p>
<span>{{ t('h5.placeOrder.selfShipRecipient') }}</span>
{{ displayWarehouseRecipient(opt) }}
</p>
<p>
<span>{{ t('h5.placeOrder.selfShipPhone') }}</span>
{{ opt.contactPhone || '-' }}
</p>
<p class="is-addr">
<span>{{ t('h5.placeOrder.selfShipAddress') }}</span>
{{ displayWarehouseShippingAddress(opt) }}
</p>
<p v-if="opt.remark">
<span>{{ t('h5.placeOrder.selfShipRemark') }}</span>
{{ opt.remark }}
</p>
</template>
</div>
</div>
<p class="h5-po__hint">{{ t('h5.placeOrder.selfShipAddressHint') }}</p>
</template>
</div>
</div>
<template v-if="form.inboundMethod === 'doorPickup'">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.pickupAddress') }}</label>
<a-select
:model-value="selectedPickupAddressId"
allow-clear
:loading="pickupAddressLoading"
:error="fieldHasError('sender')"
:placeholder="t('h5.placeOrder.pickupAddressPh')"
@change="onPickupAddressChange"
@focus="loadPickupAddresses"
>
<a-option v-for="opt in pickupAddressOptions" :key="opt.id" :value="opt.id">
{{ opt.label }}
</a-option>
</a-select>
<p v-if="fieldError('sender')" class="h5-po__field-err">{{ fieldError('sender') }}</p>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.expectPickupTime') }}</label>
<a-date-picker
v-model="form.expectPickupTime"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
:error="fieldHasError('expectPickupTime')"
:placeholder="t('h5.placeOrder.expectPickupTimePh')"
/>
<p v-if="fieldError('expectPickupTime')" class="h5-po__field-err">
{{ fieldError('expectPickupTime') }}
</p>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.carrierService') }}</label>
<a-select
v-model="form.carrierService"
allow-search
allow-clear
:placeholder="t('h5.placeOrder.carrierServicePh')"
>
<a-option
v-for="opt in carrierServiceOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-option>
</a-select>
</div>
<p class="h5-po__hint">{{ t('h5.placeOrder.doorPickupBatchHint') }}</p>
</template>
</section>
<section v-show="step === 1" class="h5-po__card">
<div class="h5-po__receiver-toolbar">
<div class="h5-po__field h5-po__field--grow">
<label class="h5-po__label">{{ t('h5.placeOrder.receiverAddressBook') }}</label>
<a-select
:model-value="selectedReceiverAddressId"
allow-clear
:loading="receiverAddressLoading"
:placeholder="t('h5.placeOrder.receiverAddressBookPh')"
@change="onReceiverAddressChange"
@focus="loadReceiverAddresses"
>
<a-option v-for="opt in receiverAddressOptions" :key="opt.id" :value="opt.id">
{{ opt.label }}
</a-option>
</a-select>
</div>
<button
type="button"
class="h5-po__save-addr"
:disabled="savingReceiverAddress"
@click="saveReceiverAddress"
>
{{
savingReceiverAddress
? t('h5.placeOrder.savingReceiver')
: t('h5.placeOrder.saveReceiver')
}}
</button>
</div>
<p class="h5-po__hint h5-po__hint--tight">{{ t('h5.placeOrder.receiverAddressBookHint') }}</p>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.receiverName') }}</label>
<a-input
v-model="form.receiver.recipientsName"
allow-clear
:error="fieldHasError('receiver.recipientsName')"
/>
<p v-if="fieldError('receiver.recipientsName')" class="h5-po__field-err">
{{ fieldError('receiver.recipientsName') }}
</p>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.receiverPhone') }}</label>
<a-input
v-model="form.receiver.phone"
allow-clear
:error="fieldHasError('receiver.phone')"
/>
<p v-if="fieldError('receiver.phone')" class="h5-po__field-err">
{{ fieldError('receiver.phone') }}
</p>
</div>
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.country') }}</label>
<a-select
v-model="form.receiver.countryCode"
allow-search
allow-clear
:error="fieldHasError('receiver.countryCode')"
>
<a-option v-for="opt in countryOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-option>
</a-select>
<p v-if="fieldError('receiver.countryCode')" class="h5-po__field-err">
{{ fieldError('receiver.countryCode') }}
</p>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.zip') }}</label>
<a-input
v-model="form.receiver.zipCode"
allow-clear
:error="fieldHasError('receiver.zipCode')"
/>
<p v-if="fieldError('receiver.zipCode')" class="h5-po__field-err">
{{ fieldError('receiver.zipCode') }}
</p>
</div>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.address') }}</label>
<a-textarea
v-model="form.receiver.streetAddress1"
:error="fieldHasError('receiver.streetAddress1')"
:auto-size="{ minRows: 2, maxRows: 3 }"
/>
<p v-if="fieldError('receiver.streetAddress1')" class="h5-po__field-err">
{{ fieldError('receiver.streetAddress1') }}
</p>
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.city') }}</label>
<a-input v-model="form.receiver.city" allow-clear />
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.province') }}</label>
<a-input v-model="form.receiver.province" allow-clear />
</div>
</div>
<div class="h5-po__field h5-po__field--street-number">
<label class="h5-po__label">{{ t('h5.placeOrder.streetNumber') }}</label>
<div class="h5-po__street-number-row">
<a-input v-model="form.receiver.streetNumber" allow-clear />
<button
type="button"
class="h5-po__more h5-po__more--inline"
@click="showReceiverMore = !showReceiverMore"
>
{{
showReceiverMore
? t('h5.placeOrder.hideReceiverOptional')
: t('h5.placeOrder.showReceiverOptional')
}}
</button>
</div>
</div>
<div v-show="showReceiverMore" class="h5-po__more-panel">
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.company') }}</label>
<a-input v-model="form.receiver.companyName" allow-clear />
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.telePhone') }}</label>
<a-input v-model="form.receiver.telePhone" allow-clear />
</div>
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.email') }}</label>
<a-input v-model="form.receiver.email" allow-clear />
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.address2') }}</label>
<a-input v-model="form.receiver.streetAddress2" allow-clear />
</div>
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.address3') }}</label>
<a-input v-model="form.receiver.streetAddress3" allow-clear />
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.taxType') }}</label>
<a-input v-model="form.receiver.taxType" allow-clear :placeholder="t('h5.placeOrder.typePh')" />
</div>
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.taxNo') }}</label>
<a-input v-model="form.receiver.taxNo" allow-clear />
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.certificateType') }}</label>
<a-input
v-model="form.receiver.certificateType"
allow-clear
:placeholder="t('h5.placeOrder.typePh')"
/>
</div>
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.certificateNo') }}</label>
<a-input v-model="form.receiver.certificateNo" allow-clear />
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.eori') }}</label>
<a-input v-model="form.receiver.eori" allow-clear />
</div>
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.vat') }}</label>
<a-input v-model="form.receiver.vat" allow-clear />
</div>
</div>
</div>
</section>
<section v-show="step === 2" class="h5-po__declare">
<div class="h5-po__declare-bar">
<button type="button" class="h5-po__sku-btn" @click="openDeclarationSkuSheet">
{{ t('h5.placeOrder.pickSku') }}
</button>
<p class="h5-po__declare-bar-tip">{{ t('h5.placeOrder.pickSkuHint') }}</p>
</div>
<p v-if="declarationLiveHint" class="h5-po__field-err h5-po__field-err--block">
{{ declarationLiveHint }}
</p>
<div class="h5-po__card h5-po__card--currency">
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.declaredTotal') }}</label>
<a-input
v-model="declaredTotalInput"
allow-clear
:placeholder="t('h5.placeOrder.declaredTotalPh')"
@focus="declaredTotalInputFocused = true"
@blur="handleDeclaredTotalBlur"
@change="handleDeclaredTotalChange"
/>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.currency') }}</label>
<a-select
v-model="form.declaredCurrency"
allow-clear
:error="fieldHasError('declaredCurrency')"
>
<a-option v-for="opt in currencyOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-option>
</a-select>
<p v-if="fieldError('declaredCurrency')" class="h5-po__field-err">
{{ fieldError('declaredCurrency') }}
</p>
</div>
</div>
</div>
<p v-if="fieldError('itemsEmpty')" class="h5-po__field-err h5-po__field-err--block">
{{ fieldError('itemsEmpty') }}
</p>
<div v-for="(row, idx) in items" :key="row._key" class="h5-po__card h5-po__item">
<div class="h5-po__item-head">
<div class="h5-po__item-head-main">
<strong>{{ t('h5.placeOrder.itemTitle', { n: idx + 1 }) }}</strong>
<span class="h5-po__item-derived">
{{ t('h5.placeOrder.amount') }} {{ row.amount || '-' }} ·
{{ t('h5.placeOrder.itemWeight') }} {{ row.weight || '-' }}
</span>
</div>
<button
v-if="items.length > 1"
type="button"
class="h5-po__item-remove"
@click="removeItem(idx)"
>
{{ t('h5.placeOrder.removeItem') }}
</button>
</div>
<div v-if="row.skuImage" class="h5-po__sku-preview">
<img :src="skuImageSrc(row.skuImage)" alt="" referrerpolicy="no-referrer" />
<span>{{ row.sku || t('h5.placeOrder.sku') }}</span>
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.enName') }}</label>
<a-input
v-model="row.enName"
allow-clear
:error="fieldHasError(`items.${idx}.enName`)"
/>
<p v-if="fieldError(`items.${idx}.enName`)" class="h5-po__field-err">
{{ fieldError(`items.${idx}.enName`) }}
</p>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.cnName') }}</label>
<a-input v-model="row.cnName" allow-clear />
</div>
</div>
<div class="h5-po__three">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.unitPrice') }}</label>
<a-input
v-model="row.unitPrice"
allow-clear
:error="fieldHasError(`items.${idx}.unitPrice`)"
@input="syncItemDerived(row)"
/>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.unitWeight') }}</label>
<a-input
v-model="row.unitWeight"
allow-clear
:error="fieldHasError(`items.${idx}.unitWeight`)"
@input="syncItemDerived(row)"
/>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.qty') }}</label>
<a-input
v-model="row.quantity"
allow-clear
:error="fieldHasError(`items.${idx}.quantity`)"
@input="syncItemDerived(row)"
/>
</div>
</div>
<p
v-if="
fieldError(`items.${idx}.unitPrice`) ||
fieldError(`items.${idx}.unitWeight`) ||
fieldError(`items.${idx}.quantity`)
"
class="h5-po__field-err"
>
{{
fieldError(`items.${idx}.unitPrice`) ||
fieldError(`items.${idx}.unitWeight`) ||
fieldError(`items.${idx}.quantity`)
}}
</p>
<button
type="button"
class="h5-po__more"
@click="toggleItemMore(row._key)"
>
{{
isItemMoreOpen(row._key)
? t('h5.placeOrder.hideMore')
: t('h5.placeOrder.showMore')
}}
</button>
<div v-show="isItemMoreOpen(row._key)" class="h5-po__more-panel">
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.sku') }}</label>
<a-input v-model="row.sku" allow-clear />
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.unit') }}</label>
<a-input v-model="row.unit" allow-clear />
</div>
</div>
<div class="h5-po__magnetic">
<span>{{ t('h5.placeOrder.isMagnetic') }}</span>
<a-switch v-model="row.isMagnetic" size="small" />
</div>
<p class="h5-po__hint h5-po__hint--tight">{{ t('h5.placeOrder.isMagneticHint') }}</p>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.skuImage') }}</label>
<a-input
v-model="row.skuImage"
allow-clear
:placeholder="t('h5.placeOrder.skuImagePh')"
/>
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.imHsCode') }}</label>
<a-input v-model="row.imHsCode" allow-clear />
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.exHsCode') }}</label>
<a-input v-model="row.exHsCode" allow-clear />
</div>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.declareElement') }}</label>
<a-textarea v-model="row.declareElement" :auto-size="{ minRows: 2, maxRows: 3 }" />
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.brand') }}</label>
<a-input v-model="row.brand" allow-clear />
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.brandType') }}</label>
<a-input v-model="row.brandType" allow-clear />
</div>
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.model') }}</label>
<a-input v-model="row.model" allow-clear />
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.distribution') }}</label>
<a-input v-model="row.distribution" allow-clear />
</div>
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.material') }}</label>
<a-input v-model="row.material" allow-clear />
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.enMaterial') }}</label>
<a-input v-model="row.enMaterial" allow-clear />
</div>
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.purpose') }}</label>
<a-input v-model="row.purpose" allow-clear />
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.originCountry') }}</label>
<a-input v-model="row.originCountry" allow-clear />
</div>
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.category') }}</label>
<a-input v-model="row.category" allow-clear />
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.specifications') }}</label>
<a-input v-model="row.specifications" allow-clear />
</div>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.attributes') }}</label>
<a-input v-model="row.attributes" allow-clear />
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.salesLink') }}</label>
<a-input v-model="row.salesLink" allow-clear />
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.comment') }}</label>
<a-textarea v-model="row.comment" :auto-size="{ minRows: 2, maxRows: 3 }" />
</div>
</div>
</div>
<button type="button" class="h5-po__add-item" @click="addItem">
+ {{ t('h5.placeOrder.addItem') }}
</button>
</section>
</a-spin>
<footer class="h5-po__foot">
<button
v-if="!isEditMode"
type="button"
class="h5-po__btn is-mock"
:disabled="submitting"
@click="fillMockOrder"
>
{{ t('h5.placeOrder.mock') }}
</button>
<div class="h5-po__foot-main">
<button
v-if="step > 0"
type="button"
class="h5-po__btn is-ghost"
:disabled="submitting"
@click="step -= 1"
>
{{ t('h5.placeOrder.prev') }}
</button>
<button
v-if="step === 1"
type="button"
class="h5-po__btn is-ai"
:disabled="submitting"
@click="openReceiverAiParse"
>
{{ t('h5.placeOrder.aiParse') }}
</button>
<button
v-if="step < 2"
type="button"
class="h5-po__btn is-primary"
@click="goNext"
>
{{ t('h5.placeOrder.next') }}
</button>
<button
v-else
type="button"
class="h5-po__btn is-primary"
:disabled="submitting"
@click="submit"
>
<span v-if="submitting" class="h5-po__loading" aria-hidden="true" />
{{
submitting
? isEditMode
? t('h5.placeOrder.saving')
: t('h5.placeOrder.submitting')
: isEditMode
? t('h5.placeOrder.save')
: t('h5.placeOrder.submit')
}}
</button>
</div>
</footer>
</div>
</a-drawer>
<a-modal
v-model:visible="receiverAiVisible"
:title="t('h5.placeOrder.aiParseTitle')"
:footer="false"
unmount-on-close
width="92%"
:mask-closable="!receiverParseLoading"
>
<div class="h5-po-ai">
<p class="h5-po-ai__hint">{{ t('h5.placeOrder.aiParseHint') }}</p>
<div class="h5-po-ai__config">
<a-select
v-model="receiverAiProviderKey"
class="h5-po-ai__select"
size="small"
:placeholder="t('h5.placeOrder.aiParseProvider')"
:loading="receiverAiProvidersLoading"
allow-search
:disabled="receiverParseLoading"
@change="onReceiverAiProviderChange"
>
<a-option
v-for="p in receiverAiEnabledProviders"
:key="p.providerKey"
:value="p.providerKey"
:label="p.displayName || p.providerKey"
/>
</a-select>
<a-select
v-model="receiverAiModelId"
class="h5-po-ai__select"
size="small"
:placeholder="t('h5.placeOrder.aiParseModel')"
allow-create
allow-search
:disabled="receiverParseLoading"
@change="saveReceiverAiPrefs"
>
<a-option
v-for="m in receiverAiModelOptions"
:key="m.modelId"
:value="m.modelId"
>
{{ m.modelId }}{{ m.role ? `(${m.role})` : '' }}
</a-option>
</a-select>
<a-select
v-model="receiverAiSkillKey"
class="h5-po-ai__select h5-po-ai__select--skill"
size="small"
:placeholder="t('h5.placeOrder.aiParseSkill')"
:loading="receiverAiSkillsLoading"
allow-search
:disabled="receiverParseLoading"
@change="saveReceiverAiPrefs"
>
<a-option
v-for="s in receiverAiSkills"
:key="s.skillKey"
:value="s.skillKey"
:label="s.name || s.skillKey"
/>
</a-select>
</div>
<a-textarea
v-model="receiverPasteText"
class="h5-po-ai__textarea"
:auto-size="{ minRows: 5, maxRows: 10 }"
:placeholder="t('h5.placeOrder.aiParsePlaceholder')"
:disabled="receiverParseLoading"
@paste="onReceiverAiPaste"
/>
<p
v-if="receiverParseTip"
class="h5-po-ai__tip"
:class="`is-${receiverParseTip.tone}`"
>
{{ receiverParseTip.text }}
</p>
<div class="h5-po-ai__actions">
<button
type="button"
class="h5-po__btn is-ghost"
:disabled="receiverParseLoading"
@click="receiverAiVisible = false"
>
{{ t('h5.placeOrder.aiParseCancel') }}
</button>
<button
type="button"
class="h5-po__btn is-primary"
:disabled="receiverParseLoading || !receiverPasteText.trim()"
@click="handleReceiverAiParse"
>
<span v-if="receiverParseLoading" class="h5-po__loading" aria-hidden="true" />
{{
receiverParseLoading
? t('h5.placeOrder.aiParseLoading')
: t('h5.placeOrder.aiParseConfirm')
}}
</button>
</div>
</div>
</a-modal>
<a-modal
v-model:visible="createFailVisible"
:title="
createFailInfo?.draftSaved
? t('h5.placeOrder.failTitleDraft')
: t('h5.placeOrder.failTitle')
"
:footer="false"
:mask-closable="!createFailCancelling && !createFailSavingDraft"
unmount-on-close
width="92%"
>
<div v-if="createFailInfo" class="h5-po-fail">
<div class="h5-po-fail__hero">
<p class="h5-po-fail__reason">{{ createFailInfo.reason }}</p>
<p v-if="createFailInfo.tips?.[0]" class="h5-po-fail__tip">
<span class="h5-po-fail__tip-label">{{ t('h5.placeOrder.failHow') }}</span>
{{ createFailInfo.tips[0] }}
</p>
</div>
<div v-if="createFailInfo.draftSaved" class="h5-po-fail__status">
<span>{{ t('h5.placeOrder.failDraftNote') }}</span>
<span v-if="createFailInfo.serviceNo" class="h5-po-fail__sno">
{{ createFailInfo.serviceNo }}
</span>
</div>
<details
v-if="createFailInfo.rawReason && createFailInfo.rawReason !== createFailInfo.reason"
class="h5-po-fail__details"
>
<summary>{{ t('h5.placeOrder.failRaw') }}</summary>
<p class="h5-po-fail__raw">{{ createFailInfo.rawReason }}</p>
</details>
<div class="h5-po-fail__actions">
<button
type="button"
class="h5-po__btn is-ghost"
:disabled="createFailCancelling || createFailSavingDraft"
@click="handleCreateFailCancel"
>
{{
createFailInfo.draftSaved
? t('h5.placeOrder.failCancelDraft')
: t('h5.placeOrder.failAbandon')
}}
</button>
<button
v-if="!createFailInfo.draftSaved"
type="button"
class="h5-po__btn is-ghost"
:disabled="createFailCancelling || createFailSavingDraft"
@click="handleCreateFailSaveDraft"
>
{{
createFailSavingDraft
? t('h5.placeOrder.submitting')
: t('h5.placeOrder.failSaveDraft')
}}
</button>
<button
type="button"
class="h5-po__btn is-primary"
:disabled="createFailCancelling || createFailSavingDraft"
@click="handleCreateFailPrimary"
>
{{
createFailInfo.draftSaved
? t('h5.placeOrder.failGoDraft')
: t('h5.placeOrder.failContinue')
}}
</button>
</div>
</div>
</a-modal>
<H5SelectDeclarationSkuSheet
v-model:visible="declarationSkuVisible"
@select="handleDeclarationSkuSelect"
/>
<H5ExportCustomsGuideSheet v-model:visible="exportCustomsGuideVisible" />
</template>
<script lang="ts" setup>
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Message, Modal } from '@arco-design/web-vue';
import { IconClose, IconQuestionCircle } from '@arco-design/web-vue/es/icon';
import {
getOrderDetail,
postOrderDelete,
postOrderParcelCreate,
postOrderReceiverParseAddress,
postOrderUpdate,
} from '@/api/order';
import { listAiProviders, listAiSkills, type AiSkillItem } from '@/api/ai';
import { createSenderAddress, getSenderAddressList } from '@/api/order/senderAddress';
import { getExportQualificationList } from '@/api/customer/exportQualification';
import { getSalesProductDetail, getSalesProductList } from '@/api/channel/salesProduct';
import { getDictListAll } from '@/api/system/dict';
import { getCountryList } from '@/api/system/country';
import { getWarehouseList } from '@/api/system/warehouse';
import { resolveCurrentCustomer } from '@/api/customer/resolve';
import { resolveOmsAssetUrl } from '@/api/oms/common';
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
import {
mapWarehouseSkuToDeclarationFields,
normalizeSkuImageForStorage,
pickField,
} from '@/utils/warehouseListMapper';
import {
compareWarehouseSort,
formatWarehouseShipAddress,
formatWarehouseShipRecipient,
} from '@/utils/warehouseShipAddress';
import { getMapIdFromAccessToken } from '@/utils/jwt';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { h5Navigate } from '@/utils/device';
import { isCustomerContactRequiredError } from '@/utils/customerContactGate';
import {
buildParcelCreateFailGuide,
type ParcelCreateFailGuide,
} from '@/utils/parcelCreateFailGuide';
import {
loadDomesticPickupProductOptions,
type DomesticPickupProductOption,
} from '@/utils/domesticPickupProducts';
import {
providerModelTags,
type AiProviderItem,
} from '@/views/system/aiAssistant/providerCatalog';
import H5SelectDeclarationSkuSheet from './H5SelectDeclarationSkuSheet.vue';
import H5ExportCustomsGuideSheet from './H5ExportCustomsGuideSheet.vue';
function isSenderRequiredError(error: unknown): boolean {
const msg = String(
(error as any)?.response?.data?.message ||
(error as any)?.response?.data?.Message ||
(error as any)?.message ||
error ||
'',
);
return msg.includes('发件人姓名不能为空') || /sender\s*name/i.test(msg);
}
defineOptions({ name: 'H5PlaceOrderSheet' });
const PRICE_CHECK_ORDER_DRAFT_KEY = 'oms_price_check_order_draft';
const props = defineProps<{
visible: boolean;
/** 查价带入草稿;不传则尝试读本地缓存 */
draft?: Record<string, any> | null;
/** 传入则为编辑模式:加载详情并走 /order/update */
orderId?: number | string | null;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
success: [];
}>();
const { t } = useI18n();
const router = useRouter();
const isEditMode = computed(() => {
const id = Number(props.orderId);
return Number.isFinite(id) && id > 0;
});
/** 详情是否已有发件人(更新接口不能新建 sender) */
const editHasSender = ref(false);
const countryIdToCode = ref<Record<string, string>>({});
const step = ref(0);
const optionsLoading = ref(false);
const senderLoading = ref(false);
const showSenderForm = ref(false);
const savingSender = ref(false);
const showReceiverMore = ref(false);
const RECEIVER_AI_PREFS_KEY = 'oms.placeOrder.receiverAi';
const RECEIVER_AI_SCENE = 'receiverAddressParse';
const RECEIVER_AI_DEFAULT_SKILL_KEY = 'oms-receiver-address-parse';
const SAMPLE_EXPORT_CUSTOMS_MODE_CODE = '9610Simple';
const EXPORT_CUSTOMS_MODE_9610_SUMMARY = '9610Summary';
const receiverAiVisible = ref(false);
const receiverPasteText = ref('');
const receiverParseLoading = ref(false);
const receiverParseTip = ref<{ tone: 'ok' | 'warn' | 'danger'; text: string } | null>(null);
const receiverAiProviders = ref<AiProviderItem[]>([]);
const receiverAiSkills = ref<AiSkillItem[]>([]);
const receiverAiProviderKey = ref('');
const receiverAiModelId = ref('');
const receiverAiSkillKey = ref(RECEIVER_AI_DEFAULT_SKILL_KEY);
const receiverAiProvidersLoading = ref(false);
const receiverAiSkillsLoading = ref(false);
const exportCustomsGuideVisible = ref(false);
const declarationSkuVisible = ref(false);
const exportQualificationLoading = ref(false);
const exportQualificationOptions = ref<Array<{ id: number; label: string; isDefault: boolean }>>([]);
const exportCustomsModeOptions = ref<{ value: string; label: string }[]>([]);
const receiverAddressLoading = ref(false);
const receiverAddressOptions = ref<
Array<{ id: number; label: string; isDefault: boolean; data: Record<string, string> }>
>([]);
const selectedReceiverAddressId = ref<number | undefined>();
const savingReceiverAddress = ref(false);
const receiverAiEnabledProviders = computed(() =>
receiverAiProviders.value.filter((p) => p.enabled && Boolean(p.apiKey)),
);
const receiverAiSelectedProvider = computed(
() =>
receiverAiEnabledProviders.value.find(
(p) => p.providerKey === receiverAiProviderKey.value,
) ?? null,
);
const receiverAiModelOptions = computed(() => {
const p = receiverAiSelectedProvider.value;
if (!p) return [];
const tags = providerModelTags(p.providerType);
const extra = String(p.apiSecret || '').trim();
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
return [...tags, { modelId: extra, role: '自定义' }];
}
return tags;
});
/** 申报品「更多」展开状态,按 item._key */
const openItemMoreKeys = ref<Set<string>>(new Set());
const senderForm = reactive({
senderName: '',
phone: '',
province: '',
city: '',
streetAddress1: '',
countryCode: 'CN',
});
const submitting = ref(false);
const handoffTip = ref('');
const selectedSenderId = ref<number | undefined>();
/** 当前步骤校验失败后标红;申报步骤始终即时标红 */
const showFieldErrors = ref(false);
const drawerStyle = {
borderRadius: '20px 20px 0 0',
overflow: 'hidden',
maxHeight: 'min(92vh, 780px)',
};
const steps = computed(() => [
{ key: 'basic', label: t('h5.placeOrder.stepBasic') },
{ key: 'receiver', label: t('h5.placeOrder.stepReceiver') },
{ key: 'declaration', label: t('h5.placeOrder.stepDeclaration') },
]);
const productOptions = ref<{ value: string; label: string; productId?: number }[]>([]);
const cargoOptions = ref<{ value: string; label: string }[]>([]);
const warehouseOptions = ref<
{
value: string;
label: string;
recipient?: string;
contactPhone?: string;
shippingAddress?: string;
sort?: number | null;
remark?: string;
}[]
>([]);
const countryOptions = ref<{ value: string; label: string }[]>([]);
const currencyOptions = ref<{ value: string; label: string }[]>([]);
const senderOptions = ref<{ id: number; label: string; data: Record<string, string> }[]>([]);
const pickupAddressOptions = ref<{ id: number; label: string }[]>([]);
const pickupAddressLoading = ref(false);
const selectedPickupAddressId = ref<number | undefined>();
const carrierServiceOptions = ref<DomesticPickupProductOption[]>([]);
const additionalServiceDictRows = ref<Array<{ dictCode: string; dictName: string }>>([]);
const productAdditionalServiceOptions = ref<{ value: string; label: string }[]>([]);
const selectedAdditionalServices = ref<string[]>([]);
function parseAdditionalServiceCodes(raw: unknown): string[] {
if (Array.isArray(raw)) {
return raw.map((x) => String(x ?? '').trim()).filter(Boolean);
}
if (raw == null || raw === '') return [];
const s = String(raw).trim();
if (!s) return [];
try {
const parsed = JSON.parse(s);
if (Array.isArray(parsed)) {
return parsed.map((x) => String(x ?? '').trim()).filter(Boolean);
}
} catch {
/* ignore */
}
return s
.split(/[,,]/)
.map((x) => x.trim())
.filter(Boolean);
}
function labelAdditionalService(code: string) {
const hit = additionalServiceDictRows.value.find((r) => r.dictCode === code);
return hit?.dictName || code;
}
async function loadProductAdditionalServices(productCode: string) {
const code = trim(productCode);
if (!code) {
productAdditionalServiceOptions.value = [];
selectedAdditionalServices.value = [];
return;
}
const selected = productOptions.value.find((opt) => opt.value === code);
const productId = selected?.productId;
if (!productId) {
productAdditionalServiceOptions.value = [];
selectedAdditionalServices.value = [];
return;
}
try {
const detail = await getSalesProductDetail(productId);
const codes = parseAdditionalServiceCodes(
detail?.additionalServices ?? detail?.AdditionalServices,
);
productAdditionalServiceOptions.value = codes.map((c) => ({
value: c,
label: labelAdditionalService(c),
}));
selectedAdditionalServices.value = selectedAdditionalServices.value.filter((c) =>
codes.includes(c),
);
} catch {
productAdditionalServiceOptions.value = [];
selectedAdditionalServices.value = [];
}
}
type DeclareItem = {
_key: string;
cnName: string;
enName: string;
unitPrice: string;
unitWeight: string;
quantity: string;
amount: string;
weight: string;
sku: string;
unit: string;
imHsCode: string;
exHsCode: string;
declareElement: string;
skuImage: string;
brand: string;
brandType: string;
model: string;
material: string;
enMaterial: string;
purpose: string;
originCountry: string;
category: string;
specifications: string;
salesLink: string;
comment: string;
isMagnetic: boolean;
distribution: string;
attributes: string;
};
let itemKeySeq = 0;
function createEmptyItem(): DeclareItem {
itemKeySeq += 1;
return {
_key: `item-${itemKeySeq}`,
cnName: '',
enName: '',
unitPrice: '',
unitWeight: '',
quantity: '1',
amount: '',
weight: '',
sku: '',
unit: 'pcs',
imHsCode: '',
exHsCode: '',
declareElement: '',
skuImage: '',
brand: '',
brandType: '',
model: '',
material: '',
enMaterial: '',
purpose: '',
originCountry: '',
category: '',
specifications: '',
salesLink: '',
comment: '',
isMagnetic: false,
distribution: '',
attributes: '',
};
}
const items = ref<DeclareItem[]>([createEmptyItem()]);
const form = reactive({
clientNo: '',
productCode: '',
cargoAttribute: '',
exportCustomsMode: '9610Simple',
exportQualificationId: '',
platformCode: '',
warehouseId: '',
inboundMethod: 'selfShip',
expectPickupTime: '',
carrierService: '',
length: '10',
width: '10',
height: '10',
actualWeight: '',
quantity: '1',
declaredCurrency: 'USD',
isCod: false,
receiver: {
recipientsName: '',
phone: '',
countryCode: '',
province: '',
city: '',
streetAddress1: '',
zipCode: '',
companyName: '',
telePhone: '',
email: '',
streetNumber: '',
streetAddress2: '',
streetAddress3: '',
taxType: '0',
taxNo: '',
certificateType: '0',
certificateNo: '',
eori: '',
vat: '',
},
sender: {
senderName: '',
company: '',
phone: '',
telePhone: '',
email: '',
countryCode: '',
province: '',
city: '',
zipCode: '',
streetAddress1: '',
streetAddress2: '',
streetAddress3: '',
houseNumber: '',
},
});
function resolveExportCustomsMode9610Simple() {
const opts = exportCustomsModeOptions.value;
const byCode = opts.find(
(o) =>
o.value === SAMPLE_EXPORT_CUSTOMS_MODE_CODE ||
o.value.toLowerCase() === '9610simple',
);
if (byCode) return byCode.value;
const byLabel = opts.find((o) => /9610.*简易|简易.*9610/i.test(o.label));
if (byLabel) return byLabel.value;
return SAMPLE_EXPORT_CUSTOMS_MODE_CODE;
}
function isExportCustomsMode9610Summary(mode: unknown) {
return String(mode ?? '').trim().toLowerCase() === EXPORT_CUSTOMS_MODE_9610_SUMMARY.toLowerCase();
}
const needExportQualification = computed(() =>
isExportCustomsMode9610Summary(form.exportCustomsMode),
);
function skuImageSrc(url?: string | null) {
return resolveOmsAssetUrl(url || '') || '';
}
type WarehouseOption = (typeof warehouseOptions.value)[number];
const rbacUserStore = useRbacUserStore();
const currentCustomerCode = ref('');
async function loadCurrentCustomerCode() {
try {
const mapId = getMapIdFromAccessToken(rbacUserStore.accessToken);
const account = rbacUserStore.userInfo?.account?.trim() || '';
const data = await resolveCurrentCustomer({ mapId, account });
currentCustomerCode.value = String(data?.customerCode ?? '').trim();
} catch {
currentCustomerCode.value = '';
}
}
function displayWarehouseRecipient(opt?: WarehouseOption) {
return formatWarehouseShipRecipient(opt?.recipient, currentCustomerCode.value);
}
function displayWarehouseShippingAddress(opt?: WarehouseOption) {
return formatWarehouseShipAddress(opt?.shippingAddress, currentCustomerCode.value);
}
function warehouseHasAddress(opt?: WarehouseOption) {
if (!opt) return false;
return Boolean(opt.recipient || opt.contactPhone || opt.shippingAddress);
}
const warehousesWithShipAddress = computed(() =>
warehouseOptions.value.filter((opt) => warehouseHasAddress(opt)),
);
async function copyWarehouseShipAddress(opt?: WarehouseOption) {
if (!opt || !warehouseHasAddress(opt)) {
Message.warning(t('h5.placeOrder.selfShipAddressEmpty'));
return;
}
const text = [
opt.label || '',
`${t('h5.placeOrder.selfShipRecipient')}:${displayWarehouseRecipient(opt)}`,
`${t('h5.placeOrder.selfShipPhone')}:${opt.contactPhone || '-'}`,
`${t('h5.placeOrder.selfShipAddress')}:${displayWarehouseShippingAddress(opt)}`,
]
.filter(Boolean)
.join('\n');
try {
await navigator.clipboard.writeText(text);
Message.success(t('h5.placeOrder.selfShipCopySuccess'));
} catch {
Message.error(t('h5.orders.copyFailed'));
}
}
function trim(v: unknown) {
return String(v ?? '').trim();
}
function parseNum(v: unknown) {
const n = parseFloat(String(v ?? '').trim());
return Number.isFinite(n) ? n : undefined;
}
function syncItemDerived(row: DeclareItem) {
const unitPrice = parseNum(row.unitPrice);
const unitWeight = parseNum(row.unitWeight);
const quantity = parseNum(row.quantity);
row.amount =
unitPrice != null && quantity != null ? String(Number((unitPrice * quantity).toFixed(4))) : '';
row.weight =
unitWeight != null && quantity != null ? String(Number((unitWeight * quantity).toFixed(4))) : '';
}
function formatDerivedNum(value: number) {
if (Number.isInteger(value)) return String(value);
return value.toFixed(4).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '');
}
function calcDeclaredTotal() {
let total = 0;
let hasValue = false;
for (const row of items.value) {
const amount = parseNum(row.amount);
if (amount != null) {
total += amount;
hasValue = true;
}
}
return hasValue ? total : undefined;
}
const declaredTotalInput = ref('');
const declaredTotalInputFocused = ref(false);
function syncDeclaredTotalInputFromRows() {
if (declaredTotalInputFocused.value) return;
const total = calcDeclaredTotal();
declaredTotalInput.value = total == null ? '' : formatDerivedNum(total);
}
function applyDeclaredTotalReverse(raw: string) {
const total = parseNum(raw);
if (total == null || total < 0) {
syncDeclaredTotalInputFromRows();
return;
}
const list = items.value;
if (!list.length) return;
const othersSum = list.slice(1).reduce((sum, row) => sum + (parseNum(row.amount) ?? 0), 0);
let firstAmount = list.length === 1 ? total : total - othersSum;
if (firstAmount < 0) firstAmount = 0;
const row = list[0];
let qty = parseNum(row.quantity);
if (qty == null || qty <= 0) {
row.quantity = '1';
qty = 1;
}
row.amount = formatDerivedNum(firstAmount);
row.unitPrice = formatDerivedNum(firstAmount / qty);
declaredTotalInput.value = formatDerivedNum(calcDeclaredTotal() ?? firstAmount);
}
function handleDeclaredTotalBlur() {
declaredTotalInputFocused.value = false;
applyDeclaredTotalReverse(declaredTotalInput.value);
}
function handleDeclaredTotalChange(val: string | number) {
declaredTotalInput.value = String(val ?? '');
if (!declaredTotalInputFocused.value) {
applyDeclaredTotalReverse(declaredTotalInput.value);
}
}
function addItem() {
items.value.push(createEmptyItem());
}
function removeItem(idx: number) {
if (items.value.length <= 1) return;
const removed = items.value[idx];
items.value.splice(idx, 1);
if (removed?._key) {
const next = new Set(openItemMoreKeys.value);
next.delete(removed._key);
openItemMoreKeys.value = next;
}
}
function isItemMoreOpen(key: string) {
return openItemMoreKeys.value.has(key);
}
function toggleItemMore(key: string) {
const next = new Set(openItemMoreKeys.value);
if (next.has(key)) next.delete(key);
else next.add(key);
openItemMoreKeys.value = next;
}
function validateDeclareItem(row: DeclareItem): string | null {
if (!trim(row.enName)) return t('h5.placeOrder.err.enName');
if (parseNum(row.unitPrice) == null) return t('h5.placeOrder.err.unitPrice');
if (parseNum(row.unitWeight) == null) return t('h5.placeOrder.err.unitWeight');
if (parseNum(row.quantity) == null) return t('h5.placeOrder.err.qty');
return null;
}
function collectFieldErrors(idx: number): Record<string, string> {
const errors: Record<string, string> = {};
if (idx === 0) {
if (!trim(form.sender.senderName)) {
errors.sender = senderOptions.value.length
? t('h5.placeOrder.err.sender')
: t('h5.placeOrder.err.senderEmpty');
}
if (!trim(form.clientNo)) errors.clientNo = t('h5.placeOrder.err.clientNo');
if (!trim(form.productCode)) errors.productCode = t('h5.placeOrder.err.product');
if (!trim(form.cargoAttribute)) errors.cargoAttribute = t('h5.placeOrder.err.cargo');
if (!trim(form.warehouseId) || parseNum(form.warehouseId) == null) {
errors.warehouseId = t('h5.placeOrder.err.warehouse');
}
if (parseNum(form.actualWeight) == null) errors.actualWeight = t('h5.placeOrder.err.weight');
if ((parseNum(form.length) ?? 0) <= 0) errors.length = t('h5.placeOrder.err.length');
if ((parseNum(form.width) ?? 0) <= 0) errors.width = t('h5.placeOrder.err.width');
if ((parseNum(form.height) ?? 0) <= 0) errors.height = t('h5.placeOrder.err.height');
if (!trim(form.quantity) || (parseNum(form.quantity) ?? 0) <= 0) {
errors.quantity = t('h5.placeOrder.err.packageQty');
}
if (needExportQualification.value) {
if (!parseNum(form.exportQualificationId)) {
errors.exportQualificationId = t('h5.placeOrder.err.exportQualification');
}
if (!trim(form.platformCode)) {
errors.platformCode = t('h5.placeOrder.err.platformCode');
}
}
if (form.inboundMethod === 'doorPickup') {
if (!selectedPickupAddressId.value) {
errors.sender = t('h5.placeOrder.err.inboundPickupSender');
}
if (!trim(form.expectPickupTime)) {
errors.expectPickupTime = t('h5.placeOrder.err.expectPickupTime');
}
}
}
if (idx === 1) {
if (!trim(form.receiver.recipientsName)) {
errors['receiver.recipientsName'] = t('h5.placeOrder.err.receiverName');
}
if (!trim(form.receiver.phone)) errors['receiver.phone'] = t('h5.placeOrder.err.receiverPhone');
if (!trim(form.receiver.countryCode)) {
errors['receiver.countryCode'] = t('h5.placeOrder.err.country');
}
if (!trim(form.receiver.streetAddress1)) {
errors['receiver.streetAddress1'] = t('h5.placeOrder.err.address');
}
if (!trim(form.receiver.zipCode)) errors['receiver.zipCode'] = t('h5.placeOrder.err.zip');
}
if (idx === 2) {
if (!trim(form.declaredCurrency)) errors.declaredCurrency = t('h5.placeOrder.err.currency');
if (!items.value.length) errors.itemsEmpty = t('h5.placeOrder.err.itemsEmpty');
items.value.forEach((row, i) => {
if (!trim(row.enName)) errors[`items.${i}.enName`] = t('h5.placeOrder.err.enName');
if (parseNum(row.unitPrice) == null) {
errors[`items.${i}.unitPrice`] = t('h5.placeOrder.err.unitPrice');
}
if (parseNum(row.unitWeight) == null) {
errors[`items.${i}.unitWeight`] = t('h5.placeOrder.err.unitWeight');
}
if (parseNum(row.quantity) == null) errors[`items.${i}.quantity`] = t('h5.placeOrder.err.qty');
});
}
return errors;
}
function isDeclareRowStarted(row: DeclareItem) {
return Boolean(
trim(row.enName) ||
trim(row.cnName) ||
trim(row.unitPrice) ||
trim(row.unitWeight) ||
trim(row.sku) ||
trim(row.skuImage),
);
}
const activeFieldErrors = computed(() => {
if (step.value === 2) {
const all = collectFieldErrors(2);
if (showFieldErrors.value) return all;
const filtered: Record<string, string> = {};
Object.entries(all).forEach(([key, msg]) => {
if (key === 'declaredCurrency') {
filtered[key] = msg;
return;
}
const match = /^items\.(\d+)\./.exec(key);
if (!match) return;
const row = items.value[Number(match[1])];
if (row && isDeclareRowStarted(row)) filtered[key] = msg;
});
return filtered;
}
if (!showFieldErrors.value) return {} as Record<string, string>;
return collectFieldErrors(step.value);
});
const declarationLiveHint = computed(() => {
if (step.value !== 2 || showFieldErrors.value) return '';
return firstFieldErrorMessage(2) || '';
});
function fieldHasError(key: string) {
return Boolean(activeFieldErrors.value[key]);
}
function fieldError(key: string) {
return activeFieldErrors.value[key] || '';
}
function firstFieldErrorMessage(idx: number): string | null {
if (idx === 2) {
if (!trim(form.declaredCurrency)) return t('h5.placeOrder.err.currency');
if (!items.value.length) return t('h5.placeOrder.err.itemsEmpty');
for (let i = 0; i < items.value.length; i += 1) {
const msg = validateDeclareItem(items.value[i]);
if (msg) return t('h5.placeOrder.err.itemLine', { n: i + 1, msg });
}
return null;
}
const errors = collectFieldErrors(idx);
return Object.values(errors)[0] || null;
}
function resetForm() {
step.value = 0;
handoffTip.value = '';
showFieldErrors.value = false;
showReceiverMore.value = false;
openItemMoreKeys.value = new Set();
selectedSenderId.value = undefined;
items.value = [createEmptyItem()];
form.clientNo = '';
form.productCode = '';
form.cargoAttribute = '';
form.exportCustomsMode = resolveExportCustomsMode9610Simple();
form.exportQualificationId = '';
form.platformCode = '';
form.warehouseId = '';
form.inboundMethod = 'selfShip';
form.expectPickupTime = '';
form.carrierService = '';
form.length = '10';
form.width = '10';
form.height = '10';
form.actualWeight = '';
form.quantity = '1';
form.declaredCurrency = 'USD';
declaredTotalInput.value = '';
declaredTotalInputFocused.value = false;
selectedAdditionalServices.value = [];
productAdditionalServiceOptions.value = [];
selectedReceiverAddressId.value = undefined;
savingReceiverAddress.value = false;
declarationSkuVisible.value = false;
exportCustomsGuideVisible.value = false;
Object.keys(form.receiver).forEach((k) => {
(form.receiver as any)[k] = k === 'taxType' || k === 'certificateType' ? '0' : '';
});
Object.keys(form.sender).forEach((k) => {
(form.sender as any)[k] = '';
});
}
function ensureClientNo() {
if (trim(form.clientNo)) return;
const now = new Date();
const date = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
form.clientNo = `C${date}${Math.random().toString().slice(2, 8)}`;
}
function validateStep(idx: number): string | null {
return firstFieldErrorMessage(idx);
}
function goNext() {
const err = validateStep(step.value);
if (err) {
showFieldErrors.value = true;
if (!trim(form.sender.senderName) && !senderOptions.value.length) {
showSenderForm.value = true;
}
Message.warning(err);
return;
}
showFieldErrors.value = false;
step.value += 1;
}
function loadReceiverAiPrefs() {
try {
const raw = localStorage.getItem(RECEIVER_AI_PREFS_KEY);
if (!raw) return;
const data = JSON.parse(raw) as {
providerKey?: string;
modelId?: string;
skillKey?: string;
};
if (data.providerKey) receiverAiProviderKey.value = data.providerKey;
if (data.modelId) receiverAiModelId.value = data.modelId;
if (data.skillKey) receiverAiSkillKey.value = data.skillKey;
} catch {
/* ignore */
}
}
function saveReceiverAiPrefs() {
try {
localStorage.setItem(
RECEIVER_AI_PREFS_KEY,
JSON.stringify({
providerKey: receiverAiProviderKey.value,
modelId: receiverAiModelId.value,
skillKey: receiverAiSkillKey.value,
}),
);
} catch {
/* ignore */
}
}
function onReceiverAiProviderChange() {
const opts = receiverAiModelOptions.value;
if (!opts.some((m) => m.modelId === receiverAiModelId.value)) {
receiverAiModelId.value = opts[0]?.modelId ?? '';
}
saveReceiverAiPrefs();
}
async function loadReceiverAiProviders() {
receiverAiProvidersLoading.value = true;
try {
const items = await listAiProviders();
receiverAiProviders.value = (items ?? []) as AiProviderItem[];
if (!receiverAiProviderKey.value) {
const def =
receiverAiEnabledProviders.value.find((p) => p.isDefault) ??
receiverAiEnabledProviders.value[0];
if (def) receiverAiProviderKey.value = def.providerKey;
}
if (
receiverAiProviderKey.value &&
!receiverAiEnabledProviders.value.some(
(p) => p.providerKey === receiverAiProviderKey.value,
)
) {
receiverAiProviderKey.value =
receiverAiEnabledProviders.value[0]?.providerKey ?? '';
}
onReceiverAiProviderChange();
} catch (err: unknown) {
Message.error(err instanceof Error ? err.message : t('h5.placeOrder.aiParseFailed'));
} finally {
receiverAiProvidersLoading.value = false;
}
}
async function loadReceiverAiSkills() {
receiverAiSkillsLoading.value = true;
try {
receiverAiSkills.value = await listAiSkills({
scene: RECEIVER_AI_SCENE,
includeContent: false,
enabledOnly: true,
});
if (
!receiverAiSkills.value.some((s) => s.skillKey === receiverAiSkillKey.value) &&
receiverAiSkills.value[0]
) {
receiverAiSkillKey.value = receiverAiSkills.value[0].skillKey;
} else if (!receiverAiSkillKey.value) {
receiverAiSkillKey.value = RECEIVER_AI_DEFAULT_SKILL_KEY;
}
saveReceiverAiPrefs();
} catch {
receiverAiSkills.value = [];
} finally {
receiverAiSkillsLoading.value = false;
}
}
function ensureReceiverAiReady() {
if (!receiverAiProviderKey.value) {
Message.warning(t('h5.placeOrder.aiParseNeedProvider'));
return false;
}
if (!receiverAiModelId.value) {
Message.warning(t('h5.placeOrder.aiParseNeedModel'));
return false;
}
if (!receiverAiSkillKey.value) {
Message.warning(t('h5.placeOrder.aiParseNeedSkill'));
return false;
}
return true;
}
function openReceiverAiParse() {
receiverParseTip.value = null;
loadReceiverAiPrefs();
void Promise.all([loadReceiverAiProviders(), loadReceiverAiSkills()]);
receiverAiVisible.value = true;
}
function onReceiverAiPaste() {
nextTick(() => {
if (trim(receiverPasteText.value)) {
void handleReceiverAiParse();
}
});
}
function applyParsedReceiver(parsed: Record<string, string | undefined>) {
const assignKeys = [
'recipientsName',
'companyName',
'phone',
'telePhone',
'email',
'countryCode',
'province',
'city',
'streetNumber',
'streetAddress1',
'streetAddress2',
'streetAddress3',
'zipCode',
] as const;
for (const key of assignKeys) {
const val = trim(parsed[key] || '');
if (val) {
(form.receiver as Record<string, string>)[key] = val;
}
}
const needMore = Boolean(
trim(parsed.companyName || '') ||
trim(parsed.telePhone || '') ||
trim(parsed.email || '') ||
trim(parsed.streetNumber || '') ||
trim(parsed.streetAddress2 || '') ||
trim(parsed.streetAddress3 || '') ||
trim(form.receiver.taxNo) ||
trim(form.receiver.certificateNo) ||
trim(form.receiver.eori) ||
trim(form.receiver.vat),
);
if (needMore) {
showReceiverMore.value = true;
}
selectedReceiverAddressId.value = undefined;
}
function buildReceiverParseTip(result: {
isComplete: boolean;
missingRequired: string[];
missingRecommended: string[];
warnings: string[];
}) {
const parts: string[] = [];
if (result.missingRequired.length) {
parts.push(`${t('h5.placeOrder.aiParseMissingRequired')}${result.missingRequired.join('、')}`);
}
if (result.missingRecommended.length) {
parts.push(`${t('h5.placeOrder.aiParseMissingRecommended')}${result.missingRecommended.join('、')}`);
}
if (result.warnings.length) {
parts.push(result.warnings.slice(0, 2).join(';'));
}
if (result.isComplete && !result.missingRecommended.length && !result.warnings.length) {
receiverParseTip.value = { tone: 'ok', text: t('h5.placeOrder.aiParseComplete') };
return;
}
if (result.isComplete) {
receiverParseTip.value = {
tone: 'warn',
text: parts.length ? `${t('h5.placeOrder.aiParseRequiredOk')}${parts.join('。')}` : t('h5.placeOrder.aiParseRequiredOkShort'),
};
return;
}
receiverParseTip.value = {
tone: 'danger',
text: parts.length ? parts.join('。') : t('h5.placeOrder.aiParseIncomplete'),
};
}
async function handleReceiverAiParse() {
const raw = receiverPasteText.value.trim();
if (!raw) {
Message.warning(t('h5.placeOrder.aiParseEmpty'));
return;
}
if (!ensureReceiverAiReady()) return;
if (receiverParseLoading.value) return;
saveReceiverAiPrefs();
receiverParseLoading.value = true;
receiverParseTip.value = null;
try {
const result = await postOrderReceiverParseAddress({
rawText: raw,
providerKey: receiverAiProviderKey.value,
model: receiverAiModelId.value,
skillKey: receiverAiSkillKey.value,
});
applyParsedReceiver(result.receiver);
buildReceiverParseTip(result);
Message.success(t('h5.placeOrder.aiParseSuccess'));
if (result.isComplete) {
receiverAiVisible.value = false;
}
} catch (e: any) {
receiverParseTip.value = {
tone: 'danger',
text: e?.message || t('h5.placeOrder.aiParseFailed'),
};
Message.error(e?.message || t('h5.placeOrder.aiParseFailed'));
} finally {
receiverParseLoading.value = false;
}
}
function goToStep(idx: number) {
if (idx === step.value || submitting.value) return;
if (idx < step.value) {
showFieldErrors.value = false;
step.value = idx;
return;
}
for (let i = step.value; i < idx; i += 1) {
const err = validateStep(i);
if (err) {
showFieldErrors.value = true;
if (i === 0 && !trim(form.sender.senderName) && !senderOptions.value.length) {
showSenderForm.value = true;
}
step.value = i;
Message.warning(err);
return;
}
}
showFieldErrors.value = false;
step.value = idx;
}
function resetSenderForm() {
Object.assign(senderForm, {
senderName: '',
phone: '',
province: '',
city: '',
streetAddress1: '',
countryCode: 'CN',
});
}
function emptyReceiverForm() {
return {
recipientsName: '',
phone: '',
countryCode: '',
province: '',
city: '',
streetAddress1: '',
zipCode: '',
companyName: '',
telePhone: '',
email: '',
streetNumber: '',
streetAddress2: '',
streetAddress3: '',
taxType: '0',
taxNo: '',
certificateType: '0',
certificateNo: '',
eori: '',
vat: '',
};
}
function mapReceiverAddressOption(row: Record<string, unknown>) {
const id = Number(pickField(row, 'id', 'Id'));
if (!Number.isFinite(id) || id <= 0) return null;
const name = String(pickField(row, 'senderName', 'SenderName') || '');
const isDefault = Boolean(pickField(row, 'isDefault', 'IsDefault'));
return {
id,
label: `${name || `#${id}`}${isDefault ? ` (${t('h5.placeOrder.default')})` : ''}`,
isDefault,
data: {
recipientsName: name,
companyName: String(pickField(row, 'company', 'Company') || ''),
phone: String(pickField(row, 'phone', 'Phone') || ''),
telePhone: String(pickField(row, 'telePhone', 'TelePhone') || ''),
email: String(pickField(row, 'email', 'Email') || ''),
countryCode: String(pickField(row, 'countryCode', 'CountryCode') || ''),
province: String(pickField(row, 'province', 'Province') || ''),
city: String(pickField(row, 'city', 'City') || ''),
zipCode: String(pickField(row, 'zipCode', 'ZipCode') || ''),
streetAddress1: String(pickField(row, 'streetAddress1', 'StreetAddress1') || ''),
streetAddress2: String(pickField(row, 'streetAddress2', 'StreetAddress2') || ''),
streetAddress3: String(pickField(row, 'streetAddress3', 'StreetAddress3') || ''),
streetNumber: String(pickField(row, 'houseNumber', 'HouseNumber') || ''),
taxType: '0',
taxNo: '',
certificateType: '0',
certificateNo: '',
eori: '',
vat: '',
},
};
}
function applyReceiverAddressById(id: number | undefined, { silentEmpty = false } = {}) {
if (id == null) {
if (!silentEmpty) Object.assign(form.receiver, emptyReceiverForm());
selectedReceiverAddressId.value = undefined;
return;
}
const hit = receiverAddressOptions.value.find((opt) => opt.id === id);
if (!hit) return;
Object.assign(form.receiver, {
...emptyReceiverForm(),
...hit.data,
});
selectedReceiverAddressId.value = id;
if (
trim(hit.data.companyName) ||
trim(hit.data.telePhone) ||
trim(hit.data.email) ||
trim(hit.data.streetAddress2) ||
trim(hit.data.streetAddress3)
) {
showReceiverMore.value = true;
}
}
async function loadReceiverAddresses({ applyDefault = false } = {}) {
receiverAddressLoading.value = true;
try {
const res = await getSenderAddressList({
page: 1,
pageSize: 200,
addressType: 'receiver',
});
receiverAddressOptions.value = (res.items || [])
.map((item: Record<string, unknown>) => mapReceiverAddressOption(item))
.filter(Boolean) as typeof receiverAddressOptions.value;
if (applyDefault && !trim(form.receiver.recipientsName)) {
const defaultAddr =
receiverAddressOptions.value.find((x) => x.isDefault) || receiverAddressOptions.value[0];
if (defaultAddr) applyReceiverAddressById(defaultAddr.id);
} else if (selectedReceiverAddressId.value) {
const still = receiverAddressOptions.value.some(
(x) => x.id === selectedReceiverAddressId.value,
);
if (!still) selectedReceiverAddressId.value = undefined;
}
} catch (e: any) {
receiverAddressOptions.value = [];
Message.warning(e?.message || t('h5.placeOrder.receiverLoadFailed'));
} finally {
receiverAddressLoading.value = false;
}
}
function onReceiverAddressChange(id: number | undefined) {
applyReceiverAddressById(id);
}
async function saveReceiverAddress() {
const receiver = form.receiver;
if (!trim(receiver.recipientsName) || !trim(receiver.streetAddress1)) {
Message.warning(t('h5.placeOrder.saveReceiverRequired'));
return;
}
savingReceiverAddress.value = true;
try {
await createSenderAddress({
addressType: 'receiver',
addressName: trim(receiver.recipientsName),
senderName: trim(receiver.recipientsName),
company: trim(receiver.companyName) || undefined,
phone: trim(receiver.phone) || undefined,
telePhone: trim(receiver.telePhone) || undefined,
email: trim(receiver.email) || undefined,
countryCode: trim(receiver.countryCode) || undefined,
province: trim(receiver.province) || undefined,
city: trim(receiver.city) || undefined,
zipCode: trim(receiver.zipCode) || undefined,
streetAddress1: trim(receiver.streetAddress1),
streetAddress2: trim(receiver.streetAddress2) || undefined,
streetAddress3: trim(receiver.streetAddress3) || undefined,
houseNumber: trim(receiver.streetNumber) || undefined,
isDefault: false,
});
Message.success(t('h5.placeOrder.saveReceiverSuccess'));
await loadReceiverAddresses({ applyDefault: false });
} catch (e: any) {
Message.error(e?.message || t('h5.placeOrder.saveReceiverFailed'));
} finally {
savingReceiverAddress.value = false;
}
}
function mapExportQualificationOption(item: Record<string, unknown>) {
const id = Number(pickField(item, 'id', 'Id') ?? 0);
if (!id) return null;
const companyName = String(pickField(item, 'companyName', 'CompanyName') ?? '').trim();
const creditCode = String(pickField(item, 'socialCreditCode', 'SocialCreditCode') ?? '').trim();
const isDefault = Boolean(pickField(item, 'isDefault', 'IsDefault'));
const base = companyName || creditCode || `#${id}`;
return {
id,
label: isDefault ? `${base}(${t('h5.placeOrder.default')})` : base,
isDefault,
};
}
async function loadExportQualificationOptions({ applyDefault = true } = {}) {
exportQualificationLoading.value = true;
try {
const res = await getExportQualificationList({ page: 1, pageSize: 200 });
exportQualificationOptions.value = (res.items || [])
.map((item: Record<string, unknown>) => mapExportQualificationOption(item))
.filter(Boolean) as typeof exportQualificationOptions.value;
const currentId = parseNum(form.exportQualificationId);
const still = currentId
? exportQualificationOptions.value.some((x) => x.id === currentId)
: false;
if (still) return;
if (applyDefault && needExportQualification.value) {
const defaultOpt =
exportQualificationOptions.value.find((x) => x.isDefault) ||
exportQualificationOptions.value[0];
form.exportQualificationId = defaultOpt ? String(defaultOpt.id) : '';
} else {
form.exportQualificationId = '';
}
} catch (e: any) {
exportQualificationOptions.value = [];
Message.warning(e?.message || t('h5.placeOrder.exportQualificationLoadFailed'));
} finally {
exportQualificationLoading.value = false;
}
}
function resolveDeclarationSkuTargetIndex() {
const list = items.value || [];
const emptyIdx = list.findIndex((row) => !isDeclareRowStarted(row));
if (emptyIdx >= 0) return emptyIdx;
return -1;
}
function openDeclarationSkuSheet() {
declarationSkuVisible.value = true;
}
function handleDeclarationSkuSelect(raw: Record<string, unknown>) {
let targetIdx = resolveDeclarationSkuTargetIndex();
if (targetIdx < 0) {
addItem();
targetIdx = items.value.length - 1;
}
const mapped = mapWarehouseSkuToDeclarationFields(raw);
Object.assign(items.value[targetIdx], {
...items.value[targetIdx],
...mapped,
});
if (!trim(items.value[targetIdx].quantity)) {
items.value[targetIdx].quantity = '1';
}
syncItemDerived(items.value[targetIdx]);
if (
trim(mapped.exHsCode) ||
trim(mapped.imHsCode) ||
trim(mapped.declareElement) ||
trim(mapped.skuImage) ||
trim(mapped.comment)
) {
const next = new Set(openItemMoreKeys.value);
next.add(items.value[targetIdx]._key);
openItemMoreKeys.value = next;
}
Message.success(t('h5.placeOrder.skuPickFilled'));
}
async function saveSender() {
if (!trim(senderForm.senderName) || !trim(senderForm.streetAddress1)) {
Message.warning(t('h5.placeOrder.err.senderForm'));
return;
}
savingSender.value = true;
try {
await createSenderAddress({
addressType: 'sender',
addressName: trim(senderForm.senderName),
senderName: trim(senderForm.senderName),
phone: trim(senderForm.phone),
countryCode: trim(senderForm.countryCode) || 'CN',
province: trim(senderForm.province),
city: trim(senderForm.city),
streetAddress1: trim(senderForm.streetAddress1),
isDefault: senderOptions.value.length === 0,
});
Message.success(t('h5.placeOrder.senderSaved'));
showSenderForm.value = false;
resetSenderForm();
await loadSenders();
} catch (e: any) {
Message.error(e?.message || t('h5.placeOrder.senderSaveFailed'));
} finally {
savingSender.value = false;
}
}
function onSenderChange(id: number | undefined) {
selectedSenderId.value = id;
const hit = senderOptions.value.find((x) => x.id === id);
if (!hit) {
Object.keys(form.sender).forEach((k) => {
(form.sender as any)[k] = '';
});
return;
}
Object.assign(form.sender, hit.data);
}
function onPickupAddressChange(id: number | undefined) {
selectedPickupAddressId.value = id;
}
async function loadPickupAddresses() {
pickupAddressLoading.value = true;
try {
const res = await getSenderAddressList({ page: 1, pageSize: 200, addressType: 'pickup' });
pickupAddressOptions.value = (res.items || [])
.map((row: Record<string, unknown>) => {
const id = Number(pickField(row, 'id', 'Id'));
if (!Number.isFinite(id) || id <= 0) return null;
const name = String(
pickField(row, 'addressName', 'AddressName') ||
pickField(row, 'senderName', 'SenderName') ||
`#${id}`,
);
const phone = String(pickField(row, 'phone', 'Phone') || '');
return { id, label: phone ? `${name} · ${phone}` : name };
})
.filter(Boolean) as { id: number; label: string }[];
} catch {
pickupAddressOptions.value = [];
} finally {
pickupAddressLoading.value = false;
}
}
function randomOrderNo(prefix: string) {
const now = new Date();
const date = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(
now.getDate(),
).padStart(2, '0')}`;
const suffix = Math.random().toString().slice(2, 8);
return `${prefix}${date}${suffix}`;
}
/** 开发/联调用:一键填入 Mock 下单数据 */
function fillMockOrder() {
form.clientNo = randomOrderNo('C');
form.productCode = productOptions.value[0]?.value || form.productCode || '';
form.cargoAttribute = cargoOptions.value[0]?.value || form.cargoAttribute || 'general';
form.warehouseId = warehouseOptions.value[0]?.value || form.warehouseId || '';
form.exportCustomsMode = resolveExportCustomsMode9610Simple();
form.exportQualificationId = '';
form.platformCode = '';
form.inboundMethod = 'selfShip';
form.expectPickupTime = '';
form.carrierService = '';
form.length = '10';
form.width = '10';
form.height = '10';
form.actualWeight = '1.2';
form.quantity = '1';
selectedReceiverAddressId.value = undefined;
form.declaredCurrency =
currencyOptions.value.find((c) => c.value === 'USD')?.value ||
currencyOptions.value[0]?.value ||
'USD';
if (senderOptions.value.length) {
onSenderChange(senderOptions.value[0].id);
} else {
selectedSenderId.value = undefined;
Object.assign(form.sender, {
senderName: 'Demo Sender',
company: 'Demo Sender Co.,Ltd',
phone: '13800138000',
telePhone: '',
email: 'sender@example.com',
countryCode: 'CN',
province: 'Guangdong',
city: 'Shenzhen',
zipCode: '518000',
streetAddress1: 'Nanshan District',
streetAddress2: '',
streetAddress3: '',
houseNumber: '88',
});
}
Object.assign(form.receiver, {
recipientsName: 'Demo Receiver',
companyName: 'Demo Receiver LLC',
phone: '13900139000',
telePhone: '',
email: 'receiver@example.com',
countryCode: 'US',
province: 'California',
city: 'Los Angeles',
streetNumber: '1001',
streetAddress1: 'Sunset Blvd',
streetAddress2: '',
streetAddress3: '',
zipCode: '90001',
taxType: '0',
taxNo: '',
certificateType: '0',
certificateNo: '',
eori: '',
vat: '',
});
const demoItem = createEmptyItem();
Object.assign(demoItem, {
cnName: '手机壳',
enName: 'Phone Case',
sku: 'SKU001',
unit: 'pcs',
unitPrice: '12.5',
unitWeight: '0.3',
quantity: '1',
brand: 'DemoBrand',
model: 'Model-X',
material: 'plastic',
enMaterial: 'plastic',
purpose: 'protection',
originCountry: 'CN',
category: 'Accessories',
specifications: 'Standard',
comment: 'Mock order',
});
syncItemDerived(demoItem);
items.value = [demoItem];
syncDeclaredTotalInputFromRows();
selectedAdditionalServices.value = [];
if (form.productCode) {
void loadProductAdditionalServices(form.productCode);
}
showFieldErrors.value = false;
Message.success(t('h5.placeOrder.mockFilled'));
}
function pickDetail(raw: Record<string, any> | undefined | null, ...keys: string[]) {
if (!raw) return undefined;
for (const k of keys) {
const v = raw[k];
if (v !== undefined && v !== null && v !== '') return v;
}
return undefined;
}
function resolveCountryCode(raw: unknown) {
const s = String(raw ?? '').trim();
if (!s) return '';
if (/^[A-Za-z]{2}$/.test(s)) return s.toUpperCase();
return countryIdToCode.value[s] || countryIdToCode.value[String(Number(s))] || s;
}
function mapDetailItems(rawItems: any[]): DeclareItem[] {
const list = Array.isArray(rawItems) ? rawItems : [];
const mapped = list.map((raw) => {
const row = createEmptyItem();
const hs =
pickDetail(raw, 'imHsCode', 'ImHsCode', 'hsCode', 'HsCode', 'exHsCode', 'ExHsCode') ?? '';
Object.assign(row, {
cnName: String(pickDetail(raw, 'cnName', 'CnName') ?? ''),
enName: String(pickDetail(raw, 'enName', 'EnName') ?? ''),
sku: String(pickDetail(raw, 'sku', 'Sku') ?? ''),
unit: String(pickDetail(raw, 'unit', 'Unit') ?? 'pcs') || 'pcs',
unitPrice: String(pickDetail(raw, 'unitPrice', 'UnitPrice') ?? ''),
unitWeight: String(pickDetail(raw, 'unitWeight', 'UnitWeight') ?? ''),
quantity: String(pickDetail(raw, 'quantity', 'Quantity') ?? '1') || '1',
amount: String(pickDetail(raw, 'amount', 'Amount') ?? ''),
weight: String(pickDetail(raw, 'weight', 'Weight') ?? ''),
imHsCode: String(pickDetail(raw, 'imHsCode', 'ImHsCode') ?? hs),
exHsCode: String(pickDetail(raw, 'exHsCode', 'ExHsCode') ?? hs),
declareElement: String(pickDetail(raw, 'declareElement', 'DeclareElement') ?? ''),
skuImage: normalizeSkuImageForStorage(
String(pickDetail(raw, 'skuImage', 'SkuImage') ?? ''),
),
brand: String(pickDetail(raw, 'brand', 'Brand') ?? ''),
brandType: String(pickDetail(raw, 'brandType', 'BrandType') ?? ''),
model: String(pickDetail(raw, 'model', 'Model') ?? ''),
material: String(pickDetail(raw, 'material', 'Material') ?? ''),
enMaterial: String(pickDetail(raw, 'enMaterial', 'EnMaterial') ?? ''),
purpose: String(pickDetail(raw, 'purpose', 'Purpose') ?? ''),
originCountry: String(pickDetail(raw, 'originCountry', 'OriginCountry') ?? ''),
category: String(pickDetail(raw, 'category', 'Category') ?? ''),
specifications: String(pickDetail(raw, 'specifications', 'Specifications') ?? ''),
salesLink: String(pickDetail(raw, 'salesLink', 'SalesLink') ?? ''),
comment: String(pickDetail(raw, 'comment', 'Comment') ?? ''),
isMagnetic: Boolean(pickDetail(raw, 'isMagnetic', 'IsMagnetic')),
distribution: String(pickDetail(raw, 'distribution', 'Distribution') ?? ''),
attributes: String(pickDetail(raw, 'attributes', 'Attributes') ?? ''),
});
syncItemDerived(row);
return row;
});
return mapped.length ? mapped : [createEmptyItem()];
}
function applyOrderDetail(detail: Record<string, any>) {
const sender = detail?.sender || detail?.Sender || {};
const receiver = detail?.receiver || detail?.Receiver || {};
const weights = detail?.weightList || detail?.WeightList || [];
const weight =
(Array.isArray(weights)
? weights.find((w: any) =>
String(pickDetail(w, 'type', 'Type') ?? '')
.toLowerCase()
.includes('forecast'),
)
: null) ||
(Array.isArray(weights) ? weights[0] : null) ||
{};
form.clientNo = String(pickDetail(detail, 'clientNo', 'ClientNo') ?? '');
form.productCode = String(pickDetail(detail, 'productCode', 'ProductCode') ?? '');
form.cargoAttribute = normalizeCargoAttributeCode(
pickDetail(detail, 'cargoAttribute', 'CargoAttribute') ?? form.cargoAttribute,
);
form.exportCustomsMode =
String(pickDetail(detail, 'exportCustomsMode', 'ExportCustomsMode') ?? '') ||
resolveExportCustomsMode9610Simple();
form.exportQualificationId = String(
pickDetail(detail, 'exportQualificationId', 'ExportQualificationId') ?? '',
);
form.platformCode = String(pickDetail(detail, 'platformCode', 'PlatformCode') ?? '');
form.warehouseId = String(pickDetail(detail, 'warehouseId', 'WarehouseId') ?? '') || form.warehouseId;
form.declaredCurrency = String(
pickDetail(detail, 'declaredCurrency', 'DeclaredCurrency') ?? form.declaredCurrency,
);
form.quantity = String(pickDetail(detail, 'quantity', 'Quantity') ?? (form.quantity || '1'));
form.inboundMethod =
String(pickDetail(detail, 'inboundMethod', 'InboundMethod') ?? 'selfShip') || 'selfShip';
form.length = String(pickDetail(weight, 'length', 'Length') ?? form.length ?? '');
form.width = String(pickDetail(weight, 'width', 'Width') ?? form.width ?? '');
form.height = String(pickDetail(weight, 'height', 'Height') ?? form.height ?? '');
form.actualWeight = String(pickDetail(weight, 'actualWeight', 'ActualWeight') ?? '');
const senderName = String(pickDetail(sender, 'senderName', 'SenderName') ?? '');
editHasSender.value = Boolean(senderName || pickDetail(sender, 'phone', 'Phone'));
Object.assign(form.sender, {
senderName,
company: String(pickDetail(sender, 'company', 'Company') ?? ''),
phone: String(pickDetail(sender, 'phone', 'Phone') ?? ''),
telePhone: String(pickDetail(sender, 'telePhone', 'TelePhone') ?? ''),
email: String(pickDetail(sender, 'email', 'Email') ?? ''),
countryCode: resolveCountryCode(pickDetail(sender, 'countryCode', 'CountryCode', 'country', 'Country')),
province: String(pickDetail(sender, 'province', 'Province') ?? ''),
city: String(pickDetail(sender, 'city', 'City') ?? ''),
zipCode: String(pickDetail(sender, 'zipCode', 'ZipCode') ?? ''),
streetAddress1: String(pickDetail(sender, 'streetAddress1', 'StreetAddress1') ?? ''),
streetAddress2: String(pickDetail(sender, 'streetAddress2', 'StreetAddress2') ?? ''),
streetAddress3: String(pickDetail(sender, 'streetAddress3', 'StreetAddress3') ?? ''),
houseNumber: String(pickDetail(sender, 'houseNumber', 'HouseNumber') ?? ''),
});
Object.assign(form.receiver, {
recipientsName: String(pickDetail(receiver, 'recipientsName', 'RecipientsName') ?? ''),
companyName: String(pickDetail(receiver, 'companyName', 'CompanyName') ?? ''),
phone: String(pickDetail(receiver, 'phone', 'Phone') ?? ''),
telePhone: String(pickDetail(receiver, 'telePhone', 'TelePhone') ?? ''),
email: String(pickDetail(receiver, 'email', 'Email') ?? ''),
countryCode: resolveCountryCode(
pickDetail(receiver, 'countryCode', 'CountryCode', 'country', 'Country') ??
pickDetail(detail, 'consigneeCountry', 'ConsigneeCountry'),
),
province: String(pickDetail(receiver, 'province', 'Province') ?? ''),
city: String(pickDetail(receiver, 'city', 'City') ?? ''),
streetNumber: String(pickDetail(receiver, 'streetNumber', 'StreetNumber') ?? ''),
streetAddress1: String(pickDetail(receiver, 'streetAddress1', 'StreetAddress1') ?? ''),
streetAddress2: String(pickDetail(receiver, 'streetAddress2', 'StreetAddress2') ?? ''),
streetAddress3: String(pickDetail(receiver, 'streetAddress3', 'StreetAddress3') ?? ''),
zipCode: String(pickDetail(receiver, 'zipCode', 'ZipCode') ?? ''),
taxType: String(pickDetail(receiver, 'taxType', 'TaxType') ?? '0') || '0',
taxNo: String(pickDetail(receiver, 'taxNo', 'TaxNo') ?? ''),
certificateType: String(pickDetail(receiver, 'certificateType', 'CertificateType') ?? '0') || '0',
certificateNo: String(pickDetail(receiver, 'certificateNo', 'CertificateNo') ?? ''),
eori: String(pickDetail(receiver, 'eori', 'Eori') ?? ''),
vat: String(pickDetail(receiver, 'vat', 'Vat') ?? ''),
});
const services = detail?.additionalServices ?? detail?.AdditionalServices ?? [];
selectedAdditionalServices.value = Array.isArray(services)
? services.map((x: any) => String(x ?? '').trim()).filter(Boolean)
: [];
items.value = mapDetailItems(detail?.items || detail?.Items || []);
syncDeclaredTotalInputFromRows();
selectedSenderId.value = undefined;
showSenderForm.value = !editHasSender.value;
step.value = 0;
}
async function loadOrderForEdit(orderId: number | string) {
optionsLoading.value = true;
try {
const detail = await getOrderDetail(orderId);
applyOrderDetail(detail || {});
await loadProductAdditionalServices(form.productCode);
} catch (e: any) {
Message.error(e?.message || t('h5.placeOrder.editLoadFailed'));
emit('update:visible', false);
} finally {
optionsLoading.value = false;
}
}
function buildUpdatePayload() {
items.value.forEach((row) => syncItemDerived(row));
const payloadItems = items.value.map((row) => {
const { _key, ...rest } = row;
return {
...rest,
unitPrice: parseNum(row.unitPrice) ?? 0,
unitWeight: parseNum(row.unitWeight) ?? 0,
quantity: parseNum(row.quantity) ?? 0,
amount: parseNum(row.amount) ?? 0,
weight: parseNum(row.weight) ?? 0,
isMagnetic: Boolean(row.isMagnetic),
skuImage: normalizeSkuImageForStorage(row.skuImage),
};
});
const body: Record<string, any> = {
id: Number(props.orderId),
clientNo: trim(form.clientNo),
cargoAttribute: normalizeCargoAttributeCode(form.cargoAttribute),
quantity: trim(form.quantity) || '1',
declaredCurrency: trim(form.declaredCurrency),
isCod: false,
receiver: {
recipientsName: trim(form.receiver.recipientsName),
companyName: trim(form.receiver.companyName),
countryCode: resolveCountryCode(form.receiver.countryCode),
phone: trim(form.receiver.phone),
telePhone: trim(form.receiver.telePhone),
zipCode: trim(form.receiver.zipCode),
province: trim(form.receiver.province),
city: trim(form.receiver.city),
streetNumber: trim(form.receiver.streetNumber),
streetAddress1: trim(form.receiver.streetAddress1),
streetAddress2: trim(form.receiver.streetAddress2),
streetAddress3: trim(form.receiver.streetAddress3),
email: trim(form.receiver.email),
taxType: parseNum(form.receiver.taxType) ?? 0,
taxNo: trim(form.receiver.taxNo),
certificateType: parseNum(form.receiver.certificateType) ?? 0,
certificateNo: trim(form.receiver.certificateNo),
eori: trim(form.receiver.eori),
vat: trim(form.receiver.vat),
},
items: payloadItems,
};
if (editHasSender.value || trim(form.sender.senderName)) {
body.sender = {
senderName: trim(form.sender.senderName),
company: trim(form.sender.company),
countryCode: resolveCountryCode(form.sender.countryCode),
province: trim(form.sender.province),
city: trim(form.sender.city),
zipCode: trim(form.sender.zipCode),
streetAddress1: trim(form.sender.streetAddress1),
streetAddress2: trim(form.sender.streetAddress2),
streetAddress3: trim(form.sender.streetAddress3),
houseNumber: trim(form.sender.houseNumber),
phone: trim(form.sender.phone),
telePhone: trim(form.sender.telePhone),
email: trim(form.sender.email),
};
}
return body;
}
function buildPayload() {
items.value.forEach((row) => syncItemDerived(row));
const payloadItems = items.value.map((row) => {
const { _key, ...rest } = row;
return {
...rest,
unitPrice: parseNum(row.unitPrice) ?? 0,
unitWeight: parseNum(row.unitWeight) ?? 0,
quantity: parseNum(row.quantity) ?? 0,
amount: parseNum(row.amount) ?? 0,
weight: parseNum(row.weight) ?? 0,
isMagnetic: Boolean(row.isMagnetic),
skuImage: normalizeSkuImageForStorage(row.skuImage),
};
});
const declaredSum = payloadItems.reduce((sum, row) => sum + (Number(row.amount) || 0), 0);
const inbound = trim(form.inboundMethod) || 'selfShip';
const exportMode = trim(form.exportCustomsMode) || resolveExportCustomsMode9610Simple();
const payload: Record<string, any> = {
clientNo: trim(form.clientNo),
productCode: trim(form.productCode),
cargoAttribute: normalizeCargoAttributeCode(form.cargoAttribute),
exportCustomsMode: exportMode,
warehouseId: parseNum(form.warehouseId) ?? 0,
length: parseNum(form.length),
width: parseNum(form.width),
height: parseNum(form.height),
actualWeight: parseNum(form.actualWeight) ?? 0,
quantity: trim(form.quantity) || '1',
declaredCurrency: trim(form.declaredCurrency),
declaredSum,
inboundMethod: inbound,
isCod: false,
additionalServices: [...selectedAdditionalServices.value],
receiver: {
...form.receiver,
countryCode: trim(form.receiver.countryCode).toUpperCase(),
taxType: parseNum(form.receiver.taxType) ?? 0,
certificateType: parseNum(form.receiver.certificateType) ?? 0,
},
sender: {
...form.sender,
countryCode: trim(form.sender.countryCode).toUpperCase() || undefined,
},
items: payloadItems,
};
if (isExportCustomsMode9610Summary(exportMode)) {
payload.exportQualificationId = parseNum(form.exportQualificationId);
payload.platformCode = trim(form.platformCode);
}
if (inbound === 'doorPickup') {
if (selectedPickupAddressId.value) {
payload.senderAddressId = selectedPickupAddressId.value;
}
payload.expectPickupTime = trim(form.expectPickupTime) || undefined;
payload.carrierService = trim(form.carrierService) || undefined;
}
return payload;
}
function clearDraftStorage() {
try {
sessionStorage.removeItem(PRICE_CHECK_ORDER_DRAFT_KEY);
localStorage.removeItem(PRICE_CHECK_ORDER_DRAFT_KEY);
} catch {
// ignore
}
}
function close() {
if (submitting.value) return;
emit('update:visible', false);
}
async function submit() {
for (let i = 0; i <= 2; i += 1) {
const err = validateStep(i);
if (err) {
step.value = i;
showFieldErrors.value = true;
if (i === 0 && !trim(form.sender.senderName) && !senderOptions.value.length) {
showSenderForm.value = true;
}
Message.warning(err);
return;
}
}
submitting.value = true;
try {
if (isEditMode.value) {
await postOrderUpdate(buildUpdatePayload());
Message.success(t('h5.placeOrder.editSaved'));
emit('success');
emit('update:visible', false);
return;
}
await postOrderParcelCreate(buildPayload());
clearDraftStorage();
emit('success');
Modal.confirm({
title: t('h5.placeOrder.successTitle'),
content: t('h5.placeOrder.successContent'),
okText: t('h5.placeOrder.goOrders'),
cancelText: t('h5.placeOrder.stay'),
onOk: () => {
emit('update:visible', false);
h5Navigate(router, '/h5/orders');
},
onCancel: () => {
// 继续下单:重置表单留在抽屉
resetForm();
ensureClientNo();
void loadOptions();
void loadSenders();
},
});
} catch (e: any) {
if (isEditMode.value) {
Message.error(e?.message || t('h5.placeOrder.editSaveFailed'));
} else if (isCustomerContactRequiredError(e)) {
Message.warning(t('h5.contact.requiredToast'));
} else if (isSenderRequiredError(e)) {
step.value = 0;
if (!senderOptions.value.length) showSenderForm.value = true;
Message.warning(
senderOptions.value.length
? t('h5.placeOrder.err.sender')
: t('h5.placeOrder.err.senderEmpty'),
);
} else {
openCreateFailModal(e);
}
} finally {
submitting.value = false;
}
}
const createFailVisible = ref(false);
const createFailCancelling = ref(false);
const createFailSavingDraft = ref(false);
const createFailInfo = ref<ParcelCreateFailGuide | null>(null);
function openCreateFailModal(error: unknown) {
createFailInfo.value = buildParcelCreateFailGuide(error);
createFailVisible.value = true;
}
function focusFailSection(section: string) {
if (section === 'recipient') step.value = 1;
else if (section === 'declaration') step.value = 2;
else {
step.value = 0;
if (section === 'sender' && !senderOptions.value.length) showSenderForm.value = true;
}
showFieldErrors.value = true;
}
function handleCreateFailPrimary() {
const info = createFailInfo.value;
createFailVisible.value = false;
if (!info) return;
if (info.draftSaved && info.orderId) {
emit('update:visible', false);
h5Navigate(router, {
path: '/h5/orders',
query: { orderId: String(info.orderId) },
});
return;
}
focusFailSection(info.section);
}
async function handleCreateFailSaveDraft() {
const info = createFailInfo.value;
if (!info || info.draftSaved || createFailSavingDraft.value) return;
createFailSavingDraft.value = true;
try {
const created = await postOrderParcelCreate({
...buildPayload(),
saveAsDraft: true,
});
const orderId = Number(created?.orderId ?? created?.OrderId ?? created?.id ?? created?.Id ?? 0) || undefined;
createFailVisible.value = false;
clearDraftStorage();
Modal.confirm({
title: t('h5.placeOrder.failSaveDraftOk'),
content: t('h5.placeOrder.failDraftNote'),
okText: t('h5.placeOrder.failSaveDraftGo'),
cancelText: t('h5.placeOrder.failSaveDraftStay'),
onOk: () => {
emit('update:visible', false);
h5Navigate(router, {
path: '/h5/orders',
query: orderId ? { orderId: String(orderId) } : {},
});
},
onCancel: () => {
resetForm();
ensureClientNo();
},
});
} catch (e: any) {
Message.error(e?.message || t('h5.placeOrder.failSaveDraftFailed'));
} finally {
createFailSavingDraft.value = false;
}
}
function handleCreateFailCancel() {
const info = createFailInfo.value;
if (!info) {
createFailVisible.value = false;
return;
}
if (info.draftSaved && info.orderId) {
Modal.confirm({
title: t('h5.placeOrder.failCancelDraftTitle'),
content: t('h5.placeOrder.failCancelDraftContent'),
okText: t('h5.placeOrder.failCancelDraftOk'),
cancelText: t('h5.placeOrder.failKeep'),
okButtonProps: { status: 'danger' },
onOk: async () => {
createFailCancelling.value = true;
try {
await postOrderDelete({ id: info.orderId! });
Message.success(t('h5.placeOrder.failCancelDraftDone'));
createFailVisible.value = false;
resetForm();
ensureClientNo();
} catch (e: any) {
Message.error(e?.message || t('h5.placeOrder.failCancelDraftFailed'));
} finally {
createFailCancelling.value = false;
}
},
});
return;
}
Modal.confirm({
title: t('h5.placeOrder.failAbandonTitle'),
content: t('h5.placeOrder.failAbandonContent'),
okText: t('h5.placeOrder.failAbandonOk'),
cancelText: t('h5.placeOrder.failKeep'),
okButtonProps: { status: 'danger' },
onOk: () => {
createFailVisible.value = false;
resetForm();
ensureClientNo();
Message.success(t('h5.placeOrder.failAbandonDone'));
},
});
}
function readStoredDraft(): Record<string, any> | null {
try {
const raw =
sessionStorage.getItem(PRICE_CHECK_ORDER_DRAFT_KEY) ||
localStorage.getItem(PRICE_CHECK_ORDER_DRAFT_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function applyDraft(draft: Record<string, any> | null) {
if (!draft) return;
const filled: string[] = [];
if (draft.productCode || draft.shippingMethod) {
const code = trim(draft.productCode);
const byCode = productOptions.value.find((o) => o.value === code);
const byName = productOptions.value.find(
(o) => o.label.includes(trim(draft.shippingMethod)) || o.value === trim(draft.shippingMethod),
);
form.productCode = byCode?.value || byName?.value || code;
if (form.productCode) filled.push(t('h5.placeOrder.product'));
}
const draftServices = parseAdditionalServiceCodes(draft.additionalServices);
if (draftServices.length) {
selectedAdditionalServices.value = draftServices;
filled.push(t('h5.placeOrder.additionalServices'));
}
if (draft.cargoAttribute || draft.cargoType) {
form.cargoAttribute = normalizeCargoAttributeCode(draft.cargoAttribute || draft.cargoType);
filled.push(t('h5.placeOrder.cargo'));
}
if (draft.warehouseId != null && draft.warehouseId !== '') {
form.warehouseId = String(draft.warehouseId);
filled.push(t('h5.placeOrder.warehouse'));
}
const weight = draft.actualWeight || draft.cargoWeight;
if (weight) {
form.actualWeight = String(weight);
filled.push(t('h5.placeOrder.weight'));
}
if (draft.length && draft.width && draft.height) {
form.length = String(draft.length);
form.width = String(draft.width);
form.height = String(draft.height);
filled.push(t('h5.placeOrder.dimensions'));
}
const country = draft.countryCode || draft.destinationCountry;
if (country) {
form.receiver.countryCode = String(country).toUpperCase();
filled.push(t('h5.placeOrder.country'));
}
if (draft.zipCode || draft.postalCode) {
form.receiver.zipCode = String(draft.zipCode || draft.postalCode);
}
handoffTip.value = t('h5.placeOrder.handoffFilled', {
items: filled.join('、') || '-',
});
// 始终从基础信息开始,不因查价带入字段跳到收件
step.value = 0;
}
async function loadOptions(
draft: Record<string, any> | null,
opts?: { skipDefaults?: boolean; skipEnsureClientNo?: boolean },
) {
optionsLoading.value = true;
try {
const [
productRes,
cargoRows,
warehouseRes,
currencyRows,
countryRes,
salesProductGroupDictRows,
additionalServiceRows,
exportCustomsModeRows,
] = await Promise.all([
getSalesProductList({ page: 1, pageSize: 500, isActive: 2 }),
getDictListAll({ dictType: 'cargo_attribute' }),
getWarehouseList({ page: 1, pageSize: 500 }),
getDictListAll({ dictType: 'currency' }),
getCountryList(),
getDictListAll({ dictType: 'sales_product_group' }),
getDictListAll({ dictType: 'additional_service' }),
getDictListAll({ dictType: 'export_customs_mode' }),
]);
additionalServiceDictRows.value = (additionalServiceRows ?? [])
.map((r: any) => ({
dictCode: String(r?.dictCode ?? r?.DictCode ?? '').trim(),
dictName: String(r?.dictName ?? r?.DictName ?? '').trim(),
}))
.filter((r) => r.dictCode);
exportCustomsModeOptions.value = (exportCustomsModeRows ?? [])
.map((row: any) => ({
value: String(row?.dictCode ?? row?.DictCode ?? '').trim(),
label: String(row?.dictName ?? row?.DictName ?? '').trim(),
}))
.filter((x) => x.value);
if (!opts?.skipDefaults || !trim(form.exportCustomsMode)) {
form.exportCustomsMode = resolveExportCustomsMode9610Simple();
}
const domesticPickupGroupCodes = new Set(
(salesProductGroupDictRows ?? [])
.filter((d: any) => String(d?.dictName ?? d?.DictName ?? '').includes('国内揽收'))
.map((d: any) => String(d?.dictCode ?? d?.DictCode ?? '').trim())
.filter((code: string) => Boolean(code)),
);
productOptions.value = (productRes.items ?? [])
.filter((row: any) => {
const groupCode = String(row?.productGroup ?? row?.ProductGroup ?? '').trim();
return domesticPickupGroupCodes.size ? !domesticPickupGroupCodes.has(groupCode) : true;
})
.map((row: any) => {
const code = String(row?.productCode ?? row?.ProductCode ?? '').trim();
const name = String(row?.productName ?? row?.ProductName ?? '').trim();
const id = Number(row?.id ?? row?.Id ?? 0);
const routeSortRaw = row?.routeSort ?? row?.RouteSort;
const routeSortNum = Number(routeSortRaw);
const minTimelinessRaw = row?.minTimeliness ?? row?.MinTimeliness;
const minTimelinessNum =
minTimelinessRaw == null || minTimelinessRaw === ''
? null
: Number(minTimelinessRaw);
return {
value: code,
label: name ? `${name} ${code}` : code,
productId: Number.isFinite(id) && id > 0 ? id : undefined,
routeSort: Number.isFinite(routeSortNum) ? routeSortNum : 0,
minTimeliness:
minTimelinessNum != null && Number.isFinite(minTimelinessNum)
? minTimelinessNum
: null,
name,
};
})
.filter((x) => x.value)
.sort((a, b) => {
if (a.routeSort !== b.routeSort) return a.routeSort - b.routeSort;
const aTl = a.minTimeliness ?? Number.MAX_SAFE_INTEGER;
const bTl = b.minTimeliness ?? Number.MAX_SAFE_INTEGER;
if (aTl !== bTl) return aTl - bTl;
const byName = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
if (byName) return byName;
return a.value.localeCompare(b.value);
})
.map(({ value, label, productId }) => ({ value, label, productId }));
cargoOptions.value = (cargoRows ?? [])
.map((row: any) => ({
value: String(row?.dictCode ?? row?.DictCode ?? '').trim(),
label: String(row?.dictName ?? row?.DictName ?? '').trim(),
}))
.filter((x) => x.value);
if (!opts?.skipDefaults && cargoOptions.value.length && !form.cargoAttribute) {
form.cargoAttribute = cargoOptions.value[0].value;
}
warehouseOptions.value = (warehouseRes.items ?? [])
.map((row: any) => {
const id = row?.id ?? row?.Id;
const name = String(row?.warehouseName ?? row?.WarehouseName ?? '').trim();
const pick = (...keys: string[]) => {
for (const k of keys) {
const v = row?.[k];
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();
}
return '';
};
const sortRaw = row?.sort ?? row?.Sort;
const sortNum = sortRaw == null || sortRaw === '' ? null : Number(sortRaw);
return {
value: String(id ?? ''),
label: name || String(id),
recipient: pick('recipient', 'Recipient'),
contactPhone: pick('contactPhone', 'ContactPhone'),
shippingAddress: pick('shippingAddress', 'ShippingAddress'),
sort: sortNum != null && Number.isFinite(sortNum) ? sortNum : null,
remark: pick('remark', 'Remark'),
};
})
.filter((x) => x.value)
.sort(compareWarehouseSort);
if (!opts?.skipDefaults && warehouseOptions.value.length && !form.warehouseId) {
form.warehouseId = warehouseOptions.value[0].value;
}
currencyOptions.value = (currencyRows ?? [])
.map((row: any) => ({
value: String(row?.dictCode ?? row?.DictCode ?? '').trim(),
label: String(row?.dictName ?? row?.DictName ?? row?.dictCode ?? '').trim(),
}))
.filter((x) => x.value);
if (!opts?.skipDefaults) {
const usd = currencyOptions.value.find((x) => x.value.toUpperCase() === 'USD');
form.declaredCurrency = usd?.value || currencyOptions.value[0]?.value || 'USD';
}
const idMap: Record<string, string> = {};
countryOptions.value = (countryRes.items ?? [])
.map((row: any) => {
const code = String(row?.countryCode ?? row?.CountryCode ?? row?.code ?? '')
.trim()
.toUpperCase();
const name = String(
row?.chineseName ??
row?.ChineseName ??
row?.countryName ??
row?.CountryName ??
row?.name ??
'',
).trim();
const id = String(row?.id ?? row?.Id ?? '').trim();
if (id && code) idMap[id] = code;
return { value: code, label: name ? `${name} ${code}` : code };
})
.filter((x) => x.value);
countryIdToCode.value = idMap;
if (!opts?.skipDefaults) {
applyDraft(draft);
}
await Promise.all([
loadProductAdditionalServices(form.productCode),
loadDomesticPickupProductOptions()
.then((opts) => {
carrierServiceOptions.value = opts;
})
.catch(() => {
carrierServiceOptions.value = [];
}),
loadExportQualificationOptions({
applyDefault: !opts?.skipDefaults && needExportQualification.value,
}),
loadReceiverAddresses({ applyDefault: !opts?.skipDefaults }),
]);
if (!opts?.skipEnsureClientNo) {
ensureClientNo();
}
} catch (e: any) {
Message.error(e?.message || t('h5.placeOrder.optionsFailed'));
} finally {
optionsLoading.value = false;
}
}
async function loadSenders(opts?: { skipAutoSelect?: boolean }) {
senderLoading.value = true;
try {
const res = await getSenderAddressList({ page: 1, pageSize: 200, addressType: 'sender' });
senderOptions.value = (res.items || [])
.map((row: Record<string, unknown>) => {
const id = Number(pickField(row, 'id', 'Id'));
if (!Number.isFinite(id) || id <= 0) return null;
const name = String(pickField(row, 'senderName', 'SenderName') || '');
const isDefault = Boolean(pickField(row, 'isDefault', 'IsDefault'));
return {
id,
label: `${name || `#${id}`}${isDefault ? ` (${t('h5.placeOrder.default')})` : ''}`,
isDefault,
data: {
senderName: String(pickField(row, 'senderName', 'SenderName') || ''),
company: String(pickField(row, 'company', 'Company') || ''),
phone: String(pickField(row, 'phone', 'Phone') || ''),
telePhone: String(pickField(row, 'telePhone', 'TelePhone') || ''),
email: String(pickField(row, 'email', 'Email') || ''),
countryCode: String(pickField(row, 'countryCode', 'CountryCode') || ''),
province: String(pickField(row, 'province', 'Province') || ''),
city: String(pickField(row, 'city', 'City') || ''),
zipCode: String(pickField(row, 'zipCode', 'ZipCode') || ''),
streetAddress1: String(pickField(row, 'streetAddress1', 'StreetAddress1') || ''),
streetAddress2: String(pickField(row, 'streetAddress2', 'StreetAddress2') || ''),
streetAddress3: String(pickField(row, 'streetAddress3', 'StreetAddress3') || ''),
houseNumber: String(pickField(row, 'houseNumber', 'HouseNumber') || ''),
},
};
})
.filter(Boolean) as typeof senderOptions.value;
if (opts?.skipAutoSelect) return;
const def = (senderOptions.value as any[]).find((x) => x.isDefault) || senderOptions.value[0];
if (def) {
onSenderChange(def.id);
showSenderForm.value = false;
} else {
onSenderChange(undefined);
showSenderForm.value = true;
}
} catch {
senderOptions.value = [];
if (!opts?.skipAutoSelect) showSenderForm.value = true;
} finally {
senderLoading.value = false;
}
}
watch(
() => form.productCode,
(code) => {
void loadProductAdditionalServices(String(code ?? ''));
},
);
watch(
() => form.exportCustomsMode,
(mode) => {
if (isExportCustomsMode9610Summary(mode)) {
void loadExportQualificationOptions({ applyDefault: true });
} else {
form.exportQualificationId = '';
form.platformCode = '';
}
},
);
watch(
() =>
items.value.map(
(row) => `${row.unitPrice ?? ''}|${row.unitWeight ?? ''}|${row.quantity ?? ''}|${row.amount ?? ''}`,
),
() => {
items.value.forEach((row) => syncItemDerived(row));
syncDeclaredTotalInputFromRows();
},
);
watch(
() => props.visible,
async (visible) => {
if (!visible) {
submitting.value = false;
showSenderForm.value = false;
editHasSender.value = false;
resetSenderForm();
receiverAiVisible.value = false;
receiverPasteText.value = '';
receiverParseTip.value = null;
receiverParseLoading.value = false;
declarationSkuVisible.value = false;
exportCustomsGuideVisible.value = false;
return;
}
resetForm();
void loadCurrentCustomerCode();
if (isEditMode.value) {
await loadOptions(null, { skipDefaults: true, skipEnsureClientNo: true });
await loadSenders({ skipAutoSelect: true });
await loadPickupAddresses();
await loadOrderForEdit(props.orderId as number | string);
if (needExportQualification.value) {
await loadExportQualificationOptions({ applyDefault: false });
}
return;
}
const draft = props.draft ?? readStoredDraft();
void loadOptions(draft);
void loadSenders();
void loadPickupAddresses();
},
{ immediate: true },
);
</script>
<style lang="less">
.h5-po-drawer {
.arco-drawer-content {
border-radius: 20px 20px 0 0;
overflow: hidden;
max-height: min(92vh, 780px);
}
.arco-drawer-body {
padding: 0;
display: flex;
flex-direction: column;
max-height: min(92vh, 780px);
}
}
</style>
<style lang="less" scoped>
.h5-po {
display: flex;
flex-direction: column;
max-height: min(92vh, 780px);
padding-bottom: env(safe-area-inset-bottom, 0px);
background: #f5f7f8;
}
.h5-po__handle {
width: 36px;
height: 4px;
margin: 10px auto 0;
border-radius: 999px;
background: #c9cdd4;
flex-shrink: 0;
}
.h5-po__head {
display: flex;
align-items: center;
justify-content: center;
position: relative;
height: 48px;
flex-shrink: 0;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1d2129;
}
}
.h5-po__close {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
border: none;
border-radius: 10px;
background: transparent;
color: #86909c;
font-size: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:disabled {
opacity: 0.5;
}
}
.h5-po__steps {
display: flex;
gap: 4px;
margin: 0 16px 10px;
padding: 3px;
border-radius: 12px;
background: #eef0f3;
flex-shrink: 0;
}
.h5-po__step {
flex: 1;
height: 32px;
border: none;
border-radius: 10px;
background: transparent;
font-size: 12px;
font-weight: 500;
color: #86909c;
cursor: pointer;
&.is-active,
&.is-done {
background: #fff;
color: #1d2129;
font-weight: 600;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
}
}
.h5-po__tip {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin: 0 16px 10px;
padding: 10px 12px;
border-radius: 12px;
background: color-mix(in srgb, var(--app-primary-color) 10%, #fff);
color: #4e5969;
font-size: 12px;
line-height: 1.45;
button {
border: none;
background: transparent;
color: #86909c;
font-size: 16px;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
}
.h5-po__spin {
flex: 1;
min-height: 220px;
overflow: auto;
display: block;
width: 100%;
padding: 0 16px 12px;
box-sizing: border-box;
}
.h5-po__item {
display: flex;
flex-direction: column;
gap: 10px;
}
.h5-po__card--currency {
.h5-po__label {
margin-bottom: 6px;
}
}
.h5-po__declare {
display: flex;
flex-direction: column;
gap: 10px;
}
.h5-po__card {
background: #fff;
border-radius: 16px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.04);
&--currency {
padding-bottom: 14px;
}
:deep(.arco-input-wrapper),
:deep(.arco-textarea-wrapper),
:deep(.arco-select-view-single),
:deep(.arco-select-view-multiple) {
background: #f7f8fa !important;
border: none !important;
box-shadow: none !important;
border-radius: 12px;
min-height: 40px;
}
:deep(.arco-input-wrapper:hover),
:deep(.arco-textarea-wrapper:hover),
:deep(.arco-select-view-single:hover) {
background: #f2f3f5 !important;
}
:deep(.arco-input-wrapper.arco-input-focus),
:deep(.arco-textarea-wrapper.arco-textarea-focus),
:deep(.arco-select-view-single.arco-select-view-focus) {
background: color-mix(in srgb, var(--app-primary-color) 8%, #fff) !important;
}
:deep(.arco-input),
:deep(.arco-textarea) {
font-size: 14px;
color: #1d2129;
}
:deep(.arco-select-view-value),
:deep(.arco-select-view-input) {
font-size: 14px;
}
}
.h5-po__field {
min-width: 0;
display: flex;
flex-direction: column;
}
.h5-po__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
min-width: 0;
}
.h5-po__services {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
}
.h5-po__item-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
strong {
font-size: 14px;
font-weight: 600;
color: #1d2129;
white-space: nowrap;
}
}
.h5-po__item-head-main {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 10px;
min-width: 0;
flex: 1;
}
.h5-po__item-derived {
font-size: 12px;
font-weight: 500;
color: #86909c;
line-height: 1.35;
}
.h5-po__more {
margin-top: 2px;
height: 36px;
border: none;
border-radius: 10px;
background: #f2f3f5;
color: var(--app-primary-color);
font-size: 13px;
font-weight: 600;
cursor: pointer;
&:active {
opacity: 0.88;
}
&--inline {
margin-top: 0;
flex-shrink: 0;
height: 32px;
padding: 0 10px;
white-space: nowrap;
}
}
.h5-po__street-number-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
:deep(.arco-input-wrapper) {
flex: 1;
min-width: 0;
}
}
.h5-po__inbound {
display: flex;
width: 100%;
:deep(.arco-radio-button) {
flex: 1;
text-align: center;
}
:deep(.arco-radio-checked) {
background: var(--app-primary-color);
border-color: var(--app-primary-color);
color: #fff;
}
}
.h5-po__hint {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.4;
color: #86909c;
&--tight {
margin-top: 0;
margin-bottom: 4px;
}
}
.h5-po__label--with-help {
display: inline-flex;
align-items: center;
gap: 4px;
}
.h5-po__help {
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: transparent;
color: #86909c;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
cursor: pointer;
font-size: 14px;
&:active {
color: var(--app-primary-color);
}
}
.h5-po__summary-banner {
padding: 12px;
border-radius: 12px;
background: color-mix(in srgb, #f77234 10%, #fff);
border: 1px solid color-mix(in srgb, #f77234 20%, #e5e6eb);
strong {
display: block;
font-size: 13px;
font-weight: 650;
color: #d4380d;
margin-bottom: 4px;
}
p {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: #4e5969;
}
}
.h5-po__receiver-toolbar {
display: flex;
align-items: flex-end;
gap: 8px;
}
.h5-po__field--grow {
flex: 1;
min-width: 0;
}
.h5-po__save-addr {
flex-shrink: 0;
height: 40px;
padding: 0 12px;
border: none;
border-radius: 12px;
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
color: var(--app-primary-color);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.h5-po__declare-bar {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
border-radius: 14px;
background: #fff;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04);
}
.h5-po__sku-btn {
height: 40px;
border: none;
border-radius: 12px;
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
color: var(--app-primary-color);
font-size: 14px;
font-weight: 650;
cursor: pointer;
}
.h5-po__declare-bar-tip {
margin: 0;
font-size: 12px;
line-height: 1.4;
color: #86909c;
}
.h5-po__sku-preview {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 12px;
background: #f7f8fa;
img {
width: 44px;
height: 44px;
border-radius: 8px;
object-fit: cover;
display: block;
background: #fff;
}
span {
font-size: 12px;
color: #4e5969;
word-break: break-all;
}
}
.h5-po__magnetic {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 36px;
padding: 0 2px;
font-size: 13px;
color: #1d2129;
font-weight: 500;
}
.h5-po__wh-card {
margin-top: 8px;
padding: 12px;
border-radius: 12px;
background: #fff;
border: 1px solid color-mix(in srgb, var(--app-primary-color) 16%, #e5e6eb);
}
.h5-po__wh-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
strong {
font-size: 14px;
font-weight: 600;
}
}
.h5-po__wh-list {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 320px;
overflow-y: auto;
}
.h5-po__wh-item {
padding: 10px;
border-radius: 10px;
border: 1px solid #e5e6eb;
background: #f7f8fa;
&.is-selected {
border-color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 8%, #fff);
}
&.is-empty {
opacity: 0.72;
}
}
.h5-po__wh-item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
strong {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #1d2129;
line-height: 20px;
}
}
.h5-po__wh-badge {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 14%, #fff);
}
.h5-po__wh-copy {
border: none;
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
color: var(--app-primary-color);
font-size: 12px;
font-weight: 600;
height: 28px;
padding: 0 10px;
border-radius: 14px;
flex-shrink: 0;
}
.h5-po__wh-card p,
.h5-po__wh-item p {
margin: 8px 0 0;
font-size: 13px;
color: #1d2129;
line-height: 1.45;
span {
color: #86909c;
margin-right: 6px;
}
&.is-addr {
word-break: break-all;
}
}
.h5-po__wh-item .h5-po__hint {
margin-top: 4px;
}
.h5-po__more-panel {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 2px;
}
.h5-po__item-remove {
border: none;
background: var(--color-fill-2, #f2f3f5);
color: #86909c;
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: 8px;
cursor: pointer;
}
.h5-po__add-item {
height: 44px;
border: none;
border-radius: 14px;
background: color-mix(in srgb, var(--app-primary-color) 10%, #fff);
color: var(--app-primary-color);
font-size: 14px;
font-weight: 600;
cursor: pointer;
&:active {
opacity: 0.88;
}
}
.h5-po__label {
display: block;
margin: 0 0 6px;
font-size: 12px;
line-height: 1.3;
color: #4e5969;
}
.h5-po__sender-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.h5-po__label {
margin: 0;
font-size: 13px;
}
}
.h5-po__link {
border: none;
background: transparent;
padding: 0;
color: var(--app-primary-color);
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
}
.h5-po__sender-empty {
margin: 0;
padding: 10px 12px;
border-radius: 10px;
background: #fff7e8;
color: #ad6800;
font-size: 12px;
line-height: 1.45;
}
.h5-po__field-err {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.4;
color: #f53f3f;
&--block {
margin: 0 0 10px;
padding: 8px 10px;
border-radius: 8px;
background: #ffece8;
}
}
.h5-po__sender-form {
display: grid;
gap: 8px;
}
.h5-po__btn--block {
width: 100%;
}
.h5-po__dims,
.h5-po__three {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
min-width: 0;
}
.h5-po__dims--with-weight {
grid-template-columns: 1.15fr 1fr 1fr 1fr;
}
.h5-po__foot {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px calc(14px + env(safe-area-inset-bottom, 0px));
background: #fff;
box-shadow: 0 -6px 20px rgba(15, 23, 42, 0.04);
flex-shrink: 0;
}
.h5-po__foot-main {
flex: 1;
min-width: 0;
display: flex;
gap: 10px;
.h5-po__btn {
flex: 1;
}
.h5-po__btn.is-ghost,
.h5-po__btn.is-ai {
flex: 0 0 auto;
}
}
.h5-po-fail {
display: flex;
flex-direction: column;
gap: 12px;
}
.h5-po-fail__hero {
padding: 12px 14px;
border-radius: 8px;
border-left: 3px solid #f53f3f;
background: #fff7f7;
}
.h5-po-fail__reason {
margin: 0;
font-size: 16px;
font-weight: 600;
line-height: 1.45;
color: #1d2129;
word-break: break-word;
}
.h5-po-fail__tip {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.5;
color: #4e5969;
}
.h5-po-fail__tip-label {
margin-right: 4px;
color: var(--app-primary-color, #2d8c8c);
font-weight: 500;
}
.h5-po-fail__status {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
line-height: 1.4;
color: #0fc6c2;
background: rgba(15, 198, 194, 0.08);
}
.h5-po-fail__sno {
text-decoration: underline;
}
.h5-po-fail__details {
font-size: 12px;
color: #86909c;
summary {
cursor: pointer;
user-select: none;
}
}
.h5-po-fail__raw {
margin: 6px 0 0;
padding: 8px 10px;
border-radius: 6px;
background: #f7f8fa;
font-size: 12px;
line-height: 1.5;
color: #86909c;
word-break: break-word;
}
.h5-po-fail__actions {
display: flex;
gap: 10px;
margin-top: 4px;
.h5-po__btn {
flex: 1;
height: 44px;
}
}
.h5-po__btn {
flex: 1;
height: 48px;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
&.is-mock {
flex: 0 0 auto;
min-width: 88px;
padding: 0 12px;
background: #fff7e8;
color: #d25f00;
border: 1px solid #ffcf8b;
font-size: 13px;
&:active:not(:disabled) {
background: #ffefd6;
}
}
&.is-ghost {
flex: 0 0 88px;
background: #f2f3f5;
color: #4e5969;
&:active:not(:disabled) {
background: #e5e6eb;
}
}
&.is-ai {
flex: 0 0 auto;
min-width: 88px;
padding: 0 12px;
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
color: var(--app-primary-color);
border: 1px solid color-mix(in srgb, var(--app-primary-color) 35%, #e5e6eb);
box-shadow: none;
&:active:not(:disabled) {
background: color-mix(in srgb, var(--app-primary-color) 18%, #fff);
}
}
&.is-primary {
background: var(--app-primary-color);
color: #fff;
box-shadow: 0 8px 18px color-mix(in srgb, var(--app-primary-color) 28%, transparent);
&:active:not(:disabled) {
opacity: 0.92;
}
&:disabled {
opacity: 0.5;
box-shadow: none;
}
}
&:disabled {
cursor: not-allowed;
}
}
.h5-po-ai {
display: flex;
flex-direction: column;
gap: 10px;
}
.h5-po-ai__hint {
margin: 0;
font-size: 13px;
line-height: 1.45;
color: #86909c;
}
.h5-po-ai__config {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
}
.h5-po-ai__select {
width: 100%;
min-width: 0;
&--skill {
grid-column: 1 / -1;
}
}
.h5-po-ai__textarea {
:deep(textarea) {
font-size: 14px;
line-height: 1.5;
}
}
.h5-po-ai__tip {
margin: 0;
font-size: 12px;
line-height: 1.45;
&.is-ok {
color: #00a870;
}
&.is-warn {
color: #d48806;
}
&.is-danger {
color: #f53f3f;
}
}
.h5-po-ai__actions {
display: flex;
gap: 10px;
margin-top: 4px;
.h5-po__btn {
height: 44px;
}
.is-ghost {
flex: 0 0 88px;
}
}
.h5-po__loading {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: h5-po-spin 0.7s linear infinite;
}
@keyframes h5-po-spin {
to {
transform: rotate(360deg);
}
}
</style>