front/src/views/order/platformStoreOrder/CreateLogisticsModal.vue
zzh 33f696bc42
All checks were successful
Build and Deploy Front / deploy (push) Successful in 7m24s
feat: 抽取 PublishDiff 公共组件,完善发布/订单/渠道与多模块体验
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 23:34:54 +08:00

1718 lines
49 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 SlidingTabs from '@/components/SlidingTabs/index.vue'
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 pickSourceTabs = computed(() => {
const disabled = !warehouseId.value
return [
{ key: 'inventory', label: t('order.myOrders.stockBind.tabInventory'), disabled },
{ key: 'parcel', label: t('order.myOrders.stockBind.tabParcel'), disabled },
]
})
const keyword = ref('')
const inventoryCandidates = ref<any[]>([])
const parcelCandidates = ref<any[]>([])
const candidatesLoading = ref(false)
const batchMode = ref(true)
const batchMatchDeclaration = ref(false)
const batchQtyPerOrder = ref(1)
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 assignableDrafts = computed(() =>
drafts.value.filter((d) => !d.receiverMissing && !d.linesMissing),
)
const showBatchBar = computed(
() => isFulfill.value && drafts.value.length > 1 && assignableDrafts.value.length > 0,
)
const readyCount = computed(() => {
if (!isFulfill.value) {
return assignableDrafts.value.length
}
return assignableDrafts.value.filter((d) => 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: 50,
}
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 resolveBatchQty(draft: OrderDraft) {
if (batchMatchDeclaration.value) {
const qty = draftOrderQty(draft)
return qty > 0 ? qty : 1
}
return Math.max(1, Number(batchQtyPerOrder.value) || 1)
}
function cloneBindItems(items: BindDraftItem[]): BindDraftItem[] {
return items.map((item) => ({ ...item }))
}
function setInventoryOnDraft(draft: OrderDraft, row: any, qty: number) {
const skuId = Number(row.skuId ?? row.SkuId)
const key = bindKey({ bindType: 'inventory', skuId })
const available = Number(row.availableQty ?? row.AvailableQty ?? 0)
const exists = draft.bindItems.find((x) => x.key === key)
const line: BindDraftItem = {
key,
bindType: 'inventory',
skuId,
bindQty: qty,
skuCode: String(row.skuCode ?? row.SkuCode ?? ''),
skuName: String(row.skuName ?? row.SkuName ?? ''),
availableQty: available,
imageUrl: row.skuImageUrl ?? row.SkuImageUrl,
}
if (exists) {
Object.assign(exists, line)
} else {
draft.bindItems.push(line)
}
}
function setParcelItemOnDraft(draft: OrderDraft, parcel: any, item: any, qty: number) {
const parcelItemId = Number(item.parcelItemId ?? item.ParcelItemId)
const key = bindKey({ bindType: 'parcelItem', parcelItemId })
const available = Number(item.availableQty ?? item.AvailableQty ?? 0)
draft.bindItems = draft.bindItems.filter((x) => x.bindType !== 'parcelItem')
draft.bindItems.push({
key,
bindType: 'parcelItem',
parcelId: Number(parcel.parcelId ?? parcel.ParcelId ?? parcel.id ?? parcel.Id),
parcelItemId,
skuId: item.skuId ?? item.SkuId,
bindQty: qty,
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 copyActiveToAll() {
const source = activeDraft.value
if (!source || source.receiverMissing || source.linesMissing) return
if (!source.bindItems.length) {
$baseMessage(t('order.myOrders.stockBind.copyEmpty'), 'warning')
return
}
const targets = assignableDrafts.value.filter((d) => d.storeOrderId !== source.storeOrderId)
if (!targets.length) {
$baseMessage(t('order.myOrders.stockBind.copyNoTarget'), 'warning')
return
}
Modal.confirm({
title: t('order.myOrders.stockBind.copyToAllTitle'),
content: t('order.myOrders.stockBind.copyConfirm', { count: targets.length }),
onOk: () => {
for (const draft of targets) {
draft.bindItems = cloneBindItems(source.bindItems)
}
$baseMessage(t('order.myOrders.stockBind.copySuccess', { count: targets.length }), 'success')
},
})
}
function distributeParcelToOrders(parcel: any) {
const editable = assignableDrafts.value
const items = (parcel.items || parcel.Items || []).filter(
(x: any) => Number(x.availableQty ?? x.AvailableQty ?? 0) > 0,
)
if (!editable.length) return
if (!items.length) {
$baseMessage(t('order.myOrders.stockBind.parcelDistributeEmpty'), 'warning')
return
}
const count = Math.min(editable.length, items.length)
if (items.length < editable.length) {
$baseMessage(
t('order.myOrders.stockBind.parcelDistributePartial', {
orders: editable.length,
items: items.length,
}),
'warning',
)
}
for (let i = 0; i < count; i += 1) {
setParcelItemOnDraft(editable[i], parcel, items[i], 1)
}
$baseMessage(t('order.myOrders.stockBind.parcelDistributeSuccess', { count }), 'success')
}
function addInventory(row: any) {
const editable = assignableDrafts.value
if (batchMode.value && isFulfill.value && editable.length > 1) {
const totalQty = editable.reduce((sum, draft) => sum + resolveBatchQty(draft), 0)
const available = Number(row.availableQty ?? row.AvailableQty ?? 0)
if (totalQty > available) {
$baseMessage(
t('order.myOrders.stockBind.batchInsufficient', { need: totalQty, available }),
'warning',
)
return
}
for (const draft of editable) {
setInventoryOnDraft(draft, row, resolveBatchQty(draft))
}
$baseMessage(t('order.myOrders.stockBind.batchAssignSuccess', { count: editable.length }), 'success')
return
}
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) {
if (batchMode.value && isFulfill.value && assignableDrafts.value.length > 1) {
$baseMessage(t('order.myOrders.stockBind.parcelBatchHint'), 'info')
return
}
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 = ''
batchMode.value = props.storeOrderIds.length > 1
batchMatchDeclaration.value = false
batchQtyPerOrder.value = 1
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 v-if="showBatchBar" class="psc-batch-bar">
<div class="psc-batch-bar__left">
<a-switch v-model="batchMode" size="small" />
<span class="psc-batch-bar__label">{{ t('order.myOrders.stockBind.batchMode') }}</span>
<span class="psc-batch-bar__hint">{{ t('order.myOrders.stockBind.batchModeHint') }}</span>
</div>
<div class="psc-batch-bar__opts">
<span class="psc-batch-bar__opt-label">{{ t('order.myOrders.stockBind.batchQtyPerOrder') }}</span>
<a-input-number
v-model="batchQtyPerOrder"
:min="1"
:max="9999"
size="small"
:disabled="batchMatchDeclaration"
style="width: 88px"
/>
<a-checkbox v-model="batchMatchDeclaration">
{{ t('order.platformStoreOrder.createFulfill.batchMatchOrderQty') }}
</a-checkbox>
</div>
<a-button
size="small"
type="outline"
:disabled="!activeDraft?.bindItems.length"
@click="copyActiveToAll"
>
{{
t('order.myOrders.stockBind.copyToAll', {
count: Math.max(assignableDrafts.length - 1, 0),
})
}}
</a-button>
</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>
<SlidingTabs
v-model="leftTab"
size="mini"
class="psc-pick__tabs"
:items="pickSourceTabs"
:aria-label="t('order.platformStoreOrder.createFulfill.pickTitle')"
/>
<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">
<span>{{ parcel.trackingNo || parcel.TrackingNo || `#${parcel.parcelId || parcel.id}` }}</span>
<a-button
v-if="batchMode && assignableDrafts.length > 1"
size="mini"
type="outline"
class="psc-parcel__batch"
@click.stop="distributeParcelToOrders(parcel)"
>
{{
t('order.myOrders.stockBind.parcelDistributeBtn', {
count: assignableDrafts.length,
})
}}
</a-button>
</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-batch-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
padding: 10px 12px;
margin-bottom: 10px;
border-radius: 4px;
border: 1px solid color-mix(in srgb, @primary 22%, var(--color-border-2));
background: color-mix(in srgb, @primary 6%, var(--app-card-bg));
flex-shrink: 0;
}
.psc-batch-bar__left {
display: flex;
align-items: center;
gap: 8px;
min-width: 220px;
}
.psc-batch-bar__label {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
.psc-batch-bar__hint {
font-size: 12px;
color: var(--color-text-3);
}
.psc-batch-bar__opts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
flex: 1;
}
.psc-batch-bar__opt-label {
font-size: 12px;
color: var(--color-text-2);
white-space: nowrap;
}
.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__tabs {
width: fit-content;
max-width: 100%;
}
.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;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
color: var(--color-text-2);
padding: 2px 4px 6px;
}
.psc-parcel__batch {
margin-left: auto;
}
.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>