!236 【mall 商城】promotion【antd】迁移

Merge pull request !236 from puhui999/dev-promotion
This commit is contained in:
xingyu
2025-10-22 10:06:51 +00:00
committed by Gitee
18 changed files with 2470 additions and 396 deletions

View File

@@ -0,0 +1,357 @@
<!-- SPU 商品选择弹窗组件 -->
<script lang="ts" setup>
import type { CheckboxChangeEvent } from 'ant-design-vue/es/checkbox/interface';
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { computed, onMounted, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import { Checkbox, Radio } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCategoryList } from '#/api/mall/product/category';
import { getSpuPage } from '#/api/mall/product/spu';
import { getRangePickerDefaultProps } from '#/utils';
interface SpuTableSelectProps {
multiple?: boolean; // 是否多选模式
}
const props = withDefaults(defineProps<SpuTableSelectProps>(), {
multiple: false,
});
const emit = defineEmits<{
change: [spu: MallSpuApi.Spu | MallSpuApi.Spu[]];
}>();
// 单选:选中的 SPU ID
const selectedSpuId = ref<number>();
// 多选:选中状态 map
const checkedStatus = ref<Record<number, boolean>>({});
// 多选:选中的 SPU 列表
const checkedSpus = ref<MallSpuApi.Spu[]>([]);
// 多选:全选状态
const isCheckAll = ref(false);
// 多选:半选状态
const isIndeterminate = ref(false);
// 分类列表(扁平)
const categoryList = ref<any[]>([]);
// 分类树
const categoryTreeList = ref<any[]>([]);
// 初始化分类数据
onMounted(async () => {
try {
categoryList.value = await getCategoryList({});
categoryTreeList.value = handleTree(categoryList.value, 'id', 'parentId');
} catch (error) {
console.error('加载分类数据失败:', error);
}
});
// 搜索表单配置
const formSchema = computed<VbenFormSchema[]>(() => {
return [
{
fieldName: 'name',
label: '商品名称',
component: 'Input',
componentProps: {
placeholder: '请输入商品名称',
allowClear: true,
},
},
{
fieldName: 'categoryId',
label: '商品分类',
component: 'TreeSelect',
componentProps: {
treeData: categoryTreeList.value,
fieldNames: {
label: 'name',
value: 'id',
},
treeCheckStrictly: true,
placeholder: '请选择商品分类',
allowClear: true,
showSearch: true,
treeNodeFilterProp: 'name',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
});
// 列配置
const gridColumns = computed<VxeGridProps['columns']>(() => {
const columns: VxeGridProps['columns'] = [];
// 多选模式:添加多选列
if (props.multiple) {
columns.push({
field: 'checkbox',
title: '',
width: 55,
align: 'center',
slots: { default: 'checkbox-column', header: 'checkbox-header' },
});
} else {
// 单选模式:添加单选列
columns.push({
field: 'radio',
title: '#',
width: 55,
align: 'center',
slots: { default: 'radio-column' },
});
}
// 其他列
columns.push(
{
field: 'id',
title: '商品编号',
minWidth: 100,
align: 'center',
},
{
field: 'picUrl',
title: '商品图',
width: 100,
align: 'center',
cellRender: {
name: 'CellImage',
},
},
{
field: 'name',
title: '商品名称',
minWidth: 200,
},
{
field: 'categoryId',
title: '商品分类',
minWidth: 120,
formatter: ({ cellValue }) => {
const category = categoryList.value?.find((c) => c.id === cellValue);
return category?.name || '-';
},
},
);
return columns;
});
// 初始化表格
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: formSchema.value,
layout: 'horizontal',
collapsed: false,
},
gridOptions: {
columns: gridColumns.value,
height: 500,
border: true,
showOverflow: true,
proxyConfig: {
ajax: {
async query({ page }: any, formValues: any) {
try {
const params = {
pageNo: page.currentPage,
pageSize: page.pageSize,
tabType: 0, // 默认获取上架的商品
name: formValues.name || undefined,
categoryId: formValues.categoryId || undefined,
createTime: formValues.createTime || undefined,
};
const data = await getSpuPage(params);
// 初始化多选状态
if (props.multiple && data.list) {
data.list.forEach((spu) => {
if (checkedStatus.value[spu.id!] === undefined) {
checkedStatus.value[spu.id!] = false;
}
});
calculateIsCheckAll();
}
return {
items: data.list || [],
total: data.total || 0,
};
} catch (error) {
console.error('加载商品数据失败:', error);
return { items: [], total: 0 };
}
},
},
},
},
});
// 单选:处理选中
function handleSingleSelected(row: MallSpuApi.Spu) {
selectedSpuId.value = row.id;
emit('change', row);
modalApi.close();
}
// 多选:全选/全不选
function handleCheckAll(e: CheckboxChangeEvent) {
const checked = e.target.checked;
isCheckAll.value = checked;
isIndeterminate.value = false;
const currentList = gridApi.grid.getData();
currentList.forEach((spu: MallSpuApi.Spu) => {
handleCheckOne(checked, spu, false);
});
calculateIsCheckAll();
}
// 多选:选中单个
function handleCheckOne(
checked: boolean,
spu: MallSpuApi.Spu,
needCalc = true,
) {
if (checked) {
// 避免重复添加
const exists = checkedSpus.value.some((item) => item.id === spu.id);
if (!exists) {
checkedSpus.value.push(spu);
}
checkedStatus.value[spu.id!] = true;
} else {
const index = checkedSpus.value.findIndex((item) => item.id === spu.id);
if (index !== -1) {
checkedSpus.value.splice(index, 1);
}
checkedStatus.value[spu.id!] = false;
isCheckAll.value = false;
}
if (needCalc) {
calculateIsCheckAll();
}
}
// 多选:计算全选状态
function calculateIsCheckAll() {
const currentList = gridApi.grid.getData();
if (currentList.length === 0) {
isCheckAll.value = false;
isIndeterminate.value = false;
return;
}
const checkedCount = currentList.filter(
(spu: MallSpuApi.Spu) => checkedStatus.value[spu.id!],
).length;
isCheckAll.value = checkedCount === currentList.length;
isIndeterminate.value = checkedCount > 0 && checkedCount < currentList.length;
}
// 初始化弹窗
const [Modal, modalApi] = useVbenModal({
destroyOnClose: true,
// 多选模式时显示确认按钮
onConfirm: props.multiple
? () => {
emit('change', [...checkedSpus.value]);
modalApi.close();
}
: undefined,
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
// 关闭时清理状态
if (!props.multiple) {
selectedSpuId.value = undefined;
}
return;
}
// 打开时处理初始数据
const data = modalApi.getData<MallSpuApi.Spu | MallSpuApi.Spu[]>();
// 重置多选状态
if (props.multiple) {
checkedSpus.value = [];
checkedStatus.value = {};
isCheckAll.value = false;
isIndeterminate.value = false;
// 恢复已选中的数据
if (Array.isArray(data) && data.length > 0) {
checkedSpus.value = [...data];
checkedStatus.value = Object.fromEntries(
data.map((spu) => [spu.id!, true]),
);
}
} else {
// 单选模式:恢复已选中的 ID
if (data && !Array.isArray(data)) {
selectedSpuId.value = data.id;
}
}
// 触发查询
await gridApi.query();
},
});
</script>
<template>
<Modal :class="props.multiple ? 'w-[900px]' : 'w-[800px]'" title="选择商品">
<Grid>
<!-- 单选列 -->
<template v-if="!props.multiple" #radio-column="{ row }">
<Radio
:checked="selectedSpuId === row.id"
:value="row.id"
class="cursor-pointer"
@click="handleSingleSelected(row)"
/>
</template>
<!-- 多选表头 -->
<template v-if="props.multiple" #checkbox-header>
<Checkbox
v-model:checked="isCheckAll"
:indeterminate="isIndeterminate"
class="cursor-pointer"
@change="handleCheckAll"
/>
</template>
<!-- 多选列 -->
<template v-if="props.multiple" #checkbox-column="{ row }">
<Checkbox
v-model:checked="checkedStatus[row.id]"
class="cursor-pointer"
@change="(e) => handleCheckOne(e.target.checked, row)"
/>
</template>
</Grid>
</Modal>
</template>

View File

@@ -0,0 +1,5 @@
export { default as SpuAndSkuList } from './spu-and-sku-list.vue';
export { default as SpuSkuSelect } from './spu-sku-select.vue';
export type * from './types';

View File

@@ -0,0 +1,243 @@
<!-- SPU SKU 列表展示组件 -->
<script lang="ts" setup generic="T extends MallSpuApi.Spu">
import type { SpuProperty } from './types';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { MallSpuApi } from '#/api/mall/product/spu';
import type { RuleConfig } from '#/views/mall/product/spu/form';
import { computed, ref, watch } from 'vue';
import { confirm } from '@vben/common-ui';
import { fenToYuan } from '@vben/utils';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { SkuList } from '#/views/mall/product/spu/form';
interface SpuAndSkuListProps {
/** SPU 列表 */
spuList: T[];
/** 规则配置 */
ruleConfig: RuleConfig[];
/** SPU 属性列表 */
spuPropertyList: SpuProperty<T>[];
/** 是否可删除 */
deletable?: boolean;
}
const props = withDefaults(defineProps<SpuAndSkuListProps>(), {
deletable: false,
});
const emit = defineEmits<{
delete: [spuId: number];
}>();
// 内部数据
const spuData = ref<MallSpuApi.Spu[]>([]);
const spuPropertyListData = ref<SpuProperty<T>[]>([]);
const expandedRowKeys = ref<number[]>([]);
const skuListRef = ref<InstanceType<typeof SkuList>>();
// 列配置(动态计算)
const columns = computed<VxeGridProps['columns']>(() => {
const cols: VxeGridProps['columns'] = [
{
type: 'expand',
width: 50,
slots: { content: 'expand-content' },
},
{
field: 'id',
title: '商品编号',
minWidth: 100,
align: 'center',
},
{
field: 'picUrl',
title: '商品图',
width: 100,
align: 'center',
cellRender: {
name: 'CellImage',
},
},
{
field: 'name',
title: '商品名称',
minWidth: 300,
showOverflow: true,
},
{
field: 'price',
title: '商品售价',
minWidth: 120,
align: 'center',
formatter: ({ cellValue }) => `¥${fenToYuan(cellValue)}`,
},
{
field: 'salesCount',
title: '销量',
minWidth: 100,
align: 'center',
},
{
field: 'stock',
title: '库存',
minWidth: 100,
align: 'center',
},
];
// 如果可删除且有多个 SPU添加操作列
if (props.deletable && spuData.value.length > 1) {
cols.push({
title: '操作',
width: 100,
fixed: 'right',
align: 'center',
slots: { default: 'actions' },
});
}
return cols;
});
// 初始化表格
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: columns.value,
data: spuData.value,
border: true,
showOverflow: true,
rowConfig: {
keyField: 'id',
isHover: true,
},
expandConfig: {
trigger: 'row',
expandRowKeys: expandedRowKeys.value,
},
},
});
/**
* 删除 SPU
*/
async function handleDelete(row: MallSpuApi.Spu) {
await confirm({
content: `是否删除商品编号为 ${row.id} 的数据?`,
});
const index = spuData.value.findIndex((item) => item.id === row.id);
if (index !== -1) {
spuData.value.splice(index, 1);
emit('delete', row.id!);
}
}
/**
* 获取所有 SKU 活动配置
* @param extendedAttribute 在 SKU 上扩展的属性名称
*/
function getSkuConfigs(extendedAttribute: string) {
if (skuListRef.value) {
skuListRef.value.validateSku();
}
const configs: any[] = [];
spuPropertyListData.value.forEach((item) => {
item.spuDetail.skus?.forEach((sku: any) => {
if (sku[extendedAttribute]) {
configs.push(sku[extendedAttribute]);
}
});
});
return configs;
}
// 暴露方法给父组件
defineExpose({
getSkuConfigs,
});
// 监听 spuList 变化
watch(
() => props.spuList,
(data) => {
if (!data) return;
spuData.value = data as MallSpuApi.Spu[];
// 更新表格列配置和数据
gridApi.grid.reloadData(spuData.value);
gridApi.grid.reloadColumn(columns.value);
},
{ deep: true, immediate: true },
);
// 监听 spuPropertyList 变化
watch(
() => props.spuPropertyList,
(data) => {
if (!data) return;
spuPropertyListData.value = data as SpuProperty<T>[];
// 延迟展开所有行,确保 SKU 列表正确渲染
setTimeout(() => {
expandedRowKeys.value = data.map((item) => item.spuId);
// 手动展开每一行
data.forEach((item) => {
gridApi.grid.setRowExpand(
spuData.value.find((spu) => spu.id === item.spuId),
true,
);
});
}, 200);
},
{ deep: true, immediate: true },
);
</script>
<template>
<Grid>
<!-- 展开内容SKU 列表 -->
<template #expand-content="{ row }">
<div class="p-4">
<SkuList
ref="skuListRef"
:is-activity-component="true"
:prop-form-data="
spuPropertyListData.find((item) => item.spuId === row.id)?.spuDetail
"
:property-list="
spuPropertyListData.find((item) => item.spuId === row.id)
?.propertyList
"
:rule-config="props.ruleConfig"
>
<!-- 扩展插槽 -->
<template #extension>
<slot></slot>
</template>
</SkuList>
</div>
</template>
<!-- 操作列 -->
<template
v-if="props.deletable && props.spuList.length > 1"
#actions="{ row }"
>
<TableAction
:actions="[
{
label: '删除',
type: 'link',
danger: true,
onClick: () => handleDelete(row),
},
]"
/>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,341 @@
<!-- SPU SKU 联合选择弹窗组件 -->
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { computed, onMounted, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import { message, Radio } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCategoryList } from '#/api/mall/product/category';
import { getSpu, getSpuPage } from '#/api/mall/product/spu';
import { getRangePickerDefaultProps } from '#/utils';
import { getPropertyList, SkuList } from '#/views/mall/product/spu/form';
interface SpuSkuSelectProps {
/** 是否选择 SKU */
isSelectSku?: boolean;
/** 是否单选 SKU */
radio?: boolean;
}
const props = withDefaults(defineProps<SpuSkuSelectProps>(), {
isSelectSku: false,
radio: false,
});
const emit = defineEmits<{
confirm: [spuId: number, skuIds?: number[]];
}>();
// 单选:选中的 SPU ID
const selectedSpuId = ref<number>();
// 选中的 SKU ID 列表
const selectedSkuIds = ref<number[]>([]);
// SKU 列表数据
const spuData = ref<MallSpuApi.Spu>();
const propertyList = ref<any[]>([]);
const isExpand = ref(false);
const expandRowKeys = ref<number[]>([]);
const skuListRef = ref<InstanceType<typeof SkuList>>();
// 分类列表(扁平)
const categoryList = ref<any[]>([]);
// 分类树
const categoryTreeList = ref<any[]>([]);
// 初始化分类数据
onMounted(async () => {
try {
categoryList.value = await getCategoryList({});
categoryTreeList.value = handleTree(categoryList.value, 'id', 'parentId');
} catch (error) {
console.error('加载分类数据失败:', error);
}
});
// 搜索表单配置
const formSchema = computed<VbenFormSchema[]>(() => {
return [
{
fieldName: 'name',
label: '商品名称',
component: 'Input',
componentProps: {
placeholder: '请输入商品名称',
allowClear: true,
},
},
{
fieldName: 'categoryId',
label: '商品分类',
component: 'TreeSelect',
componentProps: {
treeData: categoryTreeList.value,
fieldNames: {
label: 'name',
value: 'id',
},
treeCheckStrictly: true,
placeholder: '请选择商品分类',
allowClear: true,
showSearch: true,
treeNodeFilterProp: 'name',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
});
// 列配置
const gridColumns = computed<VxeGridProps['columns']>(() => {
const columns: VxeGridProps['columns'] = [];
// 如果需要选择 SKU添加展开列
if (props.isSelectSku) {
columns.push({
type: 'expand',
width: 50,
slots: { content: 'expand-content' },
});
}
// 单选列
columns.push(
{
field: 'radio',
title: '#',
width: 55,
align: 'center',
slots: { default: 'radio-column' },
},
{
field: 'id',
title: '商品编号',
minWidth: 100,
align: 'center',
},
{
field: 'picUrl',
title: '商品图',
width: 100,
align: 'center',
cellRender: {
name: 'CellImage',
},
},
{
field: 'name',
title: '商品名称',
minWidth: 200,
},
{
field: 'categoryId',
title: '商品分类',
minWidth: 120,
formatter: ({ cellValue }) => {
const category = categoryList.value?.find((c) => c.id === cellValue);
return category?.name || '-';
},
},
);
return columns;
});
// 初始化表格
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: formSchema.value,
layout: 'horizontal',
collapsed: false,
},
gridOptions: {
columns: gridColumns.value,
height: 500,
border: true,
showOverflow: true,
expandConfig: {
expandRowKeys: expandRowKeys.value,
},
proxyConfig: {
ajax: {
async query({ page }: any, formValues: any) {
try {
const params = {
pageNo: page.currentPage,
pageSize: page.pageSize,
tabType: 0, // 默认获取上架的商品
name: formValues.name || undefined,
categoryId: formValues.categoryId || undefined,
createTime: formValues.createTime || undefined,
};
const data = await getSpuPage(params);
return {
items: data.list || [],
total: data.total || 0,
};
} catch (error) {
console.error('加载商品数据失败:', error);
return { items: [], total: 0 };
}
},
},
},
},
});
// 单选:处理选中 SPU
async function handleSingleSelected(row: MallSpuApi.Spu) {
selectedSpuId.value = row.id;
// 如果需要选择 SKU展开行
if (props.isSelectSku) {
await expandChange(row, [row]);
}
}
// 选择 SKU
function selectSku(skus: MallSpuApi.Sku[]) {
if (!selectedSpuId.value) {
message.warning('请先选择商品再选择相应的规格!');
return;
}
if (skus.length === 0) {
selectedSkuIds.value = [];
return;
}
if (props.radio) {
// 单选模式
selectedSkuIds.value = [skus[0]?.id!];
} else {
// 多选模式
selectedSkuIds.value = skus.map((sku) => sku.id!);
}
}
// 展开行,加载 SKU 列表
async function expandChange(
row: MallSpuApi.Spu,
expandedRows?: MallSpuApi.Spu[],
) {
// 检查是否是当前选中的 SPU
if (selectedSpuId.value && row.id !== selectedSpuId.value) {
message.warning('你已选择商品,请先取消');
expandRowKeys.value = [selectedSpuId.value];
return;
}
// 如果已经展开且是同一个 SPU不重复加载
if (isExpand.value && spuData.value?.id === row.id) {
return;
}
// 重置数据
spuData.value = undefined;
propertyList.value = [];
isExpand.value = false;
if (!expandedRows || expandedRows.length === 0) {
expandRowKeys.value = [];
return;
}
// 加载 SPU 详情
const res = await getSpu(row.id!);
propertyList.value = getPropertyList(res);
spuData.value = res;
isExpand.value = true;
expandRowKeys.value = [row.id!];
}
// 确认选择
function handleConfirm() {
if (!selectedSpuId.value) {
message.warning('没有选择任何商品');
return;
}
if (props.isSelectSku && selectedSkuIds.value.length === 0) {
message.warning('没有选择任何商品属性');
return;
}
// 返回选中的数据
emit(
'confirm',
selectedSpuId.value,
props.isSelectSku ? selectedSkuIds.value : undefined,
);
// 关闭弹窗
modalApi.close();
}
// 初始化弹窗
const [Modal, modalApi] = useVbenModal({
onConfirm: handleConfirm,
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
// 关闭时清理状态
selectedSpuId.value = undefined;
selectedSkuIds.value = [];
spuData.value = undefined;
propertyList.value = [];
isExpand.value = false;
expandRowKeys.value = [];
return;
}
// 打开时触发查询
await gridApi.query();
},
});
</script>
<template>
<Modal class="w-[900px]" title="商品选择">
<Grid>
<!-- 单选列 -->
<template #radio-column="{ row }">
<Radio
:checked="selectedSpuId === row.id"
:value="row.id"
class="cursor-pointer"
@click="handleSingleSelected(row)"
/>
</template>
<!-- SKU 展开内容 -->
<template v-if="props.isSelectSku" #expand-content>
<div v-if="isExpand" class="p-4">
<SkuList
ref="skuListRef"
:is-component="true"
:is-detail="true"
:prop-form-data="spuData"
:property-list="propertyList"
@selection-change="selectSku"
/>
</div>
</template>
</Grid>
</Modal>
</template>

View File

@@ -0,0 +1,15 @@
import type { PropertyAndValues } from '#/views/mall/product/spu/form';
/**
* SPU 属性配置
* 用于活动商品选择中,关联 SPU 和其属性列表
*/
export interface SpuProperty<T = any> {
/** SPU ID */
spuId: number;
/** SPU 详情 */
spuDetail: T;
/** 属性列表 */
propertyList: PropertyAndValues[];
}

View File

@@ -1,51 +1,18 @@
// 1. 导入类型
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
// 2. 导入 VBEN 常量和工具
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { $t } from '@vben/locales';
/** 表单配置 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'spuId',
label: '积分商城活动商品',
component: 'Input',
componentProps: {
placeholder: '请选择商品',
},
rules: 'required',
},
{
fieldName: 'sort',
label: '排序',
component: 'InputNumber',
componentProps: {
placeholder: '请输入排序',
min: 0,
},
rules: 'required',
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
rows: 4,
},
},
];
}
// 3. 导入 Zod 用于高级验证
import { z } from '#/adapter/form';
/** 列表的搜索表单 */
/**
* @description: 列表的搜索表单
*/
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
@@ -53,17 +20,20 @@ export function useGridFormSchema(): VbenFormSchema[] {
label: '活动状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择活动状态',
allowClear: true,
dictType: DICT_TYPE.COMMON_STATUS,
},
},
];
}
/** 列表的字段 */
/**
* @description: 列表的字段
*/
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '活动编号',
@@ -75,6 +45,9 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
minWidth: 80,
cellRender: {
name: 'CellImage',
props: {
height: 40,
},
},
},
{
@@ -86,55 +59,101 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
field: 'marketPrice',
title: '原价',
minWidth: 100,
formatter: 'formatAmount2',
},
{
field: 'point',
title: '兑换积分',
minWidth: 100,
},
{
field: 'price',
title: '兑换金额',
minWidth: 100,
formatter: 'formatAmount2',
formatter: 'formatAmount',
},
{
field: 'status',
title: '活动状态',
minWidth: 100,
align: 'center',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
props: {
type: DICT_TYPE.COMMON_STATUS,
value: CommonStatusEnum.ENABLE,
},
},
},
{
field: 'stock',
title: '库存',
minWidth: 80,
align: 'center',
},
{
field: 'totalStock',
title: '总库存',
minWidth: 80,
align: 'center',
},
{
field: 'redeemedQuantity',
title: '已兑换数量',
minWidth: 100,
slots: { default: 'redeemedQuantity' },
align: 'center',
// 使用 formatter 计算已兑换数量
formatter: ({ row }) => {
return (row.totalStock || 0) - (row.stock || 0);
},
},
{
field: 'createTime',
title: '创建时间',
title: $t('common.createTime'),
minWidth: 180,
align: 'center',
formatter: 'formatDateTime',
},
{
title: '操作',
title: $t('common.action'),
width: 150,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/**
* @description: 新增/修改的表单
*/
export function useFormSchema(): VbenFormSchema[] {
return [
// 隐藏的 ID 字段
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'spuId',
label: '积分商城活动商品',
component: 'Input',
rules: 'required',
formItemClass: 'col-span-2',
// 通过插槽实现自定义商品选择器
renderComponentContent: () => ({
default: () => null,
}),
},
{
fieldName: 'sort',
label: '排序',
component: 'InputNumber',
componentProps: {
min: 0,
},
rules: z.number().default(0),
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
rows: 4,
},
formItemClass: 'col-span-2',
},
];
}

View File

@@ -1,10 +1,9 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallPointActivityApi } from '#/api/mall/promotion/point';
import { computed } from 'vue';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
@@ -17,144 +16,132 @@ import {
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import PointActivityForm from './modules/form.vue';
import Form from './modules/form.vue';
defineOptions({ name: 'PromotionPointActivity' });
// 1. 使用 useVbenModal 初始化弹窗
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: PointActivityForm,
connectedComponent: Form,
destroyOnClose: true,
});
/** 获得商品已兑换数量 */
const getRedeemedQuantity = computed(
() => (row: MallPointActivityApi.PointActivity) =>
(row.totalStock || 0) - (row.stock || 0),
);
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建积分活动 */
// 2. 定义业务操作函数
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑积分活动 */
function handleEdit(row: MallPointActivityApi.PointActivity) {
function handleEdit(row: any) {
formModalApi.setData(row).open();
}
/** 关闭积分活动 */
function handleClose(row: MallPointActivityApi.PointActivity) {
confirm({
async function handleClose(row: any) {
await confirm({
title: '提示',
content: '确认关闭该积分商城活动吗?',
}).then(async () => {
await closePointActivity(row.id);
message.success('关闭成功');
handleRefresh();
});
await closePointActivity(row.id);
message.success('关闭成功');
gridApi.query();
}
/** 删除积分活动 */
async function handleDelete(row: MallPointActivityApi.PointActivity) {
async function handleDelete(row: any) {
await deletePointActivity(row.id);
handleRefresh();
message.success($t('common.delSuccess'));
gridApi.query();
}
function handleRefresh() {
gridApi.query();
}
// 计算操作按钮
const getActions = computed(() => (row: any) => {
const actions: any[] = [
{
label: $t('common.edit'),
icon: ACTION_ICON.EDIT,
onClick: handleEdit.bind(null, row),
},
];
// 如果状态是启用(0),显示关闭按钮
if (row.status === 0) {
actions.push({
label: '关闭',
icon: ACTION_ICON.CLOSE,
danger: true,
popConfirm: {
title: '确认关闭该积分商城活动吗?',
confirm: handleClose.bind(null, row),
},
});
} else {
// 否则显示删除按钮
actions.push({
label: $t('common.delete'),
icon: ACTION_ICON.DELETE,
danger: true,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.spuName]),
confirm: handleDelete.bind(null, row),
},
});
}
return actions;
});
// 3. 使用 useVbenVxeGrid 初始化列表
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getPointActivityPage({
const params = {
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
};
const data = await getPointActivityPage(params);
return { items: data.list, total: data.total };
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<MallPointActivityApi.PointActivity>,
} as VxeTableGridOptions,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【营销】积分商城活动"
url="https://doc.iocoder.cn/mall/promotion-point/"
/>
</template>
<Page
description="积分商城活动,用于管理积分兑换商品的配置"
doc-link="https://doc.iocoder.cn/mall/promotion-point/"
title="积分商城活动"
>
<!-- 弹窗组件的注册 -->
<FormModal @success="handleRefresh" />
<!-- 列表组件的渲染 -->
<Grid table-title="积分商城活动列表">
<!-- 工具栏按钮 -->
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['积分活动']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['promotion:point-activity:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #redeemedQuantity="{ row }">
{{ getRedeemedQuantity(row) }}
</template>
<!-- 操作列按钮 -->
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['promotion:point-activity:update'],
onClick: handleEdit.bind(null, row),
},
{
label: '关闭',
type: 'link',
danger: true,
auth: ['promotion:point-activity:close'],
ifShow: row.status === 0,
onClick: handleClose.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:point-activity:delete'],
ifShow: row.status !== 0,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.spuName]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
<TableAction :actions="getActions(row)" />
</template>
</Grid>
</Page>

View File

@@ -1,86 +1,208 @@
<script lang="ts" setup>
import type { MallPointActivityApi } from '#/api/mall/promotion/point';
import type { RuleConfig } from '#/views/mall/product/spu/form';
import type { SpuProperty } from '#/views/mall/promotion/components/types';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { useVbenModal } from '@vben/common-ui';
import { convertToInteger, formatToFraction } from '@vben/utils';
import { message } from 'ant-design-vue';
import { Button, InputNumber, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { VxeColumn } from '#/adapter/vxe-table';
import { getSpu } from '#/api/mall/product/spu';
import {
createPointActivity,
getPointActivity,
updatePointActivity,
} from '#/api/mall/promotion/point';
import { $t } from '#/locales';
import { getPropertyList } from '#/views/mall/product/spu/form';
import { SpuAndSkuList, SpuSkuSelect } from '../../../components';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MallPointActivityApi.PointActivity>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['积分活动'])
: $t('ui.actionTitle.create', ['积分活动']);
});
const formData = ref<MallPointActivityApi.PointActivity>(); // 用于存储当前编辑的数据
const isFormUpdate = ref(false); // 是否为编辑模式
const getTitle = computed(() =>
isFormUpdate.value ? '编辑积分活动' : '新增积分活动',
);
// 1. 使用 useVbenForm 初始化表单
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
// ================= 商品选择相关 =================
const spuSkuSelectRef = ref(); // 商品和属性选择 Ref
const spuAndSkuListRef = ref(); // SPU 和 SKU 列表组件 Ref
// SKU 规则配置
const ruleConfig: RuleConfig[] = [
{
name: 'productConfig.stock',
rule: (arg) => arg >= 1,
message: '商品可兑换库存必须大于等于 1 ',
},
{
name: 'productConfig.point',
rule: (arg) => arg >= 1,
message: '商品所需兑换积分必须大于等于 1 ',
},
{
name: 'productConfig.count',
rule: (arg) => arg >= 1,
message: '商品可兑换次数必须大于等于 1 ',
},
];
const spuList = ref<any[]>([]); // 选择的 SPU 列表
const spuPropertyList = ref<SpuProperty<any>[]>([]); // SPU 属性列表
/**
* 打开商品选择器
*/
function openSpuSelect() {
spuSkuSelectRef.value.open();
}
/**
* 选择商品后的回调
*/
async function handleSpuSelected(spuId: number, skuIds?: number[]) {
await formApi.setFieldValue('spuId', spuId);
await getSpuDetails(spuId, skuIds);
}
/**
* 获取 SPU 详情
*/
async function getSpuDetails(
spuId: number,
skuIds?: number[],
products?: MallPointActivityApi.PointProduct[],
) {
const spuProperties: SpuProperty<any>[] = [];
const res = await getSpu(spuId);
if (!res) {
return;
}
spuList.value = [];
// 筛选指定的 SKU
const selectSkus =
skuIds === undefined
? res.skus
: res.skus?.filter((sku) => skuIds.includes(sku.id!));
// 为每个 SKU 配置积分商城相关的配置
selectSkus?.forEach((sku: any) => {
let config: MallPointActivityApi.PointProduct = {
skuId: sku.id!,
stock: 0,
price: 0,
point: 0,
count: 0,
};
// 如果是编辑模式,回填已有配置
if (products !== undefined) {
const product = products.find((item) => item.skuId === sku.id);
if (product) {
product.price = formatToFraction(product.price) as unknown as number;
}
config = product || config;
}
sku.productConfig = config;
});
res.skus = selectSkus;
spuProperties.push({
spuId: res.id!,
spuDetail: res,
propertyList: getPropertyList(res),
});
spuList.value.push(res);
spuPropertyList.value = spuProperties;
}
// ================= end =================
// 2. 使用 useVbenModal 定义弹窗行为
const [Modal, modalApi] = useVbenModal({
// "确认"按钮的回调
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
if (!valid) return;
modalApi.lock();
// 提交表单
const data =
(await formApi.getValues()) as MallPointActivityApi.PointActivity;
// 确保必要的默认值
if (!data.products) {
data.products = [];
}
if (!data.sort) {
data.sort = 0;
}
try {
await (formData.value?.id
// 获取积分商城商品配置
const products: MallPointActivityApi.PointProduct[] =
spuAndSkuListRef.value?.getSkuConfigs('productConfig') || [];
// 价格需要转为分
products.forEach((item) => {
item.price = convertToInteger(item.price);
});
const data =
(await formApi.getValues()) as MallPointActivityApi.PointActivity;
data.products = products;
// 真正提交
await (isFormUpdate.value
? updatePointActivity(data)
: createPointActivity(data));
// 关闭并提示
message.success($t('ui.actionMessage.operationSuccess'));
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
// 弹窗打开时的回调
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
// 关闭时清理状态
formData.value = undefined;
isFormUpdate.value = false;
spuList.value = [];
spuPropertyList.value = [];
return;
}
// 加载数据
const data = modalApi.getData<MallPointActivityApi.PointActivity>();
const data = modalApi.getData();
if (!data || !data.id) {
// 新增模式
isFormUpdate.value = false;
return;
}
// 编辑模式
isFormUpdate.value = true;
modalApi.lock();
try {
formData.value = await getPointActivity(data.id);
// 设置到 values
await getSpuDetails(
formData.value.spuId,
formData.value.products?.map((sku) => sku.skuId),
formData.value.products,
);
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
@@ -90,16 +212,79 @@ const [Modal, modalApi] = useVbenModal({
</script>
<template>
<Modal class="w-3/5" :title="getTitle">
<div class="p-4">
<div class="mb-4 rounded border border-yellow-200 bg-yellow-50 p-4">
<p class="text-yellow-800">
<strong>注意</strong>
积分活动涉及复杂的商品选择和SKU配置当前为简化版本
完整的商品选择和积分配置功能需要在后续版本中完善
</p>
</div>
<Form />
</div>
<Modal :title="getTitle" class="w-[70%]">
<Form>
<!-- 商品选择 -->
<template #spuId>
<div class="w-full">
<Button v-if="!isFormUpdate" type="primary" @click="openSpuSelect">
选择商品
</Button>
<!-- SPU SKU 列表展示 -->
<SpuAndSkuList
v-if="spuList.length > 0"
ref="spuAndSkuListRef"
:rule-config="ruleConfig"
:spu-list="spuList"
:spu-property-list="spuPropertyList"
class="mt-4"
>
<!-- 扩展列积分商城特有配置 -->
<template #default>
<VxeColumn align="center" min-width="168" title="可兑换库存">
<template #default="{ row: sku }">
<InputNumber
v-model:value="sku.productConfig.stock"
:max="sku.stock"
:min="0"
class="w-full"
/>
</template>
</VxeColumn>
<VxeColumn align="center" min-width="168" title="可兑换次数">
<template #default="{ row: sku }">
<InputNumber
v-model:value="sku.productConfig.count"
:min="0"
class="w-full"
/>
</template>
</VxeColumn>
<VxeColumn align="center" min-width="168" title="所需积分">
<template #default="{ row: sku }">
<InputNumber
v-model:value="sku.productConfig.point"
:min="0"
class="w-full"
/>
</template>
</VxeColumn>
<VxeColumn align="center" min-width="168" title="所需金额(元)">
<template #default="{ row: sku }">
<InputNumber
v-model:value="sku.productConfig.price"
:min="0"
:precision="2"
:step="0.1"
class="w-full"
/>
</template>
</VxeColumn>
</template>
</SpuAndSkuList>
</div>
</template>
</Form>
<!-- 商品选择器弹窗 -->
<SpuSkuSelect
ref="spuSkuSelectRef"
:is-select-sku="true"
@confirm="handleSpuSelected"
/>
</Modal>
</template>

View File

@@ -0,0 +1,2 @@
export { default as PointShowcase } from './point-showcase.vue';
export { default as PointTableSelect } from './point-table-select.vue';

View File

@@ -0,0 +1,163 @@
<!-- 积分活动橱窗组件 - 用于装修时展示和选择积分活动 -->
<script lang="ts" setup>
import type { MallPointActivityApi } from '#/api/mall/promotion/point';
import { computed, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Image, Tooltip } from 'ant-design-vue';
import { getPointActivityListByIds } from '#/api/mall/promotion/point';
import PointTableSelect from './point-table-select.vue';
interface PointShowcaseProps {
modelValue: number | number[];
limit?: number;
disabled?: boolean;
}
const props = withDefaults(defineProps<PointShowcaseProps>(), {
limit: Number.MAX_VALUE,
disabled: false,
});
const emit = defineEmits<{
change: [value: any];
'update:modelValue': [value: number | number[]];
}>();
// 积分活动列表
const pointActivityList = ref<MallPointActivityApi.PointActivity[]>([]);
// 计算是否可以添加
const canAdd = computed(() => {
if (props.disabled) return false;
if (!props.limit) return true;
return pointActivityList.value.length < props.limit;
});
// 积分活动选择器引用
const pointActivityTableSelectRef = ref();
/**
* 打开积分活动选择器
*/
function openPointActivityTableSelect() {
pointActivityTableSelectRef.value.open(pointActivityList.value);
}
/**
* 选择活动后触发
*/
function handleActivitySelected(
activityList:
| MallPointActivityApi.PointActivity
| MallPointActivityApi.PointActivity[],
) {
pointActivityList.value = Array.isArray(activityList)
? activityList
: [activityList];
emitActivityChange();
}
/**
* 删除活动
*/
function handleRemoveActivity(index: number) {
pointActivityList.value.splice(index, 1);
emitActivityChange();
}
/**
* 发送变更事件
*/
function emitActivityChange() {
if (props.limit === 1) {
const pointActivity =
pointActivityList.value.length > 0 ? pointActivityList.value[0] : null;
emit('update:modelValue', pointActivity?.id || 0);
emit('change', pointActivity);
} else {
emit(
'update:modelValue',
pointActivityList.value.map((pointActivity) => pointActivity.id),
);
emit('change', pointActivityList.value);
}
}
// 监听 modelValue 变化
watch(
() => props.modelValue,
async () => {
const ids = Array.isArray(props.modelValue)
? props.modelValue
: (props.modelValue
? [props.modelValue]
: []);
// 不需要返显
if (ids.length === 0) {
pointActivityList.value = [];
return;
}
// 只有活动发生变化之后,才会查询活动
if (
pointActivityList.value.length === 0 ||
pointActivityList.value.some(
(pointActivity) => !ids.includes(pointActivity.id!),
)
) {
pointActivityList.value = await getPointActivityListByIds(ids);
}
},
{ immediate: true },
);
</script>
<template>
<div class="flex flex-wrap items-center gap-2">
<!-- 活动图片列表 -->
<div
v-for="(pointActivity, index) in pointActivityList"
:key="pointActivity.id"
class="relative flex h-[60px] w-[60px] cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300"
>
<Tooltip :title="pointActivity.spuName">
<div class="relative h-full w-full">
<Image
:preview="true"
:src="pointActivity.picUrl"
class="h-full w-full rounded-lg object-cover"
/>
<IconifyIcon
v-show="!disabled"
icon="ep:circle-close-filled"
class="absolute -right-2 -top-2 z-10 h-5 w-5 cursor-pointer text-red-500 hover:text-red-600"
@click="handleRemoveActivity(index)"
/>
</div>
</Tooltip>
</div>
<!-- 添加按钮 -->
<Tooltip v-if="canAdd" title="选择活动">
<div
class="flex h-[60px] w-[60px] cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 hover:border-blue-400"
@click="openPointActivityTableSelect"
>
<IconifyIcon icon="ep:plus" class="text-lg text-gray-400" />
</div>
</Tooltip>
</div>
<!-- 积分活动选择对话框 -->
<PointTableSelect
ref="pointActivityTableSelectRef"
:multiple="limit != 1"
@change="handleActivitySelected"
/>
</template>

View File

@@ -0,0 +1,354 @@
<!-- 积分活动表格选择器 -->
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { MallPointActivityApi } from '#/api/mall/promotion/point';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { Checkbox, Radio } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getPointActivityPage } from '#/api/mall/promotion/point';
interface PointTableSelectProps {
multiple?: boolean;
}
const props = withDefaults(defineProps<PointTableSelectProps>(), {
multiple: false,
});
const emit = defineEmits<{
change: [
value:
| MallPointActivityApi.PointActivity
| MallPointActivityApi.PointActivity[],
];
}>();
// 单选:选中的活动 ID
const selectedActivityId = ref<number>();
// 多选:选中状态映射
const checkedStatus = ref<Record<number, boolean>>({});
// 多选:选中的活动列表
const checkedActivities = ref<MallPointActivityApi.PointActivity[]>([]);
// 全选状态
const isCheckAll = ref(false);
const isIndeterminate = ref(false);
// 搜索表单配置
const formSchema = computed<VbenFormSchema[]>(() => {
return [
{
fieldName: 'status',
label: '活动状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择活动状态',
allowClear: true,
},
},
];
});
// 列配置
const gridColumns = computed<VxeGridProps['columns']>(() => {
const columns: VxeGridProps['columns'] = [];
// 多选模式
if (props.multiple) {
columns.push({
field: 'checkbox',
title: '',
width: 55,
align: 'center',
slots: { default: 'checkbox-column', header: 'checkbox-header' },
});
} else {
// 单选模式
columns.push({
field: 'radio',
title: '#',
width: 55,
align: 'center',
slots: { default: 'radio-column' },
});
}
columns.push(
{
field: 'id',
title: '活动编号',
minWidth: 80,
},
{
field: 'picUrl',
title: '商品图片',
minWidth: 80,
cellRender: {
name: 'CellImage',
props: {
height: 40,
},
},
},
{
field: 'spuName',
title: '商品标题',
minWidth: 300,
},
{
field: 'marketPrice',
title: '原价',
minWidth: 100,
formatter: 'formatAmount',
},
{
field: 'status',
title: '活动状态',
minWidth: 100,
align: 'center',
cellRender: {
name: 'CellDict',
props: {
type: DICT_TYPE.COMMON_STATUS,
},
},
},
{
field: 'stock',
title: '库存',
minWidth: 80,
align: 'center',
},
{
field: 'totalStock',
title: '总库存',
minWidth: 80,
align: 'center',
},
{
field: 'redeemedQuantity',
title: '已兑换数量',
minWidth: 100,
align: 'center',
formatter: ({ row }) => (row.totalStock || 0) - (row.stock || 0),
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
align: 'center',
formatter: 'formatDateTime',
},
);
return columns;
});
// 初始化表格
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: formSchema.value,
},
gridOptions: {
columns: gridColumns.value,
height: 500,
border: true,
showOverflow: true,
proxyConfig: {
ajax: {
async query({ page }: any, formValues: any) {
try {
const params: any = {
pageNo: page.currentPage,
pageSize: page.pageSize,
};
if (formValues.status !== undefined) {
params.status = formValues.status;
}
const data = await getPointActivityPage(params);
const list = data.list || [];
// 初始化 checkbox 绑定
list.forEach(
(activityVO) =>
(checkedStatus.value[activityVO.id] =
checkedStatus.value[activityVO.id] || false),
);
// 计算全选框状态
calculateIsCheckAll(list);
return {
items: list,
total: data.total || 0,
};
} catch (error) {
console.error('加载积分活动数据失败:', error);
return { items: [], total: 0 };
}
},
},
},
},
});
/**
* 单选:处理选中
*/
function handleSingleSelected(row: MallPointActivityApi.PointActivity) {
selectedActivityId.value = row.id;
emit('change', row);
modalApi.close();
}
/**
* 多选:全选/全不选
*/
function handleCheckAll(e: any) {
const checked = e.target.checked;
isCheckAll.value = checked;
isIndeterminate.value = false;
const list = gridApi.grid.getData();
list.forEach((pointActivity) =>
handleCheckOne(checked, pointActivity, false),
);
}
/**
* 多选:选中一行
*/
function handleCheckOne(
checked: boolean,
pointActivity: MallPointActivityApi.PointActivity,
isCalcCheckAll: boolean,
) {
if (checked) {
checkedActivities.value.push(pointActivity);
checkedStatus.value[pointActivity.id] = true;
} else {
const index = findCheckedIndex(pointActivity);
if (index > -1) {
checkedActivities.value.splice(index, 1);
checkedStatus.value[pointActivity.id] = false;
isCheckAll.value = false;
}
}
// 计算全选框状态
if (isCalcCheckAll) {
const list = gridApi.grid.getData();
calculateIsCheckAll(list);
}
}
/**
* 查找活动在已选中列表中的索引
*/
function findCheckedIndex(activityVO: MallPointActivityApi.PointActivity) {
return checkedActivities.value.findIndex((item) => item.id === activityVO.id);
}
/**
* 计算全选框状态
*/
function calculateIsCheckAll(list: MallPointActivityApi.PointActivity[]) {
isCheckAll.value = list.every(
(activityVO) => checkedStatus.value[activityVO.id],
);
isIndeterminate.value =
!isCheckAll.value &&
list.some((activityVO) => checkedStatus.value[activityVO.id]);
}
/**
* 多选:确认选择
*/
function handleConfirm() {
emit('change', [...checkedActivities.value]);
modalApi.close();
}
/**
* 打开对话框
*/
function open(pointList?: MallPointActivityApi.PointActivity[]) {
// 重置
checkedActivities.value = [];
checkedStatus.value = {};
isCheckAll.value = false;
isIndeterminate.value = false;
// 处理已选中
if (pointList && pointList.length > 0) {
checkedActivities.value = [...pointList];
checkedStatus.value = Object.fromEntries(
pointList.map((activityVO) => [activityVO.id, true]),
);
}
modalApi.open();
}
// 暴露 open 方法
defineExpose({ open });
// 初始化弹窗
const [Modal, modalApi] = useVbenModal({
onConfirm: props.multiple ? handleConfirm : undefined,
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
// 关闭时清理状态
if (!props.multiple) {
selectedActivityId.value = undefined;
}
return;
}
// 打开时触发查询
await gridApi.query();
},
});
</script>
<template>
<Modal class="w-[70%]" title="选择活动">
<Grid>
<!-- 多选表头 checkbox -->
<template v-if="props.multiple" #checkbox-header>
<Checkbox
v-model:checked="isCheckAll"
:indeterminate="isIndeterminate"
@change="handleCheckAll"
/>
</template>
<!-- 多选 checkbox -->
<template v-if="props.multiple" #checkbox-column="{ row }">
<Checkbox
v-model:checked="checkedStatus[row.id]"
@change="(e: any) => handleCheckOne(e.target.checked, row, true)"
/>
</template>
<!-- 单选 radio -->
<template v-if="!props.multiple" #radio-column="{ row }">
<Radio
:checked="selectedActivityId === row.id"
:value="row.id"
class="cursor-pointer"
@click="handleSingleSelected(row)"
/>
</template>
</Grid>
</Modal>
</template>

View File

@@ -0,0 +1,2 @@
export { default as RewardRuleCouponSelect } from './reward-rule-coupon-select.vue';
export { default as RewardRule } from './reward-rule.vue';

View File

@@ -0,0 +1,131 @@
<script lang="ts" setup>
import type { MallRewardActivityApi } from '#/api/mall/promotion/reward/rewardActivity';
import { nextTick, onMounted, ref } from 'vue';
import { useVModel } from '@vueuse/core';
import { Button, Input } from 'ant-design-vue';
// import { CouponSelect } from '@/views/mall/promotion/coupon/components'; // TODO: 根据实际路径调整
// import * as CouponTemplateApi from '#/api/mall/promotion/coupon/couponTemplate'; // TODO: API
// import { discountFormat } from '@/views/mall/promotion/coupon/formatter'; // TODO: 根据实际路径调整
defineOptions({ name: 'RewardRuleCouponSelect' });
const props = defineProps<{
modelValue: MallRewardActivityApi.RewardRule;
}>();
const emits = defineEmits<{
(e: 'update:modelValue', v: any): void;
}>();
const rewardRule = useVModel(props, 'modelValue', emits);
const list = ref<any[]>([]); // TODO: 改为 GiveCouponVO[] 类型
const CouponTemplateTakeTypeEnum = {
ADMIN: { type: 2 },
};
/** 选择优惠券 */
// const couponSelectRef = ref<InstanceType<typeof CouponSelect>>();
function selectCoupon() {
// couponSelectRef.value?.open();
}
/** 选择优惠券后的回调 */
function handleCouponChange(val: any[]) {
for (const item of val) {
if (list.value.some((v) => v.id === item.id)) {
continue;
}
list.value.push(item);
}
}
/** 删除优惠券 */
function deleteCoupon(index: number) {
list.value.splice(index, 1);
}
/** 初始化赠送的优惠券列表 */
async function initGiveCouponList() {
// if (!rewardRule.value || !rewardRule.value.giveCouponTemplateCounts) {
// return;
// }
// const tempLateIds = Object.keys(rewardRule.value.giveCouponTemplateCounts);
// const data = await CouponTemplateApi.getCouponTemplateList(tempLateIds);
// if (!data) {
// return;
// }
// data.forEach((coupon) => {
// list.value.push({
// ...coupon,
// giveCount: rewardRule.value.giveCouponTemplateCounts![coupon.id],
// });
// });
}
/** 设置赠送的优惠券 */
function setGiveCouponList() {
if (!rewardRule.value) {
return;
}
rewardRule.value.giveCouponTemplateCounts = {};
list.value.forEach((rule) => {
rewardRule.value.giveCouponTemplateCounts![rule.id] = rule.giveCount!;
});
}
defineExpose({ setGiveCouponList });
onMounted(async () => {
await nextTick();
await initGiveCouponList();
});
</script>
<template>
<div class="w-full">
<Button type="link" class="ml-2" @click="selectCoupon">添加优惠劵</Button>
<div
v-for="(item, index) in list"
:key="item.id"
class="coupon-list-item mb-2 flex justify-between rounded-lg border border-dashed border-gray-300 p-2"
>
<div class="coupon-list-item-left flex flex-wrap items-center gap-2">
<div>优惠券名称{{ item.name }}</div>
<div>
范围
<!-- <DictTag :type="DICT_TYPE.PROMOTION_PRODUCT_SCOPE" :value="item.productScope" /> -->
{{ item.productScope }}
</div>
<div class="flex items-center">
优惠
<!-- <DictTag :type="DICT_TYPE.PROMOTION_DISCOUNT_TYPE" :value="item.discountType" /> -->
<!-- {{ discountFormat(item) }} -->
{{ item.discountType }}
</div>
</div>
<div class="coupon-list-item-right flex items-center gap-2">
<span></span>
<Input
v-model:value="item.giveCount"
class="!w-150px"
placeholder=""
type="number"
/>
<span></span>
<Button type="link" danger @click="deleteCoupon(index)">删除</Button>
</div>
</div>
<!-- 优惠券选择 -->
<!-- <CouponSelect
ref="couponSelectRef"
:take-type="CouponTemplateTakeTypeEnum.ADMIN.type"
@change="handleCouponChange"
/> -->
</div>
</template>

View File

@@ -0,0 +1,184 @@
<script lang="ts" setup>
import type { MallRewardActivityApi } from '#/api/mall/promotion/reward/rewardActivity';
import { computed, ref } from 'vue';
import { useVModel } from '@vueuse/core';
import {
Button,
Col,
Form,
FormItem,
Input,
InputNumber,
Row,
Switch,
Tag,
} from 'ant-design-vue';
import RewardRuleCouponSelect from './reward-rule-coupon-select.vue';
defineOptions({ name: 'RewardRule' });
const props = defineProps<{
modelValue: MallRewardActivityApi.RewardActivity;
}>();
const emits = defineEmits<{
(e: 'update:modelValue', v: any): void;
}>();
const formData = useVModel(props, 'modelValue', emits);
const rewardRuleCouponSelectRef =
ref<InstanceType<typeof RewardRuleCouponSelect>[]>();
const PromotionConditionTypeEnum = {
PRICE: { type: 10 },
COUNT: { type: 20 },
};
const isPriceCondition = computed(() => {
return (
formData.value?.conditionType === PromotionConditionTypeEnum.PRICE.type
);
});
/** 删除优惠规则 */
function deleteRule(ruleIndex: number) {
formData.value.rules.splice(ruleIndex, 1);
}
/** 添加优惠规则 */
function addRule() {
if (!formData.value.rules) {
formData.value.rules = [];
}
formData.value.rules.push({
limit: 0,
discountPrice: 0,
freeDelivery: false,
point: 0,
});
}
/** 设置规则优惠券-提交时 */
function setRuleCoupon() {
if (!rewardRuleCouponSelectRef.value) {
return;
}
rewardRuleCouponSelectRef.value.forEach((item: any) =>
item.setGiveCouponList(),
);
}
defineExpose({ setRuleCoupon });
</script>
<template>
<Row>
<template v-if="formData.rules">
<Col v-for="(rule, index) in formData.rules" :key="index" :span="24">
<div class="mb-4">
<span class="font-bold">活动层级{{ index + 1 }}</span>
<Button
v-if="index !== 0"
type="link"
danger
class="ml-2"
@click="deleteRule(index)"
>
删除
</Button>
</div>
<Form :model="rule">
<FormItem label="优惠门槛:" label-col="{ span: 4 }">
<div class="flex items-center gap-2">
<span></span>
<InputNumber
v-if="isPriceCondition"
v-model:value="rule.limit"
:min="0"
:precision="2"
:step="0.1"
class="!w-150px"
placeholder=""
controls-position="right"
/>
<Input
v-else
v-model:value="rule.limit"
:min="0"
class="!w-150px"
placeholder=""
type="number"
/>
<span>{{ isPriceCondition ? '元' : '件' }}</span>
</div>
</FormItem>
<FormItem label="优惠内容:" label-col="{ span: 4 }">
<div class="flex flex-col gap-4">
<!-- 订单金额优惠 -->
<div class="flex items-center gap-2">
<span>订单金额优惠</span>
</div>
<div class="ml-4 flex items-center gap-2">
<span></span>
<InputNumber
v-model:value="rule.discountPrice"
:min="0"
:precision="2"
:step="0.1"
class="!w-150px"
controls-position="right"
/>
<span></span>
</div>
<!-- 包邮 -->
<div class="flex items-center gap-2">
<span>包邮</span>
<Switch
v-model:checked="rule.freeDelivery"
checked-children=""
un-checked-children=""
/>
</div>
<!-- 送积分 -->
<div class="flex items-center gap-2">
<span>送积分</span>
<span></span>
<Input
v-model:value="rule.point"
class="!w-150px"
placeholder=""
type="number"
/>
<span>积分</span>
</div>
<!-- 送优惠券 -->
<div class="flex items-center gap-2">
<span>送优惠券</span>
<RewardRuleCouponSelect
ref="rewardRuleCouponSelectRef"
v-model="rule"
/>
</div>
</div>
</FormItem>
</Form>
</Col>
</template>
<Col :span="24" class="mt-4">
<Button type="primary" @click="addRule">添加优惠规则</Button>
</Col>
<Col :span="24" class="mt-4">
<Tag color="warning">赠送积分为 0 时不赠送未选优惠券时不赠送</Tag>
</Col>
</Row>
</template>

View File

@@ -1,84 +1,20 @@
// 1. 导入类型
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
// 2. 导入 VBEN 常量和工具
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { $t } from '@vben/locales';
/** 表单配置 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '活动名称',
component: 'Input',
componentProps: {
placeholder: '请输入活动名称',
},
rules: 'required',
},
{
fieldName: 'startTime',
label: '开始时间',
component: 'DatePicker',
componentProps: {
placeholder: '请选择开始时间',
showTime: true,
valueFormat: 'x',
format: 'YYYY-MM-DD HH:mm:ss',
},
rules: 'required',
},
{
fieldName: 'endTime',
label: '结束时间',
component: 'DatePicker',
componentProps: {
placeholder: '请选择结束时间',
showTime: true,
valueFormat: 'x',
format: 'YYYY-MM-DD HH:mm:ss',
},
rules: 'required',
},
{
fieldName: 'conditionType',
label: '条件类型',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.PROMOTION_CONDITION_TYPE, 'number'),
},
rules: 'required',
},
{
fieldName: 'productScope',
label: '商品范围',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.PROMOTION_PRODUCT_SCOPE, 'number'),
},
rules: 'required',
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
rows: 4,
},
},
];
}
// 3. 导入 Zod 用于高级验证
import { z } from '#/adapter/form';
// 4. 导入项目级工具函数
import { getRangePickerDefaultProps } from '#/utils';
/** 列表的搜索表单 */
/**
* @description: 列表的搜索表单
*/
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
@@ -95,9 +31,9 @@ export function useGridFormSchema(): VbenFormSchema[] {
label: '活动状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择活动状态',
allowClear: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
{
@@ -105,28 +41,30 @@ export function useGridFormSchema(): VbenFormSchema[] {
label: '活动时间',
component: 'RangePicker',
componentProps: {
placeholder: ['活动开始日期', '活动结束日期'],
...getRangePickerDefaultProps(),
allowClear: true,
valueFormat: 'YYYY-MM-DD HH:mm:ss',
},
},
];
}
/** 列表的字段 */
/**
* @description: 列表的字段
*/
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '活动名称',
minWidth: 140,
minWidth: 200,
},
{
field: 'productScope',
title: '活动范围',
minWidth: 100,
minWidth: 120,
align: 'center',
cellRender: {
name: 'CellDict',
name: 'CellDictTag',
props: { type: DICT_TYPE.PROMOTION_PRODUCT_SCOPE },
},
},
@@ -134,20 +72,23 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
field: 'startTime',
title: '活动开始时间',
minWidth: 180,
align: 'center',
formatter: 'formatDateTime',
},
{
field: 'endTime',
title: '活动结束时间',
minWidth: 180,
align: 'center',
formatter: 'formatDateTime',
},
{
field: 'status',
title: '状态',
minWidth: 100,
align: 'center',
cellRender: {
name: 'CellDict',
name: 'CellDictTag',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
@@ -159,9 +100,77 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
},
{
title: '操作',
width: 180,
width: 200,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/**
* @description: 新增/修改的表单
*/
export function useFormSchema(): VbenFormSchema[] {
return [
// 隐藏的 ID 字段
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '活动名称',
component: 'Input',
componentProps: {
placeholder: '请输入活动名称',
},
rules: z.string().min(1, '活动名称不能为空'),
},
{
fieldName: 'startAndEndTime',
label: '活动时间',
component: 'RangePicker',
componentProps: {
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
placeholder: [$t('common.startTimeText'), $t('common.endTimeText')],
},
rules: z.array(z.any()).min(1, '活动时间不能为空'),
},
{
fieldName: 'conditionType',
label: '条件类型',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.PROMOTION_CONDITION_TYPE, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number(),
},
{
fieldName: 'productScope',
label: '活动范围',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.PROMOTION_PRODUCT_SCOPE, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number(),
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
rows: 4,
},
},
];
}

View File

@@ -1,8 +1,9 @@
<script lang="ts" setup>
// 严格遵循导入顺序原则
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallRewardActivityApi } from '#/api/mall/promotion/reward/rewardActivity';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
@@ -15,46 +16,32 @@ import {
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import RewardActivityForm from './modules/form.vue';
import Form from './modules/form.vue';
defineOptions({ name: 'PromotionRewardActivity' });
// 1. 使用 useVbenModal 初始化弹窗
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: RewardActivityForm,
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建满减送活动 */
// 2. 定义业务操作函数
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑满减送活动 */
function handleEdit(row: MallRewardActivityApi.RewardActivity) {
formModalApi.setData(row).open();
formModalApi.setData({ id: row.id }).open();
}
/** 关闭活动 */
async function handleClose(row: MallRewardActivityApi.RewardActivity) {
try {
await confirm({
content: '确认关闭该满减送活动吗?',
});
} catch {
return;
}
const hideLoading = message.loading({
content: '正在关闭中',
content: '活动关闭中...',
duration: 0,
});
try {
await closeRewardActivity(row.id as number);
await closeRewardActivity(row.id!);
message.success({
content: '关闭成功',
});
@@ -64,105 +51,81 @@ async function handleClose(row: MallRewardActivityApi.RewardActivity) {
}
}
/** 删除活动 */
async function handleDelete(row: MallRewardActivityApi.RewardActivity) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
});
try {
await deleteRewardActivity(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
});
handleRefresh();
} finally {
hideLoading();
}
await deleteRewardActivity(row.id!);
message.success($t('common.delSuccess'));
handleRefresh();
}
function handleRefresh() {
gridApi.query();
}
// 3. 使用 useVbenVxeGrid 初始化列表
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getRewardActivityPage({
const params = {
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
};
return await getRewardActivityPage(params);
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<MallRewardActivityApi.RewardActivity>,
} as VxeTableGridOptions,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【营销】满减送"
url="https://doc.iocoder.cn/mall/promotion-record/"
/>
</template>
<Page>
<!-- 弹窗组件的注册 -->
<FormModal @success="handleRefresh" />
<Grid table-title="满减送活动列表">
<!-- 列表组件的渲染 -->
<Grid table-title="满减送活动">
<!-- 工具栏按钮 -->
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['满减送活动']),
type: 'primary',
label: $t('ui.actionTitle.create', ['活动']),
icon: ACTION_ICON.ADD,
auth: ['promotion:reward-activity:create'],
onClick: handleCreate,
},
]"
/>
</template>
<!-- 操作列按钮 -->
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['promotion:reward-activity:update'],
onClick: handleEdit.bind(null, row),
},
{
label: '关闭',
type: 'link',
icon: ACTION_ICON.CLOSE,
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:reward-activity:close'],
ifShow: row.status === 0,
onClick: handleClose.bind(null, row),
popConfirm: {
title: '确认关闭该满减活动吗?',
confirm: handleClose.bind(null, row),
},
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:reward-activity:delete'],
ifShow: row.status !== 0,
danger: true,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),

View File

@@ -1,103 +1,216 @@
<script lang="ts" setup>
import type { VbenFormProps } from '#/adapter/form';
import type { MallRewardActivityApi } from '#/api/mall/promotion/reward/rewardActivity';
import { computed, ref } from 'vue';
import { computed, nextTick, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { useVbenModal } from '@vben/common-ui';
import {
PromotionConditionTypeEnum,
PromotionProductScopeEnum,
} from '@vben/constants';
import { convertToInteger, formatToFraction } from '@vben/utils';
import { message } from 'ant-design-vue';
import { Alert, FormItem, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createRewardActivity,
getReward,
updateRewardActivity,
} from '#/api/mall/promotion/reward/rewardActivity';
import { $t } from '#/locales';
import { SpuAndSkuList } from '#/views/mall/promotion/components';
import RewardRule from '../components/reward-rule.vue';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MallRewardActivityApi.RewardActivity>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['满减送活动'])
: $t('ui.actionTitle.create', ['满减送活动']);
const formData = ref<MallRewardActivityApi.RewardActivity>({
conditionType: PromotionConditionTypeEnum.PRICE.type,
productScope: PromotionProductScopeEnum.ALL.scope,
rules: [],
});
const getTitle = computed(() =>
formData.value?.id ? '编辑满减送' : '新增满减送',
);
// 1. 使用 useVbenForm 初始化表单
const [Form, formApi] = useVbenForm({
schema: useFormSchema(),
showDefaultActions: false,
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 100,
},
wrapperClass: 'grid-cols-2',
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
} as VbenFormProps['commonConfig'],
});
const rewardRuleRef = ref<InstanceType<typeof RewardRule>>();
// 2. 使用 useVbenModal 定义弹窗行为
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
if (!valid) return;
modalApi.lock();
// 提交表单
const data =
(await formApi.getValues()) as MallRewardActivityApi.RewardActivity;
// 确保必要的默认值
if (!data.rules) {
data.rules = [];
}
try {
await (formData.value?.id
? updateRewardActivity(data)
: createRewardActivity(data));
// 关闭并提示
const data = await formApi.getValues();
// 1. 设置活动规则优惠券
rewardRuleRef.value?.setRuleCoupon();
// 2. 时间段转换
if (data.startAndEndTime && Array.isArray(data.startAndEndTime)) {
data.startTime = data.startAndEndTime[0];
data.endTime = data.startAndEndTime[1];
delete data.startAndEndTime;
}
// 3. 规则元转分
data.rules?.forEach((item: any) => {
item.discountPrice = convertToInteger(item.discountPrice || 0);
if (data.conditionType === PromotionConditionTypeEnum.PRICE.type) {
item.limit = convertToInteger(item.limit || 0);
}
});
// 4. 设置商品范围
setProductScopeValues(data);
if (formData.value?.id) {
await updateRewardActivity(<MallRewardActivityApi.RewardActivity>data);
message.success($t('common.updateSuccess'));
} else {
await createRewardActivity(<MallRewardActivityApi.RewardActivity>data);
message.success($t('common.createSuccess'));
}
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<MallRewardActivityApi.RewardActivity>();
if (!data || !data.id) {
formData.value = {
conditionType: PromotionConditionTypeEnum.PRICE.type,
productScope: PromotionProductScopeEnum.ALL.scope,
rules: [],
};
return;
}
const data = modalApi.getData();
if (!data || !data.id) return;
modalApi.lock();
try {
formData.value = await getReward(data.id);
// 设置到 values
await formApi.setValues(formData.value);
const result = await getReward(data.id);
// 转区段时间
result.startAndEndTime = [result.startTime, result.endTime];
// 规则分转元
result.rules?.forEach((item: any) => {
item.discountPrice = formatToFraction(item.discountPrice || 0);
if (result.conditionType === PromotionConditionTypeEnum.PRICE.type) {
item.limit = formatToFraction(item.limit || 0);
}
});
formData.value = result;
await formApi.setValues(result);
// 获得商品范围
await getProductScope();
} finally {
modalApi.unlock();
}
},
});
/** 获得商品范围 */
async function getProductScope() {
switch (formData.value.productScope) {
case PromotionProductScopeEnum.CATEGORY.scope: {
await nextTick();
let productCategoryIds = formData.value.productScopeValues as any;
if (
Array.isArray(productCategoryIds) &&
productCategoryIds.length === 1
) {
// 单选时使用数组不能反显
productCategoryIds = productCategoryIds[0];
}
// 设置品类编号
formData.value.productCategoryIds = productCategoryIds;
break;
}
case PromotionProductScopeEnum.SPU.scope: {
// 设置商品编号
formData.value.productSpuIds = formData.value.productScopeValues;
break;
}
default: {
break;
}
}
}
/** 设置商品范围 */
function setProductScopeValues(data: any) {
switch (formData.value.productScope) {
case PromotionProductScopeEnum.CATEGORY.scope: {
data.productScopeValues = Array.isArray(formData.value.productCategoryIds)
? formData.value.productCategoryIds
: [formData.value.productCategoryIds];
break;
}
case PromotionProductScopeEnum.SPU.scope: {
data.productScopeValues = formData.value.productSpuIds;
break;
}
default: {
break;
}
}
}
</script>
<template>
<Modal class="w-4/5" :title="getTitle">
<Modal :title="getTitle" class="!w-[65%]">
<Alert
description="【营销】满减送"
message="提示"
show-icon
type="info"
class="mb-4"
/>
<Form />
<!-- 简化说明 -->
<div class="mt-4 rounded bg-blue-50 p-4">
<p class="text-sm text-blue-600">
<strong>说明</strong> 当前为简化版本的满减送活动表单
复杂的商品选择优惠规则配置等功能已简化仅保留基础字段配置
如需完整功能请参考原始 Element UI 版本的实现
</p>
</div>
<!-- 优惠设置 -->
<FormItem label="优惠设置">
<RewardRule ref="rewardRuleRef" v-model="formData" />
</FormItem>
<!-- 商品范围选择 -->
<FormItem
v-if="formData.productScope === PromotionProductScopeEnum.SPU.scope"
label="选择商品"
>
<SpuAndSkuList
v-model:spu-ids="formData.productSpuIds"
:rule-config="[]"
:spu-property-list="[]"
:deletable="true"
:spu-list="[]"/>
</FormItem>
<!-- 分类选择 -->
<!-- 注意category 选择器暂未实现需要根据实际需求添加 -->
</Modal>
</template>