优化仓储与店铺新增弹窗布局,修复 SKU 表单编译错误。

统一 OMS 表单样式与交互,补齐 i18n 文案。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-11 18:25:48 +08:00
parent 87b73589bc
commit fb1e566a21
9 changed files with 1299 additions and 163 deletions

View File

@ -113,6 +113,17 @@ 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.dimensions': 'Dimensions',
'warehouse.sku.form.length': 'L',
'warehouse.sku.form.width': 'W',
'warehouse.sku.form.height': 'H',
'warehouse.sku.form.clearSize': 'Clear size',
'warehouse.sku.form.volumeEstimate': 'Volume estimate',
'warehouse.modal.noWarehouse': 'No warehouse selected',
'warehouse.modal.section.basic': 'Basic Info',
'warehouse.modal.section.weightSize': 'Weight & Size',
'warehouse.modal.section.products': 'Products',
'warehouse.sku.modal.add': 'Add SKU',
'warehouse.sku.modal.edit': 'Edit SKU',
'warehouse.sku.actions.delete': 'Delete SKU',
@ -137,14 +148,16 @@ export default {
'warehouse.inventory.actions.relocate': 'Relocate',
'warehouse.inventory.drawer.binDetail': 'Bin Detail',
'warehouse.inventory.modal.relocate': 'Relocate',
'warehouse.inventory.form.sourceBin': 'Source Bin ID',
'warehouse.inventory.form.targetBin': 'Target Bin ID',
'warehouse.inventory.form.sourceBin': 'Source Bin',
'warehouse.inventory.form.targetBin': 'Target Bin',
'warehouse.inventory.form.qty': 'Qty',
'warehouse.inventory.form.selectBin': 'Select bin',
'warehouse.inventory.message.loadFailed': 'Failed to load inventory',
'warehouse.inventory.message.binDetailFailed': 'Failed to load bin detail',
'warehouse.inventory.message.relocateRequired': 'Fill relocate fields',
'warehouse.inventory.message.relocateSuccess': 'Relocated',
'warehouse.inventory.message.relocateFailed': 'Relocate failed',
'warehouse.inventory.message.sameBin': 'Source and target bin must differ',
'warehouse.parcel.status.all': 'All',
'warehouse.parcel.status.inTransit': 'In Transit',
'warehouse.parcel.status.arrived': 'Arrived',
@ -163,6 +176,10 @@ export default {
'warehouse.parcel.form.carrierName': 'Carrier',
'warehouse.parcel.form.productName': 'Product',
'warehouse.parcel.form.quantity': 'Qty',
'warehouse.parcel.form.remark': 'Remark',
'warehouse.parcel.form.itemRemark': 'Item remark',
'warehouse.parcel.form.currentWarehouse': 'Current warehouse',
'warehouse.parcel.form.autoAddRow': 'A new row is added after you fill product name',
'warehouse.parcel.modal.add': 'Add Parcel',
'warehouse.parcel.actions.delete': 'Delete Parcel',
'warehouse.parcel.message.loadFailed': 'Failed to load parcels',
@ -170,6 +187,7 @@ export default {
'warehouse.parcel.message.saveFailed': 'Save failed',
'warehouse.parcel.message.deleteSuccess': 'Deleted',
'warehouse.parcel.message.required': 'Warehouse and tracking no required',
'warehouse.parcel.message.itemsRequired': 'Add at least one product',
'warehouse.inbound.status.all': 'All',
'warehouse.inbound.status.forecasted': 'Forecasted',
'warehouse.inbound.status.completed': 'Completed',
@ -190,6 +208,20 @@ export default {
'warehouse.inbound.form.markBoxNo': 'Box No',
'warehouse.inbound.form.sku': 'SKU',
'warehouse.inbound.form.plannedQty': 'Planned Qty',
'warehouse.inbound.form.markPlaceholder': 'Usually the tracking number',
'warehouse.inbound.form.boxSkuDetail': 'Boxes & SKU lines',
'warehouse.inbound.form.boxCount': 'Boxes {n}',
'warehouse.inbound.form.skuCount': 'SKU {n}',
'warehouse.inbound.form.totalQty': 'Total qty {n}',
'warehouse.inbound.form.boxWeight': 'Weight/g',
'warehouse.inbound.form.boxLength': 'L/cm',
'warehouse.inbound.form.boxWidth': 'W/cm',
'warehouse.inbound.form.boxHeight': 'H/cm',
'warehouse.inbound.form.selectSku': 'Select SKU',
'warehouse.inbound.actions.addBox': 'Add box',
'warehouse.inbound.actions.addBox5': 'Add 5 boxes',
'warehouse.inbound.actions.removeBox': 'Remove boxes',
'warehouse.inbound.actions.addSku': 'Add SKU line',
'warehouse.inbound.modal.add': 'Add Inbound',
'warehouse.inbound.drawer.detail': 'Inbound Detail',
'warehouse.inbound.box': 'Box',
@ -201,14 +233,22 @@ export default {
'warehouse.inbound.message.saveSuccess': 'Saved',
'warehouse.inbound.message.saveFailed': 'Save failed',
'warehouse.inbound.message.deleteSuccess': 'Deleted',
'warehouse.inbound.message.required': 'Select warehouse and SKU',
'warehouse.inbound.message.required': 'Select warehouse',
'warehouse.inbound.message.boxRequired': 'Add SKU lines to at least one box',
'warehouse.inbound.message.detailFailed': 'Failed to load detail',
'order.platformStore.platform.manual': 'Manual',
'order.platformStore.table.siteCountry': 'Site Country',
'order.platformStore.table.autoFulfill': 'Auto Fulfill',
'order.platformStore.table.authTime': 'Auth Time',
'order.platformStore.table.createTime': 'Created At',
'order.platformStore.form.siteCountry': 'Site Country',
'order.platformStore.form.siteCountry': 'Site',
'order.platformStore.form.authCode': 'Auth code',
'order.platformStore.form.tip': 'Note',
'order.platformStore.form.authRenewTip': 'Re-authorizing the same store renews binding without changing the nickname.',
'order.platformStore.form.manualTip': 'Enter any name; the system creates a mock manual store.',
'order.platformStore.site.crossBorder': 'Cross-border',
'order.platformStore.actions.bind': 'Bind store',
'order.platformStore.actions.startAuth': 'Start authorization',
'order.platformStore.auth.active': 'Active',
'order.platformStore.auth.expired': 'Expired',
'order.platformStore.auth.revoked': 'Revoked',
@ -224,6 +264,12 @@ export default {
'order.platformStore.form.account': 'Account',
'order.platformStore.form.autoFulfill': 'Auto Fulfill',
'order.platformStore.modal.add': 'Add Store',
'order.platformStore.modal.bind': 'Bind Store',
'order.platformStore.form.nicknamePlaceholder': 'Store nickname (custom)',
'order.platformStore.form.authCodePlaceholder': 'Auth code from store management',
'order.platformStore.form.sitePlaceholder': 'Select site',
'order.platformStore.form.selectPlatformHint': 'Select a platform to continue',
'order.platformStore.actions.close': 'Close',
'order.platformStore.modal.edit': 'Edit Store',
'order.platformStore.actions.auth': 'Authorize',
'order.platformStore.actions.delete': 'Delete Store',
@ -232,7 +278,10 @@ export default {
'order.platformStore.message.saveFailed': 'Save failed',
'order.platformStore.message.deleteSuccess': 'Deleted',
'order.platformStore.message.required': 'Nickname is required',
'order.platformStore.message.platformRequired': 'Select a platform',
'order.platformStore.message.authCodeRequired': 'Auth code is required',
'order.platformStore.message.authPlaceholder': 'OAuth placeholder',
'order.platformStore.message.authStarted': 'Authorization started; complete it in the new window',
'order.platformStore.message.authFailed': 'Failed to get auth URL',
'order.platformStoreOrder.status.all': 'All',
'order.platformStoreOrder.status.pending': 'Pending',

View File

@ -113,6 +113,17 @@ export default {
'warehouse.sku.form.skuName': 'SKU 名称',
'warehouse.sku.form.weightG': '重量(g)',
'warehouse.sku.form.remark': '备注',
'warehouse.sku.form.imageUrl': '图片地址',
'warehouse.sku.form.dimensions': '尺寸',
'warehouse.sku.form.length': '长',
'warehouse.sku.form.width': '宽',
'warehouse.sku.form.height': '高',
'warehouse.sku.form.clearSize': '清空尺寸',
'warehouse.sku.form.volumeEstimate': '体积估算',
'warehouse.modal.noWarehouse': '未选择仓库',
'warehouse.modal.section.basic': '基础信息',
'warehouse.modal.section.weightSize': '重量与尺寸',
'warehouse.modal.section.products': '商品信息',
'warehouse.sku.modal.add': '新增 SKU',
'warehouse.sku.modal.edit': '编辑 SKU',
'warehouse.sku.actions.delete': '删除 SKU',
@ -137,14 +148,16 @@ export default {
'warehouse.inventory.actions.relocate': '换库位',
'warehouse.inventory.drawer.binDetail': '库位明细',
'warehouse.inventory.modal.relocate': '换库位',
'warehouse.inventory.form.sourceBin': '源库位 ID',
'warehouse.inventory.form.targetBin': '目标库位 ID',
'warehouse.inventory.form.sourceBin': '源库位',
'warehouse.inventory.form.targetBin': '目标库位',
'warehouse.inventory.form.qty': '数量',
'warehouse.inventory.form.selectBin': '请选择库位',
'warehouse.inventory.message.loadFailed': '加载库存失败',
'warehouse.inventory.message.binDetailFailed': '加载库位明细失败',
'warehouse.inventory.message.relocateRequired': '请填写换库位信息',
'warehouse.inventory.message.relocateSuccess': '换库位成功',
'warehouse.inventory.message.relocateFailed': '换库位失败',
'warehouse.inventory.message.sameBin': '源库位与目标库位不能相同',
'warehouse.parcel.status.all': '全部',
'warehouse.parcel.status.inTransit': '在途',
'warehouse.parcel.status.arrived': '已到仓',
@ -163,6 +176,10 @@ export default {
'warehouse.parcel.form.carrierName': '快递公司',
'warehouse.parcel.form.productName': '商品名称',
'warehouse.parcel.form.quantity': '数量',
'warehouse.parcel.form.remark': '备注',
'warehouse.parcel.form.itemRemark': '商品备注',
'warehouse.parcel.form.currentWarehouse': '当前仓库',
'warehouse.parcel.form.autoAddRow': '填写商品名称后会自动新增下一行',
'warehouse.parcel.modal.add': '新增包裹',
'warehouse.parcel.actions.delete': '删除包裹',
'warehouse.parcel.message.loadFailed': '加载包裹失败',
@ -170,6 +187,7 @@ export default {
'warehouse.parcel.message.saveFailed': '保存失败',
'warehouse.parcel.message.deleteSuccess': '删除成功',
'warehouse.parcel.message.required': '请填写仓库和快递单号',
'warehouse.parcel.message.itemsRequired': '请至少填写一条商品信息',
'warehouse.inbound.status.all': '全部',
'warehouse.inbound.status.forecasted': '预报',
'warehouse.inbound.status.completed': '已完成',
@ -190,6 +208,20 @@ export default {
'warehouse.inbound.form.markBoxNo': '箱号',
'warehouse.inbound.form.sku': 'SKU',
'warehouse.inbound.form.plannedQty': '计划数量',
'warehouse.inbound.form.markPlaceholder': '一般填写物流的快递单号',
'warehouse.inbound.form.boxSkuDetail': '箱子与 SKU 明细',
'warehouse.inbound.form.boxCount': '箱子 {n}',
'warehouse.inbound.form.skuCount': 'SKU {n}',
'warehouse.inbound.form.totalQty': '总入库 {n}',
'warehouse.inbound.form.boxWeight': '重量/g',
'warehouse.inbound.form.boxLength': '长/cm',
'warehouse.inbound.form.boxWidth': '宽/cm',
'warehouse.inbound.form.boxHeight': '高/cm',
'warehouse.inbound.form.selectSku': '请选择库内 SKU',
'warehouse.inbound.actions.addBox': '增加箱子',
'warehouse.inbound.actions.addBox5': '一次新增 5 箱',
'warehouse.inbound.actions.removeBox': '移除箱子',
'warehouse.inbound.actions.addSku': '选择库内 SKU',
'warehouse.inbound.modal.add': '新增入库预报',
'warehouse.inbound.drawer.detail': '入库详情',
'warehouse.inbound.box': '箱',
@ -201,14 +233,22 @@ export default {
'warehouse.inbound.message.saveSuccess': '保存成功',
'warehouse.inbound.message.saveFailed': '保存失败',
'warehouse.inbound.message.deleteSuccess': '删除成功',
'warehouse.inbound.message.required': '请选择仓库和 SKU',
'warehouse.inbound.message.required': '请选择仓库',
'warehouse.inbound.message.boxRequired': '请至少为一个箱子选择 SKU',
'warehouse.inbound.message.detailFailed': '加载详情失败',
'order.platformStore.platform.manual': '手工店铺',
'order.platformStore.table.siteCountry': '站点国家',
'order.platformStore.table.autoFulfill': '自动发货',
'order.platformStore.table.authTime': '授权时间',
'order.platformStore.table.createTime': '创建时间',
'order.platformStore.form.siteCountry': '站点国家',
'order.platformStore.form.siteCountry': '开户站',
'order.platformStore.form.authCode': '授权码',
'order.platformStore.form.tip': '提示',
'order.platformStore.form.authRenewTip': '授权相同的店铺会识别为店铺续期,当店铺准备过期或已过期时可进行重复绑定(续期不会改变名称)',
'order.platformStore.form.manualTip': '可以随便取个名字,系统会自动创建一个店铺模拟绑定',
'order.platformStore.site.crossBorder': '跨境店铺',
'order.platformStore.actions.bind': '绑定店铺',
'order.platformStore.actions.startAuth': '开始授权',
'order.platformStore.auth.active': '已授权',
'order.platformStore.auth.expired': '已过期',
'order.platformStore.auth.revoked': '已撤销',
@ -224,6 +264,12 @@ export default {
'order.platformStore.form.account': '平台账号',
'order.platformStore.form.autoFulfill': '自动发货',
'order.platformStore.modal.add': '新增店铺',
'order.platformStore.modal.bind': '添加店铺',
'order.platformStore.form.nicknamePlaceholder': '店铺名称(自定义)',
'order.platformStore.form.authCodePlaceholder': '授权码,可在店铺管理中获取',
'order.platformStore.form.sitePlaceholder': '请选择开户站',
'order.platformStore.form.selectPlatformHint': '请先选择要绑定的平台',
'order.platformStore.actions.close': '关闭',
'order.platformStore.modal.edit': '编辑店铺',
'order.platformStore.actions.auth': '授权',
'order.platformStore.actions.delete': '删除店铺',
@ -232,7 +278,10 @@ export default {
'order.platformStore.message.saveFailed': '保存失败',
'order.platformStore.message.deleteSuccess': '删除成功',
'order.platformStore.message.required': '请填写店铺昵称',
'order.platformStore.message.platformRequired': '请选择平台类型',
'order.platformStore.message.authCodeRequired': '请填写授权码',
'order.platformStore.message.authPlaceholder': 'OAuth 占位,待平台 SDK 接入',
'order.platformStore.message.authStarted': '已发起授权,请在新窗口完成授权',
'order.platformStore.message.authFailed': '获取授权地址失败',
'order.platformStoreOrder.status.all': '全部',
'order.platformStoreOrder.status.pending': '待处理',

View File

@ -0,0 +1,108 @@
.wh-form-modal {
padding: 4px 0 0;
}
.wh-form-section__title {
margin-bottom: 12px;
font-size: 14px;
font-weight: 500;
color: var(--color-text-1);
}
.wh-form-section__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.wh-form-section__head .wh-form-section__title {
margin-bottom: 0;
}
.wh-form-divider {
margin: 16px 0;
}
.wh-form-hint {
font-size: 12px;
color: var(--color-text-3);
}
.wh-form-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.wh-form-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.wh-form-readonly {
color: var(--color-text-1);
font-weight: 500;
}
.wh-form-sku-preview {
display: flex;
align-items: flex-start;
gap: 12px;
}
.wh-form-sku-preview__image {
flex: none;
width: 72px;
height: 72px;
border: 1px dashed var(--color-border-2);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: var(--color-fill-1);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.wh-form-sku-preview__fields {
flex: 1;
min-width: 0;
}
.wh-form-inline-size {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.wh-form-volume {
margin-top: 4px;
font-size: 12px;
color: var(--color-text-3);
}
.wh-form-box-panel {
padding: 12px;
background: var(--color-fill-1);
border-radius: 4px;
}
.wh-form-info-bar {
margin-bottom: 12px;
padding: 10px 12px;
background: var(--color-fill-1);
border-radius: 4px;
font-size: 13px;
color: var(--color-text-2);
line-height: 1.5;
}

View File

@ -0,0 +1,175 @@
.warehouse-modal-body {
max-height: 72vh;
overflow: auto;
padding: 4px 4px 0;
}
.warehouse-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.warehouse-modal-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
width: 100%;
padding-right: 24px;
}
.warehouse-modal-title-text {
font-size: 16px;
font-weight: 600;
}
.warehouse-modal-whse {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.warehouse-modal-whse-label {
font-size: 12px;
color: var(--color-text-3);
white-space: nowrap;
}
.warehouse-modal-card {
background: #fff;
border: 1px solid var(--color-border-2);
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
}
.warehouse-modal-card-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: var(--color-text-1);
}
.warehouse-modal-card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.warehouse-modal-card-head .warehouse-modal-card-title {
margin-bottom: 0;
}
.warehouse-modal-whse-badge {
padding: 4px 10px;
border-radius: 14px;
background: rgb(var(--green-1));
border: 1px solid rgb(var(--green-3));
color: rgb(var(--green-7));
font-size: 12px;
line-height: 20px;
}
.warehouse-modal-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.warehouse-modal-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 8px 0 10px;
padding: 8px 0;
border-bottom: 1px dashed var(--color-border-2);
}
.warehouse-modal-hint {
font-size: 12px;
color: var(--color-text-3);
}
.warehouse-modal-size-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin: 12px 0;
}
.warehouse-modal-size-label {
min-width: 40px;
color: var(--color-text-2);
}
.warehouse-modal-volume {
margin-top: 4px;
font-size: 12px;
color: var(--color-text-3);
}
.warehouse-modal-basic-layout {
display: flex;
gap: 16px;
}
.warehouse-modal-image-col {
flex: none;
width: 120px;
}
.warehouse-modal-form-col {
flex: 1;
min-width: 0;
}
.warehouse-modal-image-preview {
width: 104px;
height: 104px;
border: 1px dashed var(--color-border-2);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: var(--color-fill-1);
}
.warehouse-modal-image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.warehouse-modal-table-wrap {
:deep(.arco-table) {
border-radius: 6px;
}
}
.warehouse-modal-box-panel {
margin-bottom: 8px;
}
.warehouse-modal-platform-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.warehouse-modal-platform-tag {
cursor: pointer;
user-select: none;
}
.warehouse-modal-tip {
color: rgb(var(--red-6));
font-size: 13px;
line-height: 1.6;
}

View File

@ -6,7 +6,6 @@ import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import ModalPro from '@/components/ModalPro/index.vue'
import useModal from '@/components/ModalPro/useModal'
import FormPro from '@/components/FormPro/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { deletePlatformStore, getPlatformStoreAuthUrl, getPlatformStoreList, savePlatformStore } from '@/api/order/platformStore'
import { $baseMessage } from '@/utils'
@ -26,24 +25,56 @@ const currentPage = ref(1)
const pageSize = ref(30)
const totalCount = ref(0)
const platformOptions = computed(() => [
{ label: 'Shopee', value: 'shopee' },
{ label: 'Lazada', value: 'lazada' },
{ label: 'TikTok', value: 'tiktok' },
{ label: 'Temu', value: 'temu' },
{ label: t('order.platformStore.platform.manual'), value: 'manual' },
const platformDefs = [
{ code: 'shopee', label: 'Shopee' },
{ code: 'lazada', label: 'Lazada' },
{ code: 'tiktok', label: 'TikTok' },
{ code: 'temu', label: 'Temu' },
{ code: 'manual', labelKey: 'order.platformStore.platform.manual' },
] as const
const platformOptions = computed(() =>
platformDefs.map((p) => ({
label: 'labelKey' in p ? t(p.labelKey) : p.label,
value: p.code,
})),
)
const lazadaSiteOptions = computed(() => [
{ value: 'cb', label: t('order.platformStore.site.crossBorder') },
{ value: 'sg', label: '新加坡' },
{ value: 'my', label: '马来西亚' },
{ value: 'th', label: '泰国' },
{ value: 'vn', label: '越南' },
{ value: 'ph', label: '菲律宾' },
{ value: 'id', label: '印度尼西亚' },
])
const { visible: modalVisible, open: openModal, close: closeModal } = useModal()
const [registerFormModal, { openModal, closeModal }] = useModal() as any
const saving = ref(false)
const formModel = reactive({
id: 0,
platformCode: 'manual',
platformCode: '',
storeNickname: '',
platformAccount: '',
siteCountry: '',
siteCountry: 'cb',
isAutoFulfill: false,
remark: '',
authorizationCode: '',
})
const isEdit = computed(() => formModel.id > 0)
const isManualPlatform = computed(() => formModel.platformCode === 'manual')
const isOAuthPlatform = computed(() => !!formModel.platformCode && !isManualPlatform.value)
const showStoreFields = computed(() => !!formModel.platformCode || isEdit.value)
const modalTitle = computed(() =>
isEdit.value ? t('order.platformStore.modal.edit') : t('order.platformStore.modal.add'),
)
const primaryActionLabel = computed(() => {
if (isEdit.value || isManualPlatform.value) return t('warehouse.common.save')
return t('order.platformStore.actions.startAuth')
})
const hotColumns = computed(() => [
@ -58,6 +89,12 @@ const hotColumns = computed(() => [
{ key: 'createTime', label: t('order.platformStore.table.createTime'), width: 160 },
])
function platformLabel(code: string) {
const found = platformDefs.find((p) => p.code === code)
if (!found) return code
return 'labelKey' in found ? t(found.labelKey) : found.label
}
function mapRow(raw: Record<string, unknown>) {
const row = normalizeListRow(raw)
const authStatus = String(pickField(row, 'authStatus', 'AuthStatus') ?? '')
@ -111,18 +148,23 @@ function handleSearch() {
loadList()
}
function handleAdd() {
if (!hasPermission('Order.PlatformStore.Save')) return
function resetForm() {
Object.assign(formModel, {
id: 0,
platformCode: 'manual',
platformCode: '',
storeNickname: '',
platformAccount: '',
siteCountry: '',
siteCountry: 'cb',
isAutoFulfill: false,
remark: '',
authorizationCode: '',
})
openModal()
}
function handleAdd() {
if (!hasPermission('Order.PlatformStore.Save')) return
resetForm()
openModal(true)
}
function handleEdit(row: any) {
@ -130,21 +172,42 @@ function handleEdit(row: any) {
const raw = row._raw || row
Object.assign(formModel, {
id: Number(pickField(raw, 'id', 'Id') ?? 0),
platformCode: pickField(raw, 'platformCode', 'PlatformCode') ?? 'manual',
storeNickname: pickField(raw, 'storeNickname', 'StoreNickname') ?? '',
platformAccount: pickField(raw, 'platformAccount', 'PlatformAccount') ?? '',
siteCountry: pickField(raw, 'siteCountry', 'SiteCountry') ?? '',
platformCode: String(pickField(raw, 'platformCode', 'PlatformCode') ?? 'manual'),
storeNickname: String(pickField(raw, 'storeNickname', 'StoreNickname') ?? ''),
platformAccount: String(pickField(raw, 'platformAccount', 'PlatformAccount') ?? ''),
siteCountry: String(pickField(raw, 'siteCountry', 'SiteCountry') ?? 'cb'),
isAutoFulfill: Boolean(pickField(raw, 'isAutoFulfill', 'IsAutoFulfill')),
remark: pickField(raw, 'remark', 'Remark') ?? '',
remark: String(pickField(raw, 'remark', 'Remark') ?? ''),
authorizationCode: '',
})
openModal()
openModal(true)
}
function selectPlatform(code: string) {
if (isEdit.value) return
formModel.platformCode = code
if (code === 'lazada') formModel.siteCountry = 'cb'
if (code === 'temu') formModel.siteCountry = 'US'
}
function validateForm() {
if (!formModel.platformCode) {
$baseMessage(t('order.platformStore.message.platformRequired'), 'warning')
return false
}
if (!formModel.storeNickname.trim()) {
$baseMessage(t('order.platformStore.message.required'), 'warning')
return false
}
if (formModel.platformCode === 'temu' && !formModel.authorizationCode.trim()) {
$baseMessage(t('order.platformStore.message.authCodeRequired'), 'warning')
return false
}
return true
}
async function handleSave() {
if (!formModel.storeNickname.trim()) {
$baseMessage(t('order.platformStore.message.required'), 'warning')
return
}
if (!validateForm()) return
saving.value = true
try {
await savePlatformStore({ ...formModel })
@ -158,12 +221,46 @@ async function handleSave() {
}
}
async function handleStartAuth() {
if (!validateForm()) return
saving.value = true
try {
const result = await getPlatformStoreAuthUrl({
platformCode: formModel.platformCode,
storeNickname: formModel.storeNickname,
siteCountry: formModel.siteCountry,
authorizationCode: formModel.authorizationCode || undefined,
})
const authUrl = result?.authUrl || result?.AuthUrl
if (authUrl && authUrl !== '/') {
window.open(String(authUrl), '_blank')
}
$baseMessage(result?.message || t('order.platformStore.message.authStarted'), 'success')
closeModal()
await loadList()
} catch (e: any) {
$baseMessage(e?.message || t('order.platformStore.message.authFailed'), 'error')
} finally {
saving.value = false
}
}
async function handlePrimaryAction() {
if (isEdit.value || isManualPlatform.value) {
await handleSave()
return
}
await handleStartAuth()
}
async function handleAuth(row: any) {
if (!hasPermission('Order.PlatformStore.Auth')) return
const raw = row._raw || row
try {
const result = await getPlatformStoreAuthUrl({ platformCode: pickField(raw, 'platformCode', 'PlatformCode') })
$baseMessage(result?.message || result?.authUrl || t('order.platformStore.message.authPlaceholder'), 'info')
const authUrl = result?.authUrl || result?.AuthUrl
if (authUrl && authUrl !== '/') window.open(String(authUrl), '_blank')
$baseMessage(result?.message || t('order.platformStore.message.authPlaceholder'), 'info')
} catch (e: any) {
$baseMessage(e?.message || t('order.platformStore.message.authFailed'), 'error')
}
@ -257,31 +354,120 @@ onMounted(loadList)
</div>
<ModalPro
v-model:visible="modalVisible"
:title="formModel.id ? t('order.platformStore.modal.edit') : t('order.platformStore.modal.add')"
:width="640"
:title="modalTitle"
:width="560"
:mask-closable="false"
@register="registerFormModal"
@cancel="closeModal"
>
<FormPro :model="formModel" layout="horizontal" :row-gutter="20" :default-span="12">
<a-form-item :label="t('order.platformStore.form.platform')" required>
<a-select v-model="formModel.platformCode" :options="platformOptions" :disabled="formModel.id > 0" />
</a-form-item>
<a-form-item :label="t('order.platformStore.form.nickname')" required>
<a-input v-model="formModel.storeNickname" />
</a-form-item>
<a-form-item :label="t('order.platformStore.form.account')">
<a-input v-model="formModel.platformAccount" />
</a-form-item>
<a-form-item :label="t('order.platformStore.form.siteCountry')">
<a-input v-model="formModel.siteCountry" />
</a-form-item>
<a-form-item :label="t('order.platformStore.form.autoFulfill')">
<a-switch v-model="formModel.isAutoFulfill" />
</a-form-item>
</FormPro>
<div class="store-auth-modal">
<div class="store-auth-section">
<div class="store-auth-section__title">
{{ t('order.platformStore.form.platform') }}
<span class="store-auth-required">*</span>
</div>
<div v-if="isEdit" class="store-auth-platform-readonly">
<a-tag color="arcoblue">{{ platformLabel(formModel.platformCode) }}</a-tag>
</div>
<div v-else class="store-auth-platform-grid">
<button
v-for="p in platformDefs"
:key="p.code"
type="button"
class="store-auth-platform-item"
:class="{ 'is-active': formModel.platformCode === p.code }"
@click="selectPlatform(p.code)"
>
<span class="store-auth-platform-item__name">
{{ 'labelKey' in p ? t(p.labelKey) : p.label }}
</span>
</button>
</div>
</div>
<template v-if="showStoreFields">
<a-divider class="store-auth-divider" />
<a-form
:model="formModel"
layout="horizontal"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 16 }"
>
<a-form-item :label="t('order.platformStore.form.nickname')" required>
<a-input
v-model="formModel.storeNickname"
:placeholder="t('order.platformStore.form.nicknamePlaceholder')"
allow-clear
/>
</a-form-item>
<a-form-item v-if="formModel.platformCode === 'lazada'" :label="t('order.platformStore.form.siteCountry')">
<a-select
v-model="formModel.siteCountry"
:options="lazadaSiteOptions"
allow-search
:placeholder="t('order.platformStore.form.sitePlaceholder')"
/>
</a-form-item>
<a-form-item v-if="formModel.platformCode === 'temu'" :label="t('order.platformStore.form.authCode')" required>
<a-input
v-model="formModel.authorizationCode"
:placeholder="t('order.platformStore.form.authCodePlaceholder')"
allow-clear
/>
</a-form-item>
<a-form-item v-if="formModel.platformCode === 'temu'" :label="t('order.platformStore.form.siteCountry')">
<a-select v-model="formModel.siteCountry" :options="[{ value: 'US', label: '美国 US' }]" />
</a-form-item>
<a-form-item v-if="isManualPlatform" :label="t('order.platformStore.form.account')">
<a-input v-model="formModel.platformAccount" allow-clear />
</a-form-item>
<a-form-item v-if="isManualPlatform" :label="t('order.platformStore.form.autoFulfill')">
<a-switch v-model="formModel.isAutoFulfill" />
</a-form-item>
</a-form>
<a-alert
v-if="isOAuthPlatform && !isEdit"
type="info"
:show-icon="true"
class="store-auth-alert"
>
{{ t('order.platformStore.form.authRenewTip') }}
</a-alert>
<a-alert
v-if="isManualPlatform && !isEdit"
type="info"
:show-icon="true"
class="store-auth-alert"
>
{{ t('order.platformStore.form.manualTip') }}
</a-alert>
</template>
<div v-else class="store-auth-empty-hint">
{{ t('order.platformStore.form.selectPlatformHint') }}
</div>
</div>
<template #footer>
<a-button @click="closeModal">{{ t('warehouse.common.cancel') }}</a-button>
<a-button type="primary" :loading="saving" @click="handleSave">{{ t('warehouse.common.save') }}</a-button>
<a-space :size="8">
<a-button @click="closeModal">{{ t('warehouse.common.cancel') }}</a-button>
<a-button
type="primary"
:loading="saving"
:disabled="!showStoreFields"
@click="handlePrimaryAction"
>
{{ primaryActionLabel }}
</a-button>
</a-space>
</template>
</ModalPro>
</div>
@ -289,4 +475,75 @@ onMounted(loadList)
<style scoped lang="less">
@import '@/styles/warehouse-list-page.less';
.store-auth-modal {
padding: 4px 0 0;
}
.store-auth-section__title {
margin-bottom: 12px;
font-size: 14px;
font-weight: 500;
color: var(--color-text-1);
}
.store-auth-required {
margin-left: 4px;
color: rgb(var(--danger-6));
}
.store-auth-platform-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.store-auth-platform-item {
height: 40px;
padding: 0 12px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: var(--color-bg-2);
color: var(--color-text-1);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--app-primary-color);
color: var(--app-primary-color);
}
&.is-active {
border-color: var(--app-primary-color);
background: var(--color-fill-2);
color: var(--app-primary-color);
font-weight: 500;
}
}
.store-auth-platform-item__name {
font-size: 13px;
}
.store-auth-platform-readonly {
padding: 4px 0;
}
.store-auth-divider {
margin: 16px 0;
}
.store-auth-alert {
margin-top: 4px;
}
.store-auth-empty-hint {
margin-top: 8px;
padding: 24px 12px;
text-align: center;
font-size: 13px;
color: var(--color-text-3);
background: var(--color-fill-1);
border-radius: 4px;
}
</style>

View File

@ -6,7 +6,6 @@ import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import ModalPro from '@/components/ModalPro/index.vue'
import useModal from '@/components/ModalPro/useModal'
import FormPro from '@/components/FormPro/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import {
deleteWarehouseInbound,
@ -29,6 +28,23 @@ import {
defineOptions({ name: 'WarehouseInboundNotice' })
interface InboundLineForm {
skuId?: number
skuCode: string
plannedQty: number
}
interface InboundBoxForm {
key: string
boxSeqNo: string
markBoxNo: string
weightG?: number
lengthCm?: number
widthCm?: number
heightCm?: number
lines: InboundLineForm[]
}
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
@ -44,6 +60,8 @@ const tableRows = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(30)
const totalCount = ref(0)
const selectedBoxKeys = ref<string[]>([])
const activeBoxKey = ref('')
const statusTabs = [
{ value: undefined, labelKey: 'warehouse.inbound.status.all', countKey: null },
@ -55,7 +73,7 @@ const statusTabs = [
const detailVisible = ref(false)
const detailData = ref<any>(null)
const { visible: modalVisible, open: openModal, close: closeModal } = useModal()
const [registerFormModal, { openModal, closeModal }] = useModal() as any
const saving = ref(false)
const formModel = reactive({
id: 0,
@ -63,10 +81,24 @@ const formModel = reactive({
markReference: '',
inboundType: 'normal',
remark: '',
skuId: undefined as number | undefined,
plannedQty: 1,
markBoxNo: '',
})
const boxRows = ref<InboundBoxForm[]>([])
const modalWarehouseName = computed(() => {
const wh = warehouseOptions.value.find((w) => w.value === formModel.warehouseId)
return wh?.label || ''
})
const boxCount = computed(() => boxRows.value.length)
const totalSkuCount = computed(() =>
boxRows.value.reduce((sum, box) => sum + box.lines.filter((l) => l.skuId).length, 0),
)
const totalPlannedQty = computed(() =>
boxRows.value.reduce(
(sum, box) => sum + box.lines.reduce((s, line) => s + (Number(line.plannedQty) || 0), 0),
0,
),
)
const hotColumns = computed(() => [
{ key: 'noticeNo', label: t('warehouse.inbound.table.noticeNo'), width: 180 },
@ -80,6 +112,71 @@ const hotColumns = computed(() => [
{ key: 'createTime', label: t('warehouse.inbound.table.createTime'), width: 160 },
])
function createBox(seq: number): InboundBoxForm {
return {
key: `box-${Date.now()}-${seq}`,
boxSeqNo: String(seq),
markBoxNo: `BOX-${String(seq).padStart(3, '0')}`,
lines: [{ skuId: undefined, skuCode: '', plannedQty: 1 }],
}
}
function resetBoxes() {
const first = createBox(1)
boxRows.value = [first]
selectedBoxKeys.value = []
activeBoxKey.value = first.key
}
function addBox() {
const next = boxRows.value.length + 1
const box = createBox(next)
boxRows.value = [...boxRows.value, box]
activeBoxKey.value = box.key
}
function addBoxes(count: number) {
const start = boxRows.value.length
const added = Array.from({ length: count }, (_, i) => createBox(start + i + 1))
boxRows.value = [...boxRows.value, ...added]
if (added.length) activeBoxKey.value = added[added.length - 1].key
}
function removeActiveBox() {
if (boxRows.value.length <= 1) return
const key = activeBoxKey.value
const remain = boxRows.value.filter((b) => b.key !== key)
boxRows.value = remain
selectedBoxKeys.value = selectedBoxKeys.value.filter((k) => k !== key)
activeBoxKey.value = remain[remain.length - 1]?.key || ''
}
function removeSelectedBoxes() {
if (!selectedBoxKeys.value.length) {
removeActiveBox()
return
}
const remain = boxRows.value.filter((b) => !selectedBoxKeys.value.includes(b.key))
boxRows.value = remain.length ? remain : [createBox(1)]
selectedBoxKeys.value = []
activeBoxKey.value = boxRows.value[boxRows.value.length - 1].key
}
function addLine(box: InboundBoxForm) {
box.lines.push({ skuId: undefined, skuCode: '', plannedQty: 1 })
}
function removeLine(box: InboundBoxForm, index: number) {
if (box.lines.length <= 1) return
box.lines.splice(index, 1)
}
function onSkuChange(line: InboundLineForm, skuId?: number) {
const sku = skuOptions.value.find((s) => s.value === skuId)
line.skuId = skuId
line.skuCode = sku?.code || ''
}
function mapRow(raw: Record<string, unknown>) {
const row = normalizeListRow(raw)
const status = String(pickField(row, 'receiptStatus', 'ReceiptStatus') ?? '')
@ -181,18 +278,39 @@ function handleAdd() {
formModel.warehouseId = warehouseId.value
formModel.markReference = ''
formModel.remark = ''
formModel.skuId = undefined
formModel.plannedQty = 1
formModel.markBoxNo = ''
openModal()
formModel.inboundType = 'normal'
resetBoxes()
openModal(true)
}
async function handleSave() {
if (!formModel.warehouseId || !formModel.skuId) {
if (!formModel.warehouseId) {
$baseMessage(t('warehouse.inbound.message.required'), 'warning')
return
}
const sku = skuOptions.value.find((s) => s.value === formModel.skuId)
const boxes = boxRows.value
.map((box) => ({
boxSeqNo: box.boxSeqNo,
markBoxNo: box.markBoxNo,
weightG: box.weightG,
lengthCm: box.lengthCm,
widthCm: box.widthCm,
heightCm: box.heightCm,
lines: box.lines
.filter((line) => line.skuId)
.map((line) => ({
skuId: line.skuId!,
skuCode: line.skuCode,
plannedQty: Number(line.plannedQty) || 1,
})),
}))
.filter((box) => box.lines.length > 0)
if (!boxes.length) {
$baseMessage(t('warehouse.inbound.message.boxRequired'), 'warning')
return
}
saving.value = true
try {
await saveWarehouseInbound({
@ -201,13 +319,7 @@ async function handleSave() {
markReference: formModel.markReference,
inboundType: formModel.inboundType,
remark: formModel.remark,
boxes: [
{
boxSeqNo: '1',
markBoxNo: formModel.markBoxNo || 'BOX-001',
lines: [{ skuId: formModel.skuId, skuCode: sku?.code || '', plannedQty: formModel.plannedQty }],
},
],
boxes,
})
$baseMessage(t('warehouse.inbound.message.saveSuccess'), 'success')
closeModal()
@ -336,27 +448,136 @@ onMounted(async () => {
</template>
</a-drawer>
<ModalPro v-model:visible="modalVisible" :title="t('warehouse.inbound.modal.add')" :width="640" @cancel="closeModal">
<FormPro :model="formModel" layout="horizontal" :row-gutter="20" :default-span="12">
<a-form-item :label="t('warehouse.common.warehouse')" required>
<a-select v-model="formModel.warehouseId" :options="warehouseOptions" />
</a-form-item>
<a-form-item :label="t('warehouse.inbound.form.markReference')">
<a-input v-model="formModel.markReference" />
</a-form-item>
<a-form-item :label="t('warehouse.inbound.form.markBoxNo')">
<a-input v-model="formModel.markBoxNo" />
</a-form-item>
<a-form-item :label="t('warehouse.inbound.form.sku')" required>
<a-select v-model="formModel.skuId" :options="skuOptions" allow-search />
</a-form-item>
<a-form-item :label="t('warehouse.inbound.form.plannedQty')" required>
<a-input-number v-model="formModel.plannedQty" :min="1" style="width: 100%" />
</a-form-item>
</FormPro>
<ModalPro
:title="t('warehouse.inbound.modal.add')"
:width="800"
:mask-closable="false"
:body-style="{ maxHeight: '70vh', overflow: 'auto' }"
@register="registerFormModal"
@cancel="closeModal"
>
<div class="wh-form-modal">
<a-form
:model="formModel"
layout="horizontal"
: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>
<a-form-item :label="t('warehouse.inbound.form.markReference')">
<a-input v-model="formModel.markReference" :placeholder="t('warehouse.inbound.form.markPlaceholder')" allow-clear />
</a-form-item>
<a-form-item :label="t('warehouse.inbound.table.inboundType')">
<a-select v-model="formModel.inboundType">
<a-option value="normal">{{ t('warehouse.inbound.type.normal') }}</a-option>
<a-option value="return">{{ t('warehouse.inbound.type.return') }}</a-option>
</a-select>
</a-form-item>
<a-form-item :label="t('warehouse.parcel.form.remark')">
<a-input v-model="formModel.remark" allow-clear />
</a-form-item>
</a-form>
<a-divider class="wh-form-divider" />
<div class="wh-form-section__head">
<div class="wh-form-section__title">{{ t('warehouse.inbound.form.boxSkuDetail') }}</div>
<div class="wh-form-meta">
<a-tag color="arcoblue">{{ t('warehouse.inbound.form.boxCount', { n: boxCount }) }}</a-tag>
<a-tag color="green">{{ t('warehouse.inbound.form.skuCount', { n: totalSkuCount }) }}</a-tag>
<a-tag color="purple">{{ t('warehouse.inbound.form.totalQty', { n: totalPlannedQty }) }}</a-tag>
</div>
</div>
<div class="wh-form-toolbar">
<a-button type="primary" size="small" @click="addBox">{{ t('warehouse.inbound.actions.addBox') }}</a-button>
<a-button size="small" @click="addBoxes(5)">{{ t('warehouse.inbound.actions.addBox5') }}</a-button>
<a-button size="small" status="danger" @click="removeSelectedBoxes">{{ t('warehouse.inbound.actions.removeBox') }}</a-button>
</div>
<a-tabs v-model:active-key="activeBoxKey" type="rounded" size="small">
<a-tab-pane v-for="box in boxRows" :key="box.key" :title="`${t('warehouse.inbound.box')} ${box.boxSeqNo}`">
<div class="wh-form-box-panel">
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :label="t('warehouse.inbound.form.markBoxNo')" :label-col-props="{ span: 24 }">
<a-input v-model="box.markBoxNo" allow-clear />
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :label="t('warehouse.inbound.form.boxWeight')" :label-col-props="{ span: 24 }">
<a-input-number v-model="box.weightG" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :label="t('warehouse.inbound.form.boxLength')" :label-col-props="{ span: 24 }">
<a-input-number v-model="box.lengthCm" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :label="t('warehouse.inbound.form.boxWidth')" :label-col-props="{ span: 24 }">
<a-input-number v-model="box.widthCm" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :label="t('warehouse.inbound.form.boxHeight')" :label-col-props="{ span: 24 }">
<a-input-number v-model="box.heightCm" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<div class="wh-form-toolbar">
<a-button type="primary" size="small" @click="addLine(box)">{{ t('warehouse.inbound.actions.addSku') }}</a-button>
</div>
<a-table :data="box.lines" :pagination="false" size="small" :bordered="{ cell: true }">
<template #columns>
<a-table-column :title="t('warehouse.inbound.form.sku')">
<template #cell="{ record }">
<a-select
v-model="record.skuId"
:options="skuOptions"
allow-search
:placeholder="t('warehouse.inbound.form.selectSku')"
@change="(v: number) => onSkuChange(record, v)"
/>
</template>
</a-table-column>
<a-table-column :title="t('warehouse.inbound.line.plannedQty')" :width="130">
<template #cell="{ record }">
<a-input-number v-model="record.plannedQty" :min="1" style="width: 100%" />
</template>
</a-table-column>
<a-table-column :title="t('warehouse.common.delete')" :width="72" align="center">
<template #cell="{ rowIndex }">
<a-button type="text" status="danger" size="small" @click="removeLine(box, rowIndex)">
{{ t('warehouse.common.delete') }}
</a-button>
</template>
</a-table-column>
</template>
</a-table>
</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>
<a-button @click="closeModal">{{ t('warehouse.common.cancel') }}</a-button>
<a-button type="primary" :loading="saving" @click="handleSave">{{ t('warehouse.common.save') }}</a-button>
<a-space :size="8">
<a-button @click="closeModal">{{ t('warehouse.common.cancel') }}</a-button>
<a-button type="primary" :loading="saving" @click="handleSave">{{ t('warehouse.common.save') }}</a-button>
</a-space>
</template>
</ModalPro>
</div>
@ -364,6 +585,7 @@ onMounted(async () => {
<style scoped lang="less">
@import '@/styles/warehouse-list-page.less';
@import '@/styles/warehouse-form-modal.less';
.box-block {
margin-bottom: 16px;

View File

@ -5,9 +5,9 @@ import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import ModalPro from '@/components/ModalPro/index.vue'
import useModal from '@/components/ModalPro/useModal'
import FormPro from '@/components/FormPro/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import {
getWarehouseBinLocationList,
getWarehouseInventoryBinDetail,
getWarehouseInventoryList,
relocateWarehouseInventory,
@ -41,14 +41,21 @@ const binDetailVisible = ref(false)
const binDetailRows = ref<any[]>([])
const activeSku = ref<any>(null)
const { visible: relocateVisible, open: openRelocate, close: closeRelocate } = useModal()
const [registerRelocateModal, { openModal: openRelocate, closeModal: closeRelocate }] = useModal() as any
const relocateSaving = ref(false)
const binOptions = ref<Array<{ label: string; value: number }>>([])
const relocateForm = ref({
sourceBinLocationId: undefined as number | undefined,
targetBinLocationId: undefined as number | undefined,
qty: 1,
})
const modalWarehouseName = computed(() => {
const row = activeSku.value
if (!row) return ''
return resolveWarehouseLabel(row.warehouseId, warehouseNameMap.value)
})
const hotColumns = computed(() => [
{ key: 'skuCode', label: t('warehouse.inventory.table.skuCode'), width: 140 },
{ key: 'skuName', label: t('warehouse.inventory.table.skuName'), width: 200 },
@ -84,6 +91,22 @@ async function loadWarehouses() {
}
}
async function loadBinOptions(whId?: number) {
if (!whId) {
binOptions.value = []
return
}
try {
const { items } = await getWarehouseBinLocationList({ warehouseId: whId, isEnabled: true })
binOptions.value = (items || []).map((b: any) => ({
label: `${b.binCode || b.BinCode}${b.binName || b.BinName ? ` - ${b.binName || b.BinName}` : ''}`,
value: Number(b.id || b.Id),
}))
} catch {
binOptions.value = []
}
}
async function loadList() {
listLoading.value = true
try {
@ -139,11 +162,12 @@ async function showBinDetail(row: any) {
}
}
function openRelocateModal(row: any) {
async function openRelocateModal(row: any) {
if (!hasPermission('Warehouse.Inventory.Relocate')) return
activeSku.value = row
relocateForm.value = { sourceBinLocationId: undefined, targetBinLocationId: undefined, qty: 1 }
openRelocate()
await loadBinOptions(row.warehouseId)
openRelocate(true)
}
async function submitRelocate() {
@ -153,6 +177,10 @@ async function submitRelocate() {
$baseMessage(t('warehouse.inventory.message.relocateRequired'), 'warning')
return
}
if (f.sourceBinLocationId === f.targetBinLocationId) {
$baseMessage(t('warehouse.inventory.message.sameBin'), 'warning')
return
}
relocateSaving.value = true
try {
await relocateWarehouseInventory({
@ -251,21 +279,52 @@ onMounted(async () => {
</a-table>
</a-drawer>
<ModalPro v-model:visible="relocateVisible" :title="t('warehouse.inventory.modal.relocate')" :width="520" @cancel="closeRelocate">
<FormPro :model="relocateForm" layout="horizontal" :row-gutter="20" :default-span="24">
<a-form-item :label="t('warehouse.inventory.form.sourceBin')" required>
<a-input-number v-model="relocateForm.sourceBinLocationId" :min="1" style="width: 100%" />
</a-form-item>
<a-form-item :label="t('warehouse.inventory.form.targetBin')" required>
<a-input-number v-model="relocateForm.targetBinLocationId" :min="1" style="width: 100%" />
</a-form-item>
<a-form-item :label="t('warehouse.inventory.form.qty')" required>
<a-input-number v-model="relocateForm.qty" :min="1" style="width: 100%" />
</a-form-item>
</FormPro>
<ModalPro
:title="t('warehouse.inventory.modal.relocate')"
:width="520"
:mask-closable="false"
@register="registerRelocateModal"
@cancel="closeRelocate"
>
<div class="wh-form-modal">
<div v-if="activeSku" class="wh-form-info-bar">
<div>{{ t('warehouse.common.warehouse') }}:{{ modalWarehouseName || '-' }}</div>
<div>{{ activeSku.skuCode }} · {{ activeSku.skuName }}</div>
</div>
<a-form
:model="relocateForm"
layout="horizontal"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 16 }"
>
<a-form-item :label="t('warehouse.inventory.form.sourceBin')" required>
<a-select
v-model="relocateForm.sourceBinLocationId"
:options="binOptions"
allow-search
:placeholder="t('warehouse.inventory.form.selectBin')"
/>
</a-form-item>
<a-form-item :label="t('warehouse.inventory.form.targetBin')" required>
<a-select
v-model="relocateForm.targetBinLocationId"
:options="binOptions"
allow-search
:placeholder="t('warehouse.inventory.form.selectBin')"
/>
</a-form-item>
<a-form-item :label="t('warehouse.inventory.form.qty')" required>
<a-input-number v-model="relocateForm.qty" :min="1" style="width: 100%" />
</a-form-item>
</a-form>
</div>
<template #footer>
<a-button @click="closeRelocate">{{ t('warehouse.common.cancel') }}</a-button>
<a-button type="primary" :loading="relocateSaving" @click="submitRelocate">{{ t('warehouse.common.confirm') }}</a-button>
<a-space :size="8">
<a-button @click="closeRelocate">{{ t('warehouse.common.cancel') }}</a-button>
<a-button type="primary" :loading="relocateSaving" @click="submitRelocate">{{ t('warehouse.common.confirm') }}</a-button>
</a-space>
</template>
</ModalPro>
</div>
@ -273,4 +332,5 @@ onMounted(async () => {
<style scoped lang="less">
@import '@/styles/warehouse-list-page.less';
@import '@/styles/warehouse-form-modal.less';
</style>

View File

@ -1,12 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
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 ModalPro from '@/components/ModalPro/index.vue'
import useModal from '@/components/ModalPro/useModal'
import FormPro from '@/components/FormPro/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { deleteWarehouseParcel, getWarehouseParcelList, saveWarehouseParcel } from '@/api/warehouse'
import { getWarehouseList } from '@/api/system/warehouse'
@ -23,6 +22,12 @@ import {
defineOptions({ name: 'WarehouseParcel' })
interface ParcelItemRow {
productName: string
quantity: number
remark: string
}
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
@ -44,7 +49,7 @@ const statusTabs = [
{ value: 'stored', labelKey: 'warehouse.parcel.status.stored' },
]
const { visible: modalVisible, open: openModal, close: closeModal } = useModal()
const [registerFormModal, { openModal, closeModal }] = useModal() as any
const saving = ref(false)
const formModel = reactive({
id: 0,
@ -53,8 +58,10 @@ const formModel = reactive({
carrierName: '',
lifecycleStatus: 'inTransit',
remark: '',
items: [{ productName: '', quantity: 1 }],
})
const itemRows = ref<ParcelItemRow[]>([{ productName: '', quantity: 1, remark: '' }])
const filledItemCount = computed(() => itemRows.value.filter((r) => r.productName.trim()).length)
const hotColumns = computed(() => [
{ key: 'trackingNo', label: t('warehouse.parcel.table.trackingNo'), width: 160 },
@ -67,6 +74,26 @@ const hotColumns = computed(() => [
{ key: 'remark', label: t('warehouse.parcel.table.remark'), width: 140 },
])
watch(
itemRows,
(rows) => {
const last = rows[rows.length - 1]
if (last && last.productName.trim()) {
itemRows.value = [...rows, { productName: '', quantity: 1, remark: '' }]
}
},
{ deep: true },
)
function resetItems() {
itemRows.value = [{ productName: '', quantity: 1, remark: '' }]
}
function removeItemRow(index: number) {
if (itemRows.value.length <= 1) return
itemRows.value = itemRows.value.filter((_, i) => i !== index)
}
function mapRow(raw: Record<string, unknown>) {
const row = normalizeListRow(raw)
const status = String(pickField(row, 'lifecycleStatus', 'LifecycleStatus') ?? '')
@ -143,8 +170,8 @@ function handleAdd() {
formModel.carrierName = ''
formModel.lifecycleStatus = 'inTransit'
formModel.remark = ''
formModel.items = [{ productName: '', quantity: 1 }]
openModal()
resetItems()
openModal(true)
}
async function handleSave() {
@ -152,9 +179,20 @@ async function handleSave() {
$baseMessage(t('warehouse.parcel.message.required'), 'warning')
return
}
const items = itemRows.value
.filter((row) => row.productName.trim())
.map((row) => ({
productName: row.productName.trim(),
quantity: Number(row.quantity) || 1,
remark: row.remark || undefined,
}))
if (!items.length) {
$baseMessage(t('warehouse.parcel.message.itemsRequired'), 'warning')
return
}
saving.value = true
try {
await saveWarehouseParcel({ ...formModel })
await saveWarehouseParcel({ ...formModel, items })
$baseMessage(t('warehouse.parcel.message.saveSuccess'), 'success')
closeModal()
await loadList()
@ -257,27 +295,93 @@ onMounted(async () => {
</div>
</div>
<ModalPro v-model:visible="modalVisible" :title="t('warehouse.parcel.modal.add')" :width="640" @cancel="closeModal">
<FormPro :model="formModel" layout="horizontal" :row-gutter="20" :default-span="12">
<a-form-item :label="t('warehouse.common.warehouse')" required>
<a-select v-model="formModel.warehouseId" :options="warehouseOptions" />
</a-form-item>
<a-form-item :label="t('warehouse.parcel.form.trackingNo')" required>
<a-input v-model="formModel.trackingNo" />
</a-form-item>
<a-form-item :label="t('warehouse.parcel.form.carrierName')">
<a-input v-model="formModel.carrierName" />
</a-form-item>
<a-form-item :label="t('warehouse.parcel.form.productName')">
<a-input v-model="formModel.items[0].productName" />
</a-form-item>
<a-form-item :label="t('warehouse.parcel.form.quantity')">
<a-input-number v-model="formModel.items[0].quantity" :min="1" style="width: 100%" />
</a-form-item>
</FormPro>
<ModalPro
:title="t('warehouse.parcel.modal.add')"
:width="720"
:mask-closable="false"
:body-style="{ maxHeight: '70vh', overflow: 'auto' }"
@register="registerFormModal"
@cancel="closeModal"
>
<div class="wh-form-modal">
<a-form
:model="formModel"
layout="horizontal"
: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>
<a-form-item :label="t('warehouse.parcel.form.trackingNo')" required>
<a-input v-model="formModel.trackingNo" allow-clear />
</a-form-item>
<a-form-item :label="t('warehouse.parcel.form.carrierName')">
<a-input v-model="formModel.carrierName" allow-clear />
</a-form-item>
<a-form-item :label="t('warehouse.parcel.form.remark')">
<a-textarea
v-model="formModel.remark"
:max-length="500"
show-word-limit
:auto-size="{ minRows: 2, maxRows: 4 }"
/>
</a-form-item>
</a-form>
<a-divider class="wh-form-divider" />
<div class="wh-form-section__head">
<div class="wh-form-section__title">{{ t('warehouse.modal.section.products') }}</div>
<span class="wh-form-hint">{{ t('warehouse.parcel.form.autoAddRow') }}</span>
</div>
<a-table :data="itemRows" :pagination="false" size="small" :bordered="{ cell: true }">
<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')">
<template #cell="{ record }">
<a-input v-model="record.productName" 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%" />
</template>
</a-table-column>
<a-table-column :title="t('warehouse.parcel.form.itemRemark')" :width="160">
<template #cell="{ record }">
<a-input v-model="record.remark" allow-clear />
</template>
</a-table-column>
<a-table-column :title="t('warehouse.common.delete')" :width="72" align="center">
<template #cell="{ rowIndex, record }">
<a-button
v-if="record.productName.trim()"
type="text"
status="danger"
size="small"
@click="removeItemRow(rowIndex)"
>
{{ t('warehouse.common.delete') }}
</a-button>
</template>
</a-table-column>
</template>
</a-table>
<a-alert v-if="filledItemCount === 0" type="warning" :show-icon="true" class="wh-form-divider">
{{ t('warehouse.parcel.message.itemsRequired') }}
</a-alert>
</div>
<template #footer>
<a-button @click="closeModal">{{ t('warehouse.common.cancel') }}</a-button>
<a-button type="primary" :loading="saving" @click="handleSave">{{ t('warehouse.common.save') }}</a-button>
<a-space :size="8">
<a-button @click="closeModal">{{ t('warehouse.common.cancel') }}</a-button>
<a-button type="primary" :loading="saving" @click="handleSave">{{ t('warehouse.common.save') }}</a-button>
</a-space>
</template>
</ModalPro>
</div>
@ -285,4 +389,5 @@ onMounted(async () => {
<style scoped lang="less">
@import '@/styles/warehouse-list-page.less';
@import '@/styles/warehouse-form-modal.less';
</style>

View File

@ -6,9 +6,9 @@ import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import ModalPro from '@/components/ModalPro/index.vue'
import useModal from '@/components/ModalPro/useModal'
import FormPro from '@/components/FormPro/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { deleteWarehouseSku, getWarehouseSkuList, saveWarehouseSku } from '@/api/warehouse'
import { getWarehouseList } from '@/api/system/warehouse'
import { $baseMessage } from '@/utils'
import { hasPermission } from '@/utils/rbac-permission'
import {
@ -29,8 +29,10 @@ const currentPage = ref(1)
const pageSize = ref(30)
const totalCount = ref(0)
const keyword = ref('')
const warehouseOptions = ref<Array<{ label: string; value: number }>>([])
const selectedWarehouseId = ref<number>()
const { visible: modalVisible, open: openModal, close: closeModal } = useModal()
const [registerFormModal, { openModal, closeModal }] = useModal() as any
const saving = ref(false)
const formModel = reactive({
id: 0,
@ -44,6 +46,18 @@ const formModel = reactive({
remark: '',
})
const modalTitle = computed(() =>
formModel.id ? t('warehouse.sku.modal.edit') : t('warehouse.sku.modal.add'),
)
const skuVolumeText = computed(() => {
const l = Number(formModel.lengthCm || 0)
const w = Number(formModel.widthCm || 0)
const h = Number(formModel.heightCm || 0)
if (!(l > 0 && w > 0 && h > 0)) return '-'
return `${(l * w * h).toFixed(2)} cm³`
})
const hotColumns = computed(() => [
{ key: 'skuCode', label: t('warehouse.sku.table.skuCode'), width: 140 },
{ key: 'skuName', label: t('warehouse.sku.table.skuName'), width: 180 },
@ -66,6 +80,16 @@ function resetForm() {
formModel.remark = ''
}
function changeSkuCode() {
formModel.skuCode = (formModel.skuCode || '').replace(/[\u4E00-\u9FA5]/g, '')
}
function clearSkuSize() {
formModel.lengthCm = undefined
formModel.widthCm = undefined
formModel.heightCm = undefined
}
function mapRow(raw: Record<string, unknown>) {
const row = normalizeListRow(raw)
return {
@ -81,6 +105,21 @@ function mapRow(raw: Record<string, unknown>) {
}
}
async function loadWarehouses() {
try {
const { items } = await getWarehouseList({ page: 1, pageSize: 200 })
warehouseOptions.value = (items || []).map((w: any) => ({
label: w.warehouseName || w.WarehouseName || `#${w.id}`,
value: Number(w.id),
}))
if (!selectedWarehouseId.value && warehouseOptions.value.length) {
selectedWarehouseId.value = warehouseOptions.value[0].value
}
} catch {
warehouseOptions.value = []
}
}
async function loadList() {
listLoading.value = true
try {
@ -119,7 +158,7 @@ function handleSearch() {
function handleAdd() {
if (!hasPermission('Warehouse.Sku.Save')) return
resetForm()
openModal()
openModal(true)
}
function handleEdit(row: any) {
@ -134,7 +173,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') ?? '')
openModal()
openModal(true)
}
async function handleSave() {
@ -188,7 +227,10 @@ function handleActionClick(row: any, _idx: number, action?: string) {
if (action === 'delete') handleDelete(row)
}
onMounted(loadList)
onMounted(async () => {
await loadWarehouses()
await loadList()
})
</script>
<template>
@ -197,6 +239,13 @@ onMounted(loadList)
<div class="table-toolbar">
<div class="toolbar-left">
<a-space wrap :size="8">
<a-select
v-model="selectedWarehouseId"
allow-clear
:placeholder="t('warehouse.common.warehouse')"
:options="warehouseOptions"
style="width: 200px"
/>
<a-input
v-model="keyword"
allow-clear
@ -237,28 +286,89 @@ onMounted(loadList)
</div>
<ModalPro
v-model:visible="modalVisible"
:title="formModel.id ? t('warehouse.sku.modal.edit') : t('warehouse.sku.modal.add')"
:width="720"
:title="modalTitle"
:width="640"
:mask-closable="false"
@register="registerFormModal"
@cancel="closeModal"
>
<FormPro :model="formModel" layout="horizontal" :row-gutter="20" :default-span="12">
<a-form-item :label="t('warehouse.sku.form.skuCode')" field="skuCode" required>
<a-input v-model="formModel.skuCode" />
</a-form-item>
<a-form-item :label="t('warehouse.sku.form.skuName')" field="skuName" required>
<a-input v-model="formModel.skuName" />
</a-form-item>
<a-form-item :label="t('warehouse.sku.form.weightG')" field="weightG">
<a-input-number v-model="formModel.weightG" :min="0" style="width: 100%" />
</a-form-item>
<a-form-item :label="t('warehouse.sku.form.remark')" field="remark">
<a-textarea v-model="formModel.remark" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
</FormPro>
<div class="wh-form-modal">
<div class="wh-form-section__title">{{ t('warehouse.modal.section.basic') }}</div>
<a-form
:model="formModel"
layout="horizontal"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 16 }"
>
<a-form-item :label="t('warehouse.sku.form.skuCode')" required>
<a-input
v-if="!formModel.id"
v-model="formModel.skuCode"
allow-clear
@change="changeSkuCode"
/>
<span v-else class="wh-form-readonly">{{ formModel.skuCode }}</span>
</a-form-item>
<a-form-item :label="t('warehouse.sku.form.skuName')" required>
<a-input v-model="formModel.skuName" allow-clear />
</a-form-item>
<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" />
<span v-else class="wh-form-hint">—</span>
</div>
<div class="wh-form-sku-preview__fields">
<a-input v-model="formModel.skuImageUrl" allow-clear />
</div>
</div>
</a-form-item>
<a-form-item :label="t('warehouse.sku.form.remark')">
<a-textarea v-model="formModel.remark" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
</a-form>
<a-divider class="wh-form-divider" />
<div class="wh-form-section__title">{{ t('warehouse.modal.section.weightSize') }}</div>
<a-form
:model="formModel"
layout="horizontal"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 16 }"
>
<a-form-item :label="t('warehouse.sku.form.weightG')">
<a-input-number v-model="formModel.weightG" :min="0" style="width: 160px">
<template #suffix>g</template>
</a-input-number>
</a-form-item>
<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')">
<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')">
<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')">
<template #suffix>cm</template>
</a-input-number>
<a-button type="text" @click="clearSkuSize">{{ t('warehouse.sku.form.clearSize') }}</a-button>
</div>
<div class="wh-form-volume">
{{ t('warehouse.sku.form.volumeEstimate') }}:{{ skuVolumeText }}
</div>
</div>
</a-form-item>
</a-form>
</div>
<template #footer>
<a-button @click="closeModal">{{ t('warehouse.common.cancel') }}</a-button>
<a-button type="primary" :loading="saving" @click="handleSave">{{ t('warehouse.common.save') }}</a-button>
<a-space :size="8">
<a-button @click="closeModal">{{ t('warehouse.common.cancel') }}</a-button>
<a-button type="primary" :loading="saving" @click="handleSave">{{ t('warehouse.common.save') }}</a-button>
</a-space>
</template>
</ModalPro>
</div>
@ -266,4 +376,5 @@ onMounted(loadList)
<style scoped lang="less">
@import '@/styles/warehouse-list-page.less';
@import '@/styles/warehouse-form-modal.less';
</style>