This commit is contained in:
2026-09-18 16:48:35 +08:00
parent e3cca80ff2
commit 1d407620d2
59 changed files with 1879 additions and 1545 deletions
@@ -18,6 +18,26 @@ test('protects every overseas warehouse page with its independent list permissio
assert.doesNotMatch(routes, /permission: 'dashboard:view'/)
})
test('opens labeling and return relabeling on dedicated protected create pages', () => {
assert.match(routes, /path: 'overseas-warehouse\/labeling\/create'[\s\S]*?props: \{ service: 'labeling', createPage: true \}[\s\S]*?permission: 'merchant:overseas-warehouse:relabel:create'/)
assert.match(routes, /path: 'overseas-warehouse\/return-relabel\/create'[\s\S]*?props: \{ service: 'relabel', createPage: true \}[\s\S]*?permission: 'merchant:overseas-warehouse:return-order:create'/)
assert.match(services, /props\.createPage \? 'create' : 'list'/)
assert.match(services, /function returnToList\(\)/)
})
test('opens warehouse service and return receipt details as dedicated protected workspace pages', () => {
assert.match(routes, /path: 'overseas-warehouse\/labeling\/:detailNo\/detail'[\s\S]*?name: 'overseas-labeling-detail'[\s\S]*?props: \{ service: 'labeling', detailPage: true \}[\s\S]*?permission: 'merchant:overseas-warehouse:relabel:list'/)
assert.match(routes, /path: 'overseas-warehouse\/return-relabel\/:returnNo\/detail'[\s\S]*?name: 'overseas-return-relabel-detail'[\s\S]*?props: \{ detailPage: true \}[\s\S]*?permission: 'merchant:overseas-warehouse:return-order:list'/)
assert.match(routes, /path: 'overseas-warehouse\/storage-transfer\/:detailNo\/detail'[\s\S]*?name: 'overseas-storage-transfer-detail'[\s\S]*?props: \{ service: 'transfer', detailPage: true \}/)
assert.match(routes, /path: 'overseas-warehouse\/cargo-disposal\/:detailNo\/detail'[\s\S]*?name: 'overseas-cargo-disposal-detail'[\s\S]*?props: \{ service: 'disposal', detailPage: true \}/)
assert.match(services, /name: 'overseas-labeling-detail'/)
assert.match(services, /transfer: 'overseas-storage-transfer-detail'/)
assert.match(services, /disposal: 'overseas-cargo-disposal-detail'/)
assert.match(services, /class="warehouse-detail-error"/)
assert.match(returns, /name: 'overseas-return-relabel-detail'/)
assert.doesNotMatch(returns, /title="退货签收详情"/)
})
test('keeps overseas warehouse writes behind operation-specific button permissions', () => {
for (const permission of [
'merchant:overseas-warehouse:relabel:create',
+37
View File
@@ -5,18 +5,50 @@ export const overseasWarehouseModule = {
key: 'overseas-warehouse',
navigationGroup: { title: '海外仓', icon: Warehouse },
routes: [
{
path: 'overseas-warehouse/labeling/create', name: 'overseas-labeling-create',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'labeling', createPage: true }, release: 'active',
meta: { title: '新增贴标申请', workspaceTitle: '贴标申请 · 新建', description: '填写并提交海外仓贴标申请', permission: 'merchant:overseas-warehouse:relabel:create' },
},
{
path: 'overseas-warehouse/labeling/:detailNo/detail', name: 'overseas-labeling-detail',
component: () => import('./views/WarehouseServiceView.vue'),
props: { service: 'labeling', detailPage: true }, release: 'active',
meta: { title: '贴标申请详情', workspaceTitle: '贴标申请 · 详情', description: '查看贴标申请与仓库履约信息', permission: 'merchant:overseas-warehouse:relabel:list', keepAlive: false },
},
{
path: 'overseas-warehouse/labeling', name: 'overseas-labeling',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'labeling' }, release: 'active',
meta: { title: '贴标申请', description: '查询并管理海外仓贴标申请', permission: 'merchant:overseas-warehouse:relabel:list' },
navigation: { key: 'overseas-labeling', icon: Tags },
},
{
path: 'overseas-warehouse/return-relabel/create', name: 'overseas-return-relabel-create',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'relabel', createPage: true }, release: 'active',
meta: { title: '新建退货换标单', workspaceTitle: '退货换标 · 新建', description: '填写并提交退货换标订单', permission: 'merchant:overseas-warehouse:return-order:create' },
},
{
path: 'overseas-warehouse/return-relabel/:returnNo/detail', name: 'overseas-return-relabel-detail',
component: () => import('./views/ReturnOrdersView.vue'),
props: { detailPage: true }, release: 'active',
meta: { title: '退货签收详情', workspaceTitle: '退货签收 · 详情', description: '查看退货预报与仓库签收结果', permission: 'merchant:overseas-warehouse:return-order:list', keepAlive: false },
},
{
path: 'overseas-warehouse/storage-transfer/:detailNo/detail', name: 'overseas-storage-transfer-detail',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'transfer', detailPage: true }, release: 'active',
meta: { title: '仓储转运详情', workspaceTitle: '仓储转运 · 详情', description: '查看仓储转运与入库测量详情', permission: 'merchant:overseas-warehouse:transfer:list', keepAlive: false },
},
{
path: 'overseas-warehouse/storage-transfer', name: 'overseas-storage-transfer',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'transfer' }, release: 'active',
meta: { title: '仓储转运', description: '查询并管理海外仓转运订单', permission: 'merchant:overseas-warehouse:transfer:list' },
navigation: { key: 'overseas-storage-transfer', icon: Truck },
},
{
path: 'overseas-warehouse/cargo-disposal/:detailNo/detail', name: 'overseas-cargo-disposal-detail',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'disposal', detailPage: true }, release: 'active',
meta: { title: '货物废弃详情', workspaceTitle: '货物废弃 · 详情', description: '查看货物废弃申请详情', permission: 'merchant:overseas-warehouse:discard:list', keepAlive: false },
},
{
path: 'overseas-warehouse/cargo-disposal', name: 'overseas-cargo-disposal',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'disposal' }, release: 'active',
@@ -41,5 +73,10 @@ export const overseasWarehouseModule = {
meta: { title: '退货箱号管理', description: '查询签收及合箱生成的退货箱号和箱内库存', permission: 'merchant:overseas-warehouse:return-inventory:list' },
navigation: { key: 'overseas-return-cartons', icon: Boxes },
},
{
path: 'overseas-warehouse/return-cartons/:cartonMark/detail', name: 'overseas-return-carton-detail',
component: () => import('./views/ReturnInventoryView.vue'), props: { cartonOnly: true, detailPage: true }, release: 'active',
meta: { title: '退货箱明细', workspaceTitle: '退货箱号 · 详情', description: '查看退货箱号与箱内 SKU 明细', permission: 'merchant:overseas-warehouse:return-inventory:list', keepAlive: false },
},
],
} satisfies ClientModuleManifest
@@ -4,19 +4,20 @@ import test from 'node:test'
const inventory = readFileSync(new URL('./ReturnInventoryView.vue', import.meta.url), 'utf8')
const orders = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8')
const routes = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
test('desktop filters reserve six slots and inventory time and statuses have readable presentation', () => {
assert.match(inventory, /grid-template-columns: repeat\(4, minmax\(150px, 1fr\)\) 130px auto/)
test('desktop filters use the shared query grid and inventory time and statuses have readable presentation', () => {
assert.match(inventory, /<QueryToolbar[^>]*layout="grid"/)
assert.match(inventory, /label: '箱号 \/ 箱唛'/)
assert.match(inventory, /white-space: nowrap/)
assert.match(inventory, /cartonStatusLabel\(cartonDetail.status\)/)
assert.match(inventory, /formatInventoryTime\(row.createdAt\)/)
assert.match(inventory, /formatInventoryTime\(row.updatedAt\)/)
assert.match(inventory, /formatInventoryTime\(row.updatedAt \|\| row.createdAt \|\| row.receivedAt\)/)
})
test('inventory failures are visible and are not presented as empty inventory', () => {
assert.match(inventory, /catch \(error\)/)
assert.match(inventory, /<el-alert v-if="loadError"/)
assert.equal((inventory.match(/:empty-text="loadError \?/g) || []).length, 3)
assert.equal((inventory.match(/:empty-text="loadError \?/g) || []).length, 1)
assert.match(inventory, /当前账号无权访问退货库存/)
assert.match(inventory, /error\.requestId/)
assert.match(inventory, /error\.traceId/)
@@ -30,17 +31,14 @@ test('return pagination receives numeric totals from the wire format', () => {
})
test('inventory actions lead both tables and timestamps describe inventory changes', () => {
const tables = inventory.match(/<el-table\b[^>]*>[\s\S]*?<\/el-table>/g).slice(0, 3)
for (const table of tables) {
assert.match(table, /<el-table\b[^>]*>\s*<el-table-column label="操作"[^>]*fixed="left"/)
}
assert.match(inventory, /prop="updatedAt" label="库存更新时间"/)
assert.equal((inventory.match(/label: '操作', slotName: 'actions'[^\n]*fixed: 'left'/g) || []).length, 3)
assert.match(inventory, /label: '库存更新时间'/)
assert.doesNotMatch(inventory, /实收并上架|最近入库时间/)
})
test('carton management uses server carton pagination rather than deduplicating SKU pages', () => {
assert.match(inventory, /if \(props.cartonOnly\) \{\s*const page = await listReturnInventoryCartons/)
assert.match(inventory, /:data="cartonSummaryRows" row-key="cartonMark"/)
assert.match(inventory, /props.cartonOnly \? cartonSummaryRows.value/)
assert.match(inventory, /cartonSummaryRows.value = \[\]/)
})
@@ -59,3 +57,11 @@ test('warehouse selectors and displays use the catalog instead of internal IDs',
assert.match(inventory, /warehouse\.warehouseCode/)
assert.doesNotMatch(inventory, /海外仓 ID|\{\{ cartonDetail.overseasWarehouseId \}\}|prop="overseasWarehouseId"/)
})
test('opens carton details on a dedicated compact page with a visible retry state', () => {
assert.match(routes, /path: 'overseas-warehouse\/return-cartons\/:cartonMark\/detail'/)
assert.match(inventory, /name: 'overseas-return-carton-detail'/)
assert.match(inventory, /class="carton-detail-surface"/)
assert.match(inventory, /detailError \|\| '箱号详情加载失败'/)
assert.doesNotMatch(inventory, /<el-drawer/)
})
@@ -1,7 +1,11 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Printer, Search } from '@lucide/vue'
import { ArrowLeft, Printer } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import type { TableAction } from '@/components/common/tableActions'
import { ApiError } from '@/api/types'
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/public'
import {
@@ -17,10 +21,14 @@ import {
} from '../api/returnInventory'
const activeTab = ref<'summary' | 'cartons'>('summary')
const props = defineProps<{ cartonOnly?: boolean }>()
const props = defineProps<{ cartonOnly?: boolean; detailPage?: boolean }>()
const route = useRoute()
const router = useRouter()
const detailCartonMark = computed(() => String(route.params.cartonMark || ''))
if (props.cartonOnly) activeTab.value = 'cartons'
const loading = ref(false)
const loadError = ref('')
const detailError = ref('')
const warehouses = ref<MerchantWarehouse[]>([])
const warehouseError = ref('')
const warehousesLoading = ref(false)
@@ -50,9 +58,53 @@ const summaryRows = ref<ReturnInventorySummaryItem[]>([])
const cartonRows = ref<ReturnInventoryItem[]>([])
const cartonSummaryRows = ref<ReturnInventoryCartonSummary[]>([])
const total = ref(0)
const showCartonDetail = ref(false)
const cartonDetail = ref<ReturnInventoryCartonDetail>()
const query = reactive({ page: 1, size: 20, overseasWarehouseId: '', skuCode: '', cartonMark: '', status: 'AVAILABLE', returnNo: '' })
const queryModel = computed<QueryModel>({
get: () => ({ ...query }),
set: (value) => Object.assign(query, value),
})
const queryFields = computed<QueryField[]>(() => {
const fields: QueryField[] = [
{ key: 'overseasWarehouseId', label: '海外仓', type: 'select', options: warehouses.value.map((warehouse) => ({ label: warehouseLabel(String(warehouse.id)), value: String(warehouse.id) })) },
{ key: 'skuCode', label: 'SKU', type: 'input', placeholder: '请输入 SKU' },
]
if (activeTab.value === 'cartons') fields.push(
{ key: 'cartonMark', label: '箱号 / 箱唛', type: 'input', placeholder: '请输入箱号或箱唛' },
{ key: 'returnNo', label: '退货单号', type: 'input', placeholder: '请输入退货单号' },
{ key: 'status', label: '箱状态', type: 'select', options: [{ label: '可用', value: 'AVAILABLE' }, { label: '已合箱', value: 'MERGED' }] },
)
return fields
})
const summaryColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 110, fixed: 'left' },
{ key: 'warehouse', label: '海外仓', slotName: 'warehouse', minWidth: 190 },
{ prop: 'skuCode', label: 'SKU', minWidth: 190 }, { prop: 'productNameZh', label: '中文品名', minWidth: 190 },
{ prop: 'availableQuantity', label: '可用库存', width: 110, align: 'right' }, { prop: 'cartonQuantity', label: '所在箱数', width: 110, align: 'right' },
{ key: 'time', label: '库存更新时间', slotName: 'time', width: 180 },
]
const cartonItemColumns: DataTableColumn[] = [
{ prop: 'skuCode', label: 'SKU', minWidth: 200 },
{ prop: 'productNameZh', label: '中文品名', minWidth: 240 },
{ prop: 'quantity', label: '数量', width: 110, align: 'right', headerAlign: 'right' },
]
const cartonSummaryColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 150, fixed: 'left' }, { prop: 'cartonMark', label: '箱号', minWidth: 220 },
{ key: 'warehouse', label: '海外仓', slotName: 'warehouse', minWidth: 190 }, { prop: 'skuTypes', label: 'SKU 种类', width: 110, align: 'right' },
{ prop: 'totalQuantity', label: '总件数', width: 100, align: 'right' }, { prop: 'sourceReturnNo', label: '来源退货单', minWidth: 210 },
{ prop: 'sourceBatchNo', label: '签收批次', minWidth: 180 }, { key: 'dimensions', label: '箱规', slotName: 'dimensions', width: 170 },
{ key: 'weight', label: '重量', slotName: 'weight', width: 100 }, { key: 'time', label: '生成时间', slotName: 'time', width: 180 },
]
const cartonColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 150, fixed: 'left' }, { prop: 'cartonMark', label: '箱号', minWidth: 220 },
{ key: 'warehouse', label: '海外仓', slotName: 'warehouse', minWidth: 190 }, { prop: 'skuCode', label: 'SKU', minWidth: 180 },
{ prop: 'productNameZh', label: '中文品名', minWidth: 180 }, { prop: 'quantity', label: '本箱数量', width: 110, align: 'right' },
{ prop: 'cartonSkuTypes', label: 'SKU 种类', width: 110, align: 'right' }, { prop: 'cartonTotalQuantity', label: '总件数', width: 100, align: 'right' },
{ key: 'status', label: '箱状态', slotName: 'status', width: 100 }, { prop: 'sourceBatchNo', label: '签收批次', minWidth: 180 },
{ key: 'time', label: '生成时间', slotName: 'time', width: 180 },
]
const currentRows = computed<object[]>(() => activeTab.value === 'summary' ? summaryRows.value : props.cartonOnly ? cartonSummaryRows.value : cartonRows.value)
const currentColumns = computed(() => activeTab.value === 'summary' ? summaryColumns : props.cartonOnly ? cartonSummaryColumns : cartonColumns)
function params(includeCartonMark: boolean) {
return {
@@ -100,7 +152,8 @@ async function load() {
} finally { loading.value = false }
}
function search() { query.page = 1; void load() }
function search() { if (query.page === 1) void load(); else query.page = 1 }
function resetSearch() { query.status = 'AVAILABLE'; search() }
function changeTab() { query.page = 1; void load() }
function openSkuCartons(skuCode: string) {
query.skuCode = skuCode
@@ -110,11 +163,18 @@ function openSkuCartons(skuCode: string) {
}
async function openCarton(cartonMark: string) {
try {
cartonDetail.value = await getReturnInventoryCarton(cartonMark)
showCartonDetail.value = true
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '箱内明细加载失败') }
void router.push({ name: 'overseas-return-carton-detail', params: { cartonMark } })
}
async function loadCartonDetail() {
if (!detailCartonMark.value) return
loading.value = true
detailError.value = ''
try {
cartonDetail.value = await getReturnInventoryCarton(detailCartonMark.value)
} catch (error) { cartonDetail.value = undefined; detailError.value = error instanceof Error ? error.message : '箱内明细加载失败'; ElMessage.error(detailError.value) }
finally { loading.value = false }
}
function returnToCartons() { void router.push({ name: 'overseas-return-cartons' }) }
async function printLabel(cartonMark: string) {
const printWindow = window.open('', '_blank')
@@ -136,59 +196,55 @@ async function printLabel(cartonMark: string) {
}
}
onMounted(() => { void load(); void loadWarehouses() })
function rowActions(candidate: object): TableAction[] {
const row = candidate as ReturnInventorySummaryItem | ReturnInventoryItem | ReturnInventoryCartonSummary
if (activeTab.value === 'summary') return [{ key: 'cartons', label: '查看箱号', run: () => openSkuCartons((row as ReturnInventorySummaryItem).skuCode) }]
const cartonMark = (row as ReturnInventoryItem | ReturnInventoryCartonSummary).cartonMark
return [
{ key: 'detail', label: '详情', run: () => openCarton(cartonMark) },
{ key: 'label', label: '箱唛', run: () => printLabel(cartonMark) },
]
}
watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextSize !== previousSize && nextPage !== 1) { query.page = 1; return }
void load()
})
onMounted(() => { if (props.detailPage) void Promise.all([loadCartonDetail(), loadWarehouses()]); else { void load(); void loadWarehouses() } })
</script>
<template>
<section class="return-inventory-page">
<div class="page-heading"><div><h2>{{ props.cartonOnly ? '退货箱号管理' : '退货库存管理' }}</h2><p>{{ props.cartonOnly ? '查询签收及合箱生成的退货箱号、箱规和箱内 SKU 明细。' : '库存数量以仓库 PDA 确认实收后的入账数量为准。' }}</p></div></div>
<template v-if="!props.detailPage">
<el-tabs v-if="!props.cartonOnly" v-model="activeTab" @tab-change="changeTab"><el-tab-pane label="SKU 汇总库存" name="summary" /><el-tab-pane label="箱号明细" name="cartons" /></el-tabs>
<div class="filter-bar" :class="{ 'carton-filters': activeTab === 'cartons' }">
<el-select v-model="query.overseasWarehouseId" clearable filterable placeholder="海外仓" :loading="warehousesLoading"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="warehouseLabel(String(warehouse.id))" :value="String(warehouse.id)" /></el-select>
<el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="search" />
<el-input v-if="activeTab === 'cartons'" v-model="query.cartonMark" clearable placeholder="箱号 / 箱唛" @keyup.enter="search" />
<el-input v-if="activeTab === 'cartons'" v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="search" />
<el-select v-if="activeTab === 'cartons'" v-model="query.status" clearable placeholder="箱状态"><el-option label="可用" value="AVAILABLE" /><el-option label="已合箱" value="MERGED" /></el-select>
<el-button type="primary" @click="search"><Search :size="16" />查询</el-button>
</div>
<QueryToolbar v-model="queryModel" layout="grid" :ariaLabel="`${props.cartonOnly ? '退货箱号' : '退货库存'}筛选`" :fields="queryFields" :search-on-reset="false" @search="search" @reset="resetSearch" />
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" />
<el-alert v-if="warehouseError" :title="warehouseError" type="warning" show-icon :closable="false"><el-button link type="primary" @click="loadWarehouses">重试加载海外仓</el-button></el-alert>
<el-table v-if="activeTab === 'summary'" v-loading="loading" :data="summaryRows" :empty-text="loadError ? '库存加载失败,请核对权限或重试' : '暂无数据'">
<el-table-column label="操作" width="110" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openSkuCartons(row.skuCode)">查看箱号</el-button></template></el-table-column>
<el-table-column label="海外仓" min-width="150"><template #default="{ row }">{{ warehouseLabel(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="180" />
<el-table-column prop="productNameZh" label="中文品名" min-width="190" show-overflow-tooltip /><el-table-column prop="availableQuantity" label="可用库存" min-width="120" align="right" />
<el-table-column prop="cartonQuantity" label="所在箱数" min-width="120" align="right" /><el-table-column prop="updatedAt" label="库存更新时间" min-width="180"><template #default="{ row }"><span class="inventory-time">{{ formatInventoryTime(row.updatedAt) }}</span></template></el-table-column>
</el-table>
<el-table v-else-if="props.cartonOnly" v-loading="loading" :data="cartonSummaryRows" row-key="cartonMark" :empty-text="loadError ? '库存加载失败,请核对权限或重试' : '暂无数据'">
<el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openCarton(row.cartonMark)">详情</el-button><el-button link type="primary" @click="printLabel(row.cartonMark)"><Printer :size="14" />箱唛</el-button></template></el-table-column>
<el-table-column prop="cartonMark" label="箱号" min-width="235" />
<el-table-column label="海外仓" min-width="150"><template #default="{ row }">{{ warehouseLabel(row.overseasWarehouseId) }}</template></el-table-column>
<el-table-column prop="skuTypes" label="箱内 SKU 种类" min-width="130" align="right" />
<el-table-column prop="totalQuantity" label="箱内总件数" min-width="115" align="right" />
<el-table-column prop="sourceReturnNo" label="来源退货单" min-width="235" />
<el-table-column prop="sourceBatchNo" label="签收批次" min-width="190" />
<el-table-column label="箱规" min-width="160"><template #default="{ row }">{{ row.length && row.width && row.height ? `${row.length} × ${row.width} × ${row.height} cm` : '—' }}</template></el-table-column>
<el-table-column label="重量" min-width="100"><template #default="{ row }">{{ row.weight ? `${row.weight} kg` : '—' }}</template></el-table-column>
<el-table-column prop="createdAt" label="箱号生成时间" min-width="180"><template #default="{ row }"><span class="inventory-time">{{ formatInventoryTime(row.createdAt) }}</span></template></el-table-column>
</el-table>
<el-table v-else v-loading="loading" :data="cartonRows" :empty-text="loadError ? '库存加载失败,请核对权限或重试' : '暂无数据'">
<el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openCarton(row.cartonMark)">详情</el-button><el-button link type="primary" @click="printLabel(row.cartonMark)"><Printer :size="14" />箱唛</el-button></template></el-table-column>
<el-table-column prop="cartonMark" label="箱号" min-width="235"><template #default="{ row }"><el-button link type="primary" @click="openCarton(row.cartonMark)">{{ row.cartonMark }}</el-button></template></el-table-column>
<el-table-column label="海外仓" min-width="150"><template #default="{ row }">{{ warehouseLabel(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="150" />
<el-table-column prop="productNameZh" label="中文品名" min-width="170" show-overflow-tooltip /><el-table-column prop="quantity" label="本箱 SKU 数量" min-width="135" align="right" />
<el-table-column prop="cartonSkuTypes" label="箱内 SKU 种类" min-width="130" align="right" /><el-table-column prop="cartonTotalQuantity" label="箱内总件数" min-width="115" align="right" />
<el-table-column prop="status" label="箱状态" min-width="100"><template #default="{ row }">{{ cartonStatusLabel(row.status) }}</template></el-table-column><el-table-column prop="sourceBatchNo" label="签收批次" min-width="190" /><el-table-column prop="receivedAt" label="箱号生成时间" min-width="180"><template #default="{ row }"><span class="inventory-time">{{ formatInventoryTime(row.receivedAt) }}</span></template></el-table-column>
</el-table>
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="total" layout="total, prev, pager, next" @current-change="load" />
<el-drawer v-model="showCartonDetail" title="退货箱明细" size="680px"><template v-if="cartonDetail">
<el-descriptions :column="2" border><el-descriptions-item label="箱号">{{ cartonDetail.cartonMark }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ warehouseLabel(cartonDetail.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="来源退货单">{{ cartonDetail.sourceReturnNo || '—' }}</el-descriptions-item><el-descriptions-item label="签收批次">{{ cartonDetail.sourceBatchNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ cartonStatusLabel(cartonDetail.status) }}</el-descriptions-item><el-descriptions-item label="SKU 种类">{{ cartonDetail.skuTypes }}</el-descriptions-item><el-descriptions-item label="箱内总件数">{{ cartonDetail.totalQuantity }}</el-descriptions-item><el-descriptions-item label="箱规">{{ cartonDetail.length && cartonDetail.width && cartonDetail.height ? `${cartonDetail.length} × ${cartonDetail.width} × ${cartonDetail.height} cm` : '—' }}</el-descriptions-item><el-descriptions-item label="重量">{{ cartonDetail.weight ? `${cartonDetail.weight} kg` : '—' }}</el-descriptions-item></el-descriptions>
<div class="drawer-heading"><h3>箱内 SKU</h3><el-button type="primary" @click="printLabel(cartonDetail.cartonMark)"><Printer :size="16" />打印箱唛</el-button></div>
<el-table :data="cartonDetail.items" size="small"><el-table-column prop="skuCode" label="SKU" min-width="170" /><el-table-column prop="productNameZh" label="中文品名" min-width="180" /><el-table-column prop="quantity" label="数量" min-width="100" align="right" /></el-table>
</template></el-drawer>
<div class="return-inventory-table">
<DataTable v-model:page="query.page" v-model:page-size="query.size" :data="currentRows" :columns="currentColumns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :row-actions="rowActions" :empty-text="loadError ? '库存加载失败,请核对权限或重试' : '暂无数据'">
<template #toolbar><strong>{{ props.cartonOnly ? '退货箱号' : activeTab === 'summary' ? 'SKU 汇总库存' : '箱号明细' }}</strong><span>共 {{ total }} 条</span></template>
<template #warehouse="{ row }">{{ warehouseLabel(row.overseasWarehouseId) }}</template>
<template #status="{ row }"><el-tag effect="light">{{ cartonStatusLabel(row.status) }}</el-tag></template>
<template #dimensions="{ row }">{{ row.length && row.width && row.height ? `${row.length} × ${row.width} × ${row.height} cm` : '—' }}</template>
<template #weight="{ row }">{{ row.weight ? `${row.weight} kg` : '—' }}</template>
<template #time="{ row }"><span class="inventory-time">{{ formatInventoryTime(row.updatedAt || row.createdAt || row.receivedAt) }}</span></template>
</DataTable>
</div>
</template>
<article v-else v-loading="loading" class="carton-detail-surface">
<template v-if="cartonDetail">
<header class="carton-detail-header"><button type="button" class="carton-detail-back" @click="returnToCartons"><ArrowLeft :size="16" />返回列表</button><div><strong>{{ cartonDetail.cartonMark }}</strong><span>{{ warehouseLabel(cartonDetail.overseasWarehouseId) }} · 退货箱明细</span></div><div class="carton-detail-actions"><el-tag effect="light">{{ cartonStatusLabel(cartonDetail.status) }}</el-tag><el-button type="primary" @click="printLabel(cartonDetail.cartonMark)"><Printer :size="16" />打印箱唛</el-button></div></header>
<section class="carton-detail-section"><h3>箱号概览</h3><dl class="carton-detail-grid"><div><dt>来源退货单</dt><dd>{{ cartonDetail.sourceReturnNo || '—' }}</dd></div><div><dt>签收批次</dt><dd>{{ cartonDetail.sourceBatchNo || '—' }}</dd></div><div><dt>SKU 种类</dt><dd>{{ cartonDetail.skuTypes }}</dd></div><div><dt>箱内总件数</dt><dd>{{ cartonDetail.totalQuantity }}</dd></div><div><dt>箱规</dt><dd>{{ cartonDetail.length && cartonDetail.width && cartonDetail.height ? `${cartonDetail.length} × ${cartonDetail.width} × ${cartonDetail.height} cm` : '—' }}</dd></div><div><dt>重量</dt><dd>{{ cartonDetail.weight ? `${cartonDetail.weight} kg` : '—' }}</dd></div><div><dt>签收时间</dt><dd>{{ formatInventoryTime(cartonDetail.receivedAt) }}</dd></div></dl></section>
<section class="carton-detail-section"><h3>箱内 SKU</h3><DataTable :data="cartonDetail.items" :columns="cartonItemColumns" row-key="skuCode" empty-text="暂无箱内 SKU" /></section>
</template>
<div v-else class="carton-detail-error" role="alert"><strong>{{ detailError || '箱号详情加载失败' }}</strong><el-button @click="loadCartonDetail">重新加载</el-button></div>
</article>
</section>
</template>
<style scoped>
.return-inventory-page { padding: 20px; } .page-heading { margin-bottom: 14px; } h2 { margin: 0; font-size: 22px; } p { margin: 6px 0 0; color: var(--el-text-color-secondary); } .filter-bar { display: grid; grid-template-columns: repeat(2, minmax(180px, 320px)) auto; justify-content: start; gap: 10px; margin-bottom: 14px; } .filter-bar.carton-filters { grid-template-columns: repeat(4, minmax(150px, 1fr)) 130px auto; } .filter-bar > * { min-width: 0; } .inventory-time { white-space: nowrap; } .el-pagination { margin-top: 16px; justify-content: flex-end; } .drawer-heading { display: flex; align-items: center; justify-content: space-between; margin: 24px 0 12px; } .drawer-heading h3 { margin: 0; font-size: 16px; }
@media (max-width: 760px) { .return-inventory-page { padding: 12px; } .filter-bar, .filter-bar.carton-filters { grid-template-columns: 1fr 1fr; } }
.return-inventory-page { display: grid; gap: 12px; } .return-inventory-page :deep(.el-tabs__header) { margin: 0; } .return-inventory-table { overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; } .inventory-time { white-space: nowrap; }
.carton-detail-surface{overflow:hidden;min-height:240px;border:1px solid var(--client-border);border-radius:var(--client-radius);background:#fff}.carton-detail-header{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:14px;min-height:64px;padding:10px 16px;border-bottom:1px solid var(--client-border)}.carton-detail-back{display:inline-flex;align-items:center;gap:5px;padding:5px 0;border:0;background:transparent;color:var(--el-color-primary);cursor:pointer}.carton-detail-header>div:nth-child(2){display:flex;min-width:0;flex-direction:column;gap:3px}.carton-detail-header strong{overflow:hidden;color:#1f2d45;font-size:18px;text-overflow:ellipsis;white-space:nowrap}.carton-detail-header span{color:#7c899c;font-size:12px}.carton-detail-actions{display:flex;align-items:center;gap:8px}.carton-detail-section+.carton-detail-section{border-top:1px solid var(--client-border)}.carton-detail-section>h3{display:flex;min-height:40px;align-items:center;margin:0;padding:0 16px;border-bottom:1px solid var(--client-border);background:var(--client-header-bg);font-size:14px}.carton-detail-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:0}.carton-detail-grid div{display:grid;min-height:42px;grid-template-columns:88px minmax(0,1fr);align-items:center;gap:8px;padding:5px 12px;border-right:1px solid var(--client-border);border-bottom:1px solid var(--client-border)}.carton-detail-grid dt{color:var(--client-muted);font-size:12px}.carton-detail-grid dd{margin:0;overflow:hidden;color:var(--client-text);font-size:13px;font-weight:600;text-overflow:ellipsis;white-space:nowrap}@media(max-width:1100px){.carton-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:720px){.carton-detail-header{grid-template-columns:1fr auto}.carton-detail-back{grid-column:1/-1}.carton-detail-grid{grid-template-columns:1fr}}
.carton-detail-error{display:flex;min-height:220px;align-items:center;justify-content:center;gap:12px;color:#b42318}
</style>
@@ -38,15 +38,23 @@ test('退货列表和详情使用客户可读仓库、状态与时间', () => {
})
test('可操作退货表格把操作列固定在最左侧', () => {
const mainTable = view.slice(view.indexOf('<el-table v-loading="loading"'), view.indexOf('<el-pagination'))
const createTable = view.slice(view.indexOf('<el-table :data="form.items"'), view.indexOf('</el-table>', view.indexOf('<el-table :data="form.items"')))
assert.match(mainTable, /^<el-table[\s\S]*?<el-table-column label="操作" width="90" fixed="left">/)
assert.match(view, /label: '操作', slotName: 'actions', width: 90, fixed: 'left'/)
assert.match(view, /<DataTable[^>]*:row-actions="rowActions"/)
assert.match(createTable, /^<el-table[\s\S]*?<el-table-column label="操作" width="80" fixed="left">/)
})
test('分页初始化不会重复请求退货列表', () => {
assert.match(view, /function changePage\(page: number\) \{\s*if \(page === query\.page\) return/)
assert.match(view, /:current-page="query\.page" :page-size="query\.size"/)
assert.match(view, /@current-change="changePage"/)
assert.match(view, /watch\(\(\) => \[query\.page, query\.size\] as const/)
assert.match(view, /v-model:page="query\.page" v-model:page-size="query\.size"/)
assert.doesNotMatch(view, /@current-change="load"/)
})
test('退货签收详情使用独立路由和紧凑连续页面', () => {
assert.match(view, /name: 'overseas-return-relabel-detail'/)
assert.match(view, /class="return-detail-surface"/)
assert.match(view, /class="return-detail-grid"/)
assert.match(view, /function returnToList\(\)/)
assert.doesNotMatch(view, /showDetail/)
assert.doesNotMatch(view, /class="return-detail-dialog"/)
})
@@ -1,17 +1,24 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Search } from '@lucide/vue'
import { ArrowLeft, Plus } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import type { TableAction } from '@/components/common/tableActions'
import { useAuthStore } from '@/modules/auth/public'
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/public'
import { createReturnOrder, getReturnOrder, listReturnOrders, type ReturnOrderDetail, type ReturnOrderSummary, type ReturnReceipt } from '../api/returnOrders'
const props = withDefaults(defineProps<{ detailPage?: boolean; returnNo?: string }>(), { detailPage: false, returnNo: '' })
const router = useRouter()
const route = useRoute()
const detailReturnNo = computed(() => props.returnNo || String(route.params.returnNo || ''))
const loading = ref(false)
const auth = useAuthStore()
const canCreate = computed(() => auth.hasPermission('merchant:overseas-warehouse:return-order:create'))
const creating = ref(false)
const showCreate = ref(false)
const showDetail = ref(false)
const selected = ref<ReturnOrderDetail>()
const rows = ref<ReturnOrderSummary[]>([])
const total = ref(0)
@@ -19,6 +26,36 @@ const operationWarehouses = ref<MerchantWarehouse[]>([])
const operationWarehousesLoading = ref(false)
const operationWarehousesError = ref('')
const query = reactive({ page: 1, size: 15, returnNo: '', trackingNo: '', skuCode: '', status: '' })
const queryModel = computed<QueryModel>({ get: () => ({ ...query }), set: (value) => Object.assign(query, value) })
const queryFields: QueryField[] = [
{ key: 'returnNo', label: '退货单号', type: 'input', placeholder: '请输入退货单号' },
{ key: 'trackingNo', label: '快递单号', type: 'input', placeholder: '请输入快递单号' },
{ key: 'skuCode', label: 'SKU', type: 'input', placeholder: '请输入 SKU' },
{ key: 'status', label: '签收状态', type: 'select', options: [{ label: '待签收', value: 'PENDING_RECEIPT' }, { label: '部分签收', value: 'PARTIALLY_RECEIVED' }, { label: '已完成', value: 'COMPLETED' }] },
]
const tableColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 90, fixed: 'left' },
{ prop: 'returnNo', label: '退货单号', minWidth: 190 }, { prop: 'customerReferenceNo', label: '客户参考号', minWidth: 160 },
{ key: 'warehouse', label: '操作仓', slotName: 'warehouse', minWidth: 200 }, { key: 'relabel', label: '换标', slotName: 'relabel', width: 100 },
{ prop: 'skuTypes', label: 'SKU 种类', width: 105 }, { prop: 'expectedQuantity', label: '预报数量', width: 110 },
{ prop: 'receivedQuantity', label: '实收数量', width: 110 }, { key: 'status', label: '状态', slotName: 'status', width: 120 },
{ key: 'createdAt', label: '创建时间', slotName: 'createdAt', width: 180 },
]
const itemColumns: DataTableColumn[] = [
{ prop: 'skuCode', label: 'SKU', minWidth: 190 },
{ prop: 'productNameZh', label: '中文品名', minWidth: 220 },
{ prop: 'expectedQuantity', label: '预报数量', width: 110 },
{ prop: 'receivedQuantity', label: '实收数量', width: 110 },
]
const receiptColumns: DataTableColumn[] = [
{ prop: 'receiptBatchNo', label: '批次号', minWidth: 170 },
{ prop: 'receiptNo', label: '签收单号', minWidth: 170 },
{ prop: 'cartonMark', label: '箱号', minWidth: 170 },
{ key: 'trackingNo', label: '快递单号', slotName: 'trackingNo', minWidth: 160 },
{ key: 'startedBy', label: '开始方式', slotName: 'startedBy', width: 110 },
{ prop: 'receivedQuantity', label: '实收', width: 90 },
{ key: 'signedAt', label: '签收时间', slotName: 'signedAt', minWidth: 180 },
]
interface SkuDraft { id: number; skuCode: string; productNameZh: string; expectedQuantity: number }
let skuSequence = 0
function emptySku(): SkuDraft { return { id: ++skuSequence, skuCode: '', productNameZh: '', expectedQuantity: 1 } }
@@ -78,7 +115,20 @@ function changePage(page: number) {
query.page = page
void load()
}
async function openDetail(returnNo: string) { selected.value = await getReturnOrder(returnNo); showDetail.value = true }
function search() { if (query.page === 1) void load(); else query.page = 1 }
function rowActions(candidate: object): TableAction[] {
const row = candidate as ReturnOrderSummary
return [{ key: 'detail', label: '详情', run: () => openDetail(row.returnNo) }]
}
async function loadDetail() {
if (!detailReturnNo.value) return
loading.value = true
try { selected.value = await getReturnOrder(detailReturnNo.value) }
catch (error) { ElMessage.error(error instanceof Error ? error.message : '退货签收详情加载失败') }
finally { loading.value = false }
}
function openDetail(returnNo: string) { void router.push({ name: 'overseas-return-relabel-detail', params: { returnNo } }) }
function returnToList() { void router.push({ name: 'overseas-return-relabel' }) }
async function submit() {
const items = form.items.map(({ skuCode, productNameZh, expectedQuantity }) => ({ skuCode: skuCode.trim(), productNameZh: productNameZh.trim(), expectedQuantity }))
if (!form.overseasWarehouseId || items.some(item => !item.skuCode || item.expectedQuantity <= 0)) { ElMessage.warning('请填写操作仓、SKU 和预报数量'); return }
@@ -87,7 +137,7 @@ async function submit() {
creating.value = true
try {
const detail = await createReturnOrder({ overseasWarehouseId: form.overseasWarehouseId, warehouseContactName: form.warehouseContactName.trim(), warehouseContactPhone: form.warehouseContactPhone.trim(), customerReferenceNo: form.customerReferenceNo || undefined, remark: form.remark || undefined, needRelabel: form.needRelabel, idempotencyKey: crypto.randomUUID(), items: items.map(item => ({ ...item, productNameZh: item.productNameZh || undefined })) })
ElMessage.success('退货预报已创建'); showCreate.value = false; resetForm(); await load(); await openDetail(detail.order.returnNo)
ElMessage.success('退货预报已创建'); showCreate.value = false; resetForm(); await router.push({ name: 'overseas-return-relabel-detail', params: { returnNo: detail.order.returnNo } })
} finally { creating.value = false }
}
function statusType(status: string) { return status === 'COMPLETED' ? 'success' : status === 'PARTIALLY_RECEIVED' ? 'warning' : 'info' }
@@ -101,32 +151,30 @@ function formatDateTime(value?: string | null) {
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date)
}
onMounted(() => { void Promise.all([load(), loadOperationWarehouses()]) })
onMounted(() => {
if (props.detailPage) void Promise.all([loadDetail(), loadOperationWarehouses()])
else void Promise.all([load(), loadOperationWarehouses()])
})
watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextSize !== previousSize && nextPage !== 1) { query.page = 1; return }
if (!props.detailPage) void load()
})
</script>
<template>
<section class="return-orders-page">
<div class="page-heading"><div><h2>退货签收</h2><p>预报退货 SKU 和数量,仓库 PDA 实收后自动更新签收状态。</p></div><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新建退货预报</el-button></div>
<div class="filter-bar">
<el-input v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="query.page=1; load()" />
<el-input v-model="query.trackingNo" clearable placeholder="快递单号" @keyup.enter="query.page=1; load()" />
<el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="query.page=1; load()" />
<el-select v-model="query.status" clearable placeholder="签收状态"><el-option label="待签收" value="PENDING_RECEIPT" /><el-option label="部分签收" value="PARTIALLY_RECEIVED" /><el-option label="已完成" value="COMPLETED" /></el-select>
<el-button @click="query.page=1; load()"><Search :size="16" />查询</el-button>
<template v-if="!props.detailPage">
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="退货签收筛选" :fields="queryFields" @search="search" />
<div class="return-table-card">
<DataTable v-model:page="query.page" v-model:page-size="query.size" :data="rows" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[15, 30, 50, 100]" :row-actions="rowActions" row-key="returnNo" empty-text="暂无退货预报">
<template #toolbar><strong>退货签收</strong><span>共 {{ total }} 条</span></template>
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新建退货预报</el-button></template>
<template #warehouse="{ row }">{{ operationWarehouseLabel(row.overseasWarehouseId) }}</template>
<template #relabel="{ row }"><el-tag v-if="row.needRelabel" type="warning" effect="light">需要换标</el-tag><span v-else>否</span></template>
<template #status="{ row }"><el-tag :type="statusType(row.status)" effect="light">{{ statusLabel(row.status) }}</el-tag></template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</DataTable>
</div>
<el-table v-loading="loading" class="return-table-card" :data="rows" @row-click="row => openDetail(row.returnNo)">
<el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button link type="primary" @click.stop="openDetail(row.returnNo)">详情</el-button></template></el-table-column>
<el-table-column prop="returnNo" label="退货单号" min-width="190" />
<el-table-column prop="customerReferenceNo" label="客户参考号" min-width="150" />
<el-table-column label="操作仓" min-width="190"><template #default="{ row }">{{ operationWarehouseLabel(row.overseasWarehouseId) }}</template></el-table-column>
<el-table-column label="换标" width="100"><template #default="{ row }"><el-tag v-if="row.needRelabel" type="warning">需要换标</el-tag><span v-else>否</span></template></el-table-column>
<el-table-column prop="skuTypes" label="SKU种类" width="100" />
<el-table-column prop="expectedQuantity" label="预报数量" width="110" />
<el-table-column prop="receivedQuantity" label="实收数量" width="110" />
<el-table-column label="状态" width="130"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="创建时间" min-width="180"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
</el-table>
<div class="return-pagination"><span class="return-pagination-total">共 {{ total }} 条</span><el-select v-model="query.size" class="return-page-size-select" aria-label="每页条数" @change="query.page=1; load()"><el-option v-for="size in [15, 30, 50, 100]" :key="size" :label="`${size}条/页`" :value="size" /></el-select><el-pagination :current-page="query.page" :page-size="query.size" :total="total" :hide-on-single-page="false" layout="prev, pager, next" @current-change="changePage" /></div>
<el-dialog v-model="showCreate" title="新建退货预报" width="1120px" destroy-on-close>
<el-form label-width="190px" class="return-create-form">
@@ -147,34 +195,69 @@ onMounted(() => { void Promise.all([load(), loadOperationWarehouses()]) })
</el-table>
<template #footer><el-button @click="showCreate=false">取消</el-button><el-button v-if="canCreate" type="primary" :loading="creating" @click="submit">提交预报</el-button></template>
</el-dialog>
<el-dialog v-model="showDetail" title="退货签收详情" width="min(980px, calc(100vw - 32px))" top="8vh" class="return-detail-dialog" destroy-on-close>
</template>
<article v-else v-loading="loading" class="return-detail-surface">
<template v-if="selected">
<div class="detail-summary"><div><span>退货单号</span><strong>{{ selected.order.returnNo }}</strong></div><div><span>状态</span><el-tag :type="statusType(selected.order.status)">{{ statusLabel(selected.order.status) }}</el-tag></div><div><span>海外仓</span><strong>{{ operationWarehouseLabel(selected.order.overseasWarehouseId) }}</strong></div><div><span>实收 / 预报</span><strong>{{ selected.order.receivedQuantity }} / {{ selected.order.expectedQuantity }}</strong></div><div><span>海外仓联系人</span><strong>{{ selected.order.warehouseContactName || '—' }}</strong></div><div><span>联系电话</span><strong>{{ selected.order.warehouseContactPhone || '—' }}</strong></div></div>
<p v-if="selected.remark" class="remark detail-remark">备注:{{ selected.remark }}</p>
<section class="detail-section"><h3>预报与实收</h3><el-table :data="selected.items" size="small" border><el-table-column prop="skuCode" label="SKU" min-width="180" /><el-table-column prop="productNameZh" label="中文品名" min-width="220" /><el-table-column prop="expectedQuantity" label="预报" width="100" /><el-table-column prop="receivedQuantity" label="实收" width="100" /></el-table></section>
<section class="detail-section"><h3>签收批次</h3><el-table :data="selected.receipts" size="small" border><el-table-column prop="receiptBatchNo" label="批次号" min-width="160" /><el-table-column prop="receiptNo" label="签收单号" min-width="160" /><el-table-column prop="cartonMark" label="箱号" min-width="170" /><el-table-column prop="trackingNo" label="快递单号" min-width="150"><template #default="{ row }">{{ row.trackingNo || '无快递单号' }}</template></el-table-column><el-table-column label="开始方式" width="110"><template #default="{ row }">{{ startedByLabel(row.startedBy) }}</template></el-table-column><el-table-column prop="receivedQuantity" label="实收" width="80" /><el-table-column label="签收时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.signedAt) }}</template></el-table-column></el-table></section>
<header class="return-detail-header">
<button type="button" class="return-back" @click="returnToList"><ArrowLeft :size="16" />返回列表</button>
<div class="return-detail-title"><strong>{{ selected.order.returnNo }}</strong><span>{{ operationWarehouseLabel(selected.order.overseasWarehouseId) }} · 退货签收</span></div>
<el-tag :type="statusType(selected.order.status)" effect="light">{{ statusLabel(selected.order.status) }}</el-tag>
</header>
<section class="return-detail-section">
<h3>签收概览</h3>
<div class="return-detail-grid">
<div><span>实收 / 预报</span><strong>{{ selected.order.receivedQuantity }} / {{ selected.order.expectedQuantity }}</strong></div>
<div><span>SKU 种类</span><strong>{{ selected.order.skuTypes }}</strong></div>
<div><span>是否换标</span><strong>{{ selected.order.needRelabel ? '需要换标' : '否' }}</strong></div>
<div><span>客户参考号</span><strong>{{ selected.order.customerReferenceNo || '—' }}</strong></div>
<div><span>海外仓联系人</span><strong>{{ selected.order.warehouseContactName || '—' }}</strong></div>
<div><span>联系电话</span><strong>{{ selected.order.warehouseContactPhone || '—' }}</strong></div>
<div><span>创建时间</span><strong>{{ formatDateTime(selected.order.createdAt) }}</strong></div>
</div>
<div v-if="selected.remark" class="return-detail-remark"><span>备注</span><strong>{{ selected.remark }}</strong></div>
</section>
<section class="return-detail-section">
<h3>预报与实收</h3>
<DataTable :data="selected.items" :columns="itemColumns" row-key="skuCode" empty-text="暂无 SKU 明细" />
</section>
<section class="return-detail-section">
<h3>签收批次</h3>
<DataTable :data="selected.receipts" :columns="receiptColumns" row-key="receiptNo" empty-text="暂无签收批次">
<template #trackingNo="{ row }">{{ row.trackingNo || '无快递单号' }}</template>
<template #startedBy="{ row }">{{ startedByLabel(row.startedBy) }}</template>
<template #signedAt="{ row }">{{ formatDateTime(row.signedAt) }}</template>
</DataTable>
</section>
</template>
<template #footer><el-button type="primary" @click="showDetail = false">关闭</el-button></template>
</el-dialog>
</article>
</section>
</template>
<style scoped>
.return-orders-page { padding: 20px; } .page-heading { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; margin-bottom:18px; } h2 { margin:0; font-size:22px; } p { margin:6px 0 0; color:var(--el-text-color-secondary); } .filter-bar { display:grid; grid-template-columns:repeat(4,minmax(120px,1fr)) auto; gap:10px; margin-bottom:14px; } .return-create-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:20px; } .return-form-wide { grid-column:1 / -1; } .items-title { display:flex; justify-content:space-between; align-items:center; margin:16px 0 4px; font-weight:600; } .items-hint { margin-bottom:10px; color:var(--el-text-color-secondary); font-size:12px; } .el-pagination { margin-top:16px; justify-content:flex-end; } h3 { margin:24px 0 10px; font-size:16px; } h4 { margin:16px 0 8px; font-size:14px; } .remark { white-space:pre-wrap; } .warehouse-error, .duplicate-sku-error { margin-top:6px; color:var(--el-color-danger); font-size:12px; } :deep(.duplicate-sku-input .el-input__wrapper) { box-shadow:0 0 0 1px var(--el-color-danger) inset; } :deep(.el-dialog .el-select) { width:100%; }
.return-table-card { overflow:hidden; border:1px solid #e7ebf1; border-radius:4px; background:#fff; box-shadow:0 1px 3px rgba(25,42,70,.04); }
.return-orders-page { display:grid; gap:12px; } .return-create-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:20px; } .return-form-wide { grid-column:1 / -1; } .items-title { display:flex; justify-content:space-between; align-items:center; margin:16px 0 4px; font-weight:600; } .items-hint { margin-bottom:10px; color:var(--el-text-color-secondary); font-size:12px; } h3 { margin:24px 0 10px; font-size:16px; } h4 { margin:16px 0 8px; font-size:14px; } .remark { white-space:pre-wrap; } .warehouse-error, .duplicate-sku-error { margin-top:6px; color:var(--el-color-danger); font-size:12px; } :deep(.duplicate-sku-input .el-input__wrapper) { box-shadow:0 0 0 1px var(--el-color-danger) inset; } :deep(.el-dialog .el-select) { width:100%; }
.return-table-card { overflow:hidden; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; box-shadow:none; }
.return-table-card :deep(.el-table) { width:100%; }
.return-pagination { display:flex; justify-content:flex-end; padding:14px 18px; border-top:1px solid #eef1f5; }
.return-pagination :deep(.el-pagination) { margin:0; }
.return-pagination-total { color:#526176; font-size:14px; }
.return-page-size-select { width:112px; margin-left:8px; }
.return-page-size-select :deep(.el-input__inner), .return-page-size-select :deep(.el-select__selected-item) { color:#526176 !important; opacity:1 !important; -webkit-text-fill-color:#526176; }
.detail-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px 24px; padding:18px; border:1px solid #e5ebf3; border-radius:6px; background:#f8faff; }
.detail-summary div { display:flex; min-width:0; flex-direction:column; gap:6px; }
.detail-summary span { color:#7c899c; font-size:12px; }
.detail-summary strong { overflow-wrap:anywhere; color:#26364f; font-size:14px; }
.detail-remark { margin:14px 0 0; padding:10px 12px; border-left:3px solid #7aa7dc; background:#f7faff; color:#526176; }
.detail-section h3 { margin:22px 0 10px; color:#26364f; font-size:15px; }
.return-detail-dialog :deep(.el-dialog__body) { padding-top:4px; }
@media (max-width: 760px) { .return-orders-page { padding:12px; } .page-heading { align-items:stretch; flex-direction:column; } .filter-bar { grid-template-columns:1fr 1fr; } }
@media (max-width: 720px) { .detail-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .return-pagination { padding:12px; } }
.return-detail-surface { overflow:hidden; min-height:240px; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; }
.return-detail-header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:14px; min-height:64px; padding:10px 16px; border-bottom:1px solid var(--client-border); }
.return-back { display:inline-flex; align-items:center; gap:5px; padding:5px 0; border:0; background:transparent; color:var(--el-color-primary); cursor:pointer; }
.return-detail-title { display:flex; min-width:0; flex-direction:column; gap:3px; }
.return-detail-title strong { overflow:hidden; color:#1f2d45; font-size:18px; text-overflow:ellipsis; white-space:nowrap; }
.return-detail-title span { color:#7c899c; font-size:12px; }
.return-detail-section + .return-detail-section { border-top:1px solid var(--client-border); }
.return-detail-section > h3 { min-height:40px; margin:0; padding:11px 16px 9px; color:#26364f; font-size:14px; }
.return-detail-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); border-top:1px solid #edf1f5; }
.return-detail-grid div { display:flex; min-width:0; min-height:48px; align-items:center; gap:10px; padding:8px 14px; border-right:1px solid #edf1f5; border-bottom:1px solid #edf1f5; }
.return-detail-grid span,.return-detail-remark span { flex:0 0 92px; color:#8490a1; font-size:12px; }
.return-detail-grid strong,.return-detail-remark strong { min-width:0; overflow-wrap:anywhere; color:#2c3c55; font-size:13px; }
.return-detail-remark { display:flex; align-items:flex-start; gap:10px; padding:10px 14px; background:#f8fafc; }
.return-detail-section :deep(.data-table) { border:0; border-radius:0; }
@media (max-width: 760px) { .return-create-form { grid-template-columns:1fr; } }
@media (max-width: 1100px) { .return-detail-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
@media (max-width: 720px) { .return-detail-header { grid-template-columns:1fr auto; } .return-back { grid-column:1/-1; } .return-pagination { padding:12px; } }
</style>
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Eye, Plus, Printer, Search, Trash2, Upload, X } from '@lucide/vue'
import SingleLineText from '@/components/common/SingleLineText.vue'
import { ArrowLeft, Download, Plus, Printer, Trash2, Upload, X } from '@lucide/vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import type { TableAction } from '@/components/common/tableActions'
import { useAuthStore } from '@/modules/auth/public'
import {
createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer,
@@ -33,8 +36,11 @@ interface ImportedRelabelBinding extends SkuRelabelRow { templateType: RelabelTe
interface ServiceConfig { title: string; createTitle: string; numberLabel: string; emptyText: string; columns: string[] }
interface TransferCarton { cartonMark: string; customerCartonMark?: string; cartonIndex: number; totalCartons: number; status: string }
const props = defineProps<{ service: ServiceType }>()
const props = withDefaults(defineProps<{ service: ServiceType; createPage?: boolean; detailPage?: boolean; detailNo?: string }>(), { createPage: false, detailPage: false, detailNo: '' })
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const detailNumber = computed(() => props.detailNo || String(route.params.detailNo || ''))
const canCreate = computed(() => auth.hasPermission(({
labeling: 'merchant:overseas-warehouse:relabel:create',
transfer: 'merchant:overseas-warehouse:transfer:create',
@@ -52,6 +58,15 @@ const configs: Record<ServiceType, ServiceConfig> = {
relabel: { title: '退货换标', createTitle: '新建退货换标单', numberLabel: '退货换标单号', emptyText: '暂无退货换标订单', columns: ['操作', '退货换标单号', '来源 FBA 仓', '目的 FBA 仓', 'SKU 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '创建时间'] },
}
const config = computed(() => configs[props.service])
const listQueryModel = computed<QueryModel>({
get: () => ({ ...filters, createdDates: [filters.start, filters.end].filter(Boolean) }),
set: (value) => Object.assign(filters, {
number: String(value.number || ''), inboundNo: String(value.inboundNo || ''), warehouse: String(value.warehouse || ''),
destination: String(value.destination || ''), status: String(value.status || ''),
start: Array.isArray(value.createdDates) ? String(value.createdDates[0] || '') : '',
end: Array.isArray(value.createdDates) ? String(value.createdDates[1] || '') : '',
}),
})
const statusOptions = computed(() => props.service === 'labeling'
? [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }]
: props.service === 'disposal'
@@ -65,7 +80,7 @@ const statusOptions = computed(() => props.service === 'labeling'
{ label: '部分收货', value: 'PARTIALLY_RECEIVED' }, { label: '已收货', value: 'RECEIVED' },
{ label: '审核驳回', value: 'REJECTED' }]
: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已通过', value: 'APPROVED' }, { label: '待分配', value: 'PENDING_ASSIGNMENT' }, { label: '已分配', value: 'ALLOCATED' }, { label: '已出库', value: 'OUTBOUNDED' }, { label: '部分收货', value: 'PARTIALLY_RECEIVED' }, { label: '已收货', value: 'RECEIVED' }, { label: '已拒绝', value: 'REJECTED' }])
const mode = ref<PageMode>('list')
const mode = ref<PageMode>(props.detailPage ? 'detail' : props.createPage ? 'create' : 'list')
const filters = reactive({ number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '' })
const form = reactive({ customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', privateDestinationAddress: '', privateDestinationPostalCode: '', privateDestinationContact: '', privateDestinationPhone: '', address: '', contact: '', phone: '', pickupContact: '', pickupPhone: '', relabelContact: '', relabelPhone: '', deliveryMethod: '', relabelDeliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', estimatedShipAt: '', fbaShipmentNo: '', volumeCbm: 0.01 })
const transferDestinationType = ref<'NONE' | 'FBA' | 'PRIVATE'>('NONE')
@@ -111,6 +126,7 @@ interface ServiceDetail extends Record<string, unknown> {
cartons?: TransferCarton[]; pallets?: TransferPallet[]; measurementPhotos?: TransferMeasurementPhoto[]; customerConfirmedAt?: string
}
const selectedDetail = ref<ServiceDetail | null>(null)
const detailError = ref('')
const selectedRelabelDetail = ref<RelabelDetail | null>(null)
const completionPhotoUrls = reactive<Record<string, string>>({})
const completionPhotoLoading = reactive<Record<string, boolean>>({})
@@ -127,6 +143,25 @@ const operationWarehouses = ref<MerchantWarehouse[]>([])
const fbaWarehousesLoading = ref(false)
const fbaWarehousesError = ref('')
const operationWarehousesError = ref('')
const listQueryFields = computed<QueryField[]>(() => {
const fields: QueryField[] = [
{ key: 'number', label: config.value.numberLabel, type: 'input', placeholder: `请输入${config.value.numberLabel}` },
]
if (props.service !== 'labeling' && props.service !== 'relabel') fields.push({ key: 'inboundNo', label: '进仓编号', type: 'input', placeholder: '请输入进仓编号' })
fields.push({
key: 'warehouse', label: props.service === 'relabel' ? '来源 FBA 仓' : '操作仓', type: 'select',
options: (props.service === 'relabel' ? fbaWarehouses.value : operationWarehouses.value).map((warehouse) => ({ label: warehouseLabel(warehouse), value: String(warehouse.id) })),
})
if (props.service === 'transfer' || props.service === 'relabel') fields.push({ key: 'destination', label: '目的 FBA 仓', type: 'input', placeholder: '请输入仓代码' })
fields.push(
{ key: 'status', label: '订单状态', type: 'select', options: statusOptions.value },
{ key: 'createdDates', label: '创建时间', type: 'daterange' },
)
return fields
})
const listColumns = computed<DataTableColumn[]>(() => config.value.columns.map((label, index) => index === 0
? { key: 'actions', label, slotName: 'actions', width: props.service === 'transfer' ? 210 : 160, fixed: 'left' }
: { key: String(index - 1), prop: String(index - 1), label, minWidth: index === 1 ? 190 : 130 }))
const discardCandidates = ref<import('../api/warehouseServices').DiscardCandidate[]>([])
const selectedDiscardOrderId = ref('')
const discardCandidatesLoading = ref(false)
@@ -164,7 +199,15 @@ const skuTotalCreateInput = ref<HTMLInputElement | null>(null)
let cargoSequence = 0
let skuRelabelSequence = 0
watch(() => props.service, () => { mode.value = 'list'; page.value = 1; resetFilters(); resetForm(); void loadPage() })
watch(() => [props.service, props.createPage, props.detailPage, detailNumber.value] as const, () => {
mode.value = props.detailPage ? 'detail' : props.createPage ? 'create' : 'list'
page.value = 1
resetFilters()
resetForm()
if (props.createPage) void openCreate()
else if (props.detailPage && detailNumber.value) void loadDetailPage()
else void loadPage()
})
watch(mode, (value) => { if (value !== 'detail') { clearCompletionPhotos(); clearMeasurementPhotos() } })
watch(() => form.relabel, (value) => {
if (props.service === 'transfer' && value) {
@@ -193,7 +236,12 @@ async function loadPage() {
if (props.service === 'labeling' || props.service === 'relabel' || props.service === 'transfer' || props.service === 'disposal') await loadWarehouses()
await load()
}
onMounted(() => { void loadPage(); void nextTick(refreshWarehouseMarkLabels) })
onMounted(() => {
if (props.createPage) void openCreate()
else if (props.detailPage && detailNumber.value) void loadDetailPage()
else void loadPage()
void nextTick(refreshWarehouseMarkLabels)
})
onBeforeUnmount(() => { clearCompletionPhotos(); clearMeasurementPhotos() })
function resetFilters() { Object.assign(filters, { number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '' }) }
function resetForm() {
@@ -219,6 +267,15 @@ async function openCreate() {
if (props.service !== 'disposal') { addCargoRow(); return }
}
function returnToList() {
if (props.createPage || props.detailPage) {
const routeName = ({ labeling: 'overseas-labeling', transfer: 'overseas-storage-transfer', disposal: 'overseas-cargo-disposal', relabel: 'overseas-return-relabel' } as Record<ServiceType, string>)[props.service]
void router.push({ name: routeName })
return
}
mode.value = 'list'
}
async function openEdit(transferNo: string) {
try {
resetForm()
@@ -669,9 +726,20 @@ async function load() {
} catch (error) { submittedRows.value = []; total.value = 0; ElMessage.error(error instanceof Error ? error.message : '列表加载失败') }
finally { loading.value = false }
}
function query() { page.value = 1; void load() }
function changePage(value: number) { page.value = value; void load() }
function changePageSize(value: number) { pageSize.value = value; page.value = 1; void load() }
function query() { if (page.value === 1) void load(); else page.value = 1 }
function resetListQuery() { resetFilters(); query() }
function listRowActions(candidate: object): TableAction[] {
const item = candidate as Record<string, string>
const actions: TableAction[] = [{ key: 'detail', label: '详情', run: () => openDetail(item) }]
if (canCreate.value && props.service === 'transfer' && item.__status === 'DRAFT') actions.push({ key: 'edit', label: '编辑', run: () => openEdit(item['0']) })
if (canConfirmTransfer.value && props.service === 'transfer' && item.__status === 'WAIT_CUSTOMER_CONFIRMATION') actions.push({ key: 'confirm-measurement', label: '确认测量', run: () => confirmTransferFromList(item['0']) })
if (canConfirmRelabel.value && props.service === 'labeling' && item.__status === 'CUSTOMER_CONFIRM_PENDING' && !item.__confirmedAt) actions.push({ key: 'confirm-relabel', label: '确认换标', run: () => confirmRelabelFromList(item['0']) })
return actions
}
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextSize !== previousSize && nextPage !== 1) { page.value = 1; return }
void load()
})
async function submit(action: 'draft' | 'submit' = 'submit') {
const primaryWarehouse = props.service === 'relabel' ? form.sourceWarehouse : form.warehouse
const relabelRows = labelingMode.value === 'carton' ? cartonDetailRows.value : skuRelabelRows.value
@@ -751,12 +819,14 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
if (shippingLabelFile.value) await uploadRelabelShippingLabel(created.application.relabelNo, shippingLabelFile.value)
} catch (error) {
// Creation already committed: leave the creation form even if the detail reload fails.
mode.value = 'list'
await load()
await openDetail({ '0': created.application.relabelNo })
if (props.createPage) await router.push({ name: 'overseas-labeling-detail', params: { detailNo: created.application.relabelNo } })
else { mode.value = 'list'; await load(); await openDetail({ '0': created.application.relabelNo }) }
await ElMessageBox.alert(`申请 ${created.application.relabelNo} 已创建,部分附件上传失败:${error instanceof Error ? error.message : '上传失败'}。请在原单详情补传失败附件,不要重复下单。`, '申请已创建,附件待补传', { confirmButtonText: '知道了' })
return
}
ElMessage.success(`${config.value.title}提交成功`)
await router.push({ name: 'overseas-labeling-detail', params: { detailNo: created.application.relabelNo } })
return
} else if (props.service === 'disposal') {
const items = cargoRows.value.filter((item) => item.quantity > 0).map((item) => {
if (!item.sourceOrderId || item.sourceOrderId !== selectedDiscardOrderId.value || item.quantity > Number(item.availableQuantity || 0)) {
@@ -784,11 +854,19 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
return
}
}
ElMessage.success(props.service === 'transfer' ? '转运单草稿保存成功' : `${config.value.title}提交成功`); mode.value = 'list'; await load()
ElMessage.success(props.service === 'transfer' ? '转运单草稿保存成功' : `${config.value.title}提交成功`)
if (props.createPage) await router.push({ name: props.service === 'relabel' ? 'overseas-return-relabel' : 'overseas-labeling' })
else { mode.value = 'list'; await load() }
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '提交失败') }
finally { saving.value = false }
}
async function openDetail(value: Record<string, string>) {
if (['labeling', 'transfer', 'disposal'].includes(props.service) && !props.detailPage) {
const routeName = ({ labeling: 'overseas-labeling-detail', transfer: 'overseas-storage-transfer-detail', disposal: 'overseas-cargo-disposal-detail' } as Partial<Record<ServiceType, string>>)[props.service]
if (routeName) void router.push({ name: routeName, params: { detailNo: value['0'] } })
return
}
detailError.value = ''
try {
const detail = props.service === 'labeling' ? await getRelabel(value['0']) : props.service === 'disposal' ? await getDiscard(value['0']) : await getTransfer(value['0'])
const discardSummary = props.service === 'disposal' ? (detail as { summary?: ServiceDetail }).summary : undefined
@@ -797,7 +875,11 @@ async function openDetail(value: Record<string, string>) {
mode.value = 'detail'
if (props.service === 'labeling') void loadCompletionPhotos()
if (props.service === 'transfer') void loadMeasurementPhotos()
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '详情加载失败') }
} catch (error) { selectedDetail.value = null; selectedRelabelDetail.value = null; detailError.value = error instanceof Error ? error.message : '详情加载失败'; mode.value = 'detail'; ElMessage.error(detailError.value) }
}
async function loadDetailPage() {
await loadWarehouses()
await openDetail({ '0': detailNumber.value })
}
async function confirmTransferMeasurements() {
const transferNo = String(selectedDetail.value?.transferNo || selectedDetail.value?.['0'] || '')
@@ -993,36 +1075,20 @@ function selectRelabelInventory(inboundOrderId: string) {
</script>
<template>
<section class="warehouse-service-page">
<section class="warehouse-service-page" :class="{ 'warehouse-service-page--detail': mode === 'detail' }">
<template v-if="mode === 'list'">
<div class="warehouse-breadcrumb"><span>首页</span><b>/</b><strong>{{ config.title }}</strong></div>
<div class="warehouse-page-heading"><h2>{{ config.title }}</h2><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />{{ props.service === 'relabel' ? '新建退货换标单' : props.service === 'transfer' ? '新增订单' : `新增${config.title}` }}</el-button></div>
<div class="warehouse-card warehouse-filter-card">
<div class="warehouse-filter-grid">
<label><span>{{ config.numberLabel }}</span><el-input v-model="filters.number" :placeholder="`请输入${config.numberLabel}`" clearable /></label>
<label v-if="props.service !== 'labeling' && props.service !== 'relabel'"><span>进仓编号</span><el-input v-model="filters.inboundNo" placeholder="请输入进仓编号" clearable /></label>
<label><span>{{ props.service === 'relabel' ? '来源 FBA 仓' : '操作仓' }}</span><el-select v-model="filters.warehouse" clearable :loading="fbaWarehousesLoading" placeholder="全部"><el-option label="全部" value="" /><el-option v-for="warehouse in (props.service === 'relabel' ? fbaWarehouses : operationWarehouses)" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select></label>
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span>目的 FBA 仓</span><el-input v-model="filters.destination" placeholder="请输入仓代码" clearable /></label>
<label><span>订单状态</span><el-select v-model="filters.status" placeholder="全部状态" clearable><el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></label>
<label><span>创建时间</span><el-date-picker v-model="filters.start" type="date" placeholder="开始日期" value-format="YYYY-MM-DD" /></label>
<label><span>&nbsp;</span><el-date-picker v-model="filters.end" type="date" placeholder="结束日期" value-format="YYYY-MM-DD" /></label>
</div>
<div class="warehouse-filter-actions"><el-button type="primary" @click="query"><Search :size="16" />查询</el-button><el-button @click="resetFilters(); query()">重置</el-button></div>
</div>
<div v-loading="loading" class="warehouse-card warehouse-table-card">
<div v-if="props.service === 'transfer' && submittedRows.some((item) => item.__status === 'WAIT_CUSTOMER_CONFIRMATION')" class="warehouse-hint">部分入库完成订单已有 PDA 实测数据,请在对应行确认测量数据。</div>
<div class="warehouse-table-scroll"><table><thead><tr><th v-for="column in config.columns" :key="column">{{ column }}</th></tr></thead><tbody><tr v-for="(row, rowIndex) in submittedRows" :key="rowIndex"><td><div class="warehouse-list-actions"><button class="warehouse-link" type="button" @click="openDetail(row)"><Eye :size="15" />查看</button><el-button v-if="canCreate && props.service === 'transfer' && row.__status === 'DRAFT'" native-type="button" size="small" type="primary" @click="openEdit(row['0'])">编辑</el-button><el-button v-if="canConfirmTransfer && props.service === 'transfer' && row.__status === 'WAIT_CUSTOMER_CONFIRMATION'" size="small" type="primary" @click="confirmTransferFromList(row['0'])">确认测量数据</el-button><el-button v-if="canConfirmRelabel && props.service === 'labeling' && row.__status === 'CUSTOMER_CONFIRM_PENDING' && !row.__confirmedAt" size="small" type="primary" @click="confirmRelabelFromList(row['0'])">换标完成确认</el-button></div></td><td v-for="(_, index) in config.columns.slice(1)" :key="index"><SingleLineText :value="row[String(index)]" /></td></tr><tr v-if="submittedRows.length === 0"><td :colspan="config.columns.length"><div class="warehouse-empty">{{ config.emptyText }}</div></td></tr></tbody></table></div>
<div class="warehouse-pagination">
<span class="warehouse-pagination-total">共 {{ total }} 条</span>
<el-select v-model="pageSize" class="warehouse-page-size-select" aria-label="每页条数" @change="changePageSize"><el-option v-for="size in [15, 30, 50, 100]" :key="size" :label="`${size}条/页`" :value="size" /></el-select>
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" :hide-on-single-page="false" layout="prev, pager, next" @current-change="changePage" />
</div>
<QueryToolbar v-model="listQueryModel" layout="grid" :ariaLabel="`${config.title}筛选`" :fields="listQueryFields" :search-on-reset="false" @search="query" @reset="resetListQuery" />
<div class="warehouse-card warehouse-table-card">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="submittedRows" :columns="listColumns" :loading="loading" :total="total" :page-sizes="[15, 30, 50, 100]" :row-actions="listRowActions" :empty-text="config.emptyText">
<template #toolbar><strong>{{ config.title }}</strong><span>共 {{ total }} 条</span></template>
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />{{ props.service === 'relabel' ? '新建退货换标单' : props.service === 'transfer' ? '新增订单' : `新增${config.title}` }}</el-button></template>
<template v-if="props.service === 'transfer' && submittedRows.some((item) => item.__status === 'WAIT_CUSTOMER_CONFIRMATION')" #before-table><div class="warehouse-list-notice">部分入库完成订单已有 PDA 实测数据,请在对应行确认测量数据。</div></template>
</DataTable>
</div>
</template>
<template v-else-if="mode === 'create' || mode === 'edit'">
<div class="warehouse-breadcrumb"><button type="button" @click="mode = 'list'">{{ config.title }}</button><b>/</b><strong>{{ mode === 'edit' ? '编辑' : '新增' }}</strong></div>
<div class="warehouse-page-heading"><h2>{{ mode === 'edit' ? `编辑转运草稿 ${editingTransferNo}` : config.createTitle }}</h2><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div>
<div class="warehouse-page-heading"><h2>{{ mode === 'edit' ? `编辑转运草稿 ${editingTransferNo}` : config.createTitle }}</h2><el-button @click="returnToList"><ArrowLeft :size="16" />返回列表</el-button></div>
<form :class="{ 'warehouse-transfer-form': props.service === 'transfer', 'warehouse-transfer-form--relabel': props.service === 'transfer' && form.relabel }" @submit.prevent="submit('submit')">
<div class="warehouse-card warehouse-form-section warehouse-transfer-main-card"><h3>{{ props.service === 'transfer' ? '入库与转运信息' : props.service === 'relabel' ? '退货信息' : props.service === 'labeling' ? '订单信息' : '基础信息' }}</h3>
<div class="warehouse-form-grid">
@@ -1136,9 +1202,13 @@ function selectRelabelInventory(inboundOrderId: string) {
</template>
<template v-else>
<div class="warehouse-breadcrumb"><button type="button" @click="mode = 'list'">{{ config.title }}</button><b>/</b><strong>订单详情</strong></div>
<div class="warehouse-page-heading"><h2>{{ config.title }}详情</h2><div><el-button v-if="canCancelDiscard && props.service === 'disposal' && selectedDetail?.status === 'PENDING_REVIEW'" type="danger" @click="cancelSelectedDiscard">取消申请</el-button><el-button v-if="canCreate && props.service === 'transfer' && selectedDetail?.status === 'DRAFT'" type="primary" @click="openEdit(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))">编辑</el-button><el-button v-if="canConfirmTransfer && props.service === 'transfer' && selectedDetail?.status === 'WAIT_CUSTOMER_CONFIRMATION'" type="primary" @click="confirmTransferMeasurements">确认测量数据</el-button><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div></div>
<div class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div>
<header class="warehouse-detail-header">
<button type="button" class="warehouse-detail-back" @click="returnToList"><ArrowLeft :size="16" />返回列表</button>
<div class="warehouse-detail-title"><strong>{{ props.service === 'labeling' ? selectedRelabelDetail?.application.relabelNo : selectedDetail?.transferNo || selectedDetail?.['0'] || `${config.title}详情` }}</strong><span>{{ config.title }}详情</span></div>
<div class="warehouse-detail-actions"><el-button v-if="canCancelDiscard && props.service === 'disposal' && selectedDetail?.status === 'PENDING_REVIEW'" type="danger" @click="cancelSelectedDiscard">取消申请</el-button><el-button v-if="canCreate && props.service === 'transfer' && selectedDetail?.status === 'DRAFT'" type="primary" @click="openEdit(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))">编辑</el-button><el-button v-if="canConfirmTransfer && props.service === 'transfer' && selectedDetail?.status === 'WAIT_CUSTOMER_CONFIRMATION'" type="primary" @click="confirmTransferMeasurements">确认测量数据</el-button><el-tag effect="light">{{ statusLabel(props.service === 'labeling' ? selectedRelabelDetail?.application.status || '' : String(selectedDetail?.status || '')) }}</el-tag></div>
</header>
<div v-if="detailError" class="warehouse-detail-error" role="alert"><strong>{{ detailError }}</strong><el-button @click="loadDetailPage">重新加载</el-button></div>
<div v-else class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div v-if="props.service === 'labeling'" class="warehouse-detail-grid"><div><span>操作仓</span><strong>{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}</strong></div><div><span>客户参考号</span><strong>{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}</strong></div><div><span>SKU 数</span><strong>{{ selectedRelabelDetail?.application.itemCount ?? '—' }}</strong></div><div><span>总箱数</span><strong>{{ selectedRelabelDetail?.application.totalPackageCount ?? '—' }}</strong></div><div><span>贴标截止时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}</strong></div><div><span>仓库联系人</span><strong>{{ selectedRelabelDetail?.application.contactName || '—' }}</strong></div><div><span>联系电话</span><strong>{{ selectedRelabelDetail?.application.contactPhone || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}</strong></div></div><div v-else class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div>
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card">
<div class="warehouse-section-heading"><h3>货物明细</h3><el-button v-if="selectedDetail?.status !== 'DRAFT'" native-type="button" type="primary" @click="printTransferCartons(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))"><Printer :size="16" />打印箱唛</el-button></div>
<div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU</th><th>中文品名</th><th>预计箱数</th><th>客户箱唛号</th></tr></thead><tbody><tr v-for="(item, index) in selectedDetail?.items || []" :key="`${item.skuCode}-${index}`"><td>{{ item.skuCode || '—' }}</td><td>{{ item.productNameZh || '—' }}</td><td>{{ item.packageCount ?? '—' }}</td><td>{{ item.shippingMark || '—' }}</td></tr><tr v-if="!selectedDetail?.items?.length"><td colspan="4"><div class="warehouse-empty">暂无货物明细</div></td></tr></tbody></table></div>
@@ -1152,7 +1222,7 @@ function selectRelabelInventory(inboundOrderId: string) {
<div class="warehouse-section-heading"><h3>换标完成与出库</h3></div>
<div class="warehouse-detail-grid warehouse-fulfillment-grid">
<div><span>派送方式</span><strong>{{ deliveryMethodLabel(selectedRelabelDetail?.application.deliveryMethod || '') || '无需出库' }}</strong></div>
<div><span>履约状态</span><strong>{{ selectedRelabelDetail?.application.fulfillmentStatus || '—' }}</strong></div>
<div><span>履约状态</span><strong>{{ statusLabel(selectedRelabelDetail?.application.fulfillmentStatus || '') }}</strong></div>
<div><span>客户确认时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.customerConfirmedAt) }}</strong></div>
</div>
<div class="warehouse-evidence"><span>仓库完工照片</span><div v-if="selectedRelabelDetail?.completionEvidence?.photoFileIds?.length" class="warehouse-photo-grid"><div v-for="(fileId, index) in selectedRelabelDetail.completionEvidence.photoFileIds" :key="fileId" class="warehouse-photo-item"><el-image v-if="completionPhotoUrls[fileId]" :src="completionPhotoUrls[fileId]" :preview-src-list="completionPhotoPreviewUrls" :initial-index="completionPhotoPreviewUrls.indexOf(completionPhotoUrls[fileId])" fit="cover" preview-teleported /><div v-else-if="completionPhotoLoading[fileId]" class="warehouse-photo-placeholder">照片 {{ index + 1 }} 加载中…</div><button v-else class="warehouse-photo-placeholder warehouse-photo-retry" type="button" @click="loadCompletionPhoto(fileId)">{{ completionPhotoErrors[fileId] ? `照片 ${index + 1} 加载失败,点击重试` : `加载照片 ${index + 1}` }}</button></div></div><strong v-else>仓库尚未上传完工照片</strong></div>
@@ -1184,7 +1254,9 @@ function selectRelabelInventory(inboundOrderId: string) {
</template>
<style scoped>
.warehouse-service-page{color:#25324a}.warehouse-breadcrumb{display:flex;align-items:center;gap:9px;margin:0 0 14px;color:#9aa3b2;font-size:12px}.warehouse-breadcrumb b{font-weight:400}.warehouse-breadcrumb strong{color:#606b7c}.warehouse-breadcrumb button{padding:0;border:0;background:transparent;color:#4376b8;cursor:pointer}.warehouse-page-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.warehouse-page-heading h2{margin:0;color:#17233a;font-size:22px;letter-spacing:0}.warehouse-card{margin-bottom:12px;border:1px solid #e7ebf1;border-radius:4px;background:#fff;box-shadow:0 1px 3px rgba(25,42,70,.04)}.warehouse-filter-card{padding:20px 22px 16px}.warehouse-filter-grid{display:grid;grid-template-columns:repeat(4,minmax(180px,1fr));gap:16px 20px}.warehouse-filter-grid label,.warehouse-form-grid label{display:flex;min-width:0;flex-direction:column;gap:7px}.warehouse-filter-grid label>span,.warehouse-form-grid label>span{color:#596579;font-size:12px}.warehouse-filter-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}.warehouse-table-card{overflow:hidden}.warehouse-table-scroll{overflow-x:auto}table{width:100%;min-width:940px;border-collapse:collapse;font-size:12px}th{height:44px;padding:0 14px;border-bottom:1px solid #e8edf3;background:#f7f9fc;color:#4d5a6e;font-weight:650;text-align:left;white-space:nowrap}td{height:48px;padding:8px 14px;border-bottom:1px solid #eef1f5;color:#526176;white-space:nowrap}.warehouse-empty{display:grid;height:150px;color:#a0a9b7;place-items:center}.warehouse-link,.warehouse-delete{display:inline-flex;align-items:center;gap:5px;padding:4px;border:0;background:transparent;color:#2176d2;cursor:pointer}.warehouse-delete{color:#d64545}.warehouse-pagination{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:14px 18px;color:#778398;font-size:12px}.warehouse-pagination span{margin-right:8px}.warehouse-page-number{width:28px;height:28px;border:1px solid #2176d2;border-radius:3px;background:#2176d2;color:#fff}.warehouse-form-section{padding:22px}.warehouse-form-section h3{margin:0 0 18px;padding-bottom:12px;border-bottom:1px solid #edf0f4;color:#26364f;font-size:15px;letter-spacing:0}.warehouse-form-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr));gap:18px 28px}.warehouse-form-grid i{margin-right:3px;color:#e24b4b;font-style:normal}.warehouse-span-2{grid-column:1/-1}.warehouse-section-heading{display:flex;align-items:center;justify-content:space-between}.warehouse-section-heading h3{flex:1}.warehouse-edit-table td{padding:9px 10px}.warehouse-edit-table :deep(.el-input-number){width:130px}.warehouse-hint{margin:12px 0 0;color:#8792a2;font-size:12px}.warehouse-submit-bar{display:flex;justify-content:center;gap:10px;padding:12px 0 30px}.warehouse-detail-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}.warehouse-detail-grid div{display:flex;flex-direction:column;gap:7px}.warehouse-detail-grid span{color:#8a95a5;font-size:12px}.warehouse-detail-grid strong{color:#334157;font-size:13px}:deep(.el-button){border-radius:4px}:deep(.el-button span){display:inline-flex;align-items:center;gap:6px}:deep(.el-select),:deep(.el-date-editor.el-input){width:100%}@media(max-width:1100px){.warehouse-filter-grid{grid-template-columns:repeat(2,1fr)}.warehouse-detail-grid{grid-template-columns:repeat(2,1fr)}}
.warehouse-service-page{display:grid;gap:12px;color:#25324a}.warehouse-breadcrumb{display:flex;align-items:center;gap:9px;margin:0 0 14px;color:#9aa3b2;font-size:12px}.warehouse-breadcrumb b{font-weight:400}.warehouse-breadcrumb strong{color:#606b7c}.warehouse-breadcrumb button{padding:0;border:0;background:transparent;color:#4376b8;cursor:pointer}.warehouse-page-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.warehouse-page-heading h2{margin:0;color:#17233a;font-size:22px;letter-spacing:0}.warehouse-card{margin-bottom:0;border:1px solid var(--client-border);border-radius:var(--client-radius);background:#fff;box-shadow:none}.warehouse-filter-card{padding:20px 22px 16px}.warehouse-filter-grid{display:grid;grid-template-columns:repeat(4,minmax(180px,1fr));gap:16px 20px}.warehouse-filter-grid label,.warehouse-form-grid label{display:flex;min-width:0;flex-direction:column;gap:7px}.warehouse-filter-grid label>span,.warehouse-form-grid label>span{color:#596579;font-size:12px}.warehouse-filter-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}.warehouse-table-card{overflow:hidden}.warehouse-list-notice{padding:8px 12px;border-bottom:1px solid var(--client-border);background:#fff8e8;color:#8a6116;font-size:12px}.warehouse-table-scroll{overflow-x:auto}table{width:100%;min-width:940px;border-collapse:collapse;font-size:12px}th{height:44px;padding:0 14px;border-bottom:1px solid #e8edf3;background:#f7f9fc;color:#4d5a6e;font-weight:650;text-align:left;white-space:nowrap}td{height:48px;padding:8px 14px;border-bottom:1px solid #eef1f5;color:#526176;white-space:nowrap}.warehouse-empty{display:grid;height:72px;color:#a0a9b7;place-items:center}.warehouse-link,.warehouse-delete{display:inline-flex;align-items:center;gap:5px;padding:4px;border:0;background:transparent;color:#2176d2;cursor:pointer}.warehouse-delete{color:#d64545}.warehouse-pagination{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:14px 18px;color:#778398;font-size:12px}.warehouse-pagination span{margin-right:8px}.warehouse-page-number{width:28px;height:28px;border:1px solid #2176d2;border-radius:3px;background:#2176d2;color:#fff}.warehouse-form-section{padding:22px}.warehouse-form-section h3{margin:0 0 18px;padding-bottom:12px;border-bottom:1px solid #edf0f4;color:#26364f;font-size:15px;letter-spacing:0}.warehouse-form-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr));gap:18px 28px}.warehouse-form-grid i{margin-right:3px;color:#e24b4b;font-style:normal}.warehouse-span-2{grid-column:1/-1}.warehouse-section-heading{display:flex;align-items:center;justify-content:space-between}.warehouse-section-heading h3{flex:1}.warehouse-edit-table td{padding:9px 10px}.warehouse-edit-table :deep(.el-input-number){width:130px}.warehouse-hint{margin:12px 0 0;color:#8792a2;font-size:12px}.warehouse-submit-bar{display:flex;justify-content:center;gap:10px;padding:12px 0 30px}.warehouse-detail-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}.warehouse-detail-grid div{display:flex;flex-direction:column;gap:7px}.warehouse-detail-grid span{color:#8a95a5;font-size:12px}.warehouse-detail-grid strong{color:#334157;font-size:13px}:deep(.el-button){border-radius:4px}:deep(.el-button span){display:inline-flex;align-items:center;gap:6px}:deep(.el-select),:deep(.el-date-editor.el-input){width:100%}@media(max-width:1100px){.warehouse-filter-grid{grid-template-columns:repeat(2,1fr)}.warehouse-detail-grid{grid-template-columns:repeat(2,1fr)}}
.warehouse-service-page--detail{display:block;overflow:hidden;border:1px solid var(--client-border);border-radius:var(--client-radius);background:#fff}.warehouse-detail-header{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:14px;min-height:64px;padding:10px 16px;border-bottom:1px solid var(--client-border)}.warehouse-detail-back{display:inline-flex;align-items:center;gap:5px;padding:5px 0;border:0;background:transparent;color:var(--el-color-primary);cursor:pointer}.warehouse-detail-title{display:flex;min-width:0;flex-direction:column;gap:3px}.warehouse-detail-title strong{overflow:hidden;color:#1f2d45;font-size:18px;text-overflow:ellipsis;white-space:nowrap}.warehouse-detail-title span{color:#7c899c;font-size:12px}.warehouse-detail-actions{display:flex;align-items:center;gap:8px}.warehouse-service-page--detail>.warehouse-card{border:0;border-top:1px solid var(--client-border);border-radius:0}.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card{border-top:0}.warehouse-service-page--detail>.warehouse-form-section{padding:0}.warehouse-service-page--detail .warehouse-form-section h3{min-height:40px;margin:0;padding:11px 16px 9px;border-bottom:1px solid #edf1f5;font-size:14px}.warehouse-service-page--detail .warehouse-detail-grid{grid-template-columns:repeat(4,minmax(0,1fr));gap:0;margin:0}.warehouse-service-page--detail .warehouse-detail-grid>div{min-height:48px;flex-direction:row;align-items:center;gap:10px;padding:8px 14px;border-right:1px solid #edf1f5;border-bottom:1px solid #edf1f5}.warehouse-service-page--detail .warehouse-detail-grid span{flex:0 0 90px}.warehouse-service-page--detail .warehouse-detail-grid strong{min-width:0;overflow-wrap:anywhere}.warehouse-service-page--detail .warehouse-fulfillment-grid{margin-bottom:0}.warehouse-service-page--detail .warehouse-evidence{margin:12px 14px}.warehouse-service-page--detail .warehouse-completion-remark,.warehouse-service-page--detail .warehouse-hint{margin:10px 14px}.warehouse-service-page--detail .warehouse-table-scroll table{margin:0}.warehouse-service-page--detail .warehouse-empty{height:64px}@media(max-width:1100px){.warehouse-service-page--detail .warehouse-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:720px){.warehouse-detail-header{grid-template-columns:1fr auto}.warehouse-detail-back{grid-column:1/-1}.warehouse-detail-actions{justify-content:flex-end}}
.warehouse-detail-error{display:flex;min-height:220px;align-items:center;justify-content:center;gap:12px;color:#b42318}
.warehouse-checkbox-field{display:flex;min-height:32px;align-items:center;justify-content:flex-start;gap:16px}.warehouse-checkbox-field>span{color:#596579;font-size:12px}.warehouse-checkbox-field :deep(.el-checkbox){margin-right:0}
.warehouse-choice-field{display:grid;grid-template-columns:150px auto 1fr;align-items:center;gap:14px;min-height:40px;padding:12px 14px;border:1px solid #e1e8f2;border-radius:5px;background:#f8faff}.warehouse-choice-field>span{color:#34445c;font-size:13px;font-weight:650}.warehouse-choice-field>span i{margin-right:3px;color:#e24b4b;font-style:normal}.warehouse-choice-field>small{color:#7a879a;font-size:12px}.warehouse-receiving-card{display:grid;grid-template-columns:2fr 1fr 1fr;gap:22px;padding:15px 16px;border:1px solid #cfe0f5;border-radius:5px;background:#f6faff}.warehouse-receiving-card div{display:flex;min-width:0;flex-direction:column;gap:6px}.warehouse-receiving-card span{color:#7c899c;font-size:12px}.warehouse-receiving-card strong{overflow-wrap:anywhere;color:#314561;font-size:13px;font-weight:600}
.warehouse-hidden-input{display:none}.warehouse-label-table{min-width:1120px}.warehouse-attachment-tip{margin:10px 0 12px;color:#7d8797;font-size:12px}.warehouse-mark-mapping{display:flex;flex-wrap:wrap;gap:8px;margin:4px 0 8px;padding:12px;border:1px solid #dce7f5;border-radius:5px;background:#f7faff}.warehouse-mark-mapping strong{width:100%;color:#34445c}.warehouse-mark-mapping span{padding:5px 9px;border-radius:3px;background:#eaf2fc;color:#3e6593;font-size:12px}
@@ -164,8 +164,8 @@ test('贴标详情展示箱唛、SKU 子明细和独立FBA目的仓', () => {
})
test('客户在换标列表确认完工且照片使用二进制下载', () => {
assert.match(view, /row\.__status === 'CUSTOMER_CONFIRM_PENDING'/)
assert.match(view, /confirmRelabelFromList\(row\['0'\]\)/)
assert.match(view, /item\.__status === 'CUSTOMER_CONFIRM_PENDING'/)
assert.match(view, /confirmRelabelFromList\(item\['0'\]\)/)
assert.doesNotMatch(view, /selectedRelabelDetail\?\.application\.status === 'CUSTOMER_CONFIRM_PENDING'/)
assert.match(api, /overseasBlobRequest/)
})
@@ -102,7 +102,7 @@ test('仓储转运列表操作仓使用海外仓接口数据', () => {
assert.match(view, /loadAllWarehouseRecords\('OVERSEAS', getMerchantWarehouses\)/)
assert.match(view, /warehouseDisplay\(item\.operatingWarehouseId\)/)
assert.match(view, /\['OVERSEAS', 'OVERSEAS_PARTNER'\]/)
assert.match(view, /v-for="warehouse in \(props\.service === 'relabel' \? fbaWarehouses : operationWarehouses\)"/)
assert.match(view, /props\.service === 'relabel' \? fbaWarehouses\.value : operationWarehouses\.value/)
assert.doesNotMatch(view, /美西洛杉矶仓/)
})
@@ -128,8 +128,8 @@ test('客户仓储转运展示每个实际流转状态的中文名称', () => {
})
test('PDA 打托完成后客户可从列表或详情确认并查看实测数据', () => {
assert.match(view, /row\.__status === 'WAIT_CUSTOMER_CONFIRMATION'/)
assert.match(view, /confirmTransferFromList\(row\['0'\]\)/)
assert.match(view, /item\.__status === 'WAIT_CUSTOMER_CONFIRMATION'/)
assert.match(view, /confirmTransferFromList\(item\['0'\]\)/)
assert.match(view, /selectedDetail\?\.status === 'WAIT_CUSTOMER_CONFIRMATION'/)
assert.match(view, /PDA 打托实测数据/)
for (const label of ['托盘号', '库位', '箱数', '长 × 宽 × 高(cm)', '毛重(kg)', '体积(m³)', '完成时间']) assert.equal(view.includes(label), true)
@@ -155,7 +155,7 @@ test('仓储转运详情自动加载 PDA 测量照片并释放 Blob URL', () =>
})
test('PDA 完成后主单显示入库完成并保留客户确认测量入口', () => {
assert.match(view, /row\.__status === 'WAIT_CUSTOMER_CONFIRMATION'.*确认测量数据/s)
assert.match(view, /item\.__status === 'WAIT_CUSTOMER_CONFIRMATION'.*确认测量/s)
assert.match(view, /selectedDetail\?\.status === 'WAIT_CUSTOMER_CONFIRMATION'.*确认测量数据/s)
assert.doesNotMatch(view, /确认实测并入库/)
})
@@ -167,7 +167,7 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提
assert.match(api, /warehouse-transfers\/\$\{encodeURIComponent\(transferNo\)\}\/submit/)
assert.match(view, /submit\('draft'\).*保存草稿/s)
assert.match(view, /async function openEdit\(transferNo: string\)/)
assert.match(view, /row\.__status === 'DRAFT'[^>]*[\s\S]*?@click="openEdit\(row\['0'\]\)"[^>]*>编辑/)
assert.match(view, /item\.__status === 'DRAFT'.*openEdit\(item\['0'\]\)/s)
assert.match(view, /v-if="props\.service === 'transfer'"[^>]*type="primary"[^>]*native-type="submit"[^>]*>提交订单/)
assert.match(view, /await updateTransfer\(editingTransferNo\.value, payload\)/)
assert.match(view, /await submitTransfer\(transferNo\)/)