feat:【mall 商城】快递模版的迁移(ele 100%)

This commit is contained in:
YunaiV
2025-10-12 19:45:52 +08:00
parent 31a583419a
commit 0d02c60478
5 changed files with 675 additions and 14 deletions

View File

@@ -1,11 +1,109 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
/** 计费方式列标题映射 */
export const CHARGE_MODE_TITLE_MAP: Record<
number,
{
extraCountTitle: string;
startCountTitle: string;
}
> = {
1: { startCountTitle: '首件', extraCountTitle: '续件' },
2: { startCountTitle: '首件重量(kg)', extraCountTitle: '续件重量(kg)' },
3: { startCountTitle: '首件体积(m³)', extraCountTitle: '续件体积(m³)' },
};
/** 包邮方式列标题映射 */
export const FREE_MODE_TITLE_MAP: Record<number, { freeCountTitle: string }> = {
1: { freeCountTitle: '包邮件数' },
2: { freeCountTitle: '包邮重量(kg)' },
3: { freeCountTitle: '包邮体积(m³)' },
};
/** 运费设置表格列 */
export function useChargesColumns(
chargeMode = 1,
): VxeTableGridOptions['columns'] {
const chargeTitleMap = CHARGE_MODE_TITLE_MAP[chargeMode];
return [
{
field: 'areaIds',
title: '区域',
minWidth: 300,
slots: { default: 'areaIds' },
},
{
field: 'startCount',
title: chargeTitleMap?.startCountTitle,
width: 120,
slots: { default: 'startCount' },
},
{
field: 'startPrice',
title: '运费(元)',
width: 120,
slots: { default: 'startPrice' },
},
{
field: 'extraCount',
title: chargeTitleMap?.extraCountTitle,
width: 120,
slots: { default: 'extraCount' },
},
{
field: 'extraPrice',
title: '续费(元)',
width: 120,
slots: { default: 'extraPrice' },
},
{
title: '操作',
width: 80,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 包邮设置表格列 */
export function useFreesColumns(
chargeMode = 1,
): VxeTableGridOptions['columns'] {
const freeTitleMap = FREE_MODE_TITLE_MAP[chargeMode];
return [
{
field: 'areaIds',
title: '区域',
minWidth: 300,
slots: { default: 'areaIds' },
},
{
field: 'freeCount',
title: freeTitleMap?.freeCountTitle,
width: 120,
slots: { default: 'freeCount' },
},
{
field: 'freePrice',
title: '包邮金额(元)',
width: 120,
slots: { default: 'freePrice' },
},
{
title: '操作',
width: 80,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
@@ -21,6 +119,9 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'Input',
fieldName: 'name',
label: '模板名称',
componentProps: {
placeholder: '请输入模板名称',
},
rules: 'required',
},
{
@@ -32,17 +133,32 @@ export function useFormSchema(): VbenFormSchema[] {
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
rules: z.number().default(1),
},
{
fieldName: 'sort',
label: '显示顺序',
component: 'InputNumber',
componentProps: {
placeholder: '请输入显示顺序',
min: 0,
controlsPosition: 'right',
class: '!w-full',
},
rules: 'required',
},
{
fieldName: 'charges',
label: '运费设置',
component: 'Input',
formItemClass: 'col-span-3',
},
{
fieldName: 'frees',
label: '包邮设置',
component: 'Input',
formItemClass: 'col-span-3',
},
];
}
@@ -53,12 +169,17 @@ export function useGridFormSchema(): VbenFormSchema[] {
fieldName: 'name',
label: '模板名称',
component: 'Input',
componentProps: {
placeholder: '请输入模板名称',
clearable: true,
},
},
{
fieldName: 'chargeMode',
label: '计费方式',
component: 'Select',
componentProps: {
placeholder: '请选择计费方式',
clearable: true,
options: getDictOptions(DICT_TYPE.EXPRESS_CHARGE_MODE, 'number'),
},
@@ -72,14 +193,17 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
{
field: 'id',
title: '编号',
minWidth: 100,
},
{
field: 'name',
title: '模板名称',
minWidth: 200,
},
{
field: 'chargeMode',
title: '计费方式',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.EXPRESS_CHARGE_MODE },
@@ -88,10 +212,12 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
{
field: 'sort',
title: '显示顺序',
minWidth: 100,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{

View File

@@ -22,7 +22,7 @@ const [FormModal, formModalApi] = useVbenModal({
});
/** 刷新表格 */
function onRefresh() {
function handleRefresh() {
gridApi.query();
}
@@ -46,7 +46,7 @@ async function handleDelete(
try {
await deleteDeliveryExpressTemplate(row.id as number);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
handleRefresh();
} finally {
loadingInstance.close();
}
@@ -73,6 +73,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
@@ -84,7 +85,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<FormModal @success="handleRefresh" />
<Grid table-title="快递模板列表">
<template #toolbar-tools>
<TableAction
@@ -104,6 +105,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['trade:delivery:express-template:update'],
@@ -111,8 +113,8 @@ const [Grid, gridApi] = useVbenVxeGrid({
},
{
label: $t('common.delete'),
link: true,
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['trade:delivery:express-template:delete'],
popConfirm: {

View File

@@ -0,0 +1,230 @@
<script lang="ts" setup>
import type { MallDeliveryExpressTemplateApi } from '#/api/mall/trade/delivery/expressTemplate';
import type { SystemAreaApi } from '#/api/system/area';
import { computed, nextTick, ref, watch } from 'vue';
import { ElInputNumber, ElTreeSelect } from 'element-plus';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { CHARGE_MODE_TITLE_MAP, useChargesColumns } from '../data';
interface Props {
items?: MallDeliveryExpressTemplateApi.TemplateCharge[];
chargeMode?: number;
areaTree?: SystemAreaApi.Area[];
}
const props = withDefaults(defineProps<Props>(), {
items: () => [],
chargeMode: 1,
areaTree: () => [],
});
const emit = defineEmits(['update:items']);
const tableData = ref<any[]>([]);
const columnTitle = computed(() => CHARGE_MODE_TITLE_MAP[props.chargeMode]);
/** 表格配置 */
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useChargesColumns(props.chargeMode),
data: tableData.value,
minHeight: 200,
autoResize: true,
border: true,
rowConfig: {
keyField: 'seq',
isHover: true,
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
},
});
/** 监听外部传入的数据 */
watch(
() => props.items,
async (items) => {
if (!items) {
return;
}
tableData.value = [...items];
await nextTick();
await gridApi.grid.reloadData(tableData.value);
},
{
immediate: true,
},
);
/** 监听计费方式变化 */
watch(
() => props.chargeMode,
() => {
const columns = useChargesColumns(props.chargeMode);
if (gridApi.grid && columns) {
gridApi.grid.reloadColumn(columns);
}
},
);
/** 处理新增 */
function handleAdd() {
const newRow = {
areaIds: [],
startCount: undefined,
startPrice: undefined,
extraCount: undefined,
extraPrice: undefined,
};
tableData.value.push(newRow);
emit('update:items', [...tableData.value]);
}
/** 处理删除 */
function handleDelete(row: any) {
const index = tableData.value.findIndex((item) => item.seq === row.seq);
if (index !== -1) {
tableData.value.splice(index, 1);
}
emit('update:items', [...tableData.value]);
}
/** 处理行数据变更 */
function handleRowChange(row: any) {
const index = tableData.value.findIndex((item) => item.seq === row.seq);
if (index === -1) {
tableData.value.push(row);
} else {
tableData.value[index] = row;
}
emit('update:items', [...tableData.value]);
}
/** 表单校验 */
function validate() {
for (let i = 0; i < tableData.value.length; i++) {
const item = tableData.value[i];
if (!item.areaIds || item.areaIds.length === 0) {
throw new Error(`运费设置第 ${i + 1} 行:区域不能为空`);
}
if (!item.startCount || item.startCount <= 0) {
throw new Error(
`运费设置第 ${i + 1} 行:${columnTitle.value?.startCountTitle}必须大于 0`,
);
}
if (!item.startPrice || item.startPrice <= 0) {
throw new Error(`运费设置第 ${i + 1}运费必须大于0`);
}
if (!item.extraCount || item.extraCount <= 0) {
throw new Error(
`运费设置第 ${i + 1} 行:${columnTitle.value?.extraCountTitle}必须大于 0`,
);
}
if (!item.extraPrice || item.extraPrice <= 0) {
throw new Error(`运费设置第 ${i + 1} 行:续费必须大于 0`);
}
}
}
defineExpose({
validate,
});
</script>
<template>
<Grid class="w-full">
<template #areaIds="{ row }">
<!-- TODO 芋艿可优化使用 Cascade不过貌似 ele multiple 貌似有 bug -->
<ElTreeSelect
v-model="row.areaIds"
:data="areaTree"
:props="{
label: 'name',
value: 'id',
children: 'children',
}"
placeholder="请选择地区"
class="w-full"
multiple
show-checkbox
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="1"
@change="handleRowChange(row)"
/>
</template>
<template #startCount="{ row }">
<ElInputNumber
v-model="row.startCount"
:min="1"
@change="handleRowChange(row)"
controls-position="right"
class="!w-full"
/>
</template>
<template #startPrice="{ row }">
<ElInputNumber
v-model="row.startPrice"
:min="0"
:precision="2"
@change="handleRowChange(row)"
controls-position="right"
class="!w-full"
/>
</template>
<template #extraCount="{ row }">
<ElInputNumber
v-model="row.extraCount"
:min="1"
@change="handleRowChange(row)"
controls-position="right"
class="!w-full"
/>
</template>
<template #extraPrice="{ row }">
<ElInputNumber
v-model="row.extraPrice"
:min="0"
:precision="2"
@change="handleRowChange(row)"
controls-position="right"
class="!w-full"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '删除',
type: 'danger',
link: true,
popConfirm: {
title: '确认删除该区域吗?',
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
<template #bottom>
<TableAction
class="mt-2 flex justify-center"
:actions="[
{
label: '添加计费区域',
type: 'default',
onClick: handleAdd,
},
]"
/>
</template>
</Grid>
</template>

View File

@@ -1,9 +1,11 @@
<script lang="ts" setup>
import type { MallDeliveryExpressTemplateApi } from '#/api/mall/trade/delivery/expressTemplate';
import type { SystemAreaApi } from '#/api/system/area';
import { computed, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { cloneDeep, fenToYuan, yuanToFen } from '@vben/utils';
import { ElMessage } from 'element-plus';
@@ -13,12 +15,19 @@ import {
getDeliveryExpressTemplate,
updateDeliveryExpressTemplate,
} from '#/api/mall/trade/delivery/expressTemplate';
import { getAreaTree } from '#/api/system/area';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
import ChargeItemForm from './charge-item-form.vue';
import FreeItemForm from './free-item-form.vue';
const emit = defineEmits(['success']);
const formData = ref<MallDeliveryExpressTemplateApi.ExpressTemplate>();
const chargeItemFormRef = ref<InstanceType<typeof ChargeItemForm>>();
const freeItemFormRef = ref<InstanceType<typeof FreeItemForm>>();
const areaTree = ref<SystemAreaApi.Area[]>([]);
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['快递模板'])
@@ -30,26 +39,83 @@ const [Form, formApi] = useVbenForm({
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
wrapperClass: 'grid-cols-3',
layout: 'vertical',
schema: useFormSchema(),
showDefaultActions: false,
handleValuesChange: (values, changedFields) => {
// 目的:触发子表单的 columns 变化
if (changedFields.includes('chargeMode')) {
formData.value!.chargeMode = values.chargeMode;
}
},
});
// TODO @xingyu城市处理
/** 更新运费设置 */
const handleUpdateCharges = async (
charges: MallDeliveryExpressTemplateApi.TemplateCharge[],
) => {
formData.value =
await formApi.getValues<MallDeliveryExpressTemplateApi.ExpressTemplate>();
formData.value.charges = charges;
await formApi.setValues({
charges,
});
};
/** 更新包邮设置 */
const handleUpdateFrees = async (
frees: MallDeliveryExpressTemplateApi.TemplateFree[],
) => {
formData.value =
await formApi.getValues<MallDeliveryExpressTemplateApi.ExpressTemplate>();
formData.value.frees = frees;
await formApi.setValues({
frees,
});
};
/** 创建或更新快递模板 */
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const chargeFormInstance = Array.isArray(chargeItemFormRef.value)
? chargeItemFormRef.value[0]
: chargeItemFormRef.value;
const freeFormInstance = Array.isArray(freeItemFormRef.value)
? freeItemFormRef.value[0]
: freeItemFormRef.value;
try {
chargeFormInstance.validate();
freeFormInstance.validate();
} catch (error: any) {
ElMessage.error(error.message || '子表单验证失败');
return;
}
modalApi.lock();
// 提交表单
const data =
(await formApi.getValues()) as MallDeliveryExpressTemplateApi.ExpressTemplate;
const data = cloneDeep(
await formApi.getValues(),
) as MallDeliveryExpressTemplateApi.ExpressTemplate;
try {
// 转换金额单位
data.charges?.forEach(
(item: MallDeliveryExpressTemplateApi.TemplateCharge) => {
item.startPrice = yuanToFen(item.startPrice);
item.extraPrice = yuanToFen(item.extraPrice);
},
);
data.frees?.forEach(
(item: MallDeliveryExpressTemplateApi.TemplateFree) => {
item.freePrice = yuanToFen(item.freePrice);
},
);
await (formData.value?.id
? updateDeliveryExpressTemplate(data)
: createDeliveryExpressTemplate(data));
@@ -75,6 +141,18 @@ const [Modal, modalApi] = useVbenModal({
modalApi.lock();
try {
formData.value = await getDeliveryExpressTemplate(data.id);
// 转换金额单位
formData.value.charges?.forEach(
(item: MallDeliveryExpressTemplateApi.TemplateCharge) => {
item.startPrice = Number.parseFloat(fenToYuan(item.startPrice));
item.extraPrice = Number.parseFloat(fenToYuan(item.extraPrice));
},
);
formData.value.frees?.forEach(
(item: MallDeliveryExpressTemplateApi.TemplateFree) => {
item.freePrice = Number.parseFloat(fenToYuan(item.freePrice));
},
);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
@@ -82,10 +160,34 @@ const [Modal, modalApi] = useVbenModal({
}
},
});
/** 初始化 */
onMounted(async () => {
areaTree.value = await getAreaTree();
});
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Form class="mx-4" />
<Modal class="w-1/2" :title="getTitle">
<Form class="mx-3">
<template #charges>
<ChargeItemForm
ref="chargeItemFormRef"
:items="formData?.charges"
:charge-mode="formData?.chargeMode"
:area-tree="areaTree"
@update:items="handleUpdateCharges"
/>
</template>
<template #frees>
<FreeItemForm
ref="freeItemFormRef"
:items="formData?.frees"
:charge-mode="formData?.chargeMode"
:area-tree="areaTree"
@update:items="handleUpdateFrees"
/>
</template>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,201 @@
<script lang="ts" setup>
import type { MallDeliveryExpressTemplateApi } from '#/api/mall/trade/delivery/expressTemplate';
import type { SystemAreaApi } from '#/api/system/area';
import { computed, nextTick, ref, watch } from 'vue';
import { ElInputNumber, ElTreeSelect } from 'element-plus';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { FREE_MODE_TITLE_MAP, useFreesColumns } from '../data';
interface Props {
items?: MallDeliveryExpressTemplateApi.TemplateFree[];
chargeMode?: number;
areaTree?: SystemAreaApi.Area[];
}
const props = withDefaults(defineProps<Props>(), {
items: () => [],
chargeMode: 1,
areaTree: () => [],
});
const emit = defineEmits(['update:items']);
const tableData = ref<any[]>([]);
const columnTitle = computed(() => FREE_MODE_TITLE_MAP[props.chargeMode]);
/** 表格配置 */
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useFreesColumns(props.chargeMode),
data: tableData.value,
minHeight: 200,
autoResize: true,
border: true,
rowConfig: {
keyField: 'seq',
isHover: true,
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
},
});
/** 监听外部传入的数据 */
watch(
() => props.items,
async (items) => {
if (!items) {
return;
}
tableData.value = [...items];
await nextTick();
await gridApi.grid.reloadData(tableData.value);
},
{
immediate: true,
},
);
/** 监听计费方式变化 */
watch(
() => props.chargeMode,
() => {
const columns = useFreesColumns(props.chargeMode);
if (gridApi.grid && columns) {
gridApi.grid.reloadColumn(columns);
}
},
);
/** 处理新增 */
function handleAdd() {
const newRow = {
areaIds: [],
freeCount: undefined,
freePrice: undefined,
};
tableData.value.push(newRow);
emit('update:items', [...tableData.value]);
}
/** 处理删除 */
function handleDelete(row: any) {
const index = tableData.value.findIndex((item) => item.seq === row.seq);
if (index !== -1) {
tableData.value.splice(index, 1);
}
emit('update:items', [...tableData.value]);
}
/** 处理行数据变更 */
function handleRowChange(row: any) {
const index = tableData.value.findIndex((item) => item.seq === row.seq);
if (index === -1) {
tableData.value.push(row);
} else {
tableData.value[index] = row;
}
emit('update:items', [...tableData.value]);
}
/** 表单校验 */
function validate() {
for (let i = 0; i < tableData.value.length; i++) {
const item = tableData.value[i];
if (!item.areaIds || item.areaIds.length === 0) {
throw new Error(`包邮设置第 ${i + 1} 行:区域不能为空`);
}
if (!item.freeCount || item.freeCount <= 0) {
throw new Error(
`包邮设置第 ${i + 1} 行:${columnTitle.value?.freeCountTitle}必须大于 0`,
);
}
if (!item.freePrice || item.freePrice <= 0) {
throw new Error(`包邮设置第 ${i + 1} 行:包邮金额必须大于 0`);
}
}
}
defineExpose({
validate,
});
</script>
<template>
<Grid class="w-full">
<template #areaIds="{ row }">
<!-- TODO 芋艿可优化使用 Cascade不过貌似 ele multiple 貌似有 bug -->
<ElTreeSelect
v-model="row.areaIds"
:data="areaTree"
:props="{
label: 'name',
value: 'id',
children: 'children',
}"
placeholder="请选择地区"
class="w-full"
multiple
show-checkbox
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="1"
@change="handleRowChange(row)"
/>
</template>
<template #freeCount="{ row }">
<ElInputNumber
v-model="row.freeCount"
:min="1"
@change="handleRowChange(row)"
controls-position="right"
class="!w-full"
/>
</template>
<template #freePrice="{ row }">
<ElInputNumber
v-model="row.freePrice"
:min="0"
:precision="2"
@change="handleRowChange(row)"
controls-position="right"
class="!w-full"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '删除',
type: 'danger',
link: true,
popConfirm: {
title: '确认删除该区域吗?',
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
<template #bottom>
<TableAction
class="mt-2 flex justify-center"
:actions="[
{
label: '添加包邮区域',
type: 'default',
onClick: handleAdd,
},
]"
/>
</template>
</Grid>
</template>