feat(wms):完成 md item 的迁移

pull/345/head
YunaiV 2026-05-18 22:35:58 +08:00
parent cd42a653c5
commit 8d69e4d9f0
4 changed files with 935 additions and 0 deletions

View File

@ -0,0 +1,267 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { h, markRaw } from 'vue';
import { Button } from 'ant-design-vue';
import { z } from '#/adapter/form';
import { getItemBrandSimpleList } from '#/api/wms/md/item/brand';
import { generateWmsCode } from '#/views/wms/utils/constants';
import { WmsItemBrandSelect } from './brand/components';
import { WmsItemCategorySelect } from './category/components';
/** 新增/修改商品的表单 */
export function useFormSchema(formApi?: any): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '商品名称',
component: 'Input',
componentProps: {
maxLength: 60,
placeholder: '请输入商品名称',
},
rules: z.string().min(1, '商品名称不能为空').max(60),
},
{
fieldName: 'categoryId',
label: '商品分类',
component: markRaw(WmsItemCategorySelect),
rules: 'required',
},
{
fieldName: 'code',
label: '商品编号',
component: 'Input',
componentProps: {
maxLength: 20,
placeholder: '请输入商品编号',
},
rules: z.string().min(1, '商品编号不能为空').max(20),
suffix: () => {
return h(
Button,
{
type: 'default',
onClick: () => {
formApi?.setFieldValue('code', generateWmsCode('I'));
},
},
{ default: () => '生成' },
);
},
},
{
fieldName: 'unit',
label: '商品单位',
component: 'Input',
componentProps: {
maxLength: 20,
placeholder: '请输入单位',
},
},
{
fieldName: 'brandId',
label: '商品品牌',
component: markRaw(WmsItemBrandSelect),
},
// TODO @AItextarea 组件。vue3 + ep 也要调整下;
{
fieldName: 'remark',
label: '备注',
component: 'Input',
componentProps: {
maxLength: 255,
placeholder: '请输入备注',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'categoryId',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'code',
label: '商品编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入商品编号',
},
},
{
fieldName: 'name',
label: '商品名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入商品名称',
},
},
{
fieldName: 'brandId',
label: '商品品牌',
component: 'ApiSelect',
componentProps: {
allowClear: true,
api: getItemBrandSimpleList,
labelField: 'name',
placeholder: '请选择商品品牌',
showSearch: true,
valueField: 'id',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'itemInfo',
title: '商品信息',
minWidth: 220,
slots: { default: 'itemInfo' },
},
{
field: 'skuInfo',
title: '规格信息',
minWidth: 180,
slots: { default: 'skuInfo' },
},
{
field: 'priceInfo',
title: '金额(元)',
minWidth: 140,
slots: { default: 'priceInfo' },
},
{
field: 'weightInfo',
title: '重量(kg)',
minWidth: 140,
slots: { default: 'weightInfo' },
},
{
field: 'dimensionInfo',
title: '长宽高(cm)',
minWidth: 180,
align: 'right',
slots: { default: 'dimensionInfo' },
},
{
field: 'actions',
title: '操作',
width: 120,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** SKU 选择弹窗搜索表单 */
export function useSkuSelectGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'itemName',
label: '商品名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入商品名称',
},
},
{
fieldName: 'itemCode',
label: '商品编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入商品编号',
},
},
{
fieldName: 'name',
label: '规格名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入规格名称',
},
},
{
fieldName: 'code',
label: '规格编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入规格编号',
},
},
{
fieldName: 'barCode',
label: '条码',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入条码',
},
},
];
}
/** SKU 选择弹窗列表字段 */
export function useSkuSelectGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 50 },
{
field: 'itemInfo',
title: '商品信息',
minWidth: 220,
slots: { default: 'itemInfo' },
},
{
field: 'skuInfo',
title: '规格信息',
minWidth: 220,
slots: { default: 'skuInfo' },
},
{
field: 'priceInfo',
title: '金额(元)',
minWidth: 160,
slots: { default: 'priceInfo' },
},
{
field: 'weightInfo',
title: '重量(kg)',
minWidth: 160,
slots: { default: 'weightInfo' },
},
{
field: 'dimensionInfo',
title: '长宽高(cm)',
minWidth: 180,
align: 'right',
slots: { default: 'dimensionInfo' },
},
];
}

View File

@ -0,0 +1,274 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsItemApi } from '#/api/wms/md/item';
import type { WmsItemSkuApi } from '#/api/wms/md/item/sku';
import { ref } from 'vue';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Card, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteItem, exportItem, getItemPage } from '#/api/wms/md/item';
import { $t } from '#/locales';
import { WmsItemCategoryTree } from '#/views/wms/md/item/category/components';
import {
formatDimensionText,
formatPrice,
formatWeight,
} from '#/views/wms/utils/format';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
type ItemSkuRow = WmsItemSkuApi.ItemSku;
defineOptions({ name: 'WmsItem' });
const currentRows = ref<ItemSkuRow[]>([]);
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 展开商品 SKU 列表 */
function buildItemSkuRows(items: WmsItemApi.Item[]) {
return items.flatMap((item) => {
const skus = item.skus?.length ? item.skus : [{}];
return skus.map((sku) => ({
...sku,
itemId: item.id,
itemCode: item.code,
itemName: item.name,
categoryId: item.categoryId,
categoryName: item.categoryName,
unit: item.unit,
brandId: item.brandId,
brandName: item.brandName,
}));
});
}
/** 创建商品 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑商品 */
function handleEdit(row: ItemSkuRow) {
formModalApi.setData({ id: row.itemId }).open();
}
/** 删除商品 */
async function handleDelete(row: ItemSkuRow) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.itemName]),
duration: 0,
});
try {
await deleteItem(row.itemId!);
message.success($t('ui.actionMessage.deleteSuccess', [row.itemName]));
handleRefresh();
} finally {
hideLoading();
}
}
/** 导出商品 */
async function handleExport() {
const data = await exportItem(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '商品.xls', source: data });
}
/** 分类树点击 */
async function handleCategoryNodeClick(categoryId: number | undefined) {
await gridApi.formApi.setValues({ categoryId });
handleRefresh();
}
/** 合并商品维度的重复单元格 */
function handleSpanMethod({
column,
rowIndex,
}: {
column: { field?: string; property?: string };
rowIndex: number;
}) {
const field = column.field || column.property;
if (!['actions', 'itemInfo'].includes(field || '')) {
return { colspan: 1, rowspan: 1 };
}
const row = currentRows.value[rowIndex];
if (
rowIndex > 0 &&
currentRows.value[rowIndex - 1]?.itemId === row?.itemId
) {
return { colspan: 0, rowspan: 0 };
}
let rowspan = 1;
for (let index = rowIndex + 1; index < currentRows.value.length; index += 1) {
if (currentRows.value[index]?.itemId !== row?.itemId) {
break;
}
rowspan += 1;
}
return { colspan: 1, rowspan };
}
function hasValue(value: unknown) {
return value !== undefined && value !== null;
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const data = await getItemPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
currentRows.value = buildItemSkuRows(data.list || []);
return {
...data,
list: currentRows.value,
};
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
spanMethod: handleSpanMethod,
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<ItemSkuRow>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【基础】商品、SKU、分类、品牌"
url="https://doc.iocoder.cn/wms/md/item/"
/>
</template>
<FormModal @success="handleRefresh" />
<div class="flex h-full w-full">
<Card class="mr-4 h-full w-1/6">
<WmsItemCategoryTree @node-click="handleCategoryNodeClick" />
</Card>
<div class="w-5/6">
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['商品']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['wms:item:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['wms:item:export'],
onClick: handleExport,
},
]"
/>
</template>
<!-- TODO @AI高度不够支撑3 -->
<template #itemInfo="{ row }">
<div class="text-sm">{{ row.itemName || '-' }}</div>
<div v-if="row.itemCode" class="text-xs text-gray-500">
{{ row.itemCode }}
</div>
<div v-if="row.brandName" class="text-xs text-gray-500">
品牌{{ row.brandName }}
</div>
<div v-if="row.categoryName" class="text-xs text-gray-500">
分类{{ row.categoryName }}
</div>
</template>
<!-- TODO @AI高度不够支撑3 -->
<template #skuInfo="{ row }">
<div class="text-sm">{{ row.name || '-' }}</div>
<div v-if="row.code" class="text-xs text-gray-500">
编号{{ row.code }}
</div>
<div v-if="row.barCode" class="text-xs text-gray-500">
条码{{ row.barCode }}
</div>
</template>
<template #priceInfo="{ row }">
<div v-if="hasValue(row.costPrice)">
成本价{{ formatPrice(row.costPrice) }}
</div>
<div v-if="hasValue(row.sellingPrice)">
销售价{{ formatPrice(row.sellingPrice) }}
</div>
</template>
<template #weightInfo="{ row }">
<div v-if="hasValue(row.netWeight)">
净重{{ formatWeight(row.netWeight) }}
</div>
<div v-if="hasValue(row.grossWeight)">
毛重{{ formatWeight(row.grossWeight) }}
</div>
</template>
<template #dimensionInfo="{ row }">
{{ formatDimensionText(row.length, row.width, row.height) || '-' }}
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['wms:item:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['wms:item:delete'],
popConfirm: {
title: `确认删除商品【${row.itemName}】吗?`,
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</div>
</div>
</Page>
</template>

View File

@ -0,0 +1,103 @@
<script lang="ts" setup>
import type { WmsItemApi } from '#/api/wms/md/item';
// TODO @AI style user form
import { computed, nextTick, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createItem, getItem, updateItem } from '#/api/wms/md/item';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
import SkuForm from './sku-form.vue';
defineOptions({ name: 'WmsItemForm' });
const emit = defineEmits(['success']);
const formData = ref<WmsItemApi.Item>();
const skuFormRef = ref<any>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['商品'])
: $t('ui.actionTitle.create', ['商品']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 88,
},
wrapperClass: 'grid-cols-2',
layout: 'horizontal',
schema: [],
showDefaultActions: false,
});
/** 表单 schema 需要 formApi 引用,所以通过 setState 设置 schema */
formApi.setState({ schema: useFormSchema(formApi) });
async function resetSkuForm(item?: WmsItemApi.Item) {
await nextTick();
await skuFormRef.value?.setRows(item?.skus);
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
try {
skuFormRef.value?.validate();
} catch (error) {
message.warning((error as Error).message);
return;
}
modalApi.lock();
const data = (await formApi.getValues()) as WmsItemApi.Item;
data.skus = skuFormRef.value?.getRows() || [];
try {
await (formData.value?.id ? updateItem(data) : createItem(data));
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
await formApi.resetForm();
await resetSkuForm();
const data = modalApi.getData<WmsItemApi.Item>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getItem(data.id);
await formApi.setValues(formData.value);
await resetSkuForm(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-3/4">
<Form class="mx-4" />
<SkuForm ref="skuFormRef" class="mx-4 mt-4" />
</Modal>
</template>

View File

@ -0,0 +1,291 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsItemSkuApi } from '#/api/wms/md/item/sku';
import { nextTick, ref } from 'vue';
import { Button, Input, InputNumber, message } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { generateWmsCode } from '#/views/wms/utils/constants';
import {
DIMENSION_PRECISION,
PRICE_PRECISION,
WEIGHT_PRECISION,
} from '#/views/wms/utils/format';
interface SkuRow extends WmsItemSkuApi.ItemSku {
seq: number;
}
let seq = 0;
const tableData = ref<SkuRow[]>([]);
function buildEmptySku(): SkuRow {
seq += 1;
return {
seq,
id: undefined,
name: undefined,
barCode: undefined,
code: undefined,
length: undefined,
width: undefined,
height: undefined,
grossWeight: undefined,
netWeight: undefined,
costPrice: undefined,
sellingPrice: undefined,
};
}
function toSkuRow(sku: WmsItemSkuApi.ItemSku): SkuRow {
return {
...sku,
seq: ++seq,
};
}
function toSku(row: SkuRow): WmsItemSkuApi.ItemSku {
const { seq: _seq, ...sku } = row;
return sku;
}
async function reloadGrid() {
await nextTick();
await gridApi.grid.reloadData(tableData.value);
}
async function setRows(skus?: WmsItemSkuApi.ItemSku[]) {
tableData.value = skus?.length
? skus.map((sku) => toSkuRow(sku))
: [buildEmptySku()];
await reloadGrid();
}
function getRows() {
return tableData.value.map((row) => toSku(row));
}
function validate() {
if (tableData.value.length === 0) {
throw new Error('至少包含一个商品规格');
}
for (let index = 0; index < tableData.value.length; index += 1) {
const row = tableData.value[index];
if (!row?.name) {
throw new Error(`${index + 1} 行:规格名称不能为空`);
}
}
}
async function handleAddSku() {
tableData.value.push(buildEmptySku());
await reloadGrid();
}
async function handleDeleteSku(row: SkuRow) {
if (tableData.value.length <= 1) {
message.error('至少包含一个商品规格');
return;
}
const index = tableData.value.findIndex((item) => item.seq === row.seq);
if (index !== -1) {
tableData.value.splice(index, 1);
}
await reloadGrid();
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: [
{
field: 'name',
title: '规格名称',
minWidth: 150,
slots: { default: 'name' },
},
{
field: 'codeBarCode',
title: '编号/条码',
width: 260,
slots: { default: 'codeBarCode' },
},
{
field: 'dimension',
title: '长/宽/高(cm)',
width: 210,
slots: { default: 'dimension' },
},
{
field: 'weight',
title: '净重/毛重(kg)',
width: 180,
slots: { default: 'weight' },
},
{
field: 'price',
title: '成本价/销售价',
width: 180,
slots: { default: 'price' },
},
{
field: 'actions',
title: '操作',
align: 'center',
width: 80,
slots: { default: 'actions' },
},
],
data: tableData.value,
minHeight: 260,
autoResize: true,
border: true,
rowConfig: {
keyField: 'seq',
isHover: true,
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<SkuRow>,
});
defineExpose({
getRows,
setRows,
validate,
});
</script>
<template>
<div>
<div class="mb-3 flex items-center justify-between">
<span class="text-sm font-semibold">规格</span>
<TableAction
:actions="[
{
label: '新增规格',
type: 'primary',
onClick: handleAddSku,
},
]"
/>
</div>
<Grid class="w-full">
<template #name="{ row }">
<Input
v-model:value="row.name"
class="w-full"
:maxlength="255"
placeholder="请输入规格名称"
/>
</template>
<!-- TODO @AI高度不够支撑2 需要换行 -->
<template #codeBarCode="{ row }">
<Input
v-model:value="row.code"
class="w-full"
:maxlength="64"
placeholder="编号"
>
<template #addonAfter>
<Button @click="row.code = generateWmsCode('S')">生成</Button>
</template>
</Input>
<Input
v-model:value="row.barCode"
class="mt-1 w-full"
:maxlength="64"
placeholder="条码"
>
<template #addonAfter>
<Button @click="row.barCode = generateWmsCode()">生成</Button>
</template>
</Input>
</template>
<!-- TODO @AI宽度不够 -->
<template #dimension="{ row }">
<div class="flex w-full gap-1">
<InputNumber
v-model:value="row.length"
:controls="false"
:min="0"
:precision="DIMENSION_PRECISION"
class="!w-1/3"
placeholder="长"
/>
<InputNumber
v-model:value="row.width"
:controls="false"
:min="0"
:precision="DIMENSION_PRECISION"
class="!w-1/3"
placeholder="宽"
/>
<InputNumber
v-model:value="row.height"
:controls="false"
:min="0"
:precision="DIMENSION_PRECISION"
class="!w-1/3"
placeholder="高"
/>
</div>
</template>
<!-- TODO @AI高度不够支撑2 需要换行 -->
<template #weight="{ row }">
<InputNumber
v-model:value="row.netWeight"
:controls="false"
:min="0"
:precision="WEIGHT_PRECISION"
class="!w-full"
placeholder="净重"
/>
<InputNumber
v-model:value="row.grossWeight"
:controls="false"
:min="0"
:precision="WEIGHT_PRECISION"
class="mt-1 !w-full"
placeholder="毛重"
/>
</template>
<!-- TODO @AI高度不够支撑2 需要换行 -->
<template #price="{ row }">
<InputNumber
v-model:value="row.costPrice"
:controls="false"
:min="0"
:precision="PRICE_PRECISION"
class="!w-full"
placeholder="成本价"
/>
<InputNumber
v-model:value="row.sellingPrice"
:controls="false"
:min="0"
:precision="PRICE_PRECISION"
class="mt-1 !w-full"
placeholder="销售价"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '删除',
type: 'link',
danger: true,
onClick: handleDeleteSku.bind(null, row),
},
]"
/>
</template>
</Grid>
</div>
</template>