front/src/views/h5/customer-center/components/H5PlaceOrderSheet.vue
2026-07-29 12:47:32 +08:00

2093 lines
61 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>{{ 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
: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 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 :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
:error="fieldHasError('actualWeight')"
:placeholder="t('h5.placeOrder.weightPh')"
/>
<a-input
v-model="form.length"
:error="fieldHasError('length')"
:placeholder="t('h5.placeOrder.length')"
/>
<a-input
v-model="form.width"
:error="fieldHasError('width')"
:placeholder="t('h5.placeOrder.width')"
/>
<a-input
v-model="form.height"
: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>
</section>
<section v-show="step === 1" class="h5-po__card">
<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>
<button
type="button"
class="h5-po__more"
@click="showReceiverMore = !showReceiverMore"
>
{{
showReceiverMore
? t('h5.placeOrder.hideMore')
: t('h5.placeOrder.showMore')
}}
</button>
<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.streetNumber') }}</label>
<a-input v-model="form.receiver.streetNumber" allow-clear />
</div>
</div>
<div class="h5-po__row">
<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 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>
<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.certificateNo') }}</label>
<a-input v-model="form.receiver.certificateNo" allow-clear />
</div>
</div>
<div class="h5-po__row">
<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 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">
<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">
<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>
<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 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__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__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.model') }}</label>
<a-input v-model="row.model" 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.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="step > 0"
type="button"
class="h5-po__btn is-ghost"
:disabled="submitting"
@click="step -= 1"
>
{{ t('h5.placeOrder.prev') }}
</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 ? t('h5.placeOrder.submitting') : t('h5.placeOrder.submit') }}
</button>
</footer>
</div>
</a-drawer>
<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>
</template>
<script lang="ts" setup>
import { computed, 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 } from '@arco-design/web-vue/es/icon';
import { postOrderDelete, postOrderParcelCreate } from '@/api/order';
import { createSenderAddress, getSenderAddressList } from '@/api/order/senderAddress';
import { getSalesProductList } from '@/api/channel/salesProduct';
import { getDictListAll } from '@/api/system/dict';
import { getCountryList } from '@/api/system/country';
import { getWarehouseList } from '@/api/system/warehouse';
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
import { pickField } from '@/utils/warehouseListMapper';
import { h5Navigate } from '@/utils/device';
import { isCustomerContactRequiredError } from '@/utils/customerContactGate';
import {
buildParcelCreateFailGuide,
type ParcelCreateFailGuide,
} from '@/utils/parcelCreateFailGuide';
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;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
success: [];
}>();
const { t } = useI18n();
const router = useRouter();
const step = ref(0);
const optionsLoading = ref(false);
const senderLoading = ref(false);
const showSenderForm = ref(false);
const savingSender = ref(false);
const showReceiverMore = ref(false);
/** 申报品「更多」展开状态,按 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 }[]>([]);
const cargoOptions = ref<{ value: string; label: string }[]>([]);
const warehouseOptions = ref<{ value: string; label: 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> }[]>([]);
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;
brand: string;
model: string;
material: string;
enMaterial: string;
purpose: string;
originCountry: string;
category: string;
specifications: string;
salesLink: string;
comment: 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: '',
brand: '',
model: '',
material: '',
enMaterial: '',
purpose: '',
originCountry: '',
category: '',
specifications: '',
salesLink: '',
comment: '',
};
}
const items = ref<DeclareItem[]>([createEmptyItem()]);
const form = reactive({
clientNo: '',
productCode: '',
cargoAttribute: '',
exportCustomsMode: '9610Simple',
warehouseId: '',
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 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 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 (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.quantity),
);
}
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 = '9610Simple';
form.warehouseId = '';
form.length = '10';
form.width = '10';
form.height = '10';
form.actualWeight = '';
form.quantity = '1';
form.declaredCurrency = 'USD';
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 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',
});
}
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 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: false,
};
});
const declaredSum = payloadItems.reduce((sum, row) => sum + (Number(row.amount) || 0), 0);
return {
clientNo: trim(form.clientNo),
productCode: trim(form.productCode),
cargoAttribute: normalizeCargoAttributeCode(form.cargoAttribute),
exportCustomsMode: trim(form.exportCustomsMode) || '9610Simple',
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,
isCod: false,
receiver: {
...form.receiver,
countryCode: trim(form.receiver.countryCode).toUpperCase(),
taxType: 0,
certificateType: 0,
},
sender: {
...form.sender,
countryCode: trim(form.sender.countryCode).toUpperCase() || undefined,
},
items: payloadItems,
};
}
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 {
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 (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'));
}
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) {
optionsLoading.value = true;
try {
const [productRes, cargoRows, warehouseRes, currencyRows, countryRes, salesProductGroupDictRows] =
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' }),
]);
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();
return { value: code, label: name ? `${name} ${code}` : code };
})
.filter((x) => x.value);
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 (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();
return { value: String(id ?? ''), label: name || String(id) };
})
.filter((x) => x.value);
if (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);
const usd = currencyOptions.value.find((x) => x.value.toUpperCase() === 'USD');
form.declaredCurrency = usd?.value || currencyOptions.value[0]?.value || 'USD';
countryOptions.value = (countryRes.items ?? [])
.map((row: any) => {
const code = String(row?.countryCode ?? row?.CountryCode ?? row?.code ?? '').trim();
const name = String(row?.countryName ?? row?.CountryName ?? row?.name ?? '').trim();
return { value: code, label: name ? `${name} ${code}` : code };
})
.filter((x) => x.value);
applyDraft(draft);
ensureClientNo();
} catch (e: any) {
Message.error(e?.message || t('h5.placeOrder.optionsFailed'));
} finally {
optionsLoading.value = false;
}
}
async function loadSenders() {
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;
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 = [];
showSenderForm.value = true;
} finally {
senderLoading.value = false;
}
}
watch(
() => props.visible,
(visible) => {
if (!visible) {
submitting.value = false;
showSenderForm.value = false;
resetSenderForm();
return;
}
resetForm();
const draft = props.draft ?? readStoredDraft();
void loadOptions(draft);
void loadSenders();
},
);
</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__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;
}
}
.h5-po__more-panel {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 2px;
}
.h5-po__item-remove {
border: none;
background: color-mix(in srgb, #f53f3f 10%, #fff);
color: #f53f3f;
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;
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-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-ghost {
flex: 0 0 96px;
background: #f2f3f5;
color: #4e5969;
&:active:not(:disabled) {
background: #e5e6eb;
}
}
&.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__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>