front/src/views/h5/customer-center/components/H5CreatePickupFromOrdersSheet.vue
zzh 25445bc146 feat: 完善日夜间主题与揽收下单体验
统一暗色模式页面/顶栏/Handsontable 选中色,调整下单到仓方式文案与提示,CustomLeftMenu 卡片统一 60px;补充揽收单、问题件处理与 H5 相关页面。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 11:49:23 +08:00

618 lines
16 KiB
Vue

<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-pk-merge-drawer"
@cancel="close"
>
<div class="h5-pkm">
<div class="h5-pkm__handle" aria-hidden="true" />
<header class="h5-pkm__head">
<div>
<p class="h5-pkm__step">{{ t('h5.orders.mergePickup.createSub') }}</p>
<h3>{{ t('h5.orders.mergePickup.createTitle') }}</h3>
</div>
<button type="button" class="h5-pkm__close" :disabled="submitting" @click="close">
<icon-close />
</button>
</header>
<a-spin :loading="loadingRefs" class="h5-pkm__body">
<section class="h5-pkm__summary">
<div class="h5-pkm__summary-item">
<span>{{ t('h5.orders.mergePickup.summaryWarehouse') }}</span>
<strong>{{ warehouseLabel }}</strong>
</div>
<div class="h5-pkm__summary-item">
<span>{{ t('h5.orders.mergePickup.summaryOrders') }}</span>
<strong>{{ orderIds.length }}</strong>
</div>
</section>
<div class="h5-pkm__tags">
<span v-for="row in previewRows" :key="String(row.id)" class="h5-pkm__tag">
{{ row.serviceNo || row.id }}
</span>
</div>
<section class="h5-pkm__section">
<h4>{{ t('order.myOrders.createPickup.senderAddress') }}</h4>
<H5EmptyState
v-if="!senderOptions.length && !loadingRefs"
inline
:icon="IconLocation"
:text="t('h5.orders.mergePickup.noSender')"
/>
<button
v-for="opt in senderOptions"
:key="opt.value"
type="button"
class="h5-pkm__sender"
:class="{ 'is-active': form.senderAddressId === opt.value }"
@click="form.senderAddressId = opt.value"
>
<b>{{ opt.name }}</b>
<span>{{ opt.phone }}</span>
<em v-if="opt.address">{{ opt.address }}</em>
</button>
</section>
<section class="h5-pkm__section">
<h4>{{ t('order.myOrders.createPickup.expectTime') }}</h4>
<div class="h5-pkm__time-chips">
<button
v-for="preset in timePresets"
:key="preset.key"
type="button"
class="h5-pkm__time-chip"
:class="{ 'is-active': activeTimePreset === preset.key }"
@click="applyTimePreset(preset)"
>
{{ preset.label }}
</button>
</div>
<a-input
v-model="form.expectPickupTime"
allow-clear
class="h5-pkm__time-input"
:placeholder="t('h5.journey.ship.pickupTimePh')"
@input="activeTimePreset = 'custom'"
/>
</section>
<section class="h5-pkm__section h5-pkm__section--fold">
<button type="button" class="h5-pkm__fold" @click="showMore = !showMore">
{{ t('h5.orders.mergePickup.moreOptions') }}
<icon-down :class="{ 'is-open': showMore }" />
</button>
<div v-show="showMore" class="h5-pkm__fold-body">
<label class="h5-pkm__label">{{ t('order.myOrders.createPickup.carrierService') }}</label>
<a-select
v-model="form.carrierService"
allow-search
allow-clear
:placeholder="t('order.myOrders.createPickup.carrierServicePh')"
>
<a-option v-for="opt in carrierServiceOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-option>
</a-select>
<label class="h5-pkm__label">{{ t('order.myOrders.createPickup.remark') }}</label>
<a-textarea
v-model="form.remark"
:max-length="512"
allow-clear
:auto-size="{ minRows: 2, maxRows: 4 }"
/>
</div>
</section>
</a-spin>
<footer class="h5-pkm__foot">
<button type="button" class="h5-pkm__submit" :disabled="submitting" @click="submit">
<span v-if="submitting" class="h5-pkm__loading" aria-hidden="true" />
{{ submitting ? t('h5.placeOrder.submitting') : t('h5.orders.mergePickup.submit') }}
</button>
</footer>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import dayjs from 'dayjs'
import { Message } from '@arco-design/web-vue'
import { IconClose, IconDown, IconLocation } from '@arco-design/web-vue/es/icon'
import { createPickupOrderV2 } from '@/api/order/pickupOrder'
import { getSenderAddressList } from '@/api/order/senderAddress'
import { getWarehouseList } from '@/api/system/warehouse'
import { normalizeListRow, pickField } from '@/utils/warehouseListMapper'
import {
loadDomesticPickupProductOptions,
type DomesticPickupProductOption,
} from '@/utils/domesticPickupProducts'
import H5EmptyState from './H5EmptyState.vue'
defineOptions({ name: 'H5CreatePickupFromOrdersSheet' })
type SenderOption = {
value: number
name: string
phone: string
address: string
}
const emit = defineEmits<{ success: [payload: { pickupNo?: string; id?: number }] }>()
const { t } = useI18n()
const visible = ref(false)
const submitting = ref(false)
const loadingRefs = ref(false)
const showMore = ref(false)
const activeTimePreset = ref('tomorrow_am')
const orderIds = ref<Array<number | string>>([])
const previewRows = ref<Array<{ id: number | string; serviceNo?: string }>>([])
const senderOptions = ref<SenderOption[]>([])
const warehouseOptions = ref<{ value: number; label: string }[]>([])
const carrierServiceOptions = ref<DomesticPickupProductOption[]>([])
const form = reactive({
senderAddressId: undefined as number | undefined,
warehouseId: undefined as number | undefined,
expectPickupTime: '',
carrierService: '',
remark: '',
})
const drawerStyle = {
borderRadius: '20px 20px 0 0',
overflow: 'hidden',
maxHeight: 'min(92vh, 780px)',
}
const warehouseLabel = computed(() => {
const hit = warehouseOptions.value.find((x) => x.value === form.warehouseId)
return hit?.label || '-'
})
const timePresets = computed(() => [
{
key: 'tomorrow_am',
label: t('h5.orders.mergePickup.timePresetTomorrowAm'),
value: dayjs().add(1, 'day').hour(10).minute(0).second(0).format('YYYY-MM-DD HH:mm:ss'),
},
{
key: 'tomorrow_pm',
label: t('h5.orders.mergePickup.timePresetTomorrowPm'),
value: dayjs().add(1, 'day').hour(14).minute(0).second(0).format('YYYY-MM-DD HH:mm:ss'),
},
{
key: 'day_after_am',
label: t('h5.orders.mergePickup.timePresetDayAfterAm'),
value: dayjs().add(2, 'day').hour(10).minute(0).second(0).format('YYYY-MM-DD HH:mm:ss'),
},
])
function applyTimePreset(preset: { key: string; value: string }) {
activeTimePreset.value = preset.key
form.expectPickupTime = preset.value
}
function close() {
if (submitting.value) return
visible.value = false
}
async function loadRefs() {
loadingRefs.value = true
try {
const [senderRes, whRes, pickupProducts] = await Promise.all([
getSenderAddressList({ page: 1, pageSize: 200, addressType: 'sender' }),
getWarehouseList({ page: 1, pageSize: 200 }),
loadDomesticPickupProductOptions(),
])
senderOptions.value = (senderRes.items || [])
.map((raw: any) => {
const row = normalizeListRow(raw)
const id = Number(pickField(row, 'id', 'Id'))
const name = String(pickField(row, 'senderName', 'SenderName') || '')
const phone = String(pickField(row, 'phone', 'Phone') || '')
const address = [
pickField(row, 'province', 'Province'),
pickField(row, 'city', 'City'),
pickField(row, 'streetAddress1', 'StreetAddress1'),
]
.filter(Boolean)
.join('')
return { value: id, name, phone, address }
})
.filter((x) => x.value > 0)
if (!form.senderAddressId && senderOptions.value.length) {
form.senderAddressId = senderOptions.value[0].value
}
warehouseOptions.value = (whRes.items || [])
.map((raw: any) => {
const row = normalizeListRow(raw)
return {
value: Number(pickField(row, 'id', 'Id')),
label: String(pickField(row, 'warehouseName', 'WarehouseName', 'name') || ''),
}
})
.filter((x) => x.value > 0)
carrierServiceOptions.value = pickupProducts
} finally {
loadingRefs.value = false
}
}
async function open(rows: Array<{ id: number | string; serviceNo?: string; warehouseId?: number | string }>) {
const list = (rows || []).filter((r) => r?.id != null)
if (!list.length) {
Message.warning(t('h5.orders.mergePickup.needSelect'))
return
}
const whIds = [...new Set(list.map((r) => Number(r.warehouseId)).filter((x) => x > 0))]
if (whIds.length !== 1) {
Message.warning(t('order.myOrders.createPickup.sameWarehouse'))
return
}
orderIds.value = list.map((r) => r.id)
previewRows.value = list.map((r) => ({ id: r.id, serviceNo: r.serviceNo }))
form.warehouseId = whIds[0]
form.carrierService = ''
form.remark = ''
form.senderAddressId = undefined
showMore.value = false
activeTimePreset.value = 'tomorrow_am'
form.expectPickupTime = dayjs().add(1, 'day').hour(10).minute(0).second(0).format('YYYY-MM-DD HH:mm:ss')
visible.value = true
await loadRefs()
}
async function submit() {
if (!form.senderAddressId) {
Message.warning(t('order.myOrders.createPickup.needSender'))
return
}
if (!form.warehouseId) {
Message.warning(t('order.myOrders.createPickup.needWarehouse'))
return
}
if (!form.expectPickupTime.trim()) {
Message.warning(t('order.myOrders.createPickup.needTime'))
return
}
submitting.value = true
try {
const res: any = await createPickupOrderV2({
senderAddressId: form.senderAddressId,
warehouseId: form.warehouseId,
carrierService: form.carrierService.trim() || undefined,
expectPickupTime: form.expectPickupTime.trim(),
orderIds: orderIds.value,
remark: form.remark.trim() || undefined,
})
const pickupNo = res?.pickupNo || res?.PickupNo
Message.success(
t('order.myOrders.createPickup.success', { no: pickupNo || '', count: orderIds.value.length }),
)
visible.value = false
emit('success', { pickupNo, id: res?.id ?? res?.Id })
} catch (e: any) {
Message.error(e?.message || t('order.myOrders.createPickup.failed'))
} finally {
submitting.value = false
}
}
defineExpose({ open })
</script>
<style lang="less">
.h5-pk-merge-drawer {
.arco-drawer-content {
border-radius: 20px 20px 0 0;
overflow: hidden;
max-height: min(92vh, 780px);
}
.arco-drawer-body {
padding: 0;
}
}
</style>
<style lang="less" scoped>
.h5-pkm {
display: flex;
flex-direction: column;
max-height: min(92vh, 780px);
background: #f5f6f8;
}
.h5-pkm__handle {
width: 36px;
height: 4px;
margin: 10px auto 0;
border-radius: 999px;
background: #c9cdd4;
}
.h5-pkm__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px 16px 0;
h3 {
margin: 2px 0 0;
font-size: 18px;
font-weight: 700;
color: #1d2129;
}
}
.h5-pkm__step {
margin: 0;
font-size: 12px;
color: var(--app-primary-color);
font-weight: 600;
}
.h5-pkm__close {
width: 36px;
height: 36px;
border: none;
border-radius: 12px;
background: #fff;
color: #86909c;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
}
.h5-pkm__body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px 16px;
-webkit-overflow-scrolling: touch;
}
.h5-pkm__summary {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 10px;
}
.h5-pkm__summary-item {
padding: 12px;
border-radius: 12px;
background: #fff;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
span {
display: block;
font-size: 11px;
color: #86909c;
margin-bottom: 4px;
}
strong {
font-size: 14px;
font-weight: 700;
color: #1d2129;
line-height: 1.35;
word-break: break-all;
}
}
.h5-pkm__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.h5-pkm__tag {
display: inline-flex;
align-items: center;
max-width: 100%;
height: 26px;
padding: 0 10px;
border-radius: 999px;
background: #fff;
border: 1px solid #e5e6eb;
font-size: 12px;
color: #4e5969;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.h5-pkm__section {
margin-bottom: 14px;
padding: 14px;
border-radius: 14px;
background: #fff;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
h4 {
margin: 0 0 10px;
font-size: 14px;
font-weight: 700;
color: #1d2129;
}
&--fold {
padding-bottom: 10px;
}
}
.h5-pkm__sender {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
margin-bottom: 8px;
padding: 12px;
border: 1.5px solid #f0f1f3;
border-radius: 12px;
background: #fafbfc;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
&:last-child {
margin-bottom: 0;
}
b {
font-size: 14px;
color: #1d2129;
}
span {
font-size: 12px;
color: #4e5969;
}
em {
font-style: normal;
font-size: 12px;
color: #86909c;
line-height: 1.4;
}
&.is-active {
border-color: var(--app-primary-color);
background: var(--h5-primary-softer, #f3faf9);
}
}
.h5-pkm__time-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.h5-pkm__time-chip {
border: 1px solid #e5e6eb;
border-radius: 999px;
background: #fafbfc;
height: 32px;
padding: 0 12px;
font-size: 12px;
font-weight: 600;
color: #4e5969;
cursor: pointer;
&.is-active {
border-color: var(--app-primary-color);
background: var(--h5-primary-soft, #e6f4f4);
color: var(--app-primary-color);
}
}
.h5-pkm__time-input {
width: 100%;
}
.h5-pkm__fold {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: none;
background: transparent;
padding: 0;
font-size: 14px;
font-weight: 600;
color: #4e5969;
cursor: pointer;
:deep(.arco-icon) {
transition: transform 0.2s ease;
&.is-open {
transform: rotate(180deg);
}
}
}
.h5-pkm__fold-body {
margin-top: 12px;
}
.h5-pkm__label {
display: block;
margin: 10px 0 6px;
font-size: 12px;
font-weight: 600;
color: #86909c;
}
.h5-pkm__foot {
flex-shrink: 0;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
background: #fff;
border-top: 1px solid #f0f0f0;
}
.h5-pkm__submit {
width: 100%;
height: 48px;
border: none;
border-radius: 24px;
background: var(--app-primary-color);
color: #fff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: 0 8px 20px color-mix(in srgb, var(--app-primary-color) 28%, transparent);
&:disabled {
opacity: 0.65;
cursor: not-allowed;
box-shadow: none;
}
}
.h5-pkm__loading {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: h5-pkm-spin 0.7s linear infinite;
}
@keyframes h5-pkm-spin {
to {
transform: rotate(360deg);
}
}
</style>