front/src/views/h5/customer-center/components/H5ShipGuideSheet.vue
zzh 8a9d900628 Show full downstream forecast payloads and provider service no.
Improve batch/sync forecast result UI, invite poster composition, and expose DownstreamServiceNo on order lists.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 10:21:23 +08:00

574 lines
16 KiB
Vue

<template>
<a-drawer
:visible="visible"
placement="bottom"
height="auto"
:footer="false"
:header="false"
unmount-on-close
:drawer-style="drawerStyle"
popup-container="body"
class="h5-ship-drawer"
@cancel="close"
>
<div class="h5-ship">
<div class="h5-ship__handle" aria-hidden="true" />
<header class="h5-ship__head">
<h3>{{ t('h5.journey.ship.title') }}</h3>
<button type="button" class="h5-ship__close" @click="close">
<icon-close />
</button>
</header>
<div class="h5-ship__spin-wrap">
<p class="h5-ship__lead">{{ t('h5.journey.ship.lead') }}</p>
<div class="h5-ship__paths">
<button type="button" class="h5-ship__path" @click="openWarehouse">
<strong>{{ t('h5.journey.ship.selfTitle') }}</strong>
<span>{{ t('h5.journey.ship.selfDesc') }}</span>
</button>
<button
type="button"
class="h5-ship__path is-primary"
@click="togglePickupMode"
>
<strong>{{ t('h5.journey.ship.pickupTitle') }}</strong>
<span>{{ t('h5.journey.ship.pickupDesc') }}</span>
</button>
</div>
<template v-if="mode === 'pickup'">
<a-spin :loading="loading">
<section class="h5-ship__card">
<div class="h5-ship__card-head">
<h4>{{ t('h5.journey.ship.senderTitle') }}</h4>
<button type="button" class="h5-ship__link" @click="showSenderForm = !showSenderForm">
{{ showSenderForm ? t('h5.journey.ship.cancelAdd') : t('h5.journey.ship.addSender') }}
</button>
</div>
<div v-if="showSenderForm" class="h5-ship__form">
<a-input v-model="senderForm.senderName" :placeholder="t('h5.journey.ship.senderName')" />
<a-input v-model="senderForm.phone" :placeholder="t('h5.journey.ship.phone')" />
<a-input v-model="senderForm.province" :placeholder="t('h5.journey.ship.province')" />
<a-input v-model="senderForm.city" :placeholder="t('h5.journey.ship.city')" />
<a-textarea
v-model="senderForm.streetAddress1"
:placeholder="t('h5.journey.ship.address')"
:auto-size="{ minRows: 2, maxRows: 3 }"
/>
<button type="button" class="h5-ship__btn" :disabled="savingSender" @click="saveSender">
{{ t('h5.journey.ship.saveSender') }}
</button>
</div>
<H5EmptyState
v-else-if="!senders.length"
inline
:icon="IconLocation"
:text="t('h5.journey.ship.noSender')"
/>
<button
v-for="s in senders"
:key="s.id"
type="button"
class="h5-ship__sender"
:class="{ 'is-active': pickupForm.senderAddressId === s.id }"
@click="pickupForm.senderAddressId = s.id"
>
<b>{{ s.senderName }}</b>
<span>{{ s.phone }} · {{ s.address }}</span>
</button>
</section>
<section v-if="senders.length" class="h5-ship__card">
<h4>{{ t('h5.journey.ship.pickupFormTitle') }}</h4>
<label class="h5-ship__label">{{ t('h5.journey.ship.warehouse') }}</label>
<a-select v-model="pickupForm.warehouseId" allow-clear>
<a-option v-for="w in warehouses" :key="w.id" :value="w.id">{{ w.name }}</a-option>
</a-select>
<label class="h5-ship__label">{{ t('h5.journey.ship.product') }}</label>
<a-select v-model="pickupForm.productCode" allow-search allow-clear>
<a-option v-for="p in products" :key="p.value" :value="p.value">{{ p.label }}</a-option>
</a-select>
<label class="h5-ship__label">{{ t('h5.journey.ship.cargoName') }}</label>
<a-input v-model="pickupForm.cargoName" allow-clear />
<label class="h5-ship__label">{{ t('h5.journey.ship.weight') }}</label>
<a-input v-model="pickupForm.actualWeight" allow-clear />
<label class="h5-ship__label">{{ t('h5.journey.ship.pickupTime') }}</label>
<a-input v-model="pickupForm.pickupTime" allow-clear :placeholder="t('h5.journey.ship.pickupTimePh')" />
<button
type="button"
class="h5-ship__btn"
:disabled="creatingPickup"
@click="submitPickup"
>
{{ t('h5.journey.ship.createPickup') }}
</button>
</section>
</a-spin>
</template>
</div>
</div>
<H5WarehouseAddressSheet v-model:visible="warehouseVisible" />
<H5CompleteContactSheet
v-model:visible="contactVisible"
:profile="contactProfile"
@success="onContactSuccess"
/>
</a-drawer>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { IconClose, IconLocation } from '@arco-design/web-vue/es/icon';
import type { CustomerProfileCompleteness } from '@/api/customer';
import { getWarehouseList } from '@/api/system/warehouse';
import { getDictListAll } from '@/api/system/dict';
import { getSalesProductList } from '@/api/channel/salesProduct';
import {
createPickupOrder,
createSenderAddress,
getSenderAddressList,
} from '@/api/order/senderAddress';
import { pickField } from '@/utils/warehouseListMapper';
import {
checkCustomerContactRequired,
isCustomerContactRequiredError,
} from '@/utils/customerContactGate';
import H5WarehouseAddressSheet from './H5WarehouseAddressSheet.vue';
import H5CompleteContactSheet from './H5CompleteContactSheet.vue';
import H5EmptyState from './H5EmptyState.vue';
defineOptions({ name: 'H5ShipGuideSheet' });
const props = defineProps<{ visible: boolean }>();
const emit = defineEmits<{ 'update:visible': [boolean]; done: [] }>();
const { t } = useI18n();
const mode = ref<'guide' | 'pickup'>('guide');
const warehouseVisible = ref(false);
const contactVisible = ref(false);
const contactProfile = ref<CustomerProfileCompleteness | null>(null);
const loading = ref(false);
const showSenderForm = ref(false);
const savingSender = ref(false);
const creatingPickup = ref(false);
const senders = ref<{ id: number; senderName: string; phone: string; address: string }[]>([]);
const warehouses = ref<{ id: number; name: string }[]>([]);
const products = ref<{ value: string; label: string }[]>([]);
const senderForm = reactive({
senderName: '',
phone: '',
province: '',
city: '',
streetAddress1: '',
countryCode: 'CN',
});
const pickupForm = reactive({
senderAddressId: undefined as number | undefined,
warehouseId: undefined as number | undefined,
productCode: '',
cargoName: '',
actualWeight: '',
pickupTime: '',
});
const drawerStyle = {
borderRadius: '20px 20px 0 0',
overflow: 'hidden',
maxHeight: 'min(92vh, 780px)',
};
function close() {
emit('update:visible', false);
}
function openWarehouse() {
warehouseVisible.value = true;
}
function defaultPickupTime() {
const d = new Date();
d.setDate(d.getDate() + 1);
d.setHours(10, 0, 0, 0);
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:00`;
}
async function loadRefs() {
loading.value = true;
try {
const [senderRes, whRes, productRes, salesProductGroupDictRows] = await Promise.all([
getSenderAddressList({ page: 1, pageSize: 100, addressType: 'sender' }),
getWarehouseList({ page: 1, pageSize: 100 }),
getSalesProductList({ page: 1, pageSize: 200, isActive: 2 }),
getDictListAll({ dictType: 'sales_product_group' }),
]);
senders.value = (senderRes.items || [])
.map((row: any) => {
const id = Number(pickField(row, 'id', 'Id'));
if (!Number.isFinite(id) || id <= 0) return null;
return {
id,
senderName: String(pickField(row, 'senderName', 'SenderName') || `#${id}`),
phone: String(pickField(row, 'phone', 'Phone') || '-'),
address: String(pickField(row, 'streetAddress1', 'StreetAddress1') || '-'),
};
})
.filter(Boolean) as typeof senders.value;
if (senders.value.length && !pickupForm.senderAddressId) {
pickupForm.senderAddressId = senders.value[0].id;
}
warehouses.value = (whRes.items || [])
.map((row: any) => {
const id = Number(row?.id ?? row?.Id);
if (!Number.isFinite(id)) return null;
return {
id,
name: String(row?.warehouseName ?? row?.WarehouseName ?? id),
};
})
.filter(Boolean) as typeof warehouses.value;
if (warehouses.value.length && !pickupForm.warehouseId) {
pickupForm.warehouseId = warehouses.value[0].id;
}
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)),
);
products.value = (productRes.items || [])
.filter((row: any) => {
const groupCode = String(row?.productGroup ?? row?.ProductGroup ?? '').trim();
if (!domesticPickupGroupCodes.size) return true;
if (mode.value === 'pickup') return domesticPickupGroupCodes.has(groupCode);
return !domesticPickupGroupCodes.has(groupCode);
})
.map((row: any) => {
const code = String(row?.productCode ?? row?.ProductCode ?? '').trim();
const name = String(row?.productName ?? row?.ProductName ?? '').trim();
return code ? { value: code, label: name ? `${name} ${code}` : code } : null;
})
.filter(Boolean) as typeof products.value;
if (!pickupForm.pickupTime) pickupForm.pickupTime = defaultPickupTime();
} finally {
loading.value = false;
}
}
async function saveSender() {
if (!senderForm.senderName.trim() || !senderForm.streetAddress1.trim()) {
Message.warning(t('h5.journey.ship.senderRequired'));
return;
}
savingSender.value = true;
try {
await createSenderAddress({
addressType: 'sender',
addressName: senderForm.senderName.trim(),
senderName: senderForm.senderName.trim(),
phone: senderForm.phone.trim(),
countryCode: senderForm.countryCode || 'CN',
province: senderForm.province.trim(),
city: senderForm.city.trim(),
streetAddress1: senderForm.streetAddress1.trim(),
isDefault: senders.value.length === 0,
});
Message.success(t('h5.journey.ship.senderSaved'));
showSenderForm.value = false;
Object.assign(senderForm, {
senderName: '',
phone: '',
province: '',
city: '',
streetAddress1: '',
countryCode: 'CN',
});
await loadRefs();
} catch (e: any) {
Message.error(e?.message || t('h5.journey.ship.senderFailed'));
} finally {
savingSender.value = false;
}
}
async function togglePickupMode() {
if (mode.value === 'pickup') {
mode.value = 'guide';
void loadRefs();
return;
}
const { needContact, profile } = await checkCustomerContactRequired();
if (needContact) {
contactProfile.value = profile;
contactVisible.value = true;
return;
}
mode.value = 'pickup';
void loadRefs();
}
function onContactSuccess() {
mode.value = 'pickup';
void loadRefs();
}
async function submitPickup() {
if (!pickupForm.senderAddressId) {
Message.warning(t('h5.journey.ship.pickSender'));
return;
}
if (!pickupForm.warehouseId) {
Message.warning(t('h5.journey.ship.pickWarehouse'));
return;
}
if (!pickupForm.productCode) {
Message.warning(t('h5.journey.ship.pickProduct'));
return;
}
if (!pickupForm.cargoName.trim()) {
Message.warning(t('h5.journey.ship.pickCargo'));
return;
}
const weight = Number(pickupForm.actualWeight);
if (!Number.isFinite(weight) || weight <= 0) {
Message.warning(t('h5.journey.ship.pickWeight'));
return;
}
creatingPickup.value = true;
try {
await createPickupOrder({
senderAddressId: pickupForm.senderAddressId,
warehouseId: pickupForm.warehouseId,
productCode: pickupForm.productCode,
pickupTime: pickupForm.pickupTime || defaultPickupTime(),
cargoName: pickupForm.cargoName.trim(),
actualWeight: weight,
});
Message.success(t('h5.journey.ship.pickupOk'));
emit('done');
close();
} catch (e: any) {
if (isCustomerContactRequiredError(e)) {
const { profile } = await checkCustomerContactRequired();
contactProfile.value = profile;
contactVisible.value = true;
Message.warning(t('h5.contact.requiredToast'));
} else {
Message.error(e?.message || t('h5.journey.ship.pickupFailed'));
}
} finally {
creatingPickup.value = false;
}
}
watch(
() => props.visible,
(v) => {
if (!v) {
mode.value = 'guide';
showSenderForm.value = false;
return;
}
void loadRefs();
},
);
watch(mode, (m) => {
if (m === 'pickup' && !senders.value.length) {
showSenderForm.value = true;
}
});
</script>
<style lang="less">
.h5-ship-drawer {
.arco-drawer-content {
border-radius: 20px 20px 0 0;
overflow: hidden;
max-height: min(92vh, 780px);
}
.arco-drawer-body {
padding: 0;
max-height: min(92vh, 780px);
}
}
</style>
<style lang="less" scoped>
.h5-ship {
display: flex;
flex-direction: column;
max-height: min(92vh, 780px);
padding-bottom: env(safe-area-inset-bottom, 0px);
background: #f5f7f8;
}
.h5-ship__handle {
width: 36px;
height: 4px;
margin: 10px auto 0;
border-radius: 999px;
background: #c9cdd4;
}
.h5-ship__head {
position: relative;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
}
.h5-ship__close {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
border: none;
background: transparent;
color: #86909c;
font-size: 18px;
}
.h5-ship__spin-wrap {
flex: 1;
overflow: auto;
padding: 0 16px 16px;
}
.h5-ship__lead {
margin: 0 0 12px;
font-size: 13px;
color: #4e5969;
line-height: 1.5;
}
.h5-ship__paths {
display: grid;
gap: 10px;
margin-bottom: 12px;
}
.h5-ship__path {
text-align: left;
border: 1px solid #e5e6eb;
border-radius: 14px;
background: #fff;
padding: 14px;
cursor: pointer;
strong {
display: block;
font-size: 15px;
color: #1d2129;
margin-bottom: 4px;
}
span {
font-size: 12px;
color: #86909c;
line-height: 1.4;
}
&.is-primary {
border-color: color-mix(in srgb, var(--app-primary-color) 35%, #e5e6eb);
background: color-mix(in srgb, var(--app-primary-color) 8%, #fff);
}
}
.h5-ship__card {
background: #fff;
border-radius: 16px;
padding: 14px;
margin-bottom: 10px;
h4 {
margin: 0 0 10px;
font-size: 14px;
}
}
.h5-ship__card-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
h4 {
margin: 0;
}
}
.h5-ship__link {
border: none;
background: transparent;
color: var(--app-primary-color);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.h5-ship__form,
.h5-ship__card {
:deep(.arco-input-wrapper),
:deep(.arco-textarea-wrapper),
:deep(.arco-select-view-single) {
margin-bottom: 8px;
border-radius: 10px;
}
}
.h5-ship__label {
display: block;
margin: 8px 0 6px;
font-size: 12px;
color: #86909c;
}
.h5-ship__sender {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
text-align: left;
border: 1px solid #e5e6eb;
border-radius: 12px;
background: #fafafa;
padding: 10px 12px;
margin-bottom: 8px;
cursor: pointer;
b {
font-size: 14px;
color: #1d2129;
}
span {
font-size: 12px;
color: #86909c;
}
&.is-active {
border-color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 8%, #fff);
}
}
.h5-ship__btn {
width: 100%;
height: 44px;
margin-top: 8px;
border: none;
border-radius: 12px;
background: var(--app-primary-color);
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
&:disabled {
opacity: 0.5;
}
}
</style>