Improve batch/sync forecast result UI, invite poster composition, and expose DownstreamServiceNo on order lists. Co-authored-by: Cursor <cursoragent@cursor.com>
574 lines
16 KiB
Vue
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>
|