优化包裹多商品列表与明细侧窗,修复状态Tab筛选,并补充到仓页与插件确认页。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-15 12:46:06 +08:00
parent f5011edde6
commit 8e6f0d45ea
27 changed files with 1973 additions and 84 deletions

View File

@ -163,6 +163,7 @@
"useCssVar": true,
"useCssVars": true,
"useCurrentElement": true,
"useCurrentWarehouseStore": true,
"useCycleList": true,
"useDark": true,
"useDateFormat": true,
@ -294,6 +295,7 @@
"useVibrate": true,
"useVirtualList": true,
"useWakeLock": true,
"useWarehouseAdminCustomer": true,
"useWarehouseList": true,
"useWebNotification": true,
"useWebSocket": true,

View File

@ -102,6 +102,9 @@ server {
location ^~ /operation/printLabel {
try_files /index.html =200;
}
location ^~ /operation/parcelArrival {
try_files /index.html =200;
}
# ============ 订单前端路由(^~ 须比下方 /order/ API 正则代理更优先)============
location ^~ /order/priceCheck {
@ -144,6 +147,11 @@ server {
try_files /index.html =200;
}
# 采集插件确认页(/Plugin/AddPackage);API 走 /plugin/browser/*
location ^~ /Plugin/ {
try_files /index.html =200;
}
# ============ 系统管理前端路由(^~ 须比下方 /system/ API 正则代理更优先)============
# 页面路径为 camelCase;API 为 kebab(如 /system/dict/list、/system/batch-email/…),勿用宽泛前缀匹配 dict
location = /system/dict {
@ -222,7 +230,7 @@ server {
}
# ============ Admin API 同源反代(/rbac /order /xhs … → api.nanafa.com/admin)============
location ~ ^/(rbac|order|monitor|customer|recharge|channel|system|warehouse)/ {
location ~ ^/(rbac|order|monitor|customer|recharge|channel|system|warehouse|plugin)/ {
rewrite ^/(.*)$ /admin/$1 break;
proxy_pass http://api.nanafa.com;
proxy_http_version 1.1;

View File

@ -39,6 +39,7 @@ server {
location ^~ /operation/inboundScan { try_files /index.html =200; }
location ^~ /operation/outboundScan { try_files /index.html =200; }
location ^~ /operation/printLabel { try_files /index.html =200; }
location ^~ /operation/parcelArrival { try_files /index.html =200; }
# 订单前端路由(^~ 须比下方 /order/ API 正则代理更优先)
location ^~ /order/priceCheck { try_files /index.html =200; }
@ -54,6 +55,7 @@ server {
location = /warehouse/sku { try_files /index.html =200; }
location = /warehouse/binLocation { try_files /index.html =200; }
location ^~ /warehouse/inboundNotice { try_files /index.html =200; }
location ^~ /Plugin/ { try_files /index.html =200; }
location ^~ /channel/salesProducts { try_files /index.html =200; }
location ^~ /channel/serviceChannels { try_files /index.html =200; }
@ -109,7 +111,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/(rbac|order|monitor|customer|recharge|channel|system|warehouse)/ {
location ~ ^/(rbac|order|monitor|customer|recharge|channel|system|warehouse|plugin)/ {
rewrite ^/(.*)$ /admin/$1 break;
proxy_pass http://api.nanafa.com;
proxy_http_version 1.1;

24
src/api/plugin/index.ts Normal file
View File

@ -0,0 +1,24 @@
import { axiosInstance } from '@/api/rbac'
import { assertBusinessOk, omsUrl } from '@/api/oms/common'
function unwrapData<T = any>(body: any): T {
assertBusinessOk(body)
// 兼容 {code,data} 包裹,以及直接返回 JSON 字符串/对象
if (body != null && typeof body === 'object' && ('data' in body || 'Data' in body)) {
const nested = body.data ?? body.Data
if (nested !== undefined) return nested as T
}
return body as T
}
/** 读取采集草稿(key 来自插件 add) */
export async function getPluginPackage(key: string) {
const res = await axiosInstance.get(omsUrl('/plugin/browser/getPackage'), { params: { key } })
return unwrapData<string>(res.data)
}
/** 插件确认页默认仓库 */
export async function getPluginWhseInfo() {
const res = await axiosInstance.get(omsUrl('/plugin/browser/getPluginWhseInfo'))
return unwrapData<{ id: number; name: string } | null>(res.data)
}

View File

@ -25,6 +25,18 @@ export const deleteWarehouseSku = (id: number) => postOk('/warehouse/sku/delete'
export const bindWarehouseSkuMapping = (body: Record<string, unknown>) =>
postOk('/warehouse/sku/bind-mapping', body)
/** 上传 SKU 图片,返回相对路径 url */
export async function uploadWarehouseSkuImage(file: File): Promise<string> {
const form = new FormData()
form.append('file', file)
const res = await axiosInstance.post(omsUrl('/warehouse/sku/upload-image'), form)
assertBusinessOk(res.data)
const data = unwrapData<any>(res.data)
const url = data?.url ?? data?.fileUrl ?? data?.Url ?? data?.FileUrl
if (!url) throw new Error('上传成功但未返回图片地址')
return String(url)
}
// Bin location
export const getWarehouseBinLocationList = (body: Record<string, unknown>) =>
postList('/warehouse/bin-location/list', body).then((r) => ({ items: r.items, total: r.items.length }))
@ -48,6 +60,14 @@ export const saveWarehouseParcel = (body: Record<string, unknown>) => postOk('/w
export const deleteWarehouseParcel = (id: number) => postOk('/warehouse/parcel/delete', { id })
export const putawayWarehouseParcel = (body: Record<string, unknown>) => postOk('/warehouse/parcel/putaway', body)
// Parcel arrival(快递到仓)
export const getWarehouseParcelArrivalList = (body: Record<string, unknown>) =>
postList('/warehouse/parcel-arrival/list', body)
export const createWarehouseParcelArrival = (body: Record<string, unknown>) =>
postOk('/warehouse/parcel-arrival/create', body)
export const deleteWarehouseParcelArrival = (id: number) =>
postOk('/warehouse/parcel-arrival/delete', { id })
// Inventory
export const getWarehouseInventoryList = (body: Record<string, unknown>) =>
postList('/warehouse/inventory/list', body)

View File

@ -151,6 +151,7 @@ declare global {
const useCssVar: typeof import('@vueuse/core')['useCssVar']
const useCssVars: typeof import('vue')['useCssVars']
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
const useCurrentWarehouseStore: typeof import('./store/index')['useCurrentWarehouseStore']
const useCycleList: typeof import('@vueuse/core')['useCycleList']
const useDark: typeof import('@vueuse/core')['useDark']
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
@ -282,6 +283,7 @@ declare global {
const useVibrate: typeof import('@vueuse/core')['useVibrate']
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
const useWarehouseAdminCustomer: typeof import('./composables/useWarehouseAdminCustomer')['useWarehouseAdminCustomer']
const useWarehouseList: typeof import('./composables/useWarehouseList')['useWarehouseList']
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
@ -469,6 +471,7 @@ declare module 'vue' {
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useCurrentElement: UnwrapRef<typeof import('@vueuse/core')['useCurrentElement']>
readonly useCurrentWarehouseStore: UnwrapRef<typeof import('./store/index')['useCurrentWarehouseStore']>
readonly useCycleList: UnwrapRef<typeof import('@vueuse/core')['useCycleList']>
readonly useDark: UnwrapRef<typeof import('@vueuse/core')['useDark']>
readonly useDateFormat: UnwrapRef<typeof import('@vueuse/core')['useDateFormat']>
@ -600,6 +603,7 @@ declare module 'vue' {
readonly useVibrate: UnwrapRef<typeof import('@vueuse/core')['useVibrate']>
readonly useVirtualList: UnwrapRef<typeof import('@vueuse/core')['useVirtualList']>
readonly useWakeLock: UnwrapRef<typeof import('@vueuse/core')['useWakeLock']>
readonly useWarehouseAdminCustomer: UnwrapRef<typeof import('./composables/useWarehouseAdminCustomer')['useWarehouseAdminCustomer']>
readonly useWarehouseList: UnwrapRef<typeof import('./composables/useWarehouseList')['useWarehouseList']>
readonly useWebNotification: UnwrapRef<typeof import('@vueuse/core')['useWebNotification']>
readonly useWebSocket: UnwrapRef<typeof import('@vueuse/core')['useWebSocket']>

View File

@ -1,9 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { getWarehouseList } from '@/api/system/warehouse'
import { useCurrentWarehouseStore } from '@/store'
const props = withDefaults(
withDefaults(
defineProps<{
variant?: 'navbar' | 'login'
}>(),
@ -13,32 +14,24 @@ const props = withDefaults(
)
const { t } = useI18n()
const serviceWarehouseListLoading = ref(false)
const serviceWarehouseSelectData = ref<Array<{ label: string; value: number | string }>>([])
const serviceWarehouseSelect = ref<number | string | undefined>(undefined)
const warehouseStore = useCurrentWarehouseStore()
async function loadServiceDataList() {
serviceWarehouseListLoading.value = true
try {
const { items } = await getWarehouseList({
page: 1,
pageSize: 100,
})
const list = items ?? []
serviceWarehouseSelectData.value = list
const list = (items ?? [])
.filter((row) => row.id != null)
.map((row) => ({
label: row.warehouseName || String(row.id),
value: row.id as number | string,
value: Number(row.id),
}))
serviceWarehouseSelect.value = serviceWarehouseSelectData.value[0]?.value
warehouseStore.setOptions(list)
} catch (error) {
console.error('Failed to load service warehouse list', error)
serviceWarehouseSelectData.value = []
serviceWarehouseSelect.value = undefined
} finally {
serviceWarehouseListLoading.value = false
warehouseStore.setOptions([])
}
}
@ -50,15 +43,15 @@ onMounted(() => {
<template>
<div class="contact-service" :class="`contact-service--${variant}`">
<a-select
v-model="serviceWarehouseSelect"
:model-value="warehouseStore.warehouseId"
class="contact-service__select"
:loading="serviceWarehouseListLoading"
:placeholder="t('nav.contactService.selectWarehouse')"
popup-container="body"
allow-search
@change="(v: any) => warehouseStore.setWarehouseId(v)"
>
<a-option
v-for="opt in serviceWarehouseSelectData"
v-for="opt in warehouseStore.options"
:key="String(opt.value)"
:value="opt.value"
>

View File

@ -140,6 +140,8 @@
const emit = defineEmits<{
'update:selectedRows': [rows: any[]];
'action-click': [row: any, rowIndex: number, action?: string];
/** 数据列单击(非操作/复选框列) */
'cell-click': [payload: { row: any; rowIndex: number; columnKey: string; event: MouseEvent }];
'search': [];
'selection-change': [rows: any[]];
'checkbox-change': [row: any, checked: boolean, rowIndex: number];
@ -869,6 +871,8 @@
// 列定义中的渲染器
if (colDef.renderer) {
td.innerHTML = colDef.renderer(rowData, value);
// 允许单元格内角标(如「多」)不被 td overflow 裁切
td.style.overflow = 'visible';
return;
}
}
@ -985,13 +989,55 @@
rowIndex < props.data.length &&
colIndex !== checkboxColIndex &&
colIndex !== actionColStartIndex &&
colIndex !== actionColEndIndex
colIndex !== actionColEndIndex &&
colIndex !== serialColIndex
) {
const row = props.data[rowIndex];
if (row) {
emit('action-click', row, rowIndex);
}
}
// 数据列单击 → cell-click(供业务页打开明细浮层等)
if (
!isDoubleClick &&
rowIndex != null &&
rowIndex >= 0 &&
rowIndex < props.data.length &&
colIndex != null &&
colIndex !== checkboxColIndex &&
colIndex !== actionColStartIndex &&
colIndex !== actionColEndIndex &&
colIndex !== serialColIndex
) {
const target = event?.target as HTMLElement | null;
if (
target &&
(target.closest('input,button,a,select,textarea') ||
target.closest('.is-finish-switch-btn') ||
target.closest('.customer-status-switch'))
) {
return;
}
let dataColIndex = colIndex - colOffset;
if (props.actionColumnPosition === 'beforeLast' && actionColEndIndex >= 0) {
if (colIndex > actionColEndIndex) {
dataColIndex = colIndex - colOffset - 1;
}
}
if (dataColIndex >= 0 && dataColIndex < effectiveColumns.value.length) {
const colDef = effectiveColumns.value[dataColIndex];
const row = props.data[rowIndex];
if (colDef?.key && row) {
emit('cell-click', {
row,
rowIndex,
columnKey: colDef.key,
event: event as MouseEvent,
});
}
}
}
},
afterRender: function(isForced: boolean) {
if (!hotInstance) return;

View File

@ -452,6 +452,7 @@ const menuNameToLocaleKeyMap: Record<string, string> = {
'入库扫描': 'menu.operation.inboundScan',
'出库扫描': 'menu.operation.outboundScan',
'打印标签': 'menu.operation.printLabel',
'快递到仓': 'menu.operation.parcelArrival',
'API': 'menu.api',
'上游接口': 'menu.api.upstreamInterface',
'下游接口': 'menu.api.downstreamInterface',

View File

@ -261,6 +261,10 @@ export const PAGE_BUTTON_PARENT_ALIASES: Record<string, string> = {
'Warehouse.Parcel.Query': 'Warehouse.Parcel',
'Warehouse.InboundNotice.Query': 'Warehouse.InboundNotice',
'Warehouse.BinLocation.Query': 'Warehouse.BinLocation',
'Operation.InboundScan.Query': 'Operation.InboundScan',
'Operation.OutboundScan.Query': 'Operation.OutboundScan',
'Operation.PrintLabel.Query': 'Operation.PrintLabel',
'Operation.ParcelArrival.Query': 'Operation.ParcelArrival',
};
type BtnInput = { code: string; name: string };
@ -642,6 +646,11 @@ const CORE_GROUPS: PageButtonGroup[] = [
{ code: 'Reset', name: '重置' },
{ code: 'Print', name: '打印' },
]),
B('Operation.ParcelArrival', [
{ code: 'Query', name: '查询' },
{ code: 'Save', name: '到仓' },
{ code: 'Delete', name: '删除' },
]),
// ── 客户 / 用户 ──
B('User.Recharge', [

View File

@ -15,7 +15,7 @@
</template>
<script lang="ts" setup>
import { computed, watch } from 'vue';
import { computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAppStore } from '@/store';
import TopNav from '@/components/top-nav/index.vue';
@ -24,12 +24,17 @@
import usePermission from '@/hooks/permission';
import useResponsive from '@/hooks/responsive';
import PageLayout from './page-layout.vue';
import { setRapidPackageHostCookie } from '@/utils/rapidPackagePluginHost';
const appStore = useAppStore();
const router = useRouter();
const route = useRoute();
const permission = usePermission();
useResponsive(true);
onMounted(() => {
setRapidPackageHostCookie();
});
const footer = computed(() => appStore.footer);

View File

@ -117,7 +117,15 @@ export default {
'warehouse.sku.form.skuName': 'SKU Name',
'warehouse.sku.form.weightG': 'Weight(g)',
'warehouse.sku.form.remark': 'Remark',
'warehouse.sku.form.imageUrl': 'Image URL',
'warehouse.sku.form.imageUrl': 'SKU image',
'warehouse.sku.form.uploadImage': 'Upload image',
'warehouse.sku.form.reuploadImage': 'Replace image',
'warehouse.sku.form.clearImage': 'Clear image',
'warehouse.sku.message.uploadSuccess': 'Image uploaded',
'warehouse.sku.message.uploadFailed': 'Image upload failed',
'warehouse.sku.message.imageTooLarge': 'Image must be under 5MB',
'warehouse.sku.form.currentWarehouse': 'Current warehouse',
'warehouse.common.noCurrentWarehouse': 'Please select a warehouse in the top-right first',
'warehouse.sku.form.dimensions': 'Dimensions',
'warehouse.sku.form.length': 'L',
'warehouse.sku.form.width': 'W',
@ -196,6 +204,14 @@ export default {
'warehouse.parcel.status.stored': 'Stored',
'warehouse.parcel.table.warehouse': 'Warehouse',
'warehouse.parcel.table.items': 'Items',
'warehouse.parcel.table.attribute': 'Attributes',
'warehouse.parcel.table.quantity': 'Qty',
'warehouse.parcel.badge.multiTip': 'Multi-item parcel. Click Items / Attributes / Qty for details.',
'warehouse.parcel.drawer.itemsTitle': 'Item details',
'warehouse.parcel.drawer.totalQty': 'Total qty',
'warehouse.parcel.drawer.itemCount': '{n} line(s)',
'warehouse.parcel.drawer.edit': 'Edit parcel',
'warehouse.parcel.drawer.empty': 'No items',
'warehouse.parcel.table.arrivalTime': 'Arrival Time',
'warehouse.parcel.table.createTime': 'Created At',
'warehouse.parcel.table.trackingNo': 'Tracking No',
@ -207,6 +223,7 @@ export default {
'warehouse.parcel.form.trackingNo': 'Tracking No',
'warehouse.parcel.form.carrierName': 'Carrier',
'warehouse.parcel.form.productName': 'Product',
'warehouse.parcel.form.attribute': 'Attributes',
'warehouse.parcel.form.quantity': 'Qty',
'warehouse.parcel.form.remark': 'Remark',
'warehouse.parcel.form.itemRemark': 'Item remark',
@ -220,6 +237,21 @@ export default {
'warehouse.parcel.message.deleteSuccess': 'Deleted',
'warehouse.parcel.message.required': 'Warehouse and tracking no required',
'warehouse.parcel.message.itemsRequired': 'Add at least one product',
'warehouse.parcelArrival.form.trackingNo': 'Domestic tracking No.',
'warehouse.parcelArrival.form.placeholder.scanOrInput': 'Scan or enter domestic tracking number',
'warehouse.parcelArrival.search.trackingNo': 'Domestic tracking No.',
'warehouse.parcelArrival.table.trackingNo': 'Domestic tracking No.',
'warehouse.parcelArrival.table.warehouse': 'Warehouse',
'warehouse.parcelArrival.table.createTime': 'Created at',
'warehouse.parcelArrival.table.createBy': 'Created by',
'warehouse.parcelArrival.actions.reset': 'Reset',
'warehouse.parcelArrival.actions.arrive': 'Confirm arrival',
'warehouse.parcelArrival.actions.delete': 'Delete arrival record',
'warehouse.parcelArrival.message.loadFailed': 'Failed to load arrival records',
'warehouse.parcelArrival.message.trackingRequired': 'Please scan or enter the tracking number',
'warehouse.parcelArrival.message.arriveSuccess': 'Arrived. Parcel status updated to arrived',
'warehouse.parcelArrival.message.arriveFailed': 'Failed to mark arrival',
'warehouse.parcelArrival.message.deleteSuccess': 'Deleted',
'warehouse.inbound.status.all': 'All',
'warehouse.inbound.status.forecasted': 'Forecasted',
'warehouse.inbound.status.completed': 'Completed',
@ -391,6 +423,7 @@ export default {
'menu.operation.inboundScan': 'Inbound Scan',
'menu.operation.outboundScan': 'Outbound Scan',
'menu.operation.printLabel': 'Print Label',
'menu.operation.parcelArrival': 'Parcel Arrival',
'menu.api': 'API',
'menu.api.upstreamInterface': 'Upstream Interface',
'menu.api.downstreamInterface': 'Downstream Interface',

View File

@ -117,7 +117,15 @@ export default {
'warehouse.sku.form.skuName': 'SKU 名称',
'warehouse.sku.form.weightG': '重量(g)',
'warehouse.sku.form.remark': '备注',
'warehouse.sku.form.imageUrl': '图片地址',
'warehouse.sku.form.imageUrl': 'SKU 图片',
'warehouse.sku.form.uploadImage': '上传图片',
'warehouse.sku.form.reuploadImage': '重新上传',
'warehouse.sku.form.clearImage': '清除图片',
'warehouse.sku.message.uploadSuccess': '图片上传成功',
'warehouse.sku.message.uploadFailed': '图片上传失败',
'warehouse.sku.message.imageTooLarge': '图片不能超过 5MB',
'warehouse.sku.form.currentWarehouse': '当前仓库',
'warehouse.common.noCurrentWarehouse': '请先在右上角选择仓库',
'warehouse.sku.form.dimensions': '尺寸',
'warehouse.sku.form.length': '长',
'warehouse.sku.form.width': '宽',
@ -196,6 +204,14 @@ export default {
'warehouse.parcel.status.stored': '已上架',
'warehouse.parcel.table.warehouse': '仓库',
'warehouse.parcel.table.items': '商品信息',
'warehouse.parcel.table.attribute': '属性',
'warehouse.parcel.table.quantity': '数量',
'warehouse.parcel.badge.multiTip': '多商品包裹,点击商品/属性/数量可查看明细',
'warehouse.parcel.drawer.itemsTitle': '商品明细',
'warehouse.parcel.drawer.totalQty': '合计数量',
'warehouse.parcel.drawer.itemCount': '共 {n} 项',
'warehouse.parcel.drawer.edit': '编辑包裹',
'warehouse.parcel.drawer.empty': '暂无商品明细',
'warehouse.parcel.table.arrivalTime': '到仓时间',
'warehouse.parcel.table.createTime': '创建时间',
'warehouse.parcel.table.trackingNo': '快递单号',
@ -207,6 +223,7 @@ export default {
'warehouse.parcel.form.trackingNo': '快递单号',
'warehouse.parcel.form.carrierName': '快递公司',
'warehouse.parcel.form.productName': '商品名称',
'warehouse.parcel.form.attribute': '属性',
'warehouse.parcel.form.quantity': '数量',
'warehouse.parcel.form.remark': '备注',
'warehouse.parcel.form.itemRemark': '商品备注',
@ -220,6 +237,21 @@ export default {
'warehouse.parcel.message.deleteSuccess': '删除成功',
'warehouse.parcel.message.required': '请填写仓库和快递单号',
'warehouse.parcel.message.itemsRequired': '请至少填写一条商品信息',
'warehouse.parcelArrival.form.trackingNo': '国内快递单号',
'warehouse.parcelArrival.form.placeholder.scanOrInput': '扫描或输入国内快递单号',
'warehouse.parcelArrival.search.trackingNo': '国内快递单号',
'warehouse.parcelArrival.table.trackingNo': '国内快递单号',
'warehouse.parcelArrival.table.warehouse': '仓库',
'warehouse.parcelArrival.table.createTime': '创建时间',
'warehouse.parcelArrival.table.createBy': '创建人',
'warehouse.parcelArrival.actions.reset': '重置',
'warehouse.parcelArrival.actions.arrive': '确认到仓',
'warehouse.parcelArrival.actions.delete': '删除到仓记录',
'warehouse.parcelArrival.message.loadFailed': '加载到仓记录失败',
'warehouse.parcelArrival.message.trackingRequired': '请扫描或输入国内快递单号',
'warehouse.parcelArrival.message.arriveSuccess': '到仓成功,包裹状态已更新为已到仓',
'warehouse.parcelArrival.message.arriveFailed': '到仓失败',
'warehouse.parcelArrival.message.deleteSuccess': '删除成功',
'warehouse.inbound.status.all': '全部',
'warehouse.inbound.status.forecasted': '预报',
'warehouse.inbound.status.completed': '已完成',
@ -391,6 +423,7 @@ export default {
'menu.operation.inboundScan': '入库扫描',
'menu.operation.outboundScan': '出库扫描',
'menu.operation.printLabel': '打印标签',
'menu.operation.parcelArrival': '快递到仓',
'menu.api': 'API',
'menu.api.upstreamInterface': '上游接口',
'menu.api.downstreamInterface': '下游接口',

View File

@ -78,6 +78,15 @@ const router = createRouter({
hideInMenu: true,
},
},
{
path: '/Plugin/AddPackage',
name: 'PluginAddPackage',
component: () => import('@/views/plugin/AddPackage.vue'),
meta: {
requiresAuth: true,
hideInMenu: true,
},
},
...appRoutes,
REDIRECT_MAIN,
NOT_FOUND_ROUTE,

View File

@ -19,6 +19,8 @@ const OPERATION: AppRouteRecordRaw = {
meta: {
locale: 'menu.operation.inboundScan',
requiresAuth: true,
permission: 'Operation.InboundScan.Query',
order: 1,
},
},
{
@ -28,6 +30,8 @@ const OPERATION: AppRouteRecordRaw = {
meta: {
locale: 'menu.operation.outboundScan',
requiresAuth: true,
permission: 'Operation.OutboundScan.Query',
order: 2,
},
},
{
@ -37,6 +41,19 @@ const OPERATION: AppRouteRecordRaw = {
meta: {
locale: 'menu.operation.printLabel',
requiresAuth: true,
permission: 'Operation.PrintLabel.Query',
order: 3,
},
},
{
path: 'parcelArrival',
name: 'OperationParcelArrival',
component: () => import('@/views/warehouse/parcelArrival/index.vue'),
meta: {
locale: 'menu.operation.parcelArrival',
requiresAuth: true,
permission: 'Operation.ParcelArrival.Query',
order: 4,
},
},
],

View File

@ -4,8 +4,16 @@ import useUserStore from './modules/user';
import useTabBarStore from './modules/tab-bar';
import { useMessageStore } from './modules/message';
import { useAuthStore } from './modules/auth';
import useCurrentWarehouseStore from './modules/current-warehouse';
const pinia = createPinia();
export { useAppStore, useUserStore, useTabBarStore, useMessageStore, useAuthStore };
export {
useAppStore,
useUserStore,
useTabBarStore,
useMessageStore,
useAuthStore,
useCurrentWarehouseStore,
};
export default pinia;

View File

@ -0,0 +1,92 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { setRapidPackageWarehouseCookie } from '@/utils/rapidPackagePluginHost'
const STORAGE_KEY = 'OMS_CURRENT_WAREHOUSE'
export type CurrentWarehouse = {
id: number
name: string
}
function readStored(): CurrentWarehouse | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
const id = Number(parsed?.id)
if (!id) return null
return { id, name: String(parsed?.name || id) }
} catch {
return null
}
}
/**
* 顶栏右上角当前仓库(联系客服旁的选择器)。
* 仓储新增表单统一从此处带入 warehouseId。
*/
const useCurrentWarehouseStore = defineStore('currentWarehouse', () => {
const current = ref<CurrentWarehouse | null>(readStored())
const options = ref<Array<{ label: string; value: number }>>([])
const warehouseId = computed(() => current.value?.id)
const warehouseName = computed(() => current.value?.name || '')
function persist(wh: CurrentWarehouse | null) {
current.value = wh
try {
if (wh) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(wh))
} else {
localStorage.removeItem(STORAGE_KEY)
}
} catch {
/* ignore */
}
setRapidPackageWarehouseCookie(wh)
}
function setOptions(list: Array<{ label: string; value: number }>) {
options.value = list
if (current.value?.id) {
const hit = list.find((o) => o.value === current.value!.id)
if (hit && hit.label !== current.value.name) {
persist({ id: hit.value, name: hit.label })
} else if (!hit && list[0]) {
persist({ id: list[0].value, name: list[0].label })
}
return
}
if (list[0]) {
persist({ id: list[0].value, name: list[0].label })
}
}
function setWarehouseId(id: number | string | undefined | null) {
const num = Number(id)
if (!num) {
persist(null)
return
}
const hit = options.value.find((o) => o.value === num)
persist({ id: num, name: hit?.label || String(num) })
}
/** 新增表单默认仓库:顶栏当前仓 → 否则列表筛选项 */
function resolveDefaultWarehouseId(fallback?: number | null) {
return warehouseId.value || fallback || undefined
}
return {
current,
options,
warehouseId,
warehouseName,
setOptions,
setWarehouseId,
resolveDefaultWarehouseId,
}
})
export default useCurrentWarehouseStore

View File

@ -52,6 +52,12 @@ export const SUB_MENU_DEFAULT_ICONS: Record<string, string> = {
'menu.operation.inboundScan': 'IconImport',
'menu.operation.outboundScan': 'IconExport',
'menu.operation.printLabel': 'IconPrinter',
'menu.operation.parcelArrival': 'IconImport',
'menu.warehouse.parcel': 'IconGift',
'menu.warehouse.inventory': 'IconStorage',
'menu.warehouse.sku': 'IconTags',
'menu.warehouse.inboundNotice': 'IconFile',
'menu.warehouse.binLocation': 'IconLocation',
'menu.api.upstreamInterface': 'IconCloud',
'menu.api.downstreamInterface': 'IconCloudDownload',
'menu.api.createInterface': 'IconPlus',

View File

@ -0,0 +1,110 @@
/** 采集插件读取的站点根地址(与哪哪发 / WMS 约定一致) */
export const RAPID_PACKAGE_HOST_COOKIE = 'RAPID_PACKAGE_HOST'
/** 插件确认页默认仓库:JSON `{ id, name }` */
export const RAPID_PACKAGE_WAREHOUSE_COOKIE = 'RAPID_PACKAGE_WAREHOUSE'
/** 管理端代采客户:JSON `{ id, name }` */
export const RAPID_PACKAGE_CUSTOMER_COOKIE = 'RAPID_PACKAGE_CUSTOMER'
function writeCookie(name: string, value: string): void {
if (typeof document === 'undefined') return
const protocol = window.location.protocol
const isHttps = protocol === 'https:'
const parts = [
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
'path=/',
`expires=${new Date(Date.now() + 999 * 864e5).toUTCString()}`,
]
if (isHttps) {
parts.push('Secure', 'SameSite=None')
}
document.cookie = parts.join('; ')
}
function readJsonCookie(name: string): { id: number; name: string } | null {
if (typeof window === 'undefined') return null
try {
const fromLs = window.localStorage?.getItem(name)
const raw =
fromLs ||
document.cookie
.split(';')
.map((s) => s.trim())
.find((s) => s.startsWith(`${name}=`))
?.slice(name.length + 1)
if (!raw) return null
const parsed = JSON.parse(decodeURIComponent(raw))
const id = Number(parsed?.id)
if (!id) return null
return { id, name: String(parsed?.name || id) }
} catch {
return null
}
}
/**
* 写入 RAPID_PACKAGE_HOST,供浏览器扩展识别当前 OMS 域名。
*/
export function setRapidPackageHostCookie(): void {
if (typeof window === 'undefined') return
const url = `${window.location.protocol}//${window.location.host}`
try {
writeCookie(RAPID_PACKAGE_HOST_COOKIE, url)
} catch (e) {
console.warn('[rapidPackagePluginHost] cookie set failed', e)
}
try {
window.localStorage?.setItem(RAPID_PACKAGE_HOST_COOKIE, url)
} catch {
/* 隐私模式等 */
}
}
/** 写入当前选用的仓库,供确认页读取 */
export function setRapidPackageWarehouseCookie(warehouse: { id: number; name: string } | null): void {
if (typeof window === 'undefined') return
if (!warehouse?.id) {
try {
writeCookie(RAPID_PACKAGE_WAREHOUSE_COOKIE, '')
window.localStorage?.removeItem(RAPID_PACKAGE_WAREHOUSE_COOKIE)
} catch {
/* ignore */
}
return
}
const raw = JSON.stringify({ id: warehouse.id, name: warehouse.name || String(warehouse.id) })
try {
writeCookie(RAPID_PACKAGE_WAREHOUSE_COOKIE, raw)
window.localStorage?.setItem(RAPID_PACKAGE_WAREHOUSE_COOKIE, raw)
} catch (e) {
console.warn('[rapidPackagePluginHost] warehouse cookie set failed', e)
}
}
export function readRapidPackageWarehouseCookie(): { id: number; name: string } | null {
return readJsonCookie(RAPID_PACKAGE_WAREHOUSE_COOKIE)
}
/** 管理端写入当前代采客户,供插件草稿/查重携带 customerId */
export function setRapidPackageCustomerCookie(customer: { id: number; name: string } | null): void {
if (typeof window === 'undefined') return
if (!customer?.id) {
try {
writeCookie(RAPID_PACKAGE_CUSTOMER_COOKIE, '')
window.localStorage?.removeItem(RAPID_PACKAGE_CUSTOMER_COOKIE)
} catch {
/* ignore */
}
return
}
const raw = JSON.stringify({ id: customer.id, name: customer.name || String(customer.id) })
try {
writeCookie(RAPID_PACKAGE_CUSTOMER_COOKIE, raw)
window.localStorage?.setItem(RAPID_PACKAGE_CUSTOMER_COOKIE, raw)
} catch (e) {
console.warn('[rapidPackagePluginHost] customer cookie set failed', e)
}
}
export function readRapidPackageCustomerCookie(): { id: number; name: string } | null {
return readJsonCookie(RAPID_PACKAGE_CUSTOMER_COOKIE)
}

View File

@ -40,18 +40,67 @@ export function resolveWarehouseLabel(
return map.get(warehouseId) || `#${warehouseId}`
}
/** 商品信息:首个品名;多项时后缀 +N(其余条数) */
export function summarizeParcelItems(items: unknown): string {
const list = Array.isArray(items) ? items : []
if (!list.length) return '-'
const first = list[0] as Row
const name = pickField<string>(first, 'productName', 'ProductName') || '-'
const qty = Number(pickField(first, 'quantity', 'Quantity') ?? 0)
if (list.length === 1) return `${name} ×${qty}`
const totalQty = list.reduce(
(sum, item) => sum + Number(pickField(item as Row, 'quantity', 'Quantity') ?? 0),
0,
)
return `${name} 等${list.length}项 / ${totalQty}件`
if (list.length === 1) return name
return `${name} +${list.length - 1}`
}
/** 属性:只取第一条明细的属性(不加 +N) */
export function summarizeParcelAttributes(items: unknown): string {
const list = Array.isArray(items) ? items : []
if (!list.length) return '-'
const first = list[0] as Row
const text = String(pickField(first, 'attributeText', 'AttributeText') ?? '').trim()
return text || '-'
}
/** 数量:合计件数 */
export function summarizeParcelQuantity(items: unknown): string {
const list = Array.isArray(items) ? items : []
if (!list.length) return '-'
const total = list.reduce((sum, item) => {
const q = Number(pickField(item as Row, 'quantity', 'Quantity') ?? 0)
return sum + (q > 0 ? q : 1)
}, 0)
return String(total)
}
export type ParcelItemLine = {
productName: string
attributeText: string
quantity: number
remark: string
}
/** 规范化包裹明细行(列表浮层 / 编辑共用) */
export function normalizeParcelItemLines(items: unknown): ParcelItemLine[] {
const list = Array.isArray(items) ? items : []
return list.map((item) => {
const row = item as Row
const q = Number(pickField(row, 'quantity', 'Quantity') ?? 1)
return {
productName: String(pickField(row, 'productName', 'ProductName') ?? '').trim() || '-',
attributeText: String(pickField(row, 'attributeText', 'AttributeText') ?? '').trim() || '-',
quantity: q > 0 ? q : 1,
remark: String(pickField(row, 'remark', 'Remark') ?? '').trim() || '-',
}
})
}
/** 悬停提示:品名 ×数量,最多 maxLines 行 */
export function summarizeParcelItemsTooltip(items: unknown, maxLines = 5): string {
const lines = normalizeParcelItemLines(items)
if (!lines.length) return ''
const shown = lines.slice(0, maxLines).map((l) => `${l.productName} ×${l.quantity}`)
if (lines.length > maxLines) {
shown.push(`共 ${lines.length} 项`)
}
return shown.join('\n')
}
export function formatMoney(amount: unknown, currency?: string | null): string {

View File

@ -32,7 +32,9 @@ defineOptions({ name: 'PlatformStoreOrder' })
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const fulfillStatus = ref<string>()
/** Tab active-key;全部用 'all',请求时再转成不传 fulfillStatus */
const STATUS_TAB_ALL = 'all'
const fulfillStatus = ref<string>(STATUS_TAB_ALL)
const keyword = ref('')
const summary = ref<Record<string, number>>({})
const warehouseOptions = ref<Array<{ label: string; value: number }>>([])
@ -45,7 +47,7 @@ const pageSize = ref(30)
const totalCount = ref(0)
const statusTabs = [
{ value: undefined, labelKey: 'order.platformStoreOrder.status.all', countKey: null },
{ value: STATUS_TAB_ALL, labelKey: 'order.platformStoreOrder.status.all', countKey: null },
{ value: 'pending', labelKey: 'order.platformStoreOrder.status.pending', countKey: 'pending' as const },
{ value: 'toShip', labelKey: 'order.platformStoreOrder.status.toShip', countKey: 'toShip' as const },
{ value: 'shipped', labelKey: 'order.platformStoreOrder.status.shipped', countKey: 'shipped' as const },
@ -134,13 +136,19 @@ async function loadSummary() {
}
}
function resolveFulfillStatusFilter(): string | undefined {
const status = fulfillStatus.value
if (!status || status === STATUS_TAB_ALL) return undefined
return status
}
async function loadList() {
listLoading.value = true
try {
const { items, total } = await getPlatformStoreOrderList({
page: currentPage.value,
pageSize: pageSize.value,
fulfillStatus: fulfillStatus.value,
fulfillStatus: resolveFulfillStatusFilter(),
platformOrderNo: keyword.value || undefined,
})
tableRows.value = (items || []).map((item: Record<string, unknown>) => mapRow(item))
@ -152,6 +160,12 @@ async function loadList() {
}
}
function handleStatusTabChange(key: string | number) {
fulfillStatus.value = String(key)
currentPage.value = 1
loadList()
}
function handlePageChange(page: number) {
currentPage.value = page
loadList()
@ -316,9 +330,10 @@ onMounted(async () => {
<a-tabs
v-model:active-key="fulfillStatus"
class="status-tabs"
@change="() => { currentPage = 1; loadList() }"
hide-content
@change="handleStatusTabChange"
>
<a-tab-pane v-for="tab in statusTabs" :key="tab.value ?? 'all'">
<a-tab-pane v-for="tab in statusTabs" :key="tab.value">
<template #title>
{{ t(tab.labelKey) }}
<span v-if="tab.countKey" class="tab-count">({{ summary[tab.countKey] ?? 0 }})</span>

View File

@ -0,0 +1,494 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import { getPluginPackage, getPluginWhseInfo } from '@/api/plugin'
import { saveWarehouseParcel } from '@/api/warehouse'
import { getWarehouseList } from '@/api/system/warehouse'
import {
readRapidPackageCustomerCookie,
readRapidPackageWarehouseCookie,
setRapidPackageCustomerCookie,
setRapidPackageHostCookie,
setRapidPackageWarehouseCookie,
} from '@/utils/rapidPackagePluginHost'
import { isLogin } from '@/utils/auth'
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer'
defineOptions({ name: 'PluginAddPackage' })
interface LineRow {
productName: string
attributeText: string
quantity: number
remark: string
}
const route = useRoute()
const spinning = ref(true)
const saving = ref(false)
const {
isAdmin,
customerId: adminCustomerId,
customerOptions,
withCustomerId,
ensureMutationCustomerId,
} = useWarehouseAdminCustomer()
const form = reactive({
trackingNo: '',
orderNo: '',
carrierName: '',
remark: '',
warehouseId: undefined as number | undefined,
})
const warehouseOptions = ref<Array<{ label: string; value: number }>>([])
const itemRows = ref<LineRow[]>([{ productName: '', attributeText: '', quantity: 1, remark: '' }])
const canSave = computed(
() =>
!!form.warehouseId &&
!!form.trackingNo.trim() &&
itemRows.value.some((r) => r.productName.trim()) &&
(!isAdmin.value || !!Number(adminCustomerId.value)),
)
function ensureTrailingEmptyRow() {
if (!itemRows.value.some((r) => !r.productName.trim())) {
itemRows.value = [...itemRows.value, { productName: '', attributeText: '', quantity: 1, remark: '' }]
}
}
/** 解析草稿商品列表:兼容数组、JSON 字符串、URI 编码字符串 */
function parseDraftItems(raw: unknown): any[] {
let cur: unknown = raw
if (cur == null || cur === '') return []
if (Array.isArray(cur)) return cur
if (typeof cur === 'string') {
const s = cur.trim()
if (!s) return []
const candidates = [s]
try {
candidates.push(decodeURIComponent(s))
} catch {
/* ignore */
}
try {
candidates.push(decodeURI(s))
} catch {
/* ignore */
}
for (const part of candidates) {
try {
const parsed = JSON.parse(part)
if (Array.isArray(parsed)) return parsed
if (parsed && typeof parsed === 'object') {
const nested = (parsed as any).items ?? (parsed as any).Items ?? (parsed as any).files
if (Array.isArray(nested)) return nested
return [parsed]
}
} catch {
/* try next */
}
}
return []
}
if (typeof cur === 'object') {
const obj = cur as any
if (Array.isArray(obj.items)) return obj.items
if (Array.isArray(obj.Items)) return obj.Items
if (Array.isArray(obj.files)) return obj.files
}
return []
}
function applyDraft(raw: unknown) {
let data: any = raw
if (typeof raw === 'string') {
try {
data = JSON.parse(raw)
} catch {
try {
data = JSON.parse(decodeURIComponent(raw))
} catch {
Message.warning('草稿解析失败')
return
}
}
}
// 偶发双层包裹:{ data: {...} } / { Data: "..." }
if (data && typeof data === 'object' && (data.data != null || data.Data != null)) {
const nested = data.data ?? data.Data
if (typeof nested === 'string') {
try {
data = JSON.parse(nested)
} catch {
data = nested
}
} else if (nested && typeof nested === 'object') {
data = nested
}
}
form.trackingNo = String(data?.track_no || data?.trackingNo || '').trim()
form.orderNo = String(data?.order_no || data?.orderNo || '').trim()
let carrierRaw = String(data?.express_name || data?.carrierName || '').trim()
if (carrierRaw.includes('%')) {
try {
carrierRaw = decodeURIComponent(carrierRaw)
} catch {
/* keep original */
}
}
form.carrierName = carrierRaw
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/(快递|速递)$/g, '')
.trim()
// 备注只填订单号
if (!form.remark && form.orderNo) {
form.remark = form.orderNo
}
const items = parseDraftItems(data?.items ?? data?.Items ?? data?.files)
itemRows.value = items.length
? items.map((it: any) => ({
productName: String(it?.name || it?.productName || it?.title || '').trim(),
attributeText: [it?.attributes, it?.spec, it?.attributeText]
.filter(Boolean)
.join(' ')
.trim(),
quantity: Number(it?.quantity ?? it?.productNumber) > 0
? Number(it?.quantity ?? it?.productNumber)
: 1,
remark: String(it?.remark || '').trim(),
}))
: [{ productName: '', attributeText: '', quantity: 1, remark: '' }]
ensureTrailingEmptyRow()
}
async function loadWarehouses() {
try {
const res = await getWarehouseList({ page: 1, pageSize: 100 })
const items = (res as any)?.items ?? (res as any)?.Items ?? []
warehouseOptions.value = items.map((w: any) => ({
label: String(w.warehouseName ?? w.WarehouseName ?? w.name ?? w.Id),
value: Number(w.id ?? w.Id),
}))
} catch {
warehouseOptions.value = []
}
}
async function resolveWarehouse() {
const cookieWh = readRapidPackageWarehouseCookie()
if (cookieWh?.id) {
form.warehouseId = cookieWh.id
if (!warehouseOptions.value.some((o) => o.value === cookieWh.id)) {
warehouseOptions.value = [
{ label: cookieWh.name, value: cookieWh.id },
...warehouseOptions.value,
]
}
return
}
try {
const wh = await getPluginWhseInfo()
if (wh?.id) {
form.warehouseId = wh.id
setRapidPackageWarehouseCookie({ id: wh.id, name: wh.name })
}
} catch {
/* ignore */
}
if (!form.warehouseId && warehouseOptions.value[0]) {
form.warehouseId = warehouseOptions.value[0].value
}
}
async function loadDraftByKey(key: string) {
const k = String(key || '').trim()
if (!k) return
const data = await getPluginPackage(k)
applyDraft(data)
}
function handleMessage(event: MessageEvent) {
const payload = event.data?.data ?? event.data
if (!payload || typeof payload !== 'object') return
if (payload.code === 'addPackage' && payload.package) {
spinning.value = true
loadDraftByKey(String(payload.package))
.catch((e: any) => Message.error(e?.message || '加载草稿失败'))
.finally(() => {
spinning.value = false
})
}
if (payload.code === 'clearPackage') {
form.trackingNo = ''
form.orderNo = ''
form.carrierName = ''
form.remark = ''
itemRows.value = [{ productName: '', attributeText: '', quantity: 1, remark: '' }]
}
}
function notifyParentClosed(saved: boolean) {
const msg = {
action: saved ? 'closed' : 'closedWin',
code: saved ? 'packageSaved' : 'packageClosed',
}
try {
if (window.opener && !window.opener.closed) {
window.opener.postMessage(msg, '*')
}
} catch {
/* ignore */
}
try {
// 页面内弹层:iframe → 购物页父窗口
if (window.parent && window.parent !== window) {
window.parent.postMessage(msg, '*')
}
} catch {
/* ignore */
}
try {
window.close()
} catch {
/* ignore */
}
}
async function resolveCustomer() {
if (!isAdmin.value) return
const cookieCu = readRapidPackageCustomerCookie()
if (cookieCu?.id) {
adminCustomerId.value = cookieCu.id
if (!customerOptions.value.some((o) => o.value === cookieCu.id)) {
customerOptions.value = [
{ label: cookieCu.name, value: cookieCu.id },
...customerOptions.value,
]
}
}
}
async function handleSave() {
if (!isLogin()) {
Message.warning('请先登录 OMS')
window.location.href = '/'
return
}
if (isAdmin.value && ensureMutationCustomerId() == null) {
Message.warning('管理端请先选择客户')
return
}
if (!canSave.value) {
Message.warning('请填写快递单号、仓库与至少一个商品')
return
}
const items = itemRows.value
.filter((r) => r.productName.trim())
.map((r) => ({
productName: r.productName.trim(),
attributeText: r.attributeText.trim() || undefined,
quantity: Number(r.quantity) || 1,
remark: r.remark || undefined,
}))
saving.value = true
try {
const selected = warehouseOptions.value.find((o) => o.value === form.warehouseId)
if (selected) {
setRapidPackageWarehouseCookie({ id: selected.value, name: selected.label })
}
if (isAdmin.value && adminCustomerId.value) {
const cu = customerOptions.value.find((o) => o.value === Number(adminCustomerId.value))
setRapidPackageCustomerCookie({
id: Number(adminCustomerId.value),
name: cu?.label || String(adminCustomerId.value),
})
}
await saveWarehouseParcel(
withCustomerId({
id: 0,
warehouseId: form.warehouseId!,
trackingNo: form.trackingNo.trim(),
carrierName: form.carrierName.trim() || undefined,
lifecycleStatus: 'inTransit',
remark: form.remark.trim() || undefined,
items,
}),
)
Message.success('包裹已保存')
setTimeout(() => notifyParentClosed(true), 400)
} catch (e: any) {
Message.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(async () => {
setRapidPackageHostCookie()
window.addEventListener('message', handleMessage)
if (!isLogin()) {
Message.warning('未登录:请先打开本站登录后再使用采集插件')
spinning.value = false
return
}
try {
await loadWarehouses()
await resolveWarehouse()
await resolveCustomer()
const key = String(route.query.key || '')
if (key) {
await loadDraftByKey(key)
}
} catch (e: any) {
Message.error(e?.message || '初始化失败')
} finally {
spinning.value = false
}
})
onBeforeUnmount(() => {
window.removeEventListener('message', handleMessage)
})
</script>
<template>
<div class="plugin-add-package">
<a-spin :loading="spinning" tip="加载草稿…">
<header class="plugin-add-package__header">
<h1>添加包裹</h1>
<p>来自淘宝 / 1688 / 拼多多的采集草稿,确认后写入「我的包裹」</p>
</header>
<a-form layout="vertical" class="plugin-add-package__form">
<a-card title="基本信息" :bordered="true" size="small">
<a-row :gutter="16">
<a-col v-if="isAdmin" :span="24">
<a-form-item label="客户" required>
<a-select
v-model="adminCustomerId"
allow-search
:options="customerOptions"
placeholder="管理端请选择客户后再保存"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="快递单号" required>
<a-input v-model="form.trackingNo" allow-clear placeholder="快递单号" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="仓库" required>
<a-select
v-model="form.warehouseId"
allow-search
:options="warehouseOptions"
placeholder="选择仓库"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="快递公司">
<a-input v-model="form.carrierName" allow-clear placeholder="可选" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="平台订单号">
<a-input :model-value="form.orderNo" disabled placeholder="—" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="备注">
<a-input v-model="form.remark" allow-clear :max-length="500" />
</a-form-item>
</a-col>
</a-row>
</a-card>
<a-card title="商品明细" :bordered="true" size="small" style="margin-top: 12px">
<div v-for="(row, idx) in itemRows" :key="idx" class="plugin-add-package__line">
<a-input
v-model="row.productName"
placeholder="品名"
style="flex: 1"
@input="ensureTrailingEmptyRow"
/>
<a-input v-model="row.attributeText" placeholder="属性" style="width: 180px" />
<a-input-number v-model="row.quantity" :min="1" style="width: 100px" />
<a-input v-model="row.remark" placeholder="备注" style="width: 140px" />
<a-button
v-if="row.productName"
type="text"
status="danger"
@click="itemRows.splice(idx, 1)"
>
删除
</a-button>
</div>
</a-card>
</a-form>
<footer class="plugin-add-package__footer">
<a-space>
<a-button @click="notifyParentClosed(false)">关闭</a-button>
<a-button type="primary" :loading="saving" :disabled="!canSave" @click="handleSave">
保存包裹
</a-button>
</a-space>
</footer>
</a-spin>
</div>
</template>
<style scoped lang="less">
.plugin-add-package {
min-height: 100vh;
padding: 20px 24px 80px;
background: #f0f3f4;
box-sizing: border-box;
&__header {
margin-bottom: 16px;
h1 {
margin: 0 0 6px;
font-size: 20px;
color: #1d2129;
}
p {
margin: 0;
color: #86909c;
font-size: 13px;
}
}
&__form {
max-width: 960px;
margin: 0 auto;
}
&__line {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
&__footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 12px 24px;
background: #fff;
border-top: 1px solid #e5e6eb;
display: flex;
justify-content: flex-end;
}
}
</style>

View File

@ -29,6 +29,7 @@ import {
} from '@/utils/warehouseListMapper'
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer'
import { useCurrentWarehouseStore } from '@/store'
defineOptions({ name: 'WarehouseInboundNotice' })
@ -74,8 +75,11 @@ const {
ensureMutationCustomerId,
resolveSelectedCustomerId,
} = useWarehouseAdminCustomer()
const currentWarehouseStore = useCurrentWarehouseStore()
const receiptStatus = ref<string>()
/** Tab active-key;全部用 'all',请求时再转成不传 receiptStatus */
const STATUS_TAB_ALL = 'all'
const receiptStatus = ref<string>(STATUS_TAB_ALL)
const summary = ref({ forecasted: 0, received: 0, completed: 0 })
const keyword = ref('')
const warehouseId = ref<number>()
@ -91,7 +95,7 @@ const selectedBoxKeys = ref<string[]>([])
const activeBoxKey = ref('')
const statusTabs = [
{ value: undefined, labelKey: 'warehouse.inbound.status.all', countKey: null },
{ value: STATUS_TAB_ALL, labelKey: 'warehouse.inbound.status.all', countKey: null },
{ value: 'forecasted', labelKey: 'warehouse.inbound.status.forecasted', countKey: 'forecasted' as const },
{ value: 'received', labelKey: 'warehouse.inbound.status.received', countKey: 'received' as const },
{ value: 'completed', labelKey: 'warehouse.inbound.status.completed', countKey: 'completed' as const },
@ -267,13 +271,19 @@ async function loadSummary() {
}
}
function resolveReceiptStatusFilter(): string | undefined {
const status = receiptStatus.value
if (!status || status === STATUS_TAB_ALL) return undefined
return status
}
async function loadList() {
listLoading.value = true
try {
const { items, total } = await getWarehouseInboundList(withCustomerId({
page: currentPage.value,
pageSize: pageSize.value,
receiptStatus: receiptStatus.value,
receiptStatus: resolveReceiptStatusFilter(),
keyword: keyword.value || undefined,
warehouseId: warehouseId.value,
}))
@ -286,6 +296,12 @@ async function loadList() {
}
}
function handleStatusTabChange(key: string | number) {
receiptStatus.value = String(key)
currentPage.value = 1
loadList()
}
function handlePageChange(page: number) {
currentPage.value = page
loadList()
@ -428,11 +444,15 @@ function handleAdd() {
if (!hasPermission('Warehouse.InboundNotice.Save')) return
if (isAdmin.value && ensureMutationCustomerId() == null) return
formModel.id = 0
formModel.warehouseId = warehouseId.value
formModel.warehouseId = currentWarehouseStore.resolveDefaultWarehouseId(warehouseId.value)
formModel.markReference = ''
formModel.remark = ''
formModel.inboundType = 'normal'
resetBoxes()
if (!formModel.warehouseId) {
$baseMessage(t('warehouse.common.noCurrentWarehouse'), 'warning')
return
}
openModal(true)
}
@ -536,9 +556,10 @@ onMounted(async () => {
<a-tabs
v-model:active-key="receiptStatus"
class="status-tabs"
@change="() => { currentPage = 1; loadList() }"
hide-content
@change="handleStatusTabChange"
>
<a-tab-pane v-for="tab in statusTabs" :key="tab.value ?? 'all'">
<a-tab-pane v-for="tab in statusTabs" :key="tab.value">
<template #title>
{{ t(tab.labelKey) }}
<span v-if="tab.countKey" class="tab-count">({{ summary[tab.countKey] }})</span>
@ -692,8 +713,16 @@ onMounted(async () => {
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 16 }"
>
<a-form-item :label="t('warehouse.common.warehouse')" required>
<a-select v-model="formModel.warehouseId" :options="warehouseOptions" allow-search />
<a-form-item :label="t('warehouse.parcel.form.currentWarehouse')" required>
<span class="wh-form-readonly">
{{
modalWarehouseName
|| warehouseOptions.find((o) => o.value === formModel.warehouseId)?.label
|| currentWarehouseStore.warehouseName
|| formModel.warehouseId
|| '—'
}}
</span>
</a-form-item>
<a-form-item :label="t('warehouse.inbound.form.markReference')">
<a-input v-model="formModel.markReference" :placeholder="t('warehouse.inbound.form.markPlaceholder')" allow-clear />
@ -791,15 +820,6 @@ onMounted(async () => {
</div>
</a-tab-pane>
</a-tabs>
<a-alert
v-if="formModel.warehouseId && modalWarehouseName"
type="info"
:show-icon="true"
class="wh-form-divider"
>
{{ t('warehouse.common.warehouse') }}:{{ modalWarehouseName }}
</a-alert>
</div>
<template #footer>

View File

@ -15,17 +15,25 @@ import {
buildWarehouseNameMap,
formatDateTime,
normalizeListRow,
normalizeParcelItemLines,
pickField,
resolveWarehouseLabel,
summarizeParcelAttributes,
summarizeParcelItems,
summarizeParcelItemsTooltip,
summarizeParcelQuantity,
type ParcelItemLine,
} from '@/utils/warehouseListMapper'
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer'
import { setRapidPackageHostCookie, setRapidPackageWarehouseCookie, setRapidPackageCustomerCookie } from '@/utils/rapidPackagePluginHost'
import { useCurrentWarehouseStore } from '@/store'
defineOptions({ name: 'WarehouseParcel' })
interface ParcelItemRow {
productName: string
attributeText: string
quantity: number
remark: string
}
@ -39,8 +47,11 @@ const {
withCustomerId,
ensureMutationCustomerId,
} = useWarehouseAdminCustomer()
const currentWarehouseStore = useCurrentWarehouseStore()
const lifecycleStatus = ref<string>()
/** Tab active-key;全部用 'all',请求时再转成不传 lifecycleStatus */
const STATUS_TAB_ALL = 'all'
const lifecycleStatus = ref<string>(STATUS_TAB_ALL)
const keyword = ref('')
const warehouseId = ref<number>()
const warehouseOptions = ref<Array<{ label: string; value: number }>>([])
@ -52,7 +63,7 @@ const pageSize = ref(30)
const totalCount = ref(0)
const statusTabs = [
{ value: undefined, labelKey: 'warehouse.parcel.status.all' },
{ value: STATUS_TAB_ALL, labelKey: 'warehouse.parcel.status.all' },
{ value: 'inTransit', labelKey: 'warehouse.parcel.status.inTransit' },
{ value: 'arrived', labelKey: 'warehouse.parcel.status.arrived' },
{ value: 'stored', labelKey: 'warehouse.parcel.status.stored' },
@ -68,34 +79,142 @@ const formModel = reactive({
lifecycleStatus: 'inTransit',
remark: '',
})
const itemRows = ref<ParcelItemRow[]>([{ productName: '', quantity: 1, remark: '' }])
const itemRows = ref<ParcelItemRow[]>([{ productName: '', attributeText: '', quantity: 1, remark: '' }])
const filledItemCount = computed(() => itemRows.value.filter((r) => r.productName.trim()).length)
const DETAIL_COLUMN_KEYS = new Set(['itemsSummary', 'attributeSummary', 'quantitySummary'])
const detailVisible = ref(false)
const detailRow = ref<any>(null)
const detailItems = computed<ParcelItemLine[]>(() => {
const raw = detailRow.value?._raw || detailRow.value
return normalizeParcelItemLines(pickField(raw || {}, 'items', 'Items'))
})
const detailTrackingNo = computed(() => {
const raw = detailRow.value?._raw || detailRow.value
return String(pickField(raw || {}, 'trackingNo', 'TrackingNo') ?? detailRow.value?.trackingNo ?? '-')
})
const detailQuantityTotal = computed(() =>
detailItems.value.reduce((sum, item) => sum + (Number(item.quantity) || 0), 0),
)
function escapeHtml(s: string) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function getRowItemCount(row: any): number {
if (typeof row?._itemCount === 'number') return row._itemCount
const items = pickField(row?._raw || {}, 'items', 'Items')
return Array.isArray(items) ? items.length : 0
}
function isMultiItemRow(row: any): boolean {
return getRowItemCount(row) > 1
}
/** 快递单号:多商品时右上角红色「多」标识 */
function renderTrackingNoCell(row: any, value: any) {
const text = value == null || value === '' ? '-' : String(value)
if (!isMultiItemRow(row)) {
return escapeHtml(text)
}
const tip = t('warehouse.parcel.badge.multiTip')
return (
`<div class="parcel-tracking-cell" style="position:relative;padding:2px 14px 2px 0;min-height:18px;box-sizing:border-box;">` +
`<span>${escapeHtml(text)}</span>` +
`<span class="parcel-multi-badge" title="${escapeHtml(tip)}" ` +
`style="position:absolute;top:0;right:0;z-index:1;display:inline-flex;align-items:center;justify-content:center;` +
`min-width:16px;height:16px;padding:0 3px;border-radius:3px;background:#f53f3f;color:#fff;` +
`font-size:10px;font-weight:700;line-height:1;letter-spacing:0;">多</span>` +
`</div>`
)
}
/** 商品/属性/数量:仅多商品可点、主色;单商品普通文本 */
function renderDetailCell(row: any, value: any) {
const text = value == null || value === '' ? '-' : String(value)
if (!isMultiItemRow(row)) {
return escapeHtml(text)
}
const tip = summarizeParcelItemsTooltip(pickField(row?._raw || {}, 'items', 'Items'))
const titleAttr = tip ? ` title="${escapeHtml(tip)}"` : ''
return `<span class="parcel-detail-cell"${titleAttr} style="cursor:pointer;color:var(--app-primary-color);">${escapeHtml(text)}</span>`
}
const hotColumns = computed(() => [
{ key: 'trackingNo', label: t('warehouse.parcel.table.trackingNo'), width: 160 },
{
key: 'trackingNo',
label: t('warehouse.parcel.table.trackingNo'),
width: 168,
renderer: renderTrackingNoCell,
},
{ key: 'carrierName', label: t('warehouse.parcel.table.carrierName'), width: 120 },
{ key: 'lifecycleStatus', label: t('warehouse.parcel.table.lifecycleStatus'), width: 100 },
{ key: 'warehouseName', label: t('warehouse.parcel.table.warehouse'), width: 140 },
{ key: 'itemsSummary', label: t('warehouse.parcel.table.items'), width: 200 },
{
key: 'itemsSummary',
label: t('warehouse.parcel.table.items'),
width: 200,
renderer: renderDetailCell,
},
{
key: 'attributeSummary',
label: t('warehouse.parcel.table.attribute'),
width: 180,
renderer: renderDetailCell,
},
{
key: 'quantitySummary',
label: t('warehouse.parcel.table.quantity'),
width: 100,
renderer: renderDetailCell,
},
{ key: 'remark', label: t('warehouse.parcel.table.remark'), width: 140 },
{ key: 'arrivalTime', label: t('warehouse.parcel.table.arrivalTime'), width: 160 },
{ key: 'createTime', label: t('warehouse.parcel.table.createTime'), width: 160 },
{ key: 'remark', label: t('warehouse.parcel.table.remark'), width: 140 },
])
function openItemDetail(row: any) {
if (!isMultiItemRow(row)) return
detailRow.value = row
detailVisible.value = true
}
function closeItemDetail() {
detailVisible.value = false
detailRow.value = null
}
function handleCellClick(payload: { row: any; rowIndex: number; columnKey: string }) {
if (!DETAIL_COLUMN_KEYS.has(payload.columnKey)) return
if (!isMultiItemRow(payload.row)) return
openItemDetail(payload.row)
}
function handleEditFromDetail() {
const row = detailRow.value
if (!row) return
closeItemDetail()
handleEdit(row)
}
watch(
itemRows,
(rows) => {
const last = rows[rows.length - 1]
if (last && last.productName.trim()) {
itemRows.value = [...rows, { productName: '', quantity: 1, remark: '' }]
itemRows.value = [...rows, { productName: '', attributeText: '', quantity: 1, remark: '' }]
}
},
{ deep: true },
)
function resetItems() {
itemRows.value = [{ productName: '', quantity: 1, remark: '' }]
itemRows.value = [{ productName: '', attributeText: '', quantity: 1, remark: '' }]
}
function removeItemRow(index: number) {
@ -107,18 +226,23 @@ function mapRow(raw: Record<string, unknown>) {
const row = normalizeListRow(raw)
const status = String(pickField(row, 'lifecycleStatus', 'LifecycleStatus') ?? '')
const whId = pickField<number>(row, 'warehouseId', 'WarehouseId')
const items = pickField(row, 'items', 'Items')
const itemCount = Array.isArray(items) ? items.length : 0
return {
id: pickField<number>(row, 'id', 'Id'),
trackingNo: pickField(row, 'trackingNo', 'TrackingNo') ?? '-',
carrierName: pickField(row, 'carrierName', 'CarrierName') ?? '-',
lifecycleStatus: t(`warehouse.parcel.status.${status}`, status),
warehouseName: resolveWarehouseLabel(whId, warehouseNameMap.value),
itemsSummary: summarizeParcelItems(pickField(row, 'items', 'Items')),
itemsSummary: summarizeParcelItems(items),
attributeSummary: summarizeParcelAttributes(items),
quantitySummary: summarizeParcelQuantity(items),
arrivalTime: formatDateTime(pickField(row, 'arrivalTime', 'ArrivalTime')),
createTime: formatDateTime(pickField(row, 'createTime', 'CreateTime')),
remark: pickField(row, 'remark', 'Remark') ?? '',
_raw: row,
_status: status,
_itemCount: itemCount,
}
}
@ -134,13 +258,38 @@ async function loadWarehouses() {
}
}
watch(warehouseId, (id) => {
const opt = warehouseOptions.value.find((o) => o.value === id)
setRapidPackageWarehouseCookie(opt ? { id: opt.value, name: opt.label } : null)
})
watch(adminCustomerId, (id) => {
if (!isAdmin.value) {
setRapidPackageCustomerCookie(null)
return
}
const num = Number(id)
if (!num) {
setRapidPackageCustomerCookie(null)
return
}
const opt = customerOptions.value.find((o) => o.value === num)
setRapidPackageCustomerCookie({ id: num, name: opt?.label || String(num) })
})
function resolveLifecycleStatusFilter(): string | undefined {
const status = lifecycleStatus.value
if (!status || status === STATUS_TAB_ALL) return undefined
return status
}
async function loadList() {
listLoading.value = true
try {
const { items, total } = await getWarehouseParcelList(withCustomerId({
page: currentPage.value,
pageSize: pageSize.value,
lifecycleStatus: lifecycleStatus.value,
lifecycleStatus: resolveLifecycleStatusFilter(),
keyword: keyword.value || undefined,
warehouseId: warehouseId.value,
}))
@ -155,6 +304,12 @@ async function loadList() {
}
}
function handleStatusTabChange(key: string | number) {
lifecycleStatus.value = String(key)
currentPage.value = 1
loadList()
}
function handlePageChange(page: number) {
currentPage.value = page
loadList()
@ -175,12 +330,16 @@ function handleAdd() {
if (!hasPermission('Warehouse.Parcel.Save')) return
if (isAdmin.value && ensureMutationCustomerId() == null) return
formModel.id = 0
formModel.warehouseId = warehouseId.value
formModel.warehouseId = currentWarehouseStore.resolveDefaultWarehouseId(warehouseId.value)
formModel.trackingNo = ''
formModel.carrierName = ''
formModel.lifecycleStatus = 'inTransit'
formModel.remark = ''
resetItems()
if (!formModel.warehouseId) {
$baseMessage(t('warehouse.common.noCurrentWarehouse'), 'warning')
return
}
openModal(true)
}
@ -198,11 +357,12 @@ function handleEdit(row: any) {
if (Array.isArray(items) && items.length) {
itemRows.value = items.map((item: any) => ({
productName: String(pickField(item, 'productName', 'ProductName') ?? ''),
attributeText: String(pickField(item, 'attributeText', 'AttributeText') ?? ''),
quantity: Number(pickField(item, 'quantity', 'Quantity') ?? 1) || 1,
remark: String(pickField(item, 'remark', 'Remark') ?? ''),
}))
if (!itemRows.value.some((r) => !r.productName.trim())) {
itemRows.value = [...itemRows.value, { productName: '', quantity: 1, remark: '' }]
itemRows.value = [...itemRows.value, { productName: '', attributeText: '', quantity: 1, remark: '' }]
}
} else {
resetItems()
@ -220,6 +380,7 @@ async function handleSave() {
.filter((row) => row.productName.trim())
.map((row) => ({
productName: row.productName.trim(),
attributeText: row.attributeText.trim() || undefined,
quantity: Number(row.quantity) || 1,
remark: row.remark || undefined,
}))
@ -271,6 +432,7 @@ function handleActionClick(row: any, _i: number, action?: string) {
}
onMounted(async () => {
setRapidPackageHostCookie()
await loadWarehouses()
await loadList()
})
@ -282,9 +444,10 @@ onMounted(async () => {
<a-tabs
v-model:active-key="lifecycleStatus"
class="status-tabs"
@change="() => { currentPage = 1; loadList() }"
hide-content
@change="handleStatusTabChange"
>
<a-tab-pane v-for="tab in statusTabs" :key="tab.value ?? 'all'" :title="t(tab.labelKey)" />
<a-tab-pane v-for="tab in statusTabs" :key="tab.value" :title="t(tab.labelKey)" />
</a-tabs>
<div class="table-toolbar">
<div class="toolbar-left">
@ -341,10 +504,71 @@ onMounted(async () => {
:action-column-width="90"
:action-renderer="actionRenderer"
@action-click="handleActionClick"
@cell-click="handleCellClick"
/>
</div>
</div>
<a-drawer
v-model:visible="detailVisible"
:title="t('warehouse.parcel.drawer.itemsTitle')"
:width="840"
unmount-on-close
@cancel="closeItemDetail"
>
<div class="parcel-detail-drawer">
<div class="parcel-detail-drawer__meta">
<span>{{ t('warehouse.parcel.table.trackingNo') }}:{{ detailTrackingNo }}</span>
<span>
{{ t('warehouse.parcel.drawer.totalQty') }}:{{ detailQuantityTotal }}
· {{ t('warehouse.parcel.drawer.itemCount', { n: detailItems.length }) }}
</span>
</div>
<a-table
:data="detailItems"
:pagination="false"
size="small"
:bordered="{ cell: true }"
:scroll="{ x: '100%' }"
>
<template #columns>
<a-table-column :title="'#'" :width="48">
<template #cell="{ rowIndex }">{{ rowIndex + 1 }}</template>
</a-table-column>
<a-table-column :title="t('warehouse.parcel.form.productName')" data-index="productName" />
<a-table-column
:title="t('warehouse.parcel.form.attribute')"
data-index="attributeText"
:width="140"
/>
<a-table-column
:title="t('warehouse.parcel.form.quantity')"
data-index="quantity"
:width="80"
/>
<a-table-column
:title="t('warehouse.parcel.form.itemRemark')"
data-index="remark"
:width="120"
/>
</template>
</a-table>
<a-empty v-if="!detailItems.length" :description="t('warehouse.parcel.drawer.empty')" />
</div>
<template #footer>
<a-space :size="8">
<a-button @click="closeItemDetail">{{ t('warehouse.common.cancel') }}</a-button>
<a-button
v-if="hasPermission('Warehouse.Parcel.Save') && detailRow?._status !== 'stored'"
type="primary"
@click="handleEditFromDetail"
>
{{ t('warehouse.parcel.drawer.edit') }}
</a-button>
</a-space>
</template>
</a-drawer>
<ModalPro
:title="formModel.id ? t('warehouse.common.edit') : t('warehouse.parcel.modal.add')"
:width="720"
@ -360,7 +584,17 @@ onMounted(async () => {
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 16 }"
>
<a-form-item :label="t('warehouse.common.warehouse')" required>
<a-form-item v-if="!formModel.id" :label="t('warehouse.parcel.form.currentWarehouse')" required>
<span class="wh-form-readonly">
{{
warehouseOptions.find((o) => o.value === formModel.warehouseId)?.label
|| currentWarehouseStore.warehouseName
|| formModel.warehouseId
|| '—'
}}
</span>
</a-form-item>
<a-form-item v-else :label="t('warehouse.common.warehouse')" required>
<a-select v-model="formModel.warehouseId" :options="warehouseOptions" allow-search />
</a-form-item>
<a-form-item :label="t('warehouse.parcel.form.trackingNo')" required>
@ -396,6 +630,11 @@ onMounted(async () => {
<a-input v-model="record.productName" allow-clear />
</template>
</a-table-column>
<a-table-column :title="t('warehouse.parcel.form.attribute')" :width="160">
<template #cell="{ record }">
<a-input v-model="record.attributeText" allow-clear />
</template>
</a-table-column>
<a-table-column :title="t('warehouse.parcel.form.quantity')" :width="110">
<template #cell="{ record }">
<a-input-number v-model="record.quantity" :min="1" style="width: 100%" />
@ -440,4 +679,15 @@ onMounted(async () => {
<style scoped lang="less">
@import '@/styles/warehouse-list-page.less';
@import '@/styles/warehouse-form-modal.less';
.parcel-detail-drawer {
&__meta {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
margin-bottom: 12px;
font-size: 13px;
color: #4e5969;
}
}
</style>

View File

@ -0,0 +1,497 @@
<template>
<div class="waybill-container">
<div class="waybill-layout">
<div class="form-section">
<div class="form-section-toolbar">
<a-checkbox v-model="keepScanFocus" size="small">
{{ $t('operation.common.keepScanFocus') }}
</a-checkbox>
<a-checkbox v-model="voiceEnabled" size="small">
{{ $t('operation.common.voiceBroadcast') }}
</a-checkbox>
</div>
<a-form :model="formData" layout="vertical" class="waybill-form">
<a-form-item
v-if="isAdmin"
:label="t('warehouse.common.customer')"
class="form-item-large"
>
<a-select
v-model="adminCustomerId"
allow-clear
allow-search
:placeholder="t('warehouse.common.selectCustomer')"
:options="customerOptions"
size="large"
class="large-input"
/>
</a-form-item>
<a-form-item :label="t('warehouse.parcelArrival.form.trackingNo')" class="form-item-large">
<a-input
ref="scanInputRef"
v-model="formData.trackingNo"
:placeholder="t('warehouse.parcelArrival.form.placeholder.scanOrInput')"
size="large"
class="large-input"
@press-enter="() => void handleSubmit()"
/>
</a-form-item>
<a-form-item :label="t('warehouse.common.warehouse')" class="form-item-large">
<a-input
:model-value="currentWarehouseLabel"
readonly
class="large-input readonly-field"
:placeholder="t('warehouse.common.noCurrentWarehouse')"
/>
</a-form-item>
<a-form-item>
<div class="form-actions">
<a-button class="reset-btn" @click="handleReset">
{{ t('warehouse.parcelArrival.actions.reset') }}
</a-button>
<a-button
v-if="hasPermission('Operation.ParcelArrival.Save')"
type="primary"
class="submit-btn"
:loading="submitting"
@click="handleSubmit"
>
{{ t('warehouse.parcelArrival.actions.arrive') }}
</a-button>
</div>
</a-form-item>
</a-form>
</div>
<div class="recent-orders-section">
<div class="list-toolbar">
<div class="toolbar-left">
<a-space wrap :size="8">
<a-input
v-model="keyword"
allow-clear
:placeholder="t('warehouse.parcelArrival.search.trackingNo')"
style="width: 200px"
@press-enter="handleSearch"
/>
<a-button type="primary" @click="handleSearch">
{{ t('warehouse.common.search') }}
</a-button>
</a-space>
<p
v-if="feedbackMessage"
class="scan-feedback-text"
:class="`is-${feedbackType}`"
>
{{ feedbackMessage }}
</p>
</div>
<div class="toolbar-right">
<div class="pagination-controls">
<PaginationPro
:total-count="totalCount"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</div>
</div>
</div>
<div ref="tableContainerRef" class="list-wrap">
<HandsontablePro
:columns="hotColumns"
:data="tableRows"
:total="totalCount"
:current-page="currentPage"
:page-size="pageSize"
:height="tableHeight"
:loading="listLoading"
:read-only="true"
:stretch-columns="true"
show-action-column
:action-column-width="72"
:action-renderer="actionRenderer"
@action-click="handleActionClick"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Modal } from '@arco-design/web-vue'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { useOperationScanFocus } from '@/composables/useOperationScanFocus'
import { useOperationScanFeedback } from '@/composables/useOperationScanFeedback'
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer'
import {
createWarehouseParcelArrival,
deleteWarehouseParcelArrival,
getWarehouseParcelArrivalList,
} from '@/api/warehouse'
import { useCurrentWarehouseStore } from '@/store'
import { hasPermission } from '@/utils/rbac-permission'
import { formatDateTime, normalizeListRow, pickField } from '@/utils/warehouseListMapper'
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
defineOptions({ name: 'WarehouseParcelArrival' })
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const scanInputRef = ref<{ focus?: () => void } | null>(null)
const { keepScanFocus, maybeRefocusScan } = useOperationScanFocus('parcelArrival', scanInputRef)
const {
voiceEnabled,
feedbackType,
feedbackMessage,
clearFeedback,
showError,
showSuccess,
showWarning,
} = useOperationScanFeedback('parcelArrival')
const {
isAdmin,
customerId: adminCustomerId,
customerOptions,
withCustomerId,
ensureMutationCustomerId,
} = useWarehouseAdminCustomer()
const currentWarehouseStore = useCurrentWarehouseStore()
const submitting = ref(false)
const listLoading = ref(false)
const keyword = ref('')
const tableRows = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(30)
const totalCount = ref(0)
const formData = reactive({
trackingNo: '',
})
const currentWarehouseLabel = computed(() => {
const id = currentWarehouseStore.warehouseId
if (!id) return ''
return currentWarehouseStore.warehouseName || String(id)
})
const hotColumns = computed(() => [
{ key: 'trackingNo', label: t('warehouse.parcelArrival.table.trackingNo'), width: 200 },
{ key: 'warehouseName', label: t('warehouse.parcelArrival.table.warehouse'), width: 160 },
{ key: 'createTime', label: t('warehouse.parcelArrival.table.createTime'), width: 180 },
{ key: 'createBy', label: t('warehouse.parcelArrival.table.createBy'), width: 120 },
])
function mapRow(raw: Record<string, unknown>) {
const row = normalizeListRow(raw)
return {
id: pickField<number>(row, 'id', 'Id'),
trackingNo: pickField(row, 'trackingNo', 'TrackingNo') ?? '-',
warehouseName: pickField(row, 'warehouseName', 'WarehouseName') ?? '-',
createTime: formatDateTime(pickField(row, 'createTime', 'CreateTime')),
createBy: pickField(row, 'createBy', 'CreateBy') ?? '-',
_raw: row,
}
}
async function loadList() {
listLoading.value = true
try {
const { items, total } = await getWarehouseParcelArrivalList(
withCustomerId({
page: currentPage.value,
pageSize: pageSize.value,
keyword: keyword.value || undefined,
warehouseId: currentWarehouseStore.warehouseId,
}),
)
tableRows.value = (items || []).map((item: Record<string, unknown>) => mapRow(item))
totalCount.value = Number(total || 0)
} catch (e: any) {
showError(e?.message || t('warehouse.parcelArrival.message.loadFailed'))
tableRows.value = []
totalCount.value = 0
} finally {
listLoading.value = false
}
}
function handlePageChange(page: number) {
currentPage.value = page
loadList()
}
function handlePageSizeChange(size: number) {
pageSize.value = size
currentPage.value = 1
loadList()
}
function handleSearch() {
currentPage.value = 1
loadList()
}
function handleReset(clearStatus = true) {
formData.trackingNo = ''
if (clearStatus) clearFeedback()
maybeRefocusScan()
}
async function handleSubmit() {
if (!hasPermission('Operation.ParcelArrival.Save')) return
const trackingNo = formData.trackingNo?.trim()
if (!trackingNo) {
showWarning(t('warehouse.parcelArrival.message.trackingRequired'))
maybeRefocusScan()
return
}
const warehouseId = currentWarehouseStore.warehouseId
if (!warehouseId) {
showWarning(t('warehouse.common.noCurrentWarehouse'))
maybeRefocusScan()
return
}
if (isAdmin.value && ensureMutationCustomerId() == null) {
showWarning(t('warehouse.common.selectCustomerRequired'))
maybeRefocusScan()
return
}
submitting.value = true
try {
await createWarehouseParcelArrival(
withCustomerId({
warehouseId,
trackingNo,
}),
)
showSuccess(t('warehouse.parcelArrival.message.arriveSuccess'))
handleReset(false)
currentPage.value = 1
await loadList()
maybeRefocusScan()
} catch (e: any) {
showError(e?.message || t('warehouse.parcelArrival.message.arriveFailed'))
maybeRefocusScan()
} finally {
submitting.value = false
}
}
function handleDelete(row: any) {
if (!hasPermission('Operation.ParcelArrival.Delete')) return
Modal.confirm({
title: t('warehouse.parcelArrival.actions.delete'),
content: row.trackingNo,
onOk: async () => {
await deleteWarehouseParcelArrival(row.id)
showSuccess(t('warehouse.parcelArrival.message.deleteSuccess'))
await loadList()
},
})
}
function actionRenderer(row: any) {
if (!hasPermission('Operation.ParcelArrival.Delete') || !row?.id) return ''
return wrapWarehouseActions([
renderWarehouseActionBtn('delete', t('warehouse.common.delete')),
])
}
function handleActionClick(row: any, _i: number, action?: string) {
if (action === 'delete') handleDelete(row)
}
watch(
() => currentWarehouseStore.warehouseId,
() => {
currentPage.value = 1
loadList()
},
)
onMounted(() => {
loadList()
})
</script>
<style lang="less" scoped>
.waybill-container {
padding: 16px;
background-color: #f0f3f4;
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.waybill-layout {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 16px;
flex: 1;
min-height: 0;
min-width: 0;
}
.form-section {
background: #fff;
border-radius: 4px;
padding: 24px;
margin-bottom: 0;
flex: 3 1 0;
min-width: 0;
overflow-x: hidden;
overflow-y: auto;
align-self: stretch;
}
.form-section-toolbar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 16px;
margin: -4px 0 12px;
flex-shrink: 0;
}
.waybill-form {
.form-item-large {
:deep(.arco-form-item-label) {
font-size: 14px;
font-weight: 500;
color: #262626;
}
}
.large-input {
width: 100%;
max-width: 100%;
height: 48px;
font-size: 16px;
}
.readonly-field {
:deep(.arco-input) {
background-color: #f7f8fa;
color: #4e5969;
cursor: default;
}
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 8px;
.reset-btn,
.submit-btn {
min-width: 100px;
height: 32px;
border-radius: 4px;
}
}
}
.recent-orders-section {
background: #fff;
border-radius: 4px;
padding: 0;
flex: 7 1 0;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
.list-toolbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px 16px 0;
margin-bottom: 16px;
flex-shrink: 0;
flex-wrap: nowrap;
gap: 12px;
.toolbar-left {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.scan-feedback-text {
margin: 0;
font-size: 16px;
line-height: 1.5;
word-break: break-word;
&.is-error {
color: #f53f3f;
font-weight: 700;
}
&.is-success {
color: #00b42a;
font-weight: 600;
}
&.is-warning {
color: #d46b08;
font-weight: 600;
}
}
.toolbar-right .pagination-controls {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
flex-wrap: nowrap;
}
}
.list-wrap {
width: 100%;
flex: 1;
min-height: 0;
padding: 0 16px 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
touch-action: pan-x pan-y;
}
}
@media (max-width: 992px) {
.waybill-layout {
flex-direction: column;
overflow-y: auto;
}
.form-section {
flex: 0 0 auto;
max-width: none;
width: 100%;
min-width: 0;
}
.recent-orders-section {
flex: 1;
min-height: 360px;
}
}
</style>

View File

@ -7,8 +7,14 @@ import PaginationPro from '@/components/PaginationPro/index.vue'
import ModalPro from '@/components/ModalPro/index.vue'
import useModal from '@/components/ModalPro/useModal'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { deleteWarehouseSku, getWarehouseSkuList, saveWarehouseSku } from '@/api/warehouse'
import {
deleteWarehouseSku,
getWarehouseSkuList,
saveWarehouseSku,
uploadWarehouseSkuImage,
} from '@/api/warehouse'
import { getWarehouseList } from '@/api/system/warehouse'
import { resolveOmsAssetUrl } from '@/api/oms/common'
import { $baseMessage } from '@/utils'
import { hasPermission } from '@/utils/rbac-permission'
import {
@ -19,6 +25,7 @@ import {
} from '@/utils/warehouseListMapper'
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer'
import { useCurrentWarehouseStore } from '@/store'
defineOptions({ name: 'WarehouseSku' })
@ -31,6 +38,7 @@ const {
withCustomerId,
ensureMutationCustomerId,
} = useWarehouseAdminCustomer()
const currentWarehouseStore = useCurrentWarehouseStore()
const listLoading = ref(false)
const tableRows = ref<any[]>([])
@ -40,6 +48,8 @@ const totalCount = ref(0)
const keyword = ref('')
const warehouseOptions = ref<Array<{ label: string; value: number }>>([])
const selectedWarehouseId = ref<number>()
const imageUploading = ref(false)
const skuImageFileList = ref<any[]>([])
const [registerFormModal, { openModal, closeModal }] = useModal() as any
const saving = ref(false)
@ -67,6 +77,17 @@ const skuVolumeText = computed(() => {
return `${(l * w * h).toFixed(2)} cm³`
})
const skuImagePreview = computed(() => resolveOmsAssetUrl(formModel.skuImageUrl))
const currentWarehouseLabel = computed(() => {
const id = currentWarehouseStore.resolveDefaultWarehouseId(selectedWarehouseId.value)
return (
warehouseOptions.value.find((o) => o.value === id)?.label
|| currentWarehouseStore.warehouseName
|| (id ? String(id) : '')
)
})
const hotColumns = computed(() => [
{ key: 'skuCode', label: t('warehouse.sku.table.skuCode'), width: 140 },
{ key: 'skuName', label: t('warehouse.sku.table.skuName'), width: 180 },
@ -87,6 +108,7 @@ function resetForm() {
formModel.widthCm = undefined
formModel.heightCm = undefined
formModel.remark = ''
skuImageFileList.value = []
}
function changeSkuCode() {
@ -99,6 +121,51 @@ function clearSkuSize() {
formModel.heightCm = undefined
}
function clearSkuImage() {
formModel.skuImageUrl = ''
skuImageFileList.value = []
}
function syncImageFileList() {
if (!formModel.skuImageUrl) {
skuImageFileList.value = []
return
}
skuImageFileList.value = [
{
uid: '-1',
name: 'sku-image',
url: skuImagePreview.value,
},
]
}
async function handleSkuImageUpload(options: any) {
const file = options?.fileItem?.file ?? options?.file
if (!file) {
options?.onError?.(new Error('no file'))
return
}
if ((file as File).size > 5 * 1024 * 1024) {
$baseMessage(t('warehouse.sku.message.imageTooLarge'), 'warning')
options?.onError?.(new Error('too large'))
return
}
imageUploading.value = true
try {
const url = await uploadWarehouseSkuImage(file as File)
formModel.skuImageUrl = url
syncImageFileList()
options?.onSuccess?.(url)
$baseMessage(t('warehouse.sku.message.uploadSuccess'), 'success')
} catch (e: any) {
options?.onError?.(e)
$baseMessage(e?.message || t('warehouse.sku.message.uploadFailed'), 'error')
} finally {
imageUploading.value = false
}
}
function mapRow(raw: Record<string, unknown>) {
const row = normalizeListRow(raw)
return {
@ -164,6 +231,10 @@ function handleSearch() {
function handleAdd() {
if (!hasPermission('Warehouse.Sku.Save')) return
if (isAdmin.value && ensureMutationCustomerId() == null) return
if (!currentWarehouseStore.resolveDefaultWarehouseId(selectedWarehouseId.value)) {
$baseMessage(t('warehouse.common.noCurrentWarehouse'), 'warning')
return
}
resetForm()
openModal(true)
}
@ -181,6 +252,7 @@ function handleEdit(row: any) {
formModel.widthCm = pickField<number>(raw, 'widthCm', 'WidthCm')
formModel.heightCm = pickField<number>(raw, 'heightCm', 'HeightCm')
formModel.remark = String(pickField(raw, 'remark', 'Remark') ?? '')
syncImageFileList()
openModal(true)
}
@ -322,6 +394,9 @@ onMounted(async () => {
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 16 }"
>
<a-form-item :label="t('warehouse.sku.form.currentWarehouse')">
<span class="wh-form-readonly">{{ currentWarehouseLabel || '—' }}</span>
</a-form-item>
<a-form-item :label="t('warehouse.sku.form.skuCode')" required>
<a-input
v-if="!formModel.id"
@ -337,11 +412,33 @@ onMounted(async () => {
<a-form-item :label="t('warehouse.sku.form.imageUrl')">
<div class="wh-form-sku-preview">
<div class="wh-form-sku-preview__image">
<img v-if="formModel.skuImageUrl" :src="formModel.skuImageUrl" alt="sku" />
<img v-if="skuImagePreview" :src="skuImagePreview" alt="sku" />
<span v-else class="wh-form-hint">—</span>
</div>
<div class="wh-form-sku-preview__fields">
<a-input v-model="formModel.skuImageUrl" allow-clear />
<a-upload
:file-list="skuImageFileList"
:custom-request="handleSkuImageUpload"
:show-file-list="false"
accept="image/jpeg,image/png,image/webp,image/gif,image/bmp"
:disabled="imageUploading"
>
<a-button type="outline" :loading="imageUploading">
{{
formModel.skuImageUrl
? t('warehouse.sku.form.reuploadImage')
: t('warehouse.sku.form.uploadImage')
}}
</a-button>
</a-upload>
<a-button
v-if="formModel.skuImageUrl"
type="text"
status="danger"
@click="clearSkuImage"
>
{{ t('warehouse.sku.form.clearImage') }}
</a-button>
</div>
</div>
</a-form-item>
@ -367,13 +464,28 @@ onMounted(async () => {
<a-form-item :label="t('warehouse.sku.form.dimensions')">
<div>
<div class="wh-form-inline-size">
<a-input-number v-model="formModel.lengthCm" :min="0" style="width: 110px" :placeholder="t('warehouse.sku.form.length')">
<a-input-number
v-model="formModel.lengthCm"
:min="0"
style="width: 110px"
:placeholder="t('warehouse.sku.form.length')"
>
<template #suffix>cm</template>
</a-input-number>
<a-input-number v-model="formModel.widthCm" :min="0" style="width: 110px" :placeholder="t('warehouse.sku.form.width')">
<a-input-number
v-model="formModel.widthCm"
:min="0"
style="width: 110px"
:placeholder="t('warehouse.sku.form.width')"
>
<template #suffix>cm</template>
</a-input-number>
<a-input-number v-model="formModel.heightCm" :min="0" style="width: 110px" :placeholder="t('warehouse.sku.form.height')">
<a-input-number
v-model="formModel.heightCm"
:min="0"
style="width: 110px"
:placeholder="t('warehouse.sku.form.height')"
>
<template #suffix>cm</template>
</a-input-number>
<a-button type="text" @click="clearSkuSize">{{ t('warehouse.sku.form.clearSize') }}</a-button>

View File

@ -77,7 +77,30 @@ export default defineConfig(({ mode }) => {
if (warehouseSpaExact.some((p) => path === p || path === `${p}/`)) {
return '/index.html';
}
if (path === '/warehouse/inboundNotice' || path.startsWith('/warehouse/inboundNotice/')) {
// inboundNotice 页面无更深 API 子路径冲突;入库 API 为 /warehouse/inbound/*
if (path === '/warehouse/inboundNotice' || path === '/warehouse/inboundNotice/') {
return '/index.html';
}
},
},
'/plugin': {
target: 'http://api.nanafa.com/admin',
changeOrigin: true,
secure: false,
bypass(req) {
const path = req.url?.split('?')[0] ?? '';
if (path === '/Plugin/AddPackage' || path.startsWith('/Plugin/AddPackage/')) {
return '/index.html';
}
},
},
'/Plugin': {
target: 'http://api.nanafa.com/admin',
changeOrigin: true,
secure: false,
bypass(req) {
const path = req.url?.split('?')[0] ?? '';
if (path === '/Plugin/AddPackage' || path.startsWith('/Plugin/AddPackage/')) {
return '/index.html';
}
},
@ -129,6 +152,7 @@ export default defineConfig(({ mode }) => {
'/operation/inboundScan',
'/operation/outboundScan',
'/operation/printLabel',
'/operation/parcelArrival',
];
const isSpaPage = operationSpaPrefixes.some(
(p) => path === p || path.startsWith(`${p}/`),
@ -267,8 +291,19 @@ export default defineConfig(({ mode }) => {
{
name: 'exclude-frontend-routes-from-proxy',
configureServer(server) {
// 仅精确匹配的 SPA 路径(页面 URL 与后端 API 前缀相同,不能用「前缀匹配」,否则 /system/dict/list 会返回 index.html)
const frontendRoutesExact = ['/system/dict', '/system/warehouseAddress', '/system/announcement'];
// 仅精确匹配的 SPA 路径(页面 URL 与后端 API 前缀相同,不能用「前缀匹配」,否则 /warehouse/parcel/list 会返回 index.html)
const frontendRoutesExact = [
'/system/dict',
'/system/warehouseAddress',
'/system/announcement',
// 仓储页面路径与 API 同段,必须精确匹配(/warehouse/parcel/list 走代理)
'/warehouse/parcel',
'/warehouse/inventory',
'/warehouse/sku',
'/warehouse/inboundNotice',
'/warehouse/binLocation',
'/Plugin/AddPackage',
];
// 前端路由路径列表(这些路径不应该被代理,应该走前端路由)
// 所有前端路由都使用驼峰命名,以这些前缀开头的路径都是前端路由
@ -304,12 +339,6 @@ export default defineConfig(({ mode }) => {
'/order/batchJobs',
'/order/platformStore',
'/order/platformStoreOrder',
// 仓储前端路由(页面路径与 API 子路径相同时用精确匹配,见 /warehouse 代理 bypass)
'/warehouse/parcel',
'/warehouse/inventory',
'/warehouse/sku',
'/warehouse/inboundNotice',
'/warehouse/binLocation',
// Xhs 相关前端路由(勿用 /xhs 前缀,否则 API 会被误判为 SPA)
'/xhs/startService',
'/xhs/analysis',
@ -333,6 +362,7 @@ export default defineConfig(({ mode }) => {
'/operation/inboundScan',
'/operation/outboundScan',
'/operation/printLabel',
'/operation/parcelArrival',
// System 相关前端路由(与 router system-management 子路径一致,避免刷新被 /system 代理到后端 404)
'/system/users',
'/system/roles',