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

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

1462 lines
42 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.

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Modal } from '@arco-design/web-vue'
import { IconPlus, IconDelete } from '@arco-design/web-vue/es/icon'
import {
batchCreatePlatformStoreFulfill,
batchCreatePlatformStoreLogistics,
getPlatformStoreOrderDetail,
} from '@/api/order/platformStore'
import {
postOrderStockBindInventoryCandidates,
postOrderStockBindParcelCandidates,
} from '@/api/order'
import { resolveOmsAssetUrl } from '@/api/oms/common'
import { $baseMessage } from '@/utils'
import { normalizeListRow, pickField } from '@/utils/warehouseListMapper'
export type CreateLogisticsMode = 'logistics' | 'fulfill'
export type BindDraftItem = {
key: string
bindType: 'inventory' | 'parcelItem'
skuId?: number
inventoryId?: number
parcelId?: number
parcelItemId?: number
bindQty: number
skuCode?: string
skuName?: string
productName?: string
trackingNo?: string
availableQty?: number
imageUrl?: string
}
type OrderDraft = {
storeOrderId: number
platformOrderNo: string
orderCountry: string
recipientName: string
recipientPhone: string
recipientCountry: string
recipientProvince: string
recipientCity: string
recipientZipCode: string
recipientAddress1: string
recipientAddress2: string
lines: Array<{
platformSku: string
productName: string
quantity: number
unitPrice: string
isShipExcluded: boolean
}>
receiverMissing: boolean
linesMissing: boolean
bindItems: BindDraftItem[]
}
const props = defineProps<{
visible: boolean
mode: CreateLogisticsMode
storeOrderIds: number[]
productOptions: Array<{ label: string; value: string }>
warehouseOptions: Array<{ label: string; value: number }>
defaultProductCode?: string
defaultWarehouseId?: number
customerId?: number
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
const { t } = useI18n()
const loading = ref(false)
const submitting = ref(false)
const productCode = ref<string>()
const warehouseId = ref<number>()
const drafts = ref<OrderDraft[]>([])
const activeId = ref<number>()
const leftTab = ref<'inventory' | 'parcel'>('inventory')
const keyword = ref('')
const inventoryCandidates = ref<any[]>([])
const parcelCandidates = ref<any[]>([])
const candidatesLoading = ref(false)
let searchTimer: ReturnType<typeof setTimeout> | null = null
const modalVisible = computed({
get: () => props.visible,
set: (v: boolean) => emit('update:visible', v),
})
const isFulfill = computed(() => props.mode === 'fulfill')
const title = computed(() =>
isFulfill.value
? t('order.platformStoreOrder.createFulfill.title')
: t('order.platformStoreOrder.createLogistics.title'),
)
const activeDraft = computed(() => drafts.value.find((d) => d.storeOrderId === activeId.value))
const activeShipLines = computed(() =>
(activeDraft.value?.lines || []).filter((x) => !x.isShipExcluded),
)
const orderQtyTotal = computed(() =>
activeShipLines.value.reduce((sum, x) => sum + (Number(x.quantity) || 0), 0),
)
const bindQtyTotal = computed(() =>
(activeDraft.value?.bindItems || []).reduce((sum, x) => sum + (Number(x.bindQty) || 0), 0),
)
/** 数量不一致仅提示校对,不强制拦截(业务允许申报与绑定不对齐) */
const qtyMismatch = computed(() => {
if (!isFulfill.value || !activeDraft.value?.bindItems.length) return false
return orderQtyTotal.value !== bindQtyTotal.value
})
const readyCount = computed(() => {
if (!isFulfill.value) {
return drafts.value.filter((d) => !d.receiverMissing && !d.linesMissing).length
}
return drafts.value.filter((d) => !d.receiverMissing && !d.linesMissing && d.bindItems.length > 0).length
})
function draftOrderQty(d: OrderDraft) {
return d.lines.filter((x) => !x.isShipExcluded).reduce((s, x) => s + (Number(x.quantity) || 0), 0)
}
function draftBindQty(d: OrderDraft) {
return d.bindItems.reduce((s, x) => s + (Number(x.bindQty) || 0), 0)
}
const productLabel = computed(() => {
const opt = props.productOptions.find((x) => x.value === productCode.value)
return opt?.label || productCode.value || '-'
})
const warehouseLabel = computed(() => {
const opt = props.warehouseOptions.find((x) => x.value === warehouseId.value)
return opt?.label || '-'
})
const canSubmit = computed(() => {
if (!productCode.value) return false
if (drafts.value.length === 0) return false
if (drafts.value.some((d) => d.receiverMissing || d.linesMissing)) return false
if (isFulfill.value) {
if (!warehouseId.value) return false
if (drafts.value.some((d) => d.bindItems.length === 0)) return false
}
return true
})
function close() {
emit('update:visible', false)
}
function imgSrc(url?: string | null) {
if (!url) return ''
return resolveOmsAssetUrl(url) || url
}
function mapDetail(raw: Record<string, unknown>): OrderDraft {
const row = normalizeListRow(raw)
const recipientName = String(pickField(row, 'recipientName', 'RecipientName') ?? '')
const recipientCountry = String(
pickField(row, 'recipientCountry', 'RecipientCountry') ?? pickField(row, 'orderCountry', 'OrderCountry') ?? '',
)
const recipientZipCode = String(pickField(row, 'recipientZipCode', 'RecipientZipCode') ?? '')
const recipientAddress1 = String(pickField(row, 'recipientAddress1', 'RecipientAddress1') ?? '')
const linesRaw = (pickField(row, 'lines', 'Lines') as any[]) || []
const lines = linesRaw.map((line) => {
const r = normalizeListRow(line as Record<string, unknown>)
return {
platformSku: String(pickField(r, 'platformSku', 'PlatformSku') ?? '-'),
productName: String(pickField(r, 'productName', 'ProductName') ?? '-'),
quantity: Number(pickField(r, 'quantity', 'Quantity') ?? 0),
unitPrice: String(pickField(r, 'unitPrice', 'UnitPrice') ?? '-'),
isShipExcluded: Boolean(pickField(r, 'isShipExcluded', 'IsShipExcluded')),
}
})
const shipLines = lines.filter((x) => !x.isShipExcluded)
return {
storeOrderId: Number(pickField(row, 'id', 'Id')),
platformOrderNo: String(pickField(row, 'platformOrderNo', 'PlatformOrderNo') ?? ''),
orderCountry: String(pickField(row, 'orderCountry', 'OrderCountry') ?? ''),
recipientName,
recipientPhone: String(pickField(row, 'recipientPhone', 'RecipientPhone') ?? ''),
recipientCountry,
recipientProvince: String(pickField(row, 'recipientProvince', 'RecipientProvince') ?? ''),
recipientCity: String(pickField(row, 'recipientCity', 'RecipientCity') ?? ''),
recipientZipCode,
recipientAddress1,
recipientAddress2: String(pickField(row, 'recipientAddress2', 'RecipientAddress2') ?? ''),
lines,
receiverMissing: !recipientName || !recipientCountry || !recipientZipCode || !recipientAddress1,
linesMissing: shipLines.length === 0,
bindItems: [],
}
}
async function loadDrafts() {
loading.value = true
drafts.value = []
activeId.value = undefined
try {
const list: OrderDraft[] = []
for (const id of props.storeOrderIds) {
const detail = await getPlatformStoreOrderDetail(id, props.customerId)
list.push(mapDetail(detail as Record<string, unknown>))
}
drafts.value = list
activeId.value = list[0]?.storeOrderId
} catch (e: any) {
$baseMessage(e?.message || t('order.platformStoreOrder.message.detailFailed'), 'error')
close()
} finally {
loading.value = false
}
}
async function loadCandidates() {
if (!isFulfill.value || !warehouseId.value) {
inventoryCandidates.value = []
parcelCandidates.value = []
return
}
candidatesLoading.value = true
try {
const body = {
orderId: 0,
warehouseId: warehouseId.value,
customerId: props.customerId,
keyword: keyword.value || undefined,
page: 1,
pageSize: 30,
}
if (leftTab.value === 'inventory') {
const res = await postOrderStockBindInventoryCandidates(body)
inventoryCandidates.value = res.items || []
} else {
const res = await postOrderStockBindParcelCandidates({ ...body, lifecycleStatus: 'all' })
parcelCandidates.value = res.items || []
}
} catch {
inventoryCandidates.value = []
parcelCandidates.value = []
} finally {
candidatesLoading.value = false
}
}
function scheduleSearch() {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => void loadCandidates(), 300)
}
function selectOrder(id: number) {
activeId.value = id
}
function bindKey(item: {
bindType: string
skuId?: number
inventoryId?: number
parcelItemId?: number
}) {
if (item.bindType === 'parcelItem') return `parcelItem:${item.parcelItemId}`
return `inventory:${item.skuId}:${item.inventoryId ?? 0}`
}
function addInventory(row: any) {
const draft = activeDraft.value
if (!draft) return
const skuId = Number(row.skuId ?? row.SkuId)
const key = bindKey({ bindType: 'inventory', skuId })
const exist = draft.bindItems.find((x) => x.key === key)
const available = Number(row.availableQty ?? row.AvailableQty ?? 0)
if (exist) {
if (exist.bindQty < available) exist.bindQty += 1
return
}
draft.bindItems.push({
key,
bindType: 'inventory',
skuId,
bindQty: 1,
skuCode: String(row.skuCode ?? row.SkuCode ?? ''),
skuName: String(row.skuName ?? row.SkuName ?? ''),
availableQty: available,
imageUrl: row.skuImageUrl ?? row.SkuImageUrl,
})
}
function addParcelItem(parcel: any, item: any) {
const draft = activeDraft.value
if (!draft) return
const parcelItemId = Number(item.parcelItemId ?? item.ParcelItemId)
const key = bindKey({ bindType: 'parcelItem', parcelItemId })
const exist = draft.bindItems.find((x) => x.key === key)
const available = Number(item.availableQty ?? item.AvailableQty ?? 0)
if (exist) {
if (exist.bindQty < available) exist.bindQty += 1
return
}
draft.bindItems.push({
key,
bindType: 'parcelItem',
parcelId: Number(parcel.parcelId ?? parcel.ParcelId ?? parcel.id ?? parcel.Id),
parcelItemId,
skuId: item.skuId ?? item.SkuId,
bindQty: 1,
skuCode: String(item.skuCode ?? item.SkuCode ?? ''),
productName: String(item.productName ?? item.ProductName ?? ''),
trackingNo: String(parcel.trackingNo ?? parcel.TrackingNo ?? ''),
availableQty: available,
imageUrl: item.imageUrl ?? item.ImageUrl,
})
}
function changeQty(item: BindDraftItem, delta: number) {
const next = item.bindQty + delta
if (next < 1) return
if (item.availableQty != null && next > item.availableQty) return
item.bindQty = next
}
function removeBind(key: string) {
const draft = activeDraft.value
if (!draft) return
draft.bindItems = draft.bindItems.filter((x) => x.key !== key)
}
function clearBindsOnWarehouseChange() {
drafts.value.forEach((d) => {
d.bindItems = []
})
}
function buildBindPayload(items: BindDraftItem[]) {
return items.map((x) => ({
bindType: x.bindType,
skuId: x.skuId,
inventoryId: x.inventoryId,
parcelId: x.parcelId,
parcelItemId: x.parcelItemId,
bindQty: x.bindQty,
}))
}
async function handleSubmit(): Promise<boolean> {
if (!canSubmit.value) {
if (!productCode.value) {
$baseMessage(t('order.platformStoreOrder.createLogistics.productRequired'), 'warning')
return false
}
if (isFulfill.value && !warehouseId.value) {
$baseMessage(t('order.platformStoreOrder.message.warehouseRequired'), 'warning')
return false
}
const bad = drafts.value.find(
(d) => d.receiverMissing || d.linesMissing || (isFulfill.value && d.bindItems.length === 0),
)
if (bad) {
activeId.value = bad.storeOrderId
$baseMessage(t('order.platformStoreOrder.createLogistics.fixIncomplete'), 'warning')
}
return false
}
const confirmContent = isFulfill.value
? t('order.platformStoreOrder.createFulfill.confirm', {
count: drafts.value.length,
product: productLabel.value,
warehouse: warehouseLabel.value,
})
: t('order.platformStoreOrder.createLogistics.confirm', {
count: drafts.value.length,
product: productLabel.value,
})
const confirmed = await new Promise<boolean>((resolve) => {
Modal.confirm({
title: title.value,
content: confirmContent,
onOk: () => resolve(true),
onCancel: () => resolve(false),
})
})
if (!confirmed) return false
submitting.value = true
try {
const items = drafts.value.map((d) => ({
storeOrderId: d.storeOrderId,
bindItems: isFulfill.value ? buildBindPayload(d.bindItems) : undefined,
}))
const body = {
productCode: productCode.value,
warehouseId: warehouseId.value,
customerId: props.customerId,
items,
}
const result = isFulfill.value
? await batchCreatePlatformStoreFulfill(body)
: await batchCreatePlatformStoreLogistics(body)
const success = Number(result?.successCount ?? result?.SuccessCount ?? 0)
const fail = Number(result?.failCount ?? result?.FailCount ?? 0)
if (fail > 0) {
const failItems = (result?.items ?? result?.Items ?? []).filter(
(x: any) => !(x.success ?? x.Success),
)
const msg = failItems
.slice(0, 3)
.map((x: any) => `${x.platformOrderNo || x.PlatformOrderNo || x.id}: ${x.message || x.Message}`)
.join(';')
$baseMessage(
t('order.platformStoreOrder.createLogistics.partial', { success, fail, detail: msg }),
'warning',
)
} else {
$baseMessage(t('order.platformStoreOrder.createLogistics.success', { count: success }), 'success')
}
emit('success')
return true
} catch (e: any) {
$baseMessage(e?.message || t('order.platformStoreOrder.createLogistics.failed'), 'error')
return false
} finally {
submitting.value = false
}
}
watch(
() => props.visible,
async (open) => {
if (!open) return
productCode.value = props.defaultProductCode
warehouseId.value = props.defaultWarehouseId
leftTab.value = 'inventory'
keyword.value = ''
await loadDrafts()
if (isFulfill.value) await loadCandidates()
},
)
watch(warehouseId, () => {
if (!props.visible || !isFulfill.value) return
clearBindsOnWarehouseChange()
void loadCandidates()
})
watch(leftTab, () => {
if (!props.visible || !isFulfill.value) return
void loadCandidates()
})
</script>
<template>
<a-modal
v-model:visible="modalVisible"
:title="title"
:width="isFulfill ? '1360px' : 960"
:top="isFulfill ? '20px' : '10vh'"
:ok-loading="submitting"
:ok-text="
isFulfill
? t('order.platformStoreOrder.createFulfill.submit')
: t('order.platformStoreOrder.createLogistics.submit')
"
:ok-button-props="{ disabled: !canSubmit || loading }"
unmount-on-close
:body-style="isFulfill ? { padding: '12px 16px', maxHeight: 'calc(100vh - 140px)', overflow: 'hidden' } : undefined"
:on-before-ok="handleSubmit"
@cancel="close"
>
<div v-if="loading" class="psc-loading">{{ t('common.loading') }}</div>
<div v-else class="psc-root" :class="{ 'is-fulfill': isFulfill }">
<p class="psc-tip">
{{
isFulfill
? t('order.platformStoreOrder.createFulfill.tip')
: t('order.platformStoreOrder.createLogistics.tip')
}}
</p>
<div class="psc-config">
<div class="psc-config__item">
<span class="psc-config__label">{{ t('order.platformStoreOrder.createLogistics.product') }}</span>
<a-select
v-model="productCode"
allow-search
:options="productOptions"
:placeholder="t('order.platformStoreOrder.form.product')"
style="width: 260px"
/>
</div>
<div class="psc-config__item">
<span class="psc-config__label">
{{ t('warehouse.common.warehouse') }}
<em v-if="isFulfill">*</em>
</span>
<a-select
v-model="warehouseId"
allow-clear
:options="warehouseOptions"
:placeholder="t('warehouse.common.warehouse')"
style="width: 200px"
/>
</div>
<div class="psc-config__meta">
{{
t('order.platformStoreOrder.createLogistics.readyMeta', {
total: drafts.length,
ready: readyCount,
})
}}
</div>
</div>
<div class="psc-layout" :class="{ 'is-fulfill': isFulfill }">
<aside class="psc-list">
<div class="psc-list__head">{{ t('order.platformStoreOrder.createLogistics.orderList') }}</div>
<button
v-for="d in drafts"
:key="d.storeOrderId"
type="button"
class="psc-list__item"
:class="{
active: d.storeOrderId === activeId,
danger: d.receiverMissing || d.linesMissing || (isFulfill && !d.bindItems.length),
}"
@click="selectOrder(d.storeOrderId)"
>
<div class="psc-list__no">{{ d.platformOrderNo }}</div>
<div class="psc-list__sub">
<span>{{ d.recipientCountry || d.orderCountry || '-' }}</span>
<span>{{ d.recipientName || '-' }}</span>
</div>
<div v-if="isFulfill" class="psc-list__qty">
<span>{{ t('order.platformStoreOrder.createFulfill.orderQtyShort', { n: draftOrderQty(d) }) }}</span>
<span class="psc-list__qty-sep">/</span>
<span :class="{ warn: d.bindItems.length > 0 && draftOrderQty(d) !== draftBindQty(d) }">
{{ t('order.platformStoreOrder.createFulfill.bindQtyShort', { n: draftBindQty(d) }) }}
</span>
</div>
<div class="psc-list__tags">
<a-tag v-if="d.receiverMissing" size="small" color="red">
{{ t('order.platformStoreOrder.createLogistics.tagReceiver') }}
</a-tag>
<a-tag v-else-if="d.linesMissing" size="small" color="red">
{{ t('order.platformStoreOrder.createLogistics.tagLines') }}
</a-tag>
<a-tag v-else-if="isFulfill && !d.bindItems.length" size="small" color="orangered">
{{ t('order.platformStoreOrder.createLogistics.tagUnbound') }}
</a-tag>
<a-tag
v-else-if="isFulfill && draftOrderQty(d) !== draftBindQty(d)"
size="small"
color="orangered"
>
{{ t('order.platformStoreOrder.createFulfill.tagQtyDiff') }}
</a-tag>
<a-tag v-else-if="isFulfill" size="small" color="green">
{{ t('order.platformStoreOrder.createFulfill.tagQtyOk') }}
</a-tag>
<a-tag v-else size="small" color="green">
{{ t('order.platformStoreOrder.createLogistics.tagOk') }}
</a-tag>
</div>
</button>
</aside>
<section v-if="activeDraft" class="psc-detail">
<!-- 仅物流:收件人 + 商品纵向校对 -->
<template v-if="!isFulfill">
<div class="psc-section">
<h4>{{ t('order.platformStoreOrder.createLogistics.receiverTitle') }}</h4>
<div class="psc-receiver" :class="{ missing: activeDraft.receiverMissing }">
<div><label>{{ t('order.platformStoreOrder.recipient.name') }}</label>{{ activeDraft.recipientName || '-' }}</div>
<div><label>{{ t('order.platformStoreOrder.recipient.phone') }}</label>{{ activeDraft.recipientPhone || '-' }}</div>
<div><label>{{ t('order.platformStoreOrder.recipient.country') }}</label>{{ activeDraft.recipientCountry || '-' }}</div>
<div><label>{{ t('order.platformStoreOrder.recipient.zipCode') }}</label>{{ activeDraft.recipientZipCode || '-' }}</div>
<div class="full">
<label>{{ t('order.platformStoreOrder.recipient.address1') }}</label>
{{
[activeDraft.recipientProvince, activeDraft.recipientCity, activeDraft.recipientAddress1, activeDraft.recipientAddress2]
.filter(Boolean)
.join(' ') || '-'
}}
</div>
</div>
</div>
<div class="psc-section">
<h4>{{ t('order.platformStoreOrder.createLogistics.linesTitle') }}</h4>
<a-table
:data="activeShipLines"
:pagination="false"
size="small"
row-key="platformSku"
>
<template #columns>
<a-table-column :title="t('order.platformStoreOrder.line.sku')" data-index="platformSku" :width="120" />
<a-table-column :title="t('order.platformStoreOrder.line.name')" data-index="productName" />
<a-table-column :title="t('order.platformStoreOrder.line.qty')" data-index="quantity" :width="70" />
<a-table-column :title="t('order.platformStoreOrder.line.price')" data-index="unitPrice" :width="90" />
</template>
</a-table>
</div>
</template>
<!-- 物流&代发:订单商品 ↔ 已绑资源 对照校对 -->
<template v-else>
<div class="psc-ship-strip" :class="{ missing: activeDraft.receiverMissing }">
<strong>{{ activeDraft.platformOrderNo }}</strong>
<span>{{ activeDraft.recipientName || '-' }}</span>
<span>{{ activeDraft.recipientPhone || '-' }}</span>
<span class="psc-ship-strip__addr">
{{
[activeDraft.recipientCountry, activeDraft.recipientProvince, activeDraft.recipientCity, activeDraft.recipientAddress1]
.filter(Boolean)
.join(' ') || '-'
}}
</span>
</div>
<div class="psc-compare-bar" :class="{ mismatch: qtyMismatch, empty: !activeDraft.bindItems.length }">
<div class="psc-compare-bar__side">
<span class="psc-compare-bar__label">{{ t('order.platformStoreOrder.createFulfill.orderSide') }}</span>
<strong class="psc-compare-bar__num">{{ orderQtyTotal }}</strong>
<span class="psc-compare-bar__unit">{{ t('order.platformStoreOrder.createFulfill.pcs') }}</span>
<em>{{ t('order.platformStoreOrder.createFulfill.lineCount', { n: activeShipLines.length }) }}</em>
</div>
<div class="psc-compare-bar__vs">vs</div>
<div class="psc-compare-bar__side">
<span class="psc-compare-bar__label">{{ t('order.platformStoreOrder.createFulfill.bindSide') }}</span>
<strong class="psc-compare-bar__num" :class="{ warn: qtyMismatch }">{{ bindQtyTotal }}</strong>
<span class="psc-compare-bar__unit">{{ t('order.platformStoreOrder.createFulfill.pcs') }}</span>
<em>{{ t('order.platformStoreOrder.createFulfill.bindCount', { n: activeDraft.bindItems.length }) }}</em>
</div>
<div v-if="!activeDraft.bindItems.length" class="psc-compare-bar__msg">
{{ t('order.platformStoreOrder.createFulfill.compareNeedBind') }}
</div>
<div v-else-if="qtyMismatch" class="psc-compare-bar__msg warn">
{{ t('order.platformStoreOrder.createFulfill.compareQtyDiff', { order: orderQtyTotal, bind: bindQtyTotal }) }}
</div>
<div v-else class="psc-compare-bar__msg ok">
{{ t('order.platformStoreOrder.createFulfill.compareQtyOk') }}
</div>
</div>
<div class="psc-compare">
<div class="psc-compare__col psc-compare__col--order">
<div class="psc-compare__head">
<h4>{{ t('order.platformStoreOrder.createFulfill.orderGoodsTitle') }}</h4>
<span>{{ t('order.platformStoreOrder.createFulfill.orderGoodsHint') }}</span>
</div>
<div class="psc-compare__scroll">
<div
v-for="(line, idx) in activeShipLines"
:key="`${line.platformSku}-${idx}`"
class="psc-goods-row"
>
<div class="psc-goods-row__main">
<strong>{{ line.productName || '-' }}</strong>
<span class="psc-goods-row__sku">{{ line.platformSku || '-' }}</span>
</div>
<div class="psc-goods-row__qty">
<em>{{ t('order.platformStoreOrder.createFulfill.needQty') }}</em>
<b>{{ line.quantity }}</b>
</div>
</div>
<div v-if="!activeShipLines.length" class="psc-bind__empty">
{{ t('order.platformStoreOrder.createLogistics.tagLines') }}
</div>
</div>
</div>
<div class="psc-compare__col psc-compare__col--bound">
<div class="psc-compare__head">
<h4>{{ t('order.platformStoreOrder.createFulfill.boundTitle') }}</h4>
<span>{{ t('order.platformStoreOrder.createFulfill.boundHint') }}</span>
</div>
<div class="psc-compare__scroll">
<div
v-for="item in activeDraft.bindItems"
:key="item.key"
class="psc-bound-row"
>
<a-tag size="small" :color="item.bindType === 'inventory' ? 'arcoblue' : 'purple'">
{{
item.bindType === 'inventory'
? t('order.myOrders.stockBind.tabInventory')
: t('order.myOrders.stockBind.tabParcel')
}}
</a-tag>
<div class="psc-bound-row__main">
<strong>{{ item.skuCode || item.productName || item.trackingNo || '-' }}</strong>
<span>{{ item.skuName || item.productName || item.trackingNo }}</span>
</div>
<div class="psc-bound-row__ops">
<div class="psc-stepper">
<button type="button" class="psc-stepper__btn" @click="changeQty(item, -1)">-</button>
<span class="psc-stepper__val">{{ item.bindQty }}</span>
<button type="button" class="psc-stepper__btn" @click="changeQty(item, 1)">+</button>
</div>
<button type="button" class="psc-cart-row__remove" @click="removeBind(item.key)">
<IconDelete />
</button>
</div>
</div>
<div v-if="!activeDraft.bindItems.length" class="psc-bind__empty">
{{ t('order.platformStoreOrder.createFulfill.bindEmpty') }}
</div>
</div>
</div>
</div>
<div class="psc-pick">
<div class="psc-pick__head">
<h4>{{ t('order.platformStoreOrder.createFulfill.pickTitle') }}</h4>
<a-radio-group v-model="leftTab" type="button" size="small" :disabled="!warehouseId">
<a-radio value="inventory">{{ t('order.myOrders.stockBind.tabInventory') }}</a-radio>
<a-radio value="parcel">{{ t('order.myOrders.stockBind.tabParcel') }}</a-radio>
</a-radio-group>
<a-input
v-model="keyword"
allow-clear
size="small"
class="psc-pick__search"
:disabled="!warehouseId"
:placeholder="t('order.myOrders.stockBind.searchPlaceholder')"
@input="scheduleSearch"
@press-enter="loadCandidates"
@clear="loadCandidates"
/>
</div>
<div v-if="!warehouseId" class="psc-bind__empty">
{{ t('order.platformStoreOrder.message.warehouseRequired') }}
</div>
<div v-else class="psc-pick__scroll">
<div v-if="candidatesLoading" class="psc-bind__empty">{{ t('common.loading') }}</div>
<template v-else-if="leftTab === 'inventory'">
<button
v-for="row in inventoryCandidates"
:key="row.skuId || row.SkuId"
type="button"
class="psc-cand"
@click="addInventory(row)"
>
<img v-if="imgSrc(row.skuImageUrl || row.SkuImageUrl)" :src="imgSrc(row.skuImageUrl || row.SkuImageUrl)" alt="" />
<div class="psc-cand__main">
<strong>{{ row.skuCode || row.SkuCode }}</strong>
<span>{{ row.skuName || row.SkuName }}</span>
</div>
<div class="psc-cand__meta">
<em>{{ t('order.myOrders.stockBind.available') }} {{ row.availableQty ?? row.AvailableQty ?? 0 }}</em>
<IconPlus />
</div>
</button>
<div v-if="!inventoryCandidates.length" class="psc-bind__empty">
{{ t('order.myOrders.stockBind.emptyInventory') }}
</div>
</template>
<template v-else>
<div
v-for="parcel in parcelCandidates"
:key="parcel.parcelId || parcel.ParcelId || parcel.id"
class="psc-parcel"
>
<div class="psc-parcel__head">
{{ parcel.trackingNo || parcel.TrackingNo || `#${parcel.parcelId || parcel.id}` }}
</div>
<button
v-for="item in (parcel.items || parcel.Items || [])"
:key="item.parcelItemId || item.ParcelItemId"
type="button"
class="psc-cand"
@click="addParcelItem(parcel, item)"
>
<div class="psc-cand__main">
<strong>{{ item.productName || item.ProductName || item.skuCode }}</strong>
<span>{{ item.skuCode || item.SkuCode || item.attributeText || '' }}</span>
</div>
<div class="psc-cand__meta">
<em>{{ t('order.myOrders.stockBind.available') }} {{ item.availableQty ?? item.AvailableQty ?? 0 }}</em>
<IconPlus />
</div>
</button>
</div>
<div v-if="!parcelCandidates.length" class="psc-bind__empty">
{{ t('order.myOrders.stockBind.emptyParcel') }}
</div>
</template>
</div>
</div>
</template>
</section>
</div>
<div class="psc-footer-summary">
{{ productLabel }}
<template v-if="warehouseId"> · {{ warehouseLabel }}</template>
· {{ drafts.length }} {{ t('order.platformStoreOrder.createLogistics.unitOrders') }}
</div>
</div>
</a-modal>
</template>
<style scoped lang="less">
@primary: var(--app-primary-color);
.psc-loading {
padding: 48px;
text-align: center;
color: var(--color-text-3);
}
.psc-root.is-fulfill {
display: flex;
flex-direction: column;
height: calc(100vh - 160px);
min-height: 620px;
max-height: calc(100vh - 160px);
}
.psc-tip {
margin: 0 0 10px;
color: var(--color-text-2);
font-size: 13px;
line-height: 1.5;
flex-shrink: 0;
}
.psc-config {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
padding: 10px 12px;
margin-bottom: 10px;
background: var(--color-fill-2);
border-radius: 4px;
flex-shrink: 0;
}
.psc-config__item {
display: flex;
align-items: center;
gap: 8px;
}
.psc-config__label {
font-size: 13px;
color: var(--color-text-2);
white-space: nowrap;
em {
color: #f53f3f;
font-style: normal;
margin-left: 2px;
}
}
.psc-config__meta {
margin-left: auto;
font-size: 12px;
color: var(--color-text-3);
}
.psc-layout {
display: grid;
grid-template-columns: 240px 1fr;
gap: 12px;
min-height: 420px;
max-height: 62vh;
}
.psc-layout.is-fulfill {
flex: 1;
min-height: 0;
max-height: none;
height: auto;
grid-template-columns: 220px 1fr;
}
.psc-list {
border: 1px solid var(--color-border-2);
border-radius: 4px;
overflow: auto;
background: var(--app-card-bg);
min-height: 0;
}
.psc-list__head {
padding: 10px 12px;
font-size: 13px;
font-weight: 600;
border-bottom: 1px solid var(--color-border-2);
background: var(--color-fill-1);
position: sticky;
top: 0;
z-index: 1;
}
.psc-list__item {
display: block;
width: 100%;
text-align: left;
border: none;
border-left: 3px solid transparent;
border-bottom: 1px solid var(--color-border-2);
background: var(--app-card-bg);
padding: 10px 12px;
cursor: pointer;
&:hover {
background: var(--color-fill-2);
}
&.active {
border-left-color: @primary;
background: color-mix(in srgb, @primary 8%, var(--app-card-bg));
}
&.danger:not(.active) {
background: #fff7f7;
}
}
.psc-list__no {
font-weight: 600;
font-size: 13px;
color: var(--color-text-1);
}
.psc-list__sub {
display: flex;
gap: 8px;
margin-top: 4px;
font-size: 12px;
color: var(--color-text-3);
}
.psc-list__tags {
margin-top: 6px;
}
.psc-list__qty {
margin-top: 6px;
font-size: 12px;
color: var(--color-text-2);
font-variant-numeric: tabular-nums;
.warn {
color: #f77234;
font-weight: 600;
}
}
.psc-list__qty-sep {
margin: 0 4px;
color: #c9cdd4;
}
.psc-detail {
border: 1px solid var(--color-border-2);
border-radius: 4px;
padding: 12px;
overflow: auto;
background: var(--app-card-bg);
min-height: 0;
}
.psc-layout.is-fulfill .psc-detail {
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
}
.psc-section {
& + & {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--color-border-2);
}
h4 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
}
.psc-receiver {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 12px;
font-size: 13px;
color: var(--color-text-1);
&.missing {
background: #fff7f7;
border: 1px solid #ffece8;
border-radius: 4px;
padding: 8px;
}
label {
display: inline-block;
min-width: 48px;
color: var(--color-text-3);
margin-right: 6px;
}
.full {
grid-column: 1 / -1;
}
}
/* ── 代发:收件摘要 ── */
.psc-ship-strip {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 14px;
padding: 8px 12px;
background: var(--color-fill-1);
border-bottom: 1px solid var(--color-border-2);
font-size: 12px;
color: var(--color-text-2);
strong {
color: var(--color-text-1);
font-size: 13px;
}
&.missing {
background: #fff7f7;
}
&__addr {
flex: 1;
min-width: 180px;
color: var(--color-text-3);
}
}
/* ── 代发:数量对照条 ── */
.psc-compare-bar {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
padding: 10px 14px;
border-bottom: 1px solid var(--color-border-2);
background: #f2f9f9;
&.mismatch {
background: #fff7e8;
}
&.empty {
background: #fff7f7;
}
&__side {
display: flex;
align-items: baseline;
gap: 6px;
}
&__label {
font-size: 12px;
color: var(--color-text-3);
}
&__num {
font-size: 22px;
font-weight: 700;
color: var(--color-text-1);
font-variant-numeric: tabular-nums;
line-height: 1;
&.warn {
color: #f77234;
}
}
&__unit {
font-size: 12px;
color: var(--color-text-2);
}
em {
font-style: normal;
font-size: 12px;
color: var(--color-text-3);
}
&__vs {
font-size: 12px;
font-weight: 600;
color: #c9cdd4;
}
&__msg {
margin-left: auto;
font-size: 12px;
color: #00b42a;
&.warn {
color: #f77234;
}
&.ok {
color: #00b42a;
}
}
}
/* ── 代发:订单商品 ↔ 已绑 对照 ── */
.psc-compare {
flex: 1.2;
min-height: 220px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
border-bottom: 1px solid var(--color-border-2);
overflow: hidden;
}
.psc-compare__col {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
&--order {
border-right: 1px solid var(--color-border-2);
background: var(--app-card-bg);
}
&--bound {
background: #fcfcfd;
}
}
.psc-compare__head {
flex-shrink: 0;
padding: 10px 12px 8px;
border-bottom: 1px solid var(--color-border-2);
h4 {
margin: 0 0 2px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
span {
font-size: 12px;
color: var(--color-text-3);
}
}
.psc-compare__scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: 6px 8px;
}
.psc-goods-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px;
border-radius: 4px;
border: 1px solid var(--color-border-2);
margin-bottom: 6px;
background: var(--app-card-bg);
}
.psc-goods-row__main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
strong {
font-size: 13px;
color: var(--color-text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.psc-goods-row__sku {
font-size: 12px;
color: var(--color-text-3);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.psc-goods-row__qty {
flex-shrink: 0;
text-align: right;
em {
display: block;
font-style: normal;
font-size: 11px;
color: var(--color-text-3);
}
b {
font-size: 20px;
font-weight: 700;
color: @primary;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
}
.psc-bound-row {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
border-radius: 4px;
border: 1px solid var(--color-border-2);
margin-bottom: 6px;
background: var(--app-card-bg);
}
.psc-bound-row__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
strong {
font-size: 13px;
color: var(--color-text-1);
}
span {
font-size: 12px;
color: var(--color-text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.psc-bound-row__ops {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* ── 代发:底部挑选资源 ── */
.psc-pick {
flex: 0.9;
min-height: 180px;
display: flex;
flex-direction: column;
background: var(--color-fill-2);
overflow: hidden;
}
.psc-pick__head {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border-2);
background: var(--app-card-bg);
h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
}
}
.psc-pick__search {
width: 220px;
margin-left: auto;
}
.psc-pick__scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 8px;
align-content: start;
}
.psc-bind__empty {
padding: 28px 12px;
text-align: center;
color: var(--color-text-3);
font-size: 13px;
grid-column: 1 / -1;
}
.psc-cand {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-radius: 4px;
border: 1px solid var(--color-border-2);
background: var(--app-card-bg);
cursor: pointer;
text-align: left;
width: 100%;
&:hover {
border-color: color-mix(in srgb, @primary 45%, var(--color-border-2));
background: color-mix(in srgb, @primary 6%, var(--app-card-bg));
}
img {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
background: var(--color-fill-2);
flex-shrink: 0;
}
}
.psc-cand__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
strong {
font-size: 13px;
color: var(--color-text-1);
}
span {
font-size: 12px;
color: var(--color-text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.psc-cand__meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
color: @primary;
em {
font-style: normal;
font-size: 11px;
color: var(--color-text-3);
}
}
.psc-parcel {
grid-column: 1 / -1;
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: var(--app-card-bg);
padding: 8px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 6px;
.psc-cand {
border-color: var(--color-border-2);
}
}
.psc-parcel__head {
grid-column: 1 / -1;
font-size: 12px;
font-weight: 600;
color: var(--color-text-2);
padding: 2px 4px 6px;
}
.psc-stepper {
display: inline-flex;
align-items: center;
border: 1px solid var(--color-border-2);
border-radius: 4px;
overflow: hidden;
}
.psc-stepper__btn {
width: 28px;
height: 28px;
border: none;
background: var(--color-fill-2);
cursor: pointer;
&:hover {
background: color-mix(in srgb, @primary 10%, var(--color-fill-2));
color: @primary;
}
}
.psc-stepper__val {
min-width: 32px;
text-align: center;
font-weight: 700;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.psc-cart-row__remove {
border: none;
background: transparent;
color: var(--color-text-3);
cursor: pointer;
padding: 4px;
&:hover {
color: #f53f3f;
}
}
.psc-footer-summary {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--color-border-2);
font-size: 13px;
color: var(--color-text-2);
flex-shrink: 0;
}
</style>