This commit is contained in:
lzh
2025-12-31 10:50:50 +08:00
parent d45226b90b
commit 647dd1ac7e
468 changed files with 33538 additions and 14843 deletions

View File

@@ -21,7 +21,6 @@ import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
// 初始化 formData确保始终有值
const formData = ref<Partial<MallCommentApi.Comment>>({
descriptionScores: 5,
benefitScores: 5,
@@ -100,16 +99,14 @@ const [Modal, modalApi] = useVbenModal({
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
// 重置表单数据
// 关闭时重置表单状态
selectedSku.value = undefined;
await formApi.setValues({ spuId: undefined, skuId: undefined });
return;
}
// 加载数据
const data = modalApi.getData<MallCommentApi.Comment>();
if (!data || !data.id) {
// 新建模式:重置表单
selectedSku.value = undefined;
await formApi.setValues({ spuId: undefined, skuId: undefined });
return;
}
// 编辑模式:加载数据

View File

@@ -127,13 +127,13 @@ watch(
</SkuList>
</template>
</VxeColumn>
<VxeColumn field="id" align="center" title="商品编号" />
<VxeColumn field="id" align="center" title="商品编号" min-width="30" />
<VxeColumn title="商品图" min-width="80">
<template #default="{ row }">
<Image
v-if="row.picUrl"
:src="row.picUrl"
class="h-[30px] w-[30px] cursor-pointer"
class="!h-[30px] !w-[30px] cursor-pointer"
:preview="true"
/>
</template>

View File

@@ -2,8 +2,8 @@ import type { Ref } from 'vue';
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeGridProps, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallSpuApi } from '#/api/mall/product/spu';
import type { MallCategoryApi } from '#/api/mall/product/category';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { computed } from 'vue';

View File

@@ -4,7 +4,7 @@ import type { MallSpuApi } from '#/api/mall/product/spu';
import { computed, ref, watch } from 'vue';
import { CloseCircleFilled, PlusOutlined } from '@vben/icons';
import { IconifyIcon } from '@vben/icons';
import { Image, Tooltip } from 'ant-design-vue';
@@ -110,9 +110,9 @@ function emitSpuChange() {
class="h-full w-full rounded-lg object-cover"
/>
<!-- 删除按钮 -->
<!-- TODO @AI还是使用 IconifyIcon使用自己的 + 图标 -->
<CloseCircleFilled
<IconifyIcon
v-if="!disabled"
icon="lucide:circle-x"
class="absolute -right-2 -top-2 cursor-pointer text-xl text-red-500 opacity-0 transition-opacity hover:text-red-600 group-hover:opacity-100"
@click="handleRemoveSpu(index)"
/>
@@ -126,8 +126,7 @@ function emitSpuChange() {
class="flex h-[60px] w-[60px] cursor-pointer items-center justify-center rounded-lg border-2 border-dashed transition-colors hover:border-primary hover:bg-primary/5"
@click="handleOpenSpuSelect"
>
<!-- TODO @AI还是使用 IconifyIcon使用自己的 + 图标 -->
<PlusOutlined class="text-xl text-gray-400" />
<IconifyIcon icon="lucide:plus" class="text-xl text-gray-400" />
</div>
</Tooltip>
</div>

View File

@@ -32,29 +32,6 @@ const spuId = ref<number>();
const { params, name } = useRoute();
const { closeCurrentTab } = useTabs();
const activeTabName = ref('info');
const tabList = ref([
{
key: 'info',
tab: '基础设置',
},
{
key: 'sku',
tab: '价格库存',
},
{
key: 'delivery',
tab: '物流设置',
},
{
key: 'description',
tab: '商品详情',
},
{
key: 'other',
tab: '其它设置',
},
]);
const formLoading = ref(false); // 表单的加载中1修改时的数据加载2提交的按钮禁用
const isDetail = ref(name === 'ProductSpuDetail'); // 是否查看详情
const skuListRef = ref(); // 商品属性列表 Ref
@@ -94,22 +71,22 @@ const propertyList = ref<PropertyAndValues[]>([]); // 商品属性列表
const ruleConfig: RuleConfig[] = [
{
name: 'stock',
rule: (arg) => arg >= 0,
rule: (arg: number) => arg >= 0,
message: '商品库存必须大于等于 1 ',
},
{
name: 'price',
rule: (arg) => arg >= 0.01,
rule: (arg: number) => arg >= 0.01,
message: '商品销售价格必须大于等于 0.01 元!!!',
},
{
name: 'marketPrice',
rule: (arg) => arg >= 0.01,
rule: (arg: number) => arg >= 0.01,
message: '商品市场价格必须大于等于 0.01 元!!!',
},
{
name: 'costPrice',
rule: (arg) => arg >= 0.01,
rule: (arg: number) => arg >= 0.01,
message: '商品成本价格必须大于等于 0.00 元!!!',
},
]; // sku 相关属性校验规则
@@ -221,7 +198,7 @@ async function handleSubmit() {
item.secondBrokeragePrice = convertToInteger(item.secondBrokeragePrice);
});
}
// 处理轮播图列表 TODO @puhui999这个是必须的哇
// 处理轮播图列表:上传组件可能返回对象或字符串,统一处理成字符串数组
const newSliderPicUrls: any[] = [];
values.sliderPicUrls!.forEach((item: any) => {
// 如果是前端选的图
@@ -343,7 +320,28 @@ onMounted(async () => {
<Card
class="h-full w-full"
:loading="formLoading"
:tab-list="tabList"
:tab-list="[
{
key: 'info',
tab: '基础设置',
},
{
key: 'sku',
tab: '价格库存',
},
{
key: 'delivery',
tab: '物流设置',
},
{
key: 'description',
tab: '商品详情',
},
{
key: 'other',
tab: '其它设置',
},
]"
:active-key="activeTabName"
@tab-change="handleTabChange"
>
@@ -409,7 +407,6 @@ onMounted(async () => {
</div>
</template>
<style lang="scss" scoped>
// TODO @puhui999这个样式是必须的哇
:deep(.ant-tabs-tab-btn) {
font-size: 14px !important;
}

View File

@@ -130,7 +130,6 @@ const [Modal, modalApi] = useVbenModal({
await modalApi.close();
emit('success');
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;

View File

@@ -24,6 +24,7 @@ export function useFormSchema(): VbenFormSchema[] {
placeholder: '请输入活动名称',
},
rules: 'required',
formItemClass: 'col-span-2',
},
{
fieldName: 'startTime',
@@ -32,6 +33,7 @@ export function useFormSchema(): VbenFormSchema[] {
componentProps: {
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
showTime: true,
placeholder: '请选择开始时间',
},
rules: 'required',
@@ -43,51 +45,18 @@ export function useFormSchema(): VbenFormSchema[] {
componentProps: {
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
showTime: true,
placeholder: '请选择结束时间',
},
rules: 'required',
},
{
fieldName: 'bargainFirstPrice',
label: '砍价起始价格(元)',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
step: 0.01,
placeholder: '请输入砍价起始价格',
},
rules: 'required',
},
{
fieldName: 'bargainMinPrice',
label: '砍价底价(元)',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
step: 0.01,
placeholder: '请输入砍价底价',
},
rules: 'required',
},
{
fieldName: 'stock',
label: '活动库存',
component: 'InputNumber',
componentProps: {
min: 1,
placeholder: '请输入活动库存',
},
rules: 'required',
},
{
fieldName: 'helpMaxCount',
label: '助力人数',
component: 'InputNumber',
componentProps: {
min: 1,
placeholder: '请输入助力人数',
placeholder: '达到该人数才能砍到低价',
},
rules: 'required',
},
@@ -97,7 +66,7 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'InputNumber',
componentProps: {
min: 1,
placeholder: '请输入砍价次数',
placeholder: '最大帮砍次数',
},
rules: 'required',
},
@@ -107,7 +76,7 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'InputNumber',
componentProps: {
min: 1,
placeholder: '请输入购买限制',
placeholder: '最大购买次数',
},
rules: 'required',
},
@@ -119,7 +88,7 @@ export function useFormSchema(): VbenFormSchema[] {
min: 0,
precision: 2,
step: 0.01,
placeholder: '请输入最小砍价金额',
placeholder: '用户每次砍价的最小金额',
},
},
{
@@ -130,9 +99,16 @@ export function useFormSchema(): VbenFormSchema[] {
min: 0,
precision: 2,
step: 0.01,
placeholder: '请输入最大砍价金额',
placeholder: '用户每次砍价的最大金额',
},
},
{
fieldName: 'spuId',
label: '砍价商品',
component: 'Input',
rules: 'required',
formItemClass: 'col-span-2',
},
];
}

View File

@@ -41,23 +41,14 @@ function handleEdit(row: MallBargainActivityApi.BargainActivity) {
/** 关闭砍价活动 */
async function handleClose(row: MallBargainActivityApi.BargainActivity) {
try {
await confirm({
content: '确认关闭该砍价活动吗?',
});
} catch {
return;
}
await confirm('确认关闭该砍价活动吗?');
const hideLoading = message.loading({
content: '确认关闭该砍价活动吗?',
content: '关闭中...',
duration: 0,
});
try {
await closeBargainActivity(row.id as number);
message.success({
content: '关闭成功',
});
await closeBargainActivity(row.id!);
message.success('关闭成功');
handleRefresh();
} finally {
hideLoading();
@@ -71,10 +62,8 @@ async function handleDelete(row: MallBargainActivityApi.BargainActivity) {
duration: 0,
});
try {
await deleteBargainActivity(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
});
await deleteBargainActivity(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading();

View File

@@ -1,19 +1,32 @@
<script lang="ts" setup>
import type { MallSpuApi } from '#/api/mall/product/spu';
import type { MallBargainActivityApi } from '#/api/mall/promotion/bargain/bargainActivity';
import type {
RuleConfig,
SpuProperty,
} from '#/views/mall/product/spu/components';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { cloneDeep, 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 {
createBargainActivity,
getBargainActivity,
updateBargainActivity,
} from '#/api/mall/promotion/bargain/bargainActivity';
import { $t } from '#/locales';
import {
getPropertyList,
SpuAndSkuList,
SpuSkuSelect,
} from '#/views/mall/product/spu/components';
import { useFormSchema } from '../data';
@@ -33,14 +46,116 @@ const [Form, formApi] = useVbenForm({
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
labelWidth: 130,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
// ================= 商品选择相关 =================
const spuSkuSelectRef = ref(); // 商品和属性选择 Ref
const spuAndSkuListRef = ref(); // SPU 和 SKU 列表组件 Ref
const ruleConfig: RuleConfig[] = [
{
name: 'productConfig.bargainFirstPrice',
rule: (arg) => arg > 0,
message: '商品砍价起始价格必须大于 0 ',
},
{
name: 'productConfig.bargainMinPrice',
rule: (arg) => arg >= 0,
message: '商品砍价底价不能小于 0 ',
},
{
name: 'productConfig.stock',
rule: (arg) => arg >= 1,
message: '商品活动库存必须大于等于 1 ',
},
];
const spuList = ref<MallSpuApi.Spu[]>([]); // 选择的 SPU 列表
const spuPropertyList = ref<SpuProperty<MallSpuApi.Spu>[]>([]); // 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?: MallBargainActivityApi.BargainProduct[],
) {
const res = await getSpu(spuId);
if (!res) {
return;
}
spuList.value = [];
// 筛选指定的 SKU砍价活动只选择一个 SKU
const selectSkus =
skuIds === undefined
? res.skus
: res.skus?.filter((sku) => skuIds.includes(sku.id!));
// 为每个 SKU 配置砍价活动相关的配置
selectSkus?.forEach((sku) => {
let config: MallBargainActivityApi.BargainProduct = {
spuId: res.id!,
skuId: sku.id!,
bargainFirstPrice: 1,
bargainMinPrice: 1,
stock: 1,
};
// 如果是编辑模式,回填已有配置
if (products !== undefined) {
const product = products.find((item) => item.skuId === sku.id);
if (product) {
// 分转元
product.bargainFirstPrice = formatToFraction(
product.bargainFirstPrice,
) as unknown as number;
product.bargainMinPrice = formatToFraction(
product.bargainMinPrice,
) as unknown as number;
}
config = product || config;
}
// 动态添加 productConfig 属性到 SKU
(
sku as MallSpuApi.Sku & {
productConfig: MallBargainActivityApi.BargainProduct;
}
).productConfig = config;
});
res.skus = selectSkus;
const spuProperties: SpuProperty<MallSpuApi.Spu>[] = [
{
spuId: res.id!,
spuDetail: res,
propertyList: getPropertyList(res),
},
];
// 直接赋值,因为砍价活动只选择一个 SPU
spuList.value = [res];
spuPropertyList.value = spuProperties;
}
// ================= end =================
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
@@ -48,10 +163,36 @@ const [Modal, modalApi] = useVbenModal({
return;
}
modalApi.lock();
// 提交表单
const data =
(await formApi.getValues()) as MallBargainActivityApi.BargainActivity;
try {
// 获取砍价商品配置(深拷贝避免直接修改原对象)
const products: MallBargainActivityApi.BargainProduct[] = cloneDeep(
spuAndSkuListRef.value?.getSkuConfigs('productConfig') || [],
);
if (products.length === 0) {
message.error('请选择砍价商品');
return;
}
// 价格需要转为分
products.forEach((item) => {
item.bargainFirstPrice = convertToInteger(item.bargainFirstPrice);
item.bargainMinPrice = convertToInteger(item.bargainMinPrice);
});
// 提交表单
const values = await formApi.getValues();
const data = {
...values,
// 用户每次砍价金额元转分
randomMinPrice: values.randomMinPrice
? convertToInteger(values.randomMinPrice)
: undefined,
randomMaxPrice: values.randomMaxPrice
? convertToInteger(values.randomMaxPrice)
: undefined,
// 合并砍价商品配置(砍价活动只有一个商品)
...products[0],
} as MallBargainActivityApi.BargainActivity;
await (formData.value?.id
? updateBargainActivity(data)
: createBargainActivity(data));
@@ -66,18 +207,44 @@ const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
spuList.value = [];
spuPropertyList.value = [];
return;
}
// 加载数据
const data = modalApi.getData<MallBargainActivityApi.BargainActivity>();
if (!data || !data.id) {
return;
}
// 加载数据
modalApi.lock();
try {
formData.value = await getBargainActivity(data.id);
// 设置到 values
await formApi.setValues(formData.value);
// 对齐活动商品处理结构
await getSpuDetails(
formData.value.spuId,
[formData.value.skuId],
[
{
spuId: formData.value.spuId,
skuId: formData.value.skuId,
bargainFirstPrice: formData.value.bargainFirstPrice, // 砍价起始价格,单位分
bargainMinPrice: formData.value.bargainMinPrice, // 砍价底价
stock: formData.value.stock, // 活动库存
},
],
);
// 设置表单值时,价格字段从分转换为元
await formApi.setValues({
...formData.value,
randomMinPrice: formData.value.randomMinPrice
? formatToFraction(formData.value.randomMinPrice)
: undefined,
randomMaxPrice: formData.value.randomMaxPrice
? formatToFraction(formData.value.randomMaxPrice)
: undefined,
});
} finally {
modalApi.unlock();
}
@@ -86,7 +253,75 @@ const [Modal, modalApi] = useVbenModal({
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Form class="mx-4" />
</Modal>
<div>
<Modal :title="getTitle" class="w-[70%]">
<Form class="mx-4">
<!-- 商品选择 -->
<template #spuId>
<div class="w-full">
<Button v-if="!formData?.id" type="primary" @click="openSpuSelect">
选择商品
</Button>
<!-- SPU SKU 列表展示 -->
<SpuAndSkuList
ref="spuAndSkuListRef"
:rule-config="ruleConfig"
:spu-list="spuList"
:spu-property-list-p="spuPropertyList"
class="mt-4"
>
<!-- 扩展列砍价活动特有配置 -->
<template #default>
<VxeColumn
align="center"
min-width="168"
title="砍价起始价格(元)"
>
<template #default="{ row: sku }">
<InputNumber
v-model:value="sku.productConfig.bargainFirstPrice"
:min="0"
:precision="2"
:step="0.1"
class="w-full"
/>
</template>
</VxeColumn>
<VxeColumn align="center" min-width="168" title="砍价底价(元)">
<template #default="{ row: sku }">
<InputNumber
v-model:value="sku.productConfig.bargainMinPrice"
:min="0"
:precision="2"
:step="0.1"
class="w-full"
/>
</template>
</VxeColumn>
<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>
</template>
</SpuAndSkuList>
</div>
</template>
</Form>
</Modal>
<!-- 商品选择器弹窗单选模式 -->
<SpuSkuSelect
ref="spuSkuSelectRef"
:is-select-sku="true"
:radio="true"
@select="handleSpuSelected"
/>
</div>
</template>

View File

@@ -24,16 +24,7 @@ export function useFormSchema(): VbenFormSchema[] {
placeholder: '请输入活动名称',
},
rules: 'required',
},
{
fieldName: 'status',
label: '活动状态',
component: 'Select',
componentProps: {
placeholder: '请选择活动状态',
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
rules: 'required',
formItemClass: 'col-span-2',
},
{
fieldName: 'startTime',
@@ -41,9 +32,9 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'DatePicker',
componentProps: {
placeholder: '请选择开始时间',
showTime: false,
showTime: true,
valueFormat: 'x',
format: 'YYYY-MM-DD',
format: 'YYYY-MM-DD HH:mm:ss',
},
rules: 'required',
},
@@ -53,18 +44,18 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'DatePicker',
componentProps: {
placeholder: '请选择结束时间',
showTime: false,
showTime: true,
valueFormat: 'x',
format: 'YYYY-MM-DD',
format: 'YYYY-MM-DD HH:mm:ss',
},
rules: 'required',
},
{
fieldName: 'userSize',
label: '用户数量',
label: '拼团人数',
component: 'InputNumber',
componentProps: {
placeholder: '请输入用户数量',
placeholder: '达到该人数即成团',
min: 2,
},
rules: 'required',
@@ -74,7 +65,7 @@ export function useFormSchema(): VbenFormSchema[] {
label: '限制时长',
component: 'InputNumber',
componentProps: {
placeholder: '请输入限制时长(小时)',
placeholder: '限制时长(小时)',
min: 0,
},
rules: 'required',
@@ -106,10 +97,11 @@ export function useFormSchema(): VbenFormSchema[] {
},
},
{
// TODO
fieldName: 'spuId',
label: '拼团商品',
component: 'Input',
rules: 'required',
formItemClass: 'col-span-2',
},
];
}

View File

@@ -43,23 +43,14 @@ function handleEdit(row: MallCombinationActivityApi.CombinationActivity) {
async function handleClose(
row: MallCombinationActivityApi.CombinationActivity,
) {
try {
await confirm({
content: '确认关闭该拼团活动吗?',
});
} catch {
return;
}
await confirm('确认关闭该拼团活动吗?');
const hideLoading = message.loading({
content: '关闭中...',
duration: 0,
});
try {
await closeCombinationActivity(row.id as number);
message.success({
content: '关闭成功',
});
await closeCombinationActivity(row.id!);
message.success('关闭成功');
handleRefresh();
} finally {
hideLoading();
@@ -75,10 +66,8 @@ async function handleDelete(
duration: 0,
});
try {
await deleteCombinationActivity(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
});
await deleteCombinationActivity(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading();

View File

@@ -1,24 +1,39 @@
<script lang="ts" setup>
import type { MallSpuApi } from '#/api/mall/product/spu';
import type { MallCombinationActivityApi } from '#/api/mall/promotion/combination/combinationActivity';
import type {
RuleConfig,
SpuProperty,
} from '#/views/mall/product/spu/components';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { useVbenModal } from '@vben/common-ui';
import { cloneDeep, 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 {
createCombinationActivity,
getCombinationActivity,
updateCombinationActivity,
} from '#/api/mall/promotion/combination/combinationActivity';
import { $t } from '#/locales';
import {
getPropertyList,
SpuAndSkuList,
SpuSkuSelect,
} from '#/views/mall/product/spu/components';
import { useFormSchema } from '../data';
defineOptions({ name: 'CombinationActivityForm' });
const emit = defineEmits(['success']);
const formData = ref<MallCombinationActivityApi.CombinationActivity>();
const getTitle = computed(() => {
return formData.value?.id
@@ -33,12 +48,99 @@ const [Form, formApi] = useVbenForm({
},
labelWidth: 100,
},
wrapperClass: 'grid-cols-2',
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
// ================= 商品选择相关 =================
const spuSkuSelectRef = ref(); // 商品和属性选择 Ref
const spuAndSkuListRef = ref(); // SPU 和 SKU 列表组件 Ref
const ruleConfig: RuleConfig[] = [
{
name: 'productConfig.combinationPrice',
rule: (arg) => arg >= 0.01,
message: '商品拼团价格不能小于 0.01 ',
},
];
const spuList = ref<MallSpuApi.Spu[]>([]); // 选择的 SPU 列表
const spuPropertyList = ref<SpuProperty<MallSpuApi.Spu>[]>([]); // 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?: MallCombinationActivityApi.CombinationProduct[],
) {
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) => {
let config: MallCombinationActivityApi.CombinationProduct = {
spuId: res.id!,
skuId: sku.id!,
combinationPrice: 0,
};
// 如果是编辑模式,回填已有配置
if (products !== undefined) {
const product = products.find((item) => item.skuId === sku.id);
if (product) {
// 分转元
product.combinationPrice = formatToFraction(
product.combinationPrice,
) as unknown as number;
}
config = product || config;
}
// 动态添加 productConfig 属性到 SKU
(
sku as MallSpuApi.Sku & {
productConfig: MallCombinationActivityApi.CombinationProduct;
}
).productConfig = config;
});
res.skus = selectSkus;
const spuProperties: SpuProperty<MallSpuApi.Spu>[] = [
{
spuId: res.id!,
spuDetail: res,
propertyList: getPropertyList(res),
},
];
// 直接赋值,因为拼团活动只选择一个 SPU
spuList.value = [res];
spuPropertyList.value = spuProperties;
}
// ================= end =================
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
@@ -46,10 +148,26 @@ const [Modal, modalApi] = useVbenModal({
return;
}
modalApi.lock();
// 提交表单
const data =
(await formApi.getValues()) as MallCombinationActivityApi.CombinationActivity;
try {
// 获取拼团商品配置(深拷贝避免直接修改原对象)
const products: MallCombinationActivityApi.CombinationProduct[] =
cloneDeep(spuAndSkuListRef.value?.getSkuConfigs('productConfig') || []);
if (products.length === 0) {
message.error('请选择拼团商品');
return;
}
// 价格需要转为分
products.forEach((item) => {
item.combinationPrice = convertToInteger(item.combinationPrice);
});
// 提交表单
const values = await formApi.getValues();
const data = {
...values,
products,
} as MallCombinationActivityApi.CombinationActivity;
await (formData.value?.id
? updateCombinationActivity(data)
: createCombinationActivity(data));
@@ -64,18 +182,28 @@ const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
spuList.value = [];
spuPropertyList.value = [];
return;
}
// 加载数据
const data =
modalApi.getData<MallCombinationActivityApi.CombinationActivity>();
if (!data || !data.id) {
return;
}
// 加载数据
modalApi.lock();
try {
formData.value = await getCombinationActivity(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();
@@ -85,7 +213,49 @@ const [Modal, modalApi] = useVbenModal({
</script>
<template>
<Modal class="w-3/5" :title="getTitle">
<Form />
</Modal>
<div>
<Modal :title="getTitle" class="w-[70%]">
<Form class="mx-4">
<!-- 商品选择 -->
<template #spuId>
<div class="w-full">
<Button v-if="!formData?.id" type="primary" @click="openSpuSelect">
选择商品
</Button>
<!-- SPU SKU 列表展示 -->
<SpuAndSkuList
ref="spuAndSkuListRef"
:rule-config="ruleConfig"
:spu-list="spuList"
:spu-property-list-p="spuPropertyList"
class="mt-4"
>
<!-- 扩展列拼团活动特有配置 -->
<template #default>
<VxeColumn align="center" min-width="168" title="拼团价格(元)">
<template #default="{ row: sku }">
<InputNumber
v-model:value="sku.productConfig.combinationPrice"
:min="0"
:precision="2"
:step="0.1"
class="w-full"
/>
</template>
</VxeColumn>
</template>
</SpuAndSkuList>
</div>
</template>
</Form>
</Modal>
<!-- 商品选择器弹窗 -->
<SpuSkuSelect
ref="spuSkuSelectRef"
:is-select-sku="true"
@select="handleSpuSelected"
/>
</div>
</template>

View File

@@ -111,7 +111,6 @@ function emitActivityChange() {
>
<Tooltip :title="activity.name">
<div class="relative h-full w-full">
<!-- TODO @芋艿 -->
<Image
:src="activity.picUrl"
class="h-full w-full rounded-lg object-cover"

View File

@@ -133,9 +133,8 @@ function handleSliderChange(prop: string) {
</TabPane>
<!-- 每个组件的通用内容 -->
<!-- TODO @xingyu这里的样式貌似没 ele 版本的好看 -->
<TabPane tab="样式" key="style" force-render>
<p class="text-lg font-bold">组件样式</p>
<div class="mb-2 bg-gray-100 p-2 text-sm">组件样式</div>
<div class="flex flex-col gap-2 rounded-md p-4 shadow-lg">
<Form :model="formData">
<FormItem
@@ -181,7 +180,7 @@ function handleSliderChange(prop: string) {
class="mb-0 w-full"
>
<Row>
<Col :span="11">
<Col :span="19">
<Slider
v-model:value="
formData[dataRef.prop as keyof ComponentStyle]
@@ -192,8 +191,9 @@ function handleSliderChange(prop: string) {
class="mr-4"
/>
</Col>
<Col :span="2">
<Col :span="4">
<InputNumber
class="w-[50px]"
:max="100"
:min="0"
v-model:value="

View File

@@ -98,7 +98,7 @@ const handleDeleteComponent = () => {
<component :is="component.id" :property="component.property" />
</div>
<div
class="component-wrap absolute -bottom-1 -left-0.5 -right-0.5 -top-1 block h-full w-full"
class="component-wrap absolute -bottom-1 -left-0.5 -right-0.5 block h-full w-full"
>
<!-- 左侧组件名悬浮的小贴条 -->
<div class="component-name" v-if="component.name">
@@ -109,8 +109,6 @@ const handleDeleteComponent = () => {
class="component-toolbar"
v-if="showToolbar && component.name && active"
>
<!-- TODO @xingyu按钮少的时候会存在遮住的情况 -->
<!-- TODO @xingyu貌似中间的选中框框没全部框柱上面多了点下面少了点 -->
<VerticalButtonGroup size="small">
<Button
:disabled="!canMoveUp"
@@ -171,7 +169,6 @@ const handleDeleteComponent = () => {
</div>
</div>
</template>
<style scoped lang="scss">
$active-border-width: 2px;
$hover-border-width: 1px;

View File

@@ -100,5 +100,4 @@ function handleCloneComponent(component: DiyComponent<any>) {
</Collapse.Panel>
</Collapse>
</div>
<!-- TODO @xingyuele 里面有一些 style看看是不是都迁移完了特别是 drag-area 是全局样式 -->
</template>

View File

@@ -106,7 +106,6 @@ watch(
</div>
</Carousel>
</template>
<style lang="scss">
// Ant Design Vue Carousel 样式调整
:deep(.ant-carousel .ant-carousel-dots) {

View File

@@ -14,7 +14,7 @@ defineProps<{ property: UserCardProperty }>();
<div class="flex flex-col">
<div class="flex items-center justify-between px-4 py-6">
<div class="flex flex-1 items-center gap-4">
<Avatar :size="60">
<Avatar :size="60" class="flex items-center">
<IconifyIcon icon="ep:avatar" :size="60" />
</Avatar>
<span class="text-[18px] font-bold">芋道源码</span>

View File

@@ -38,7 +38,7 @@ const props = defineProps({
const emits = defineEmits(['reset', 'save', 'update:modelValue']); // 工具栏操作
// TODO @xingyu要不要加这个
// TODO @xingyu要不要加这个ele 里是有这个的。
// const qrcode = useQRCode(props.previewUrl, {
// errorCorrectionLevel: 'H',
// margin: 4,
@@ -175,8 +175,7 @@ function handleComponentSelected(
index: number = -1,
) {
// 使用深拷贝避免响应式追踪循环警告
// TODO @xingyu这个是必须的么ele 没有哈。
selectedComponent.value = cloneDeep(component);
selectedComponent.value = component;
selectedComponentIndex.value = index;
}
@@ -344,7 +343,7 @@ onMounted(() => {
<!-- 中心设计区域ComponentContainer -->
<Col :span="12">
<div
class="relative flex max-h-[calc(80vh)] w-full flex-1 flex-col justify-center overflow-y-auto"
class="editor-center relative flex max-h-[calc(80vh)] w-full flex-1 flex-col overflow-y-auto"
@click="handlePageSelected"
>
<!-- 手机顶部 -->
@@ -378,20 +377,20 @@ onMounted(() => {
</div>
<!-- 手机页面编辑区域 -->
<div
class="min-h-full w-full"
class="mx-auto min-h-full w-96 bg-no-repeat"
:style="{
// backgroundColor: pageConfigComponent.property.backgroundColor,
backgroundImage: `url(${pageConfigComponent.property.backgroundImage})`,
}"
>
<div
class="relative mx-auto my-0 min-h-full w-96 items-center justify-center bg-auto bg-no-repeat"
class="relative my-0 min-h-full w-full items-center justify-center bg-auto bg-no-repeat"
>
<draggable
v-model="pageComponents"
:animation="200"
:force-fallback="false"
class="min-h-full w-full"
class="min-h-[70vh] w-full"
filter=".component-toolbar"
ghost-class="draggable-ghost"
group="component"
@@ -508,5 +507,4 @@ onMounted(() => {
</div>
</PreviewModal>
</Page>
<!-- TODO @xingyu这里改造完后类似 web-ele/src/views/mall/promotion/components/diy-editor/index.vue 里的全局样式递推到子组件里的就没没了类似 property-group -->
</template>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { Space } from 'ant-design-vue';
// TODO @芋艿、@xingyu貌似上下移动的按钮被遮住了
/**
* 垂直按钮组
* Ant Design Vue 的按钮组,通过 Space 实现垂直布局

View File

@@ -2,7 +2,7 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
import { ref } from 'vue';
import { nextTick, ref } from 'vue';
import { Modal } from 'ant-design-vue';
@@ -57,24 +57,24 @@ const [Grid, gridApi] = useVbenVxeGrid({
/** 打开弹窗 */
async function open() {
visible.value = true;
// 重置查询条件并重新加载数据,与老组件行为一致
// 等待 Modal 和 Grid 组件挂载完成后再查询
await nextTick();
await gridApi.query();
}
/** 确认选择 */
function handleConfirm() {
const selectedRecords = (gridApi.grid?.getCheckboxRecords() ||
[]) as MallCouponTemplateApi.CouponTemplate[];
emit('change', selectedRecords);
closeModal();
}
/** 关闭弹窗 */
function closeModal() {
visible.value = false;
}
/** 确认选择 */
function handleConfirm() {
// 从 gridApi 获取选中的记录
const selectedRecords = (gridApi.grid?.getCheckboxRecords() ||
[]) as MallCouponTemplateApi.CouponTemplate[];
emit('change', selectedRecords);
closeModal();
}
/** 对外暴露的方法 */
defineExpose({
open,

View File

@@ -60,14 +60,10 @@ export function useFormSchema(): VbenFormSchema[] {
rules: 'required',
defaultValue: PromotionProductScopeEnum.ALL.scope,
},
// TODO @puhui999 商品选择器优化
{
fieldName: 'productSpuIds',
label: '商品',
component: 'Input',
componentProps: {
placeholder: '请选择商品',
},
dependencies: {
triggerFields: ['productScope', 'productScopeValues'],
show: (model) =>
@@ -84,14 +80,10 @@ export function useFormSchema(): VbenFormSchema[] {
},
rules: 'required',
},
// TODO @puhui999 商品分类选择器优化
{
fieldName: 'productCategoryIds',
label: '商品分类',
component: 'Input',
componentProps: {
placeholder: '请选择商品分类',
},
dependencies: {
triggerFields: ['productScope', 'productScopeValues'],
show: (model) =>

View File

@@ -16,11 +16,18 @@ import {
updateCouponTemplate,
} from '#/api/mall/promotion/coupon/couponTemplate';
import { $t } from '#/locales';
import { ProductCategorySelect } from '#/views/mall/product/category/components';
import { SpuShowcase } from '#/views/mall/product/spu/components';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MallCouponTemplateApi.CouponTemplate>();
const formData = ref<
Partial<MallCouponTemplateApi.CouponTemplate> & {
productCategoryIds?: number | number[];
productSpuIds?: number[];
}
>({});
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['优惠券模板'])
@@ -64,7 +71,7 @@ const [Modal, modalApi] = useVbenModal({
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
formData.value = {};
return;
}
// 加载数据
@@ -75,7 +82,7 @@ const [Modal, modalApi] = useVbenModal({
modalApi.lock();
try {
formData.value = await getCouponTemplate(data.id);
const processedData = await processLoadData(formData.value);
const processedData = await processLoadData(formData.value as any);
// 设置到表单
await formApi.setValues(processedData);
} finally {
@@ -144,6 +151,15 @@ async function processLoadData(
<template>
<Modal :title="getTitle" class="w-2/5">
<Form class="mx-4" />
<Form class="mx-4">
<!-- 自定义插槽商品选择 -->
<template #productSpuIds>
<SpuShowcase v-model="formData.productSpuIds" />
</template>
<!-- 自定义插槽分类选择 -->
<template #productCategoryIds>
<ProductCategorySelect v-model="formData.productCategoryIds" />
</template>
</Form>
</Modal>
</template>

View File

@@ -67,8 +67,15 @@ export function useFormSchema(): VbenFormSchema[] {
placeholder: '请输入备注',
rows: 4,
},
formItemClass: 'col-span-2',
},
{
fieldName: 'spuIds',
label: '活动商品',
component: 'Input',
rules: 'required',
formItemClass: 'col-span-2',
},
// TODO
];
}

View File

@@ -41,23 +41,14 @@ function handleEdit(row: MallDiscountActivityApi.DiscountActivity) {
/** 关闭满减活动 */
async function handleClose(row: MallDiscountActivityApi.DiscountActivity) {
try {
await confirm({
content: '确认关闭该限时折扣活动吗?',
});
} catch {
return;
}
await confirm('确认关闭该限时折扣活动吗?');
const hideLoading = message.loading({
content: '正在关闭中',
content: '正在关闭中...',
duration: 0,
});
try {
await closeDiscountActivity(row.id as number);
message.success({
content: '关闭成功',
});
await closeDiscountActivity(row.id!);
message.success('关闭成功');
handleRefresh();
} finally {
hideLoading();
@@ -71,7 +62,7 @@ async function handleDelete(row: MallDiscountActivityApi.DiscountActivity) {
duration: 0,
});
try {
await deleteDiscountActivity(row.id as number);
await deleteDiscountActivity(row.id!);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
});

View File

@@ -13,13 +13,18 @@ import {
updateDiscountActivity,
} from '#/api/mall/promotion/discount/discountActivity';
import { $t } from '#/locales';
import { SpuShowcase } from '#/views/mall/product/spu/components';
import { useFormSchema } from '../data';
defineOptions({ name: 'DiscountActivityForm' });
const emit = defineEmits(['success']);
const formData = ref<MallDiscountActivityApi.DiscountActivity>();
const formData = ref<
Partial<MallDiscountActivityApi.DiscountActivity> & {
spuIds?: number[];
}
>({});
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['限时折扣活动'])
@@ -69,7 +74,7 @@ const [Modal, modalApi] = useVbenModal({
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
formData.value = {};
return;
}
// 加载数据
@@ -91,6 +96,11 @@ const [Modal, modalApi] = useVbenModal({
<template>
<Modal class="w-3/5" :title="getTitle">
<Form />
<Form>
<!-- 自定义插槽商品选择 -->
<template #spuIds>
<SpuShowcase v-model="formData.spuIds" />
</template>
</Form>
</Modal>
</template>

View File

@@ -27,34 +27,28 @@ const route = useRoute();
const { refreshTab } = useTabs();
const domain = import.meta.env.VITE_MALL_H5_DOMAIN;
// 特殊:存储 reset 重置时,当前 selectedTemplateItem 值,从而进行恢复
const DIY_PAGE_INDEX_KEY = 'diy_page_index';
const DIY_PAGE_INDEX_KEY = 'diy_page_index'; // 特殊:存储 reset 重置时,当前 selectedTemplateItem 值,从而进行恢复
const selectedTemplateItem = ref(0);
// 左上角工具栏操作按钮
const templateItems = ref([
{ key: 0, name: '基础设置', icon: 'lucide:settings' },
{ key: 1, name: '首页', icon: 'lucide:home' },
{ key: 2, name: '我的', icon: 'lucide:user' },
]);
]); // 左上角工具栏操作按钮
const formData = ref<MallDiyTemplateApi.DiyTemplateProperty>();
// 当前编辑的属性
const currentFormData = ref<
MallDiyPageApi.DiyPage | MallDiyTemplateApi.DiyTemplateProperty
>({
property: '',
} as MallDiyPageApi.DiyPage);
// templateItem 对应的缓存
} as MallDiyPageApi.DiyPage); // 当前编辑的属性
const currentFormDataMap = ref<
Map<string, MallDiyPageApi.DiyPage | MallDiyTemplateApi.DiyTemplateProperty>
>(new Map());
// 商城 H5 预览地址
const previewUrl = ref('');
// 模板组件库
const templateLibs = [] as DiyComponentLibrary[];
// 当前组件库
const libs = ref<DiyComponentLibrary[]>(templateLibs);
>(new Map()); // templateItem 对应的缓存
const previewUrl = ref(''); // 商城 H5 预览地址
const templateLibs = [] as DiyComponentLibrary[]; // 模板组件库
const libs = ref<DiyComponentLibrary[]>(templateLibs); // 当前组件库
/** 获取详情 */
async function getPageDetail(id: any) {
@@ -73,23 +67,23 @@ async function getPageDetail(id: any) {
}
/** 模板选项切换 */
function handleTemplateItemChange(val: any) {
const changeValue = val.target.value;
function handleTemplateItemChange(valObj: any) {
const val = valObj.target.value;
// 缓存模版编辑数据
currentFormDataMap.value.set(
templateItems.value[changeValue]!.name,
templateItems.value[selectedTemplateItem.value]?.name || '',
currentFormData.value!,
);
// 切换模版
selectedTemplateItem.value = changeValue;
// 读取模版缓存
const data = currentFormDataMap.value.get(
templateItems.value[changeValue]!.name,
templateItems.value[val]?.name || '',
);
// 切换模版
selectedTemplateItem.value = val;
// 情况一:编辑模板
if (changeValue === 0) {
if (val === 0) {
libs.value = templateLibs;
currentFormData.value = (isEmpty(data) ? formData.value : data) as
| MallDiyPageApi.DiyPage
@@ -103,7 +97,7 @@ function handleTemplateItemChange(val: any) {
isEmpty(data)
? formData.value!.pages.find(
(page: MallDiyPageApi.DiyPage) =>
page.name === templateItems.value[changeValue]!.name,
page.name === templateItems.value[val]?.name,
)
: data
) as MallDiyPageApi.DiyPage | MallDiyTemplateApi.DiyTemplateProperty;

View File

@@ -132,9 +132,6 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'Input',
rules: 'required',
formItemClass: 'col-span-2',
renderComponentContent: () => ({
default: () => null,
}),
},
];
}

View File

@@ -180,7 +180,6 @@ const [Modal, modalApi] = useVbenModal({
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
// 重置表单数据(新增和编辑模式都需要)
formData.value = undefined;
spuList.value = [];
spuPropertyList.value = [];

View File

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

View File

@@ -10,7 +10,7 @@ import { Image, Tooltip } from 'ant-design-vue';
import { getPointActivityListByIds } from '#/api/mall/promotion/point';
import PointTableSelect from './point-table-select.vue';
import TableSelect from './table-select.vue';
interface PointShowcaseProps {
modelValue?: number | number[];
@@ -27,7 +27,7 @@ const props = withDefaults(defineProps<PointShowcaseProps>(), {
const emit = defineEmits(['update:modelValue', 'change']);
const pointActivityList = ref<MallPointActivityApi.PointActivity[]>([]); //
const pointTableSelectRef = ref<InstanceType<typeof PointTableSelect>>(); //
const pointTableSelectRef = ref<InstanceType<typeof TableSelect>>(); //
const isMultiple = computed(() => props.limit !== 1); //
/** 计算是否可以添加 */
@@ -140,7 +140,7 @@ function emitActivityChange() {
</div>
<!-- 活动选择对话框 -->
<PointTableSelect
<TableSelect
ref="pointTableSelectRef"
:multiple="isMultiple"
@change="handleActivitySelected"

View File

@@ -9,7 +9,6 @@ import { computed } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { dateFormatter, fenToYuanFormat } from '@vben/utils';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getPointActivityPage } from '#/api/mall/promotion/point';
@@ -92,7 +91,9 @@ const gridColumns = computed<VxeGridProps['columns']>(() => {
title: '原价',
minWidth: 100,
align: 'center',
formatter: ({ cellValue }) => fenToYuanFormat(cellValue),
formatter: ({ cellValue }) => {
return `¥${(cellValue / 100).toFixed(2)}`;
},
},
{
field: 'status',
@@ -128,7 +129,7 @@ const gridColumns = computed<VxeGridProps['columns']>(() => {
title: '创建时间',
width: 180,
align: 'center',
formatter: ({ cellValue }) => dateFormatter(cellValue),
formatter: 'formatDateTime',
},
);
return columns;

View File

@@ -217,6 +217,7 @@ export function useFormSchema(): VbenFormSchema[] {
},
rules: 'required',
},
// TODO @puhui9991新增时一直报“请输入优惠设置”2修改老数据出现报“请求参数类型错误:50.00”;
{
fieldName: 'rules',
label: '优惠设置',

View File

@@ -42,7 +42,6 @@ function handleEdit(row: MallRewardActivityApi.RewardActivity) {
/** 关闭满减送活动 */
async function handleClose(row: MallRewardActivityApi.RewardActivity) {
// TODO @puhui999这个国际化需要加下哈closing、closeSuccess
const hideLoading = message.loading({
content: $t('ui.actionMessage.closing', [row.name]),
duration: 0,

View File

@@ -11,6 +11,7 @@ import {
import { convertToInteger, formatToFraction } from '@vben/utils';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { useVbenForm } from '#/adapter/form';
import {
@@ -29,9 +30,9 @@ const emit = defineEmits(['success']);
const formData = ref<Partial<MallRewardActivityApi.RewardActivity>>({
conditionType: PromotionConditionTypeEnum.PRICE.type,
productScope: PromotionProductScopeEnum.ALL.scope,
rules: [],
});
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['满减送'])
@@ -60,7 +61,6 @@ const [Modal, modalApi] = useVbenModal({
// 提交表单
try {
const values = await formApi.getValues();
// 合并表单值和 formData含 id、productSpuIds、productCategoryIds 等)
const data = { ...formData.value, ...values };
if (data.startAndEndTime && Array.isArray(data.startAndEndTime)) {
data.startTime = data.startAndEndTime[0];
@@ -73,23 +73,6 @@ const [Modal, modalApi] = useVbenModal({
item.limit = convertToInteger(item.limit || 0);
}
});
// 设置 productScopeValues
switch (data.productScope) {
case PromotionProductScopeEnum.CATEGORY.scope: {
const categoryIds = data.productCategoryIds;
data.productScopeValues = Array.isArray(categoryIds)
? categoryIds
: categoryIds
? [categoryIds]
: [];
break;
}
case PromotionProductScopeEnum.SPU.scope: {
data.productScopeValues = data.productSpuIds;
break;
}
}
await (data.id
? updateRewardActivity(data as MallRewardActivityApi.RewardActivity)
: createRewardActivity(data as MallRewardActivityApi.RewardActivity));
@@ -114,7 +97,10 @@ const [Modal, modalApi] = useVbenModal({
modalApi.lock();
try {
const result = await getReward(data.id);
result.startAndEndTime = [result.startTime, result.endTime] as any[];
result.startAndEndTime = [
result.startTime ? dayjs(result.startTime) : undefined,
result.endTime ? dayjs(result.endTime) : undefined,
] as any[];
result.rules?.forEach((item: any) => {
item.discountPrice = formatToFraction(item.discountPrice || 0);
if (result.conditionType === PromotionConditionTypeEnum.PRICE.type) {

View File

@@ -102,46 +102,53 @@ onMounted(async () => {
</script>
<template>
<div class="w-full">
<Button type="link" class="pl-0" @click="handleSelect">添加优惠券</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"
/>
<div>
<!-- 已选优惠券列表 -->
<div v-if="list.length > 0" class="mb-2 flex flex-col gap-2">
<div
v-for="(item, index) in list"
:key="item.id"
class="flex items-center justify-between rounded-md border border-gray-200 bg-white px-3 py-2 transition-all hover:border-blue-400 hover:shadow-sm"
>
<div class="flex flex-wrap items-center gap-3">
<span class="font-medium text-gray-800">{{ item.name }}</span>
<span class="flex items-center gap-1 text-sm text-gray-500">
<DictTag
:type="DICT_TYPE.PROMOTION_PRODUCT_SCOPE"
:value="item.productScope"
/>
</span>
<span class="flex items-center gap-1 text-sm text-gray-500">
<DictTag
:type="DICT_TYPE.PROMOTION_DISCOUNT_TYPE"
:value="item.discountType"
/>
{{ discountFormat(item) }}
</span>
</div>
<div class="flex items-center">
优惠
<DictTag
:type="DICT_TYPE.PROMOTION_DISCOUNT_TYPE"
:value="item.discountType"
<div class="flex shrink-0 items-center gap-2">
<span class="text-gray-500"></span>
<Input
v-model:value="item.giveCount"
class="!w-20"
placeholder=""
type="number"
size="small"
/>
{{ discountFormat(item) }}
<span class="text-gray-500"></span>
<Button type="link" danger size="small" @click="handleDelete(index)">
删除
</Button>
</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="handleDelete(index)">删除</Button>
</div>
</div>
<!-- 优惠券选择 -->
<!-- 添加按钮 -->
<Button type="link" class="!pl-0" @click="handleSelect">
+ 添加优惠券
</Button>
<!-- 优惠券选择弹窗 -->
<CouponSelect
ref="selectRef"
:take-type="CouponTemplateTakeTypeEnum.ADMIN.type"

View File

@@ -8,6 +8,7 @@ import { PromotionConditionTypeEnum } from '@vben/constants';
import { useVModel } from '@vueuse/core';
import {
Button,
Card,
Col,
Form,
FormItem,
@@ -61,76 +62,92 @@ function handleDelete(ruleIndex: number) {
<Row :gutter="[16, 16]">
<template v-if="formData.rules">
<Col v-for="(rule, index) in formData.rules" :key="index" :span="24">
<!-- 规则标题 -->
<div class="mb-4 flex items-center">
<span class="text-base font-bold">活动层级 {{ index + 1 }}</span>
<Button
v-if="index !== 0"
type="link"
danger
class="ml-2"
@click="handleDelete(index)"
>
删除
</Button>
</div>
<Form :model="rule" layout="horizontal">
<!-- 优惠门槛 -->
<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-40"
placeholder="请输入金额"
/>
<Input
v-else
v-model:value="rule.limit"
:min="0"
class="!w-40"
placeholder="请输入数量"
type="number"
/>
<span>{{ isPriceCondition ? '元' : '件' }}</span>
<Card size="small" class="rounded-lg">
<!-- 规则标题 -->
<template #title>
<div class="flex items-center">
<span class="text-base font-medium">
活动层级 {{ index + 1 }}
</span>
</div>
</FormItem>
<!-- 优惠内容 -->
<FormItem
label="优惠内容"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<div class="flex flex-col gap-2">
<span>订单金额优惠</span>
<div class="flex items-center gap-2">
<span></span>
</template>
<template v-if="index !== 0" #extra>
<Button
type="link"
danger
size="small"
@click="handleDelete(index)"
>
删除
</Button>
</template>
<Form :model="rule" layout="horizontal">
<!-- 优惠门槛 -->
<FormItem label="优惠门槛:" :colon="false" class="mb-3">
<div
class="flex items-center gap-2 rounded-md bg-gray-50 px-3 py-2"
>
<span></span>
<InputNumber
v-model:value="rule.discountPrice"
v-if="isPriceCondition"
v-model:value="rule.limit"
:min="0"
:precision="2"
:step="0.1"
class="!w-32"
class="!w-40"
placeholder="请输入金额"
/>
<span></span>
</div>
<div class="flex items-center gap-2">
<span>包邮:</span>
<Switch
v-model:checked="rule.freeDelivery"
checked-children=""
un-checked-children=""
<Input
v-else
v-model:value="rule.limit"
:min="0"
class="!w-40"
placeholder="请输入数量"
type="number"
/>
<span>{{ isPriceCondition ? '元' : '件' }}</span>
</div>
<div>
<div>送积分:</div>
<div class="mt-2 flex items-center gap-2">
</FormItem>
<!-- 优惠内容 -->
<FormItem label="优惠内容:" :colon="false" class="!mb-0">
<div class="flex flex-col gap-3">
<!-- 订单金额优惠 -->
<div
class="flex items-center gap-2 rounded-md bg-gray-50 px-3 py-2"
>
<span class="!w-21 shrink-0 text-sm text-gray-500">
订单金额优惠
</span>
<span></span>
<InputNumber
v-model:value="rule.discountPrice"
:min="0"
:precision="2"
:step="0.1"
class="!w-32"
placeholder="请输入金额"
/>
<span></span>
</div>
<!-- 包邮 -->
<div
class="flex items-center gap-2 rounded-md bg-gray-50 px-3 py-2"
>
<span class="w-20 shrink-0 text-sm text-gray-500">包邮</span>
<Switch
v-model:checked="rule.freeDelivery"
checked-children=""
un-checked-children=""
/>
</div>
<!-- 送积分 -->
<div
class="flex items-center gap-2 rounded-md bg-gray-50 px-3 py-2"
>
<span class="w-20 shrink-0 text-sm text-gray-500">
送积分
</span>
<span></span>
<InputNumber
v-model:value="rule.point"
@@ -140,17 +157,24 @@ function handleDelete(ruleIndex: number) {
/>
<span>积分</span>
</div>
<!-- 送优惠券 -->
<div
class="flex flex-col items-start gap-2 rounded-md bg-gray-50 px-3 py-2"
>
<span class="w-20 shrink-0 text-sm text-gray-500">
送优惠券
</span>
<RewardRuleCouponSelect
:model-value="rule"
@update:model-value="
(val) => (formData.rules![index] = val)
"
/>
</div>
</div>
<div class="flex items-center gap-2">
<span class="w-20">送优惠券:</span>
<RewardRuleCouponSelect
:model-value="rule"
@update:model-value="(val) => (formData.rules![index] = val)"
/>
</div>
</div>
</FormItem>
</Form>
</FormItem>
</Form>
</Card>
</Col>
</template>

View File

@@ -59,8 +59,8 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'DatePicker',
componentProps: {
placeholder: '请选择活动开始时间',
showTime: false,
format: 'YYYY-MM-DD',
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
class: 'w-full',
},
@@ -72,8 +72,8 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'DatePicker',
componentProps: {
placeholder: '请选择活动结束时间',
showTime: false,
format: 'YYYY-MM-DD',
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
class: 'w-full',
},
@@ -137,6 +137,12 @@ export function useFormSchema(): VbenFormSchema[] {
},
formItemClass: 'col-span-2',
},
{
fieldName: 'spuId',
label: '秒杀商品',
component: 'Input',
formItemClass: 'col-span-2',
},
];
}

View File

@@ -1,115 +1,184 @@
<script lang="ts" setup>
import type { MallSpuApi } from '#/api/mall/product/spu';
import type { MallSeckillActivityApi } from '#/api/mall/promotion/seckill/seckillActivity';
import type {
RuleConfig,
SpuProperty,
} from '#/views/mall/product/spu/components';
import { computed, nextTick, ref } from 'vue';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { cloneDeep, convertToInteger, formatToFraction } from '@vben/utils';
import { Button, 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 {
createSeckillActivity,
getSeckillActivity,
updateSeckillActivity,
} from '#/api/mall/promotion/seckill/seckillActivity';
import { $t } from '#/locales';
import {
getPropertyList,
SpuAndSkuList,
SpuSkuSelect,
} from '#/views/mall/product/spu/components';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MallSeckillActivityApi.SeckillActivity>();
defineOptions({ name: 'PromotionSeckillActivityForm' });
const emit = defineEmits(['success']);
const formData = ref<MallSeckillActivityApi.SeckillActivity>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['秒杀活动'])
: $t('ui.actionTitle.create', ['秒杀活动']);
});
// ================= 商品选择相关 =================
const spuId = ref<number>();
const spuName = ref<string>('');
const skuTableData = ref<any[]>([]);
// 选择商品(占位函数,实际需要对接商品选择组件)
const handleSelectProduct = () => {
message.info('商品选择功能需要对接商品选择组件');
// TODO: 打开商品选择弹窗
// 实际使用时需要:
// 1. 打开商品选择弹窗
// 2. 选择商品后调用以下逻辑设置数据:
// spuId.value = selectedSpu.id;
// spuName.value = selectedSpu.name;
// skuTableData.value = selectedSkus.map(sku => ({
// skuId: sku.id,
// skuName: sku.name || '',
// picUrl: sku.picUrl || selectedSpu.picUrl || '',
// price: sku.price || 0,
// stock: 0,
// seckillPrice: 0,
// }));
};
// ================= end =================
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
// ================= 商品选择相关 =================
const spuSkuSelectRef = ref(); // 商品和属性选择 Ref
const spuAndSkuListRef = ref(); // SPU 和 SKU 列表组件 Ref
const ruleConfig: RuleConfig[] = [
{
name: 'productConfig.stock',
rule: (arg) => arg >= 1,
message: '商品秒杀库存必须大于等于 1 ',
},
{
name: 'productConfig.seckillPrice',
rule: (arg) => arg >= 0.01,
message: '商品秒杀价格必须大于等于 0.01 ',
},
];
const spuList = ref<MallSpuApi.Spu[]>([]); // 选择的 SPU 列表
const spuPropertyList = ref<SpuProperty<MallSpuApi.Spu>[]>([]); // 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?: MallSeckillActivityApi.SeckillProduct[],
) {
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) => {
let config: MallSeckillActivityApi.SeckillProduct = {
spuId: res.id!,
skuId: sku.id!,
stock: 0,
seckillPrice: 0,
};
// 如果是编辑模式,回填已有配置
if (products !== undefined) {
const product = products.find((item) => item.skuId === sku.id);
if (product) {
// 分转元
product.seckillPrice = formatToFraction(
product.seckillPrice,
) as unknown as number;
}
config = product || config;
}
// 动态添加 productConfig 属性到 SKU
(
sku as MallSpuApi.Sku & {
productConfig: MallSeckillActivityApi.SeckillProduct;
}
).productConfig = config;
});
res.skus = selectSkus;
const spuProperties: SpuProperty<MallSpuApi.Spu>[] = [
{
spuId: res.id!,
spuDetail: res,
propertyList: getPropertyList(res),
},
];
// 直接赋值,因为秒杀活动只选择一个 SPU
spuList.value = [res];
spuPropertyList.value = spuProperties;
}
// ================= end =================
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
// 验证商品和 SKU 配置
if (!spuId.value) {
message.error('请选择秒杀商品');
return;
}
if (skuTableData.value.length === 0) {
message.error('请至少配置一个 SKU');
return;
}
// 验证 SKU 配置
const hasInvalidSku = skuTableData.value.some(
(sku) => sku.stock < 1 || sku.seckillPrice < 0.01,
);
if (hasInvalidSku) {
message.error('请正确配置 SKU 的秒杀库存≥1和秒杀价格≥0.01');
return;
}
modalApi.lock();
try {
const values = await formApi.getValues();
// 获取秒杀商品配置(深拷贝避免直接修改原对象)
const products: MallSeckillActivityApi.SeckillProduct[] = cloneDeep(
spuAndSkuListRef.value?.getSkuConfigs('productConfig') || [],
);
if (products.length === 0) {
message.error('请选择秒杀商品');
return;
}
// 价格需要转为分
products.forEach((item) => {
item.seckillPrice = convertToInteger(item.seckillPrice);
});
// 构建提交数据
const data: any = {
// 提交表单
const values = await formApi.getValues();
const data = {
...values,
spuId: spuId.value,
products: skuTableData.value.map((sku) => ({
skuId: sku.skuId,
stock: sku.stock,
seckillPrice: Math.round(sku.seckillPrice * 100), // 转换为分
})),
};
products,
} as MallSeckillActivityApi.SeckillActivity;
await (formData.value?.id
? updateSeckillActivity(data)
: createSeckillActivity(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
@@ -120,27 +189,28 @@ const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
spuId.value = undefined;
spuName.value = '';
skuTableData.value = [];
spuList.value = [];
spuPropertyList.value = [];
return;
}
// 加载数据
const data = modalApi.getData<MallSeckillActivityApi.SeckillActivity>();
if (!data || !data.id) {
return;
}
// 加载数据
modalApi.lock();
try {
formData.value = await getSeckillActivity(data.id);
await nextTick();
// 对齐活动商品处理结构
await getSpuDetails(
formData.value.spuId!,
formData.value.products?.map((sku) => sku.skuId),
formData.value.products,
);
// 设置表单值
await formApi.setValues(formData.value);
// TODO: 加载商品和 SKU 信息
// 需要调用商品 API 获取 SPU 详情
// spuId.value = formData.value.spuId;
// await loadProductDetails(formData.value.spuId, formData.value.products);
} finally {
modalApi.unlock();
}
@@ -149,72 +219,59 @@ const [Modal, modalApi] = useVbenModal({
</script>
<template>
<Modal class="w-4/5" :title="getTitle">
<div class="mx-4">
<Form />
<div>
<Modal :title="getTitle" class="w-[70%]">
<Form class="mx-4">
<!-- 商品选择 -->
<template #spuId>
<div class="w-full">
<Button v-if="!formData?.id" type="primary" @click="openSpuSelect">
选择商品
</Button>
<!-- 商品选择区域 -->
<div class="mt-4">
<div class="mb-2 flex items-center">
<span class="text-sm font-medium">秒杀活动商品:</span>
<Button class="ml-2" type="primary" @click="handleSelectProduct">
选择商品
</Button>
<span v-if="spuName" class="ml-4 text-sm text-gray-600">
已选择: {{ spuName }}
</span>
</div>
<!-- SPU SKU 列表展示 -->
<SpuAndSkuList
ref="spuAndSkuListRef"
:rule-config="ruleConfig"
:spu-list="spuList"
:spu-property-list-p="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.seckillPrice"
:min="0"
:precision="2"
:step="0.1"
class="w-full"
/>
</template>
</VxeColumn>
</template>
</SpuAndSkuList>
</div>
</template>
</Form>
</Modal>
<!-- SKU 配置表格 -->
<div v-if="skuTableData.length > 0" class="mt-4">
<table class="w-full border-collapse border border-gray-300">
<thead>
<tr class="bg-gray-100">
<th class="border border-gray-300 px-4 py-2">商品图片</th>
<th class="border border-gray-300 px-4 py-2">SKU 名称</th>
<th class="border border-gray-300 px-4 py-2">原价()</th>
<th class="border border-gray-300 px-4 py-2">秒杀库存</th>
<th class="border border-gray-300 px-4 py-2">秒杀价格()</th>
</tr>
</thead>
<tbody>
<tr v-for="(sku, index) in skuTableData" :key="index">
<td class="border border-gray-300 px-4 py-2 text-center">
<img
v-if="sku.picUrl"
:src="sku.picUrl"
alt="商品图片"
class="h-16 w-16 object-cover"
/>
</td>
<td class="border border-gray-300 px-4 py-2">
{{ sku.skuName }}
</td>
<td class="border border-gray-300 px-4 py-2 text-center">
¥{{ (sku.price / 100).toFixed(2) }}
</td>
<td class="border border-gray-300 px-4 py-2">
<input
v-model.number="sku.stock"
type="number"
min="0"
class="w-full rounded border border-gray-300 px-2 py-1"
/>
</td>
<td class="border border-gray-300 px-4 py-2">
<input
v-model.number="sku.seckillPrice"
type="number"
min="0"
step="0.01"
class="w-full rounded border border-gray-300 px-2 py-1"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</Modal>
<!-- 商品选择器弹窗 -->
<SpuSkuSelect
ref="spuSkuSelectRef"
:is-select-sku="true"
@select="handleSpuSelected"
/>
</div>
</template>

View File

@@ -133,7 +133,6 @@ function emitActivityChange() {
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="handleOpenActivitySelect"
>
<!-- TODO @芋艿等待和 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/views/mall/product/spu/components/spu-showcase.vue 进一步统一 -->
<IconifyIcon icon="lucide:plus" class="text-xl text-gray-400" />
</div>
</Tooltip>