mirror of
https://gitee.com/yudaocode/yudao-ui-admin-vben.git
synced 2025-12-30 02:22:25 +00:00
Merge remote-tracking branch 'yudao/dev' into dev
This commit is contained in:
@@ -1,21 +1,20 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace IotStatisticsApi {
|
||||
// TODO @haohao:需要跟后端对齐,必要的 ReqVO、RespVO
|
||||
/** 统计摘要数据 */
|
||||
export interface StatisticsSummary {
|
||||
productCategoryCount: number;
|
||||
productCount: number;
|
||||
deviceCount: number;
|
||||
deviceMessageCount: number;
|
||||
productCategoryTodayCount: number;
|
||||
productTodayCount: number;
|
||||
deviceTodayCount: number;
|
||||
deviceMessageTodayCount: number;
|
||||
deviceOnlineCount: number;
|
||||
deviceOfflineCount: number;
|
||||
deviceInactiveCount: number;
|
||||
productCategoryDeviceCounts: Record<string, number>;
|
||||
export interface StatisticsSummaryRespVO {
|
||||
productCategoryCount: number; // 品类数量
|
||||
productCount: number; // 产品数量
|
||||
deviceCount: number; // 设备数量
|
||||
deviceMessageCount: number; // 上报数量
|
||||
productCategoryTodayCount: number; // 今日新增品类数量
|
||||
productTodayCount: number; // 今日新增产品数量
|
||||
deviceTodayCount: number; // 今日新增设备数量
|
||||
deviceMessageTodayCount: number; // 今日新增上报数量
|
||||
deviceOnlineCount: number; // 在线数量
|
||||
deviceOfflineCount: number; // 离线数量
|
||||
deviceInactiveCount: number; // 待激活设备数量
|
||||
productCategoryDeviceCounts: Record<string, number>; // 按品类统计的设备数量
|
||||
}
|
||||
|
||||
/** 时间戳-数值的键值对类型 */
|
||||
@@ -30,15 +29,15 @@ export namespace IotStatisticsApi {
|
||||
downstreamCounts: TimeValueItem[];
|
||||
}
|
||||
|
||||
/** 消息统计数据项(按日期) */
|
||||
export interface DeviceMessageSummaryByDate {
|
||||
time: string;
|
||||
upstreamCount: number;
|
||||
downstreamCount: number;
|
||||
/** 设备消息数量统计(按日期) */
|
||||
export interface DeviceMessageSummaryByDateRespVO {
|
||||
time: string; // 时间轴
|
||||
upstreamCount: number; // 上行消息数量
|
||||
downstreamCount: number; // 下行消息数量
|
||||
}
|
||||
|
||||
/** 消息统计接口参数 */
|
||||
export interface DeviceMessageReq {
|
||||
/** 设备消息统计请求 */
|
||||
export interface DeviceMessageReqVO {
|
||||
interval: number;
|
||||
times?: string[];
|
||||
}
|
||||
@@ -46,26 +45,17 @@ export namespace IotStatisticsApi {
|
||||
|
||||
/** 获取 IoT 统计摘要数据 */
|
||||
export function getStatisticsSummary() {
|
||||
return requestClient.get<IotStatisticsApi.StatisticsSummary>(
|
||||
return requestClient.get<IotStatisticsApi.StatisticsSummaryRespVO>(
|
||||
'/iot/statistics/get-summary',
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取设备消息的数据统计(按日期) */
|
||||
export function getDeviceMessageSummaryByDate(
|
||||
params: IotStatisticsApi.DeviceMessageReq,
|
||||
params: IotStatisticsApi.DeviceMessageReqVO,
|
||||
) {
|
||||
return requestClient.get<IotStatisticsApi.DeviceMessageSummaryByDate[]>(
|
||||
return requestClient.get<IotStatisticsApi.DeviceMessageSummaryByDateRespVO[]>(
|
||||
'/iot/statistics/get-device-message-summary-by-date',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
// TODO @haohao:貌似这里,没用到?是不是后面哪里用,或者可以删除哈?
|
||||
/** 获取设备消息统计摘要 */
|
||||
export function getDeviceMessageSummary(statType: number) {
|
||||
return requestClient.get<IotStatisticsApi.DeviceMessageSummary>(
|
||||
'/iot/statistics/get-device-message-summary',
|
||||
{ params: { statType } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
try {
|
||||
const data = modalApi.getData<any[]>();
|
||||
// 填充列表数据
|
||||
await gridApi.setGridOptions({ data });
|
||||
gridApi.setGridOptions({ data });
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
try {
|
||||
const data = modalApi.getData<any[]>();
|
||||
// 填充列表数据
|
||||
await gridApi.setGridOptions({ data });
|
||||
gridApi.setGridOptions({ data });
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { IotStatisticsApi } from '#/api/iot/statistics';
|
||||
|
||||
/** 统计数据 */
|
||||
export type StatsData = IotStatisticsApi.StatisticsSummaryRespVO;
|
||||
|
||||
/** 默认统计数据 */
|
||||
export const defaultStatsData: IotStatisticsApi.StatisticsSummary = {
|
||||
export const defaultStatsData: StatsData = {
|
||||
productCategoryCount: 0,
|
||||
productCount: 0,
|
||||
deviceCount: 0,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
// TODO @芋艿
|
||||
import type { StatsData } from './data';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
@@ -22,38 +21,7 @@ const statsData = ref<StatsData>(defaultStatsData);
|
||||
|
||||
/** 加载统计数据 */
|
||||
async function loadStatisticsData(): Promise<StatsData> {
|
||||
try {
|
||||
return await getStatisticsSummary();
|
||||
} catch (error) {
|
||||
// TODO @haohao:后续记得删除下哈。catch 部分可以删除
|
||||
// 开发环境:记录错误信息,便于调试
|
||||
console.error('获取统计数据出错:', error);
|
||||
// 开发环境:提示使用 Mock 数据,提醒检查后端接口
|
||||
console.warn('使用 Mock 数据,请检查后端接口是否已实现');
|
||||
|
||||
// TODO @haohao:后续记得删除下哈。
|
||||
// 开发调试:返回 Mock 数据,确保前端功能正常开发
|
||||
// 生产环境:建议移除 Mock 数据,直接抛出错误或返回空数据
|
||||
return {
|
||||
productCategoryCount: 12,
|
||||
productCount: 45,
|
||||
deviceCount: 328,
|
||||
deviceMessageCount: 15_678,
|
||||
productCategoryTodayCount: 2,
|
||||
productTodayCount: 5,
|
||||
deviceTodayCount: 23,
|
||||
deviceMessageTodayCount: 1234,
|
||||
deviceOnlineCount: 256,
|
||||
deviceOfflineCount: 48,
|
||||
deviceInactiveCount: 24,
|
||||
productCategoryDeviceCounts: {
|
||||
智能家居: 120,
|
||||
工业设备: 98,
|
||||
环境监测: 65,
|
||||
智能穿戴: 45,
|
||||
},
|
||||
};
|
||||
}
|
||||
return await getStatisticsSummary();
|
||||
}
|
||||
|
||||
/** 加载数据 */
|
||||
@@ -61,9 +29,6 @@ async function loadData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
statsData.value = await loadStatisticsData();
|
||||
} catch (error) {
|
||||
// TODO @haohao:后续记得删除下哈。catch 部分可以删除
|
||||
console.error('获取统计数据出错:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -139,9 +104,3 @@ onMounted(() => {
|
||||
</Row>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.vben-page-content) {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,7 @@ defineOptions({ name: 'DeviceCountCard' });
|
||||
|
||||
const props = defineProps<{
|
||||
loading?: boolean;
|
||||
statsData: IotStatisticsApi.StatisticsSummary;
|
||||
statsData: IotStatisticsApi.StatisticsSummaryRespVO;
|
||||
}>();
|
||||
|
||||
const deviceCountChartRef = ref();
|
||||
@@ -75,9 +75,3 @@ onMounted(() => {
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-card-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,7 @@ defineOptions({ name: 'DeviceStateCountCard' });
|
||||
|
||||
const props = defineProps<{
|
||||
loading?: boolean;
|
||||
statsData: IotStatisticsApi.StatisticsSummary;
|
||||
statsData: IotStatisticsApi.StatisticsSummaryRespVO;
|
||||
}>();
|
||||
|
||||
const deviceOnlineChartRef = ref();
|
||||
@@ -111,9 +111,3 @@ onMounted(() => {
|
||||
</Row>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-card-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,7 +23,7 @@ const messageChartRef = ref();
|
||||
const { renderEcharts } = useEcharts(messageChartRef);
|
||||
|
||||
const loading = ref(false);
|
||||
const messageData = ref<IotStatisticsApi.DeviceMessageSummaryByDate[]>([]);
|
||||
const messageData = ref<IotStatisticsApi.DeviceMessageSummaryByDateRespVO[]>([]);
|
||||
|
||||
/** 时间范围(仅日期,不包含时分秒) */
|
||||
const dateRange = ref<[string, string]>([
|
||||
@@ -38,7 +38,7 @@ function formatDateRangeWithTime(dates: [string, string]): [string, string] {
|
||||
}
|
||||
|
||||
/** 查询参数 */
|
||||
const queryParams = reactive<IotStatisticsApi.DeviceMessageReq>({
|
||||
const queryParams = reactive<IotStatisticsApi.DeviceMessageReqVO>({
|
||||
interval: 1, // 默认按天
|
||||
times: formatDateRangeWithTime(dateRange.value),
|
||||
});
|
||||
@@ -89,12 +89,6 @@ async function fetchMessageData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
messageData.value = await getDeviceMessageSummaryByDate(queryParams);
|
||||
} catch (error) {
|
||||
// TODO @haohao:catch 可以删除哈;
|
||||
// 开发环境:记录错误信息,便于调试
|
||||
console.error('获取消息统计数据失败:', error);
|
||||
// 错误时清空数据,避免显示错误的数据
|
||||
messageData.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
await renderChartWhenReady();
|
||||
|
||||
@@ -83,7 +83,6 @@ const [Modal, modalApi] = useVbenModal({
|
||||
}
|
||||
// 加载数据
|
||||
const data = modalApi.getData<any>();
|
||||
|
||||
if (!data || !data.id) {
|
||||
formData.value = {
|
||||
type: IotDataSinkTypeEnum.HTTP,
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
// 初始化 formData,确保始终有值
|
||||
const formData = ref<Partial<MallCommentApi.Comment>>({
|
||||
descriptionScores: 5,
|
||||
benefitScores: 5,
|
||||
@@ -100,7 +99,6 @@ const [Modal, modalApi] = useVbenModal({
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
// 重置表单数据
|
||||
selectedSku.value = undefined;
|
||||
return;
|
||||
}
|
||||
@@ -108,6 +106,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
const data = modalApi.getData<MallCommentApi.Comment>();
|
||||
if (!data || !data.id) {
|
||||
// 新建模式:重置表单
|
||||
// TODO @puhui999:这里的重置,可以统一到 103 行那么?
|
||||
selectedSku.value = undefined;
|
||||
await formApi.setValues({ spuId: undefined, skuId: undefined });
|
||||
return;
|
||||
|
||||
@@ -127,7 +127,7 @@ 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
|
||||
|
||||
@@ -110,7 +110,7 @@ function emitSpuChange() {
|
||||
class="h-full w-full rounded-lg object-cover"
|
||||
/>
|
||||
<!-- 删除按钮 -->
|
||||
<!-- TODO @AI:还是使用 IconifyIcon:使用自己的 + 图标 -->
|
||||
<!-- TODO @puhui999:还是使用 IconifyIcon:使用自己的中立的图标,方便 antd 和 ele 共享 -->
|
||||
<CloseCircleFilled
|
||||
v-if="!disabled"
|
||||
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"
|
||||
@@ -126,7 +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:使用自己的 + 图标 -->
|
||||
<!-- TODO @puhui999:还是使用 IconifyIcon:使用自己的中立的图标,方便 antd 和 ele 共享 -->
|
||||
<PlusOutlined class="text-xl text-gray-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@@ -32,6 +32,7 @@ const spuId = ref<number>();
|
||||
const { params, name } = useRoute();
|
||||
const { closeCurrentTab } = useTabs();
|
||||
const activeTabName = ref('info');
|
||||
// TODO @puhui999:这个要不要类似 ele 里,直接写到 html 里?
|
||||
const tabList = ref([
|
||||
{
|
||||
key: 'info',
|
||||
|
||||
@@ -130,7 +130,6 @@ const [Modal, modalApi] = useVbenModal({
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
},
|
||||
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
|
||||
@@ -24,6 +24,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
placeholder: '请输入活动名称',
|
||||
},
|
||||
rules: 'required',
|
||||
formItemClass: 'col-span-2',
|
||||
},
|
||||
{
|
||||
fieldName: 'startTime',
|
||||
@@ -133,13 +134,6 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
placeholder: '请输入最大砍价金额',
|
||||
},
|
||||
},
|
||||
// TODO @puhui999:这里交互不太对,可以对比下 element-plus 版本呢
|
||||
{
|
||||
fieldName: 'spuId',
|
||||
label: '砍价商品',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallBargainActivityApi } from '#/api/mall/promotion/bargain/bargainActivity';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { getSpu } from '#/api/mall/product/spu';
|
||||
import {
|
||||
createBargainActivity,
|
||||
getBargainActivity,
|
||||
updateBargainActivity,
|
||||
} from '#/api/mall/promotion/bargain/bargainActivity';
|
||||
import { $t } from '#/locales';
|
||||
import { SpuShowcase } from '#/views/mall/product/spu/components';
|
||||
import { SpuSkuSelect } from '#/views/mall/product/spu/components';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
@@ -22,21 +23,62 @@ defineOptions({ name: 'PromotionBargainActivityForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const formData = ref<Partial<MallBargainActivityApi.BargainActivity>>({});
|
||||
const formData = ref<MallBargainActivityApi.BargainActivity>();
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['砍价活动'])
|
||||
: $t('ui.actionTitle.create', ['砍价活动']);
|
||||
});
|
||||
|
||||
// ================= 商品选择相关 =================
|
||||
const spuId = ref<number>();
|
||||
const skuId = ref<number>();
|
||||
const spuName = ref<string>('');
|
||||
const skuInfo = ref<{
|
||||
picUrl: string;
|
||||
price: number;
|
||||
skuName: string;
|
||||
}>();
|
||||
|
||||
const spuSkuSelectRef = ref(); // 商品选择弹窗 Ref
|
||||
|
||||
/** 打开商品选择弹窗 */
|
||||
const handleSelectProduct = () => {
|
||||
spuSkuSelectRef.value?.open();
|
||||
};
|
||||
|
||||
/** 选择商品后的回调 */
|
||||
async function handleSpuSelected(selectedSpuId: number, skuIds?: number[]) {
|
||||
const spu = await getSpu(selectedSpuId);
|
||||
if (!spu) return;
|
||||
|
||||
spuId.value = spu.id;
|
||||
spuName.value = spu.name || '';
|
||||
|
||||
// 砍价活动只选择一个 SKU
|
||||
if (skuIds && skuIds.length > 0) {
|
||||
const selectedSku = spu.skus?.find((sku) => sku.id === skuIds[0]);
|
||||
if (selectedSku) {
|
||||
skuId.value = selectedSku.id;
|
||||
skuInfo.value = {
|
||||
skuName: selectedSku.name || '',
|
||||
picUrl: selectedSku.picUrl || spu.picUrl || '',
|
||||
price: Number(selectedSku.price) || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================= end =================
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 120,
|
||||
labelWidth: 130,
|
||||
},
|
||||
wrapperClass: 'grid-cols-2',
|
||||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
@@ -48,14 +90,34 @@ const [Modal, modalApi] = useVbenModal({
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
|
||||
// 验证商品和 SKU 选择
|
||||
if (!spuId.value) {
|
||||
message.error('请选择砍价商品');
|
||||
return;
|
||||
}
|
||||
if (!skuId.value) {
|
||||
message.error('请选择商品 SKU');
|
||||
return;
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const values = await formApi.getValues();
|
||||
const data = {
|
||||
...values,
|
||||
spuId: formData.value.spuId,
|
||||
} as MallBargainActivityApi.BargainActivity;
|
||||
modalApi.lock();
|
||||
try {
|
||||
const values = await formApi.getValues();
|
||||
const data = {
|
||||
...values,
|
||||
spuId: spuId.value,
|
||||
skuId: skuId.value,
|
||||
bargainFirstPrice: Math.round((values.bargainFirstPrice || 0) * 100),
|
||||
bargainMinPrice: Math.round((values.bargainMinPrice || 0) * 100),
|
||||
randomMinPrice: values.randomMinPrice
|
||||
? Math.round(values.randomMinPrice * 100)
|
||||
: undefined,
|
||||
randomMaxPrice: values.randomMaxPrice
|
||||
? Math.round(values.randomMaxPrice * 100)
|
||||
: undefined,
|
||||
} as MallBargainActivityApi.BargainActivity;
|
||||
await (formData.value?.id
|
||||
? updateBargainActivity(data)
|
||||
: createBargainActivity(data));
|
||||
@@ -69,10 +131,14 @@ const [Modal, modalApi] = useVbenModal({
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = {};
|
||||
formData.value = undefined;
|
||||
spuId.value = undefined;
|
||||
skuId.value = undefined;
|
||||
spuName.value = '';
|
||||
skuInfo.value = undefined;
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
// 加载表单数据
|
||||
const data = modalApi.getData<MallBargainActivityApi.BargainActivity>();
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
@@ -80,8 +146,40 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getBargainActivity(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
await nextTick();
|
||||
// 设置表单值时,价格字段从分转换为元
|
||||
await formApi.setValues({
|
||||
...formData.value,
|
||||
bargainFirstPrice: (formData.value.bargainFirstPrice || 0) / 100,
|
||||
bargainMinPrice: (formData.value.bargainMinPrice || 0) / 100,
|
||||
randomMinPrice: formData.value.randomMinPrice
|
||||
? formData.value.randomMinPrice / 100
|
||||
: undefined,
|
||||
randomMaxPrice: formData.value.randomMaxPrice
|
||||
? formData.value.randomMaxPrice / 100
|
||||
: undefined,
|
||||
});
|
||||
// 加载商品和 SKU 信息
|
||||
if (formData.value.spuId) {
|
||||
const spu = await getSpu(formData.value.spuId);
|
||||
if (spu) {
|
||||
spuId.value = spu.id;
|
||||
spuName.value = spu.name || '';
|
||||
if (formData.value.skuId) {
|
||||
const selectedSku = spu.skus?.find(
|
||||
(sku) => sku.id === formData.value?.skuId,
|
||||
);
|
||||
if (selectedSku) {
|
||||
skuId.value = selectedSku.id;
|
||||
skuInfo.value = {
|
||||
skuName: selectedSku.name || '',
|
||||
picUrl: selectedSku.picUrl || spu.picUrl || '',
|
||||
price: Number(selectedSku.price) || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
@@ -90,12 +188,74 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-2/5" :title="getTitle">
|
||||
<Form class="mx-4">
|
||||
<!-- 自定义插槽:商品选择 -->
|
||||
<template #spuId>
|
||||
<SpuShowcase v-model="formData.spuId" :limit="1" />
|
||||
</template>
|
||||
</Form>
|
||||
<Modal class="w-3/5" :title="getTitle">
|
||||
<div class="mx-4">
|
||||
<Form />
|
||||
|
||||
<!-- 商品选择区域 -->
|
||||
<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>
|
||||
|
||||
<!-- SKU 信息展示 -->
|
||||
<div v-if="skuInfo" class="mt-4">
|
||||
<table class="w-full border-collapse border border-gray-300">
|
||||
<!-- TODO @puhui999:和 element-plus 有点差别哈;ps:是不是用 grid 组件呀?或者 vxe 组件
|
||||
图片
|
||||
颜色
|
||||
版本
|
||||
商品条码
|
||||
销售价(元)
|
||||
市场价(元)
|
||||
成本价(元)
|
||||
库存
|
||||
砍价起始价格(元)
|
||||
砍价底价(元)
|
||||
活动库存
|
||||
-->
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center">
|
||||
<img
|
||||
v-if="skuInfo.picUrl"
|
||||
:src="skuInfo.picUrl"
|
||||
alt="商品图片"
|
||||
class="h-16 w-16 object-cover"
|
||||
/>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2">
|
||||
{{ skuInfo.skuName }}
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center">
|
||||
¥{{ (skuInfo.price / 100).toFixed(2) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- 商品选择器弹窗(单选模式) -->
|
||||
<SpuSkuSelect
|
||||
ref="spuSkuSelectRef"
|
||||
:is-select-sku="true"
|
||||
:radio="true"
|
||||
@select="handleSpuSelected"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -105,13 +105,6 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
|
||||
},
|
||||
},
|
||||
// TODO @puhui999:这里交互不太对,可以对比下 element-plus 版本呢
|
||||
{
|
||||
fieldName: 'spuId',
|
||||
label: '拼团商品',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,34 +1,72 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallCombinationActivityApi } from '#/api/mall/promotion/combination/combinationActivity';
|
||||
|
||||
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 { message } from 'ant-design-vue';
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { getSpu } from '#/api/mall/product/spu';
|
||||
import {
|
||||
createCombinationActivity,
|
||||
getCombinationActivity,
|
||||
updateCombinationActivity,
|
||||
} from '#/api/mall/promotion/combination/combinationActivity';
|
||||
import { $t } from '#/locales';
|
||||
import { SpuShowcase } from '#/views/mall/product/spu/components';
|
||||
import { SpuSkuSelect } from '#/views/mall/product/spu/components';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'CombinationActivityForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<Partial<MallCombinationActivityApi.CombinationActivity>>(
|
||||
{},
|
||||
);
|
||||
const formData = ref<MallCombinationActivityApi.CombinationActivity>();
|
||||
|
||||
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 spuSkuSelectRef = ref(); // 商品选择弹窗 Ref
|
||||
|
||||
/** 打开商品选择弹窗 */
|
||||
const handleSelectProduct = () => {
|
||||
spuSkuSelectRef.value?.open();
|
||||
};
|
||||
|
||||
/** 选择商品后的回调 */
|
||||
async function handleSpuSelected(selectedSpuId: number, skuIds?: number[]) {
|
||||
const spu = await getSpu(selectedSpuId);
|
||||
if (!spu) return;
|
||||
|
||||
spuId.value = spu.id;
|
||||
spuName.value = spu.name || '';
|
||||
|
||||
// 筛选指定的 SKU
|
||||
const selectedSkus = skuIds
|
||||
? spu.skus?.filter((sku) => skuIds.includes(sku.id!))
|
||||
: spu.skus;
|
||||
|
||||
skuTableData.value =
|
||||
selectedSkus?.map((sku) => ({
|
||||
skuId: sku.id!,
|
||||
skuName: sku.name || '',
|
||||
picUrl: sku.picUrl || spu.picUrl || '',
|
||||
price: sku.price || 0,
|
||||
combinationPrice: 0,
|
||||
})) || [];
|
||||
}
|
||||
|
||||
// ================= end =================
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
@@ -48,14 +86,37 @@ const [Modal, modalApi] = useVbenModal({
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
|
||||
// 验证商品和 SKU 配置
|
||||
if (!spuId.value) {
|
||||
message.error('请选择拼团商品');
|
||||
return;
|
||||
}
|
||||
if (skuTableData.value.length === 0) {
|
||||
message.error('请至少配置一个 SKU');
|
||||
return;
|
||||
}
|
||||
// 验证 SKU 配置
|
||||
const hasInvalidSku = skuTableData.value.some(
|
||||
(sku) => sku.combinationPrice < 0.01,
|
||||
);
|
||||
if (hasInvalidSku) {
|
||||
message.error('请正确配置 SKU 的拼团价格(≥0.01)');
|
||||
return;
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const values = await formApi.getValues();
|
||||
const data = {
|
||||
...values,
|
||||
spuId: formData.value.spuId,
|
||||
} as MallCombinationActivityApi.CombinationActivity;
|
||||
modalApi.lock();
|
||||
try {
|
||||
const values = await formApi.getValues();
|
||||
const data: any = {
|
||||
...values,
|
||||
spuId: spuId.value,
|
||||
products: skuTableData.value.map((sku) => ({
|
||||
skuId: sku.skuId,
|
||||
combinationPrice: Math.round(sku.combinationPrice * 100), // 转换为分
|
||||
})),
|
||||
};
|
||||
await (formData.value?.id
|
||||
? updateCombinationActivity(data)
|
||||
: createCombinationActivity(data));
|
||||
@@ -69,7 +130,10 @@ const [Modal, modalApi] = useVbenModal({
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = {};
|
||||
formData.value = undefined;
|
||||
spuId.value = undefined;
|
||||
spuName.value = '';
|
||||
skuTableData.value = [];
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
@@ -81,8 +145,32 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getCombinationActivity(data.id);
|
||||
// 设置到 values
|
||||
await nextTick();
|
||||
await formApi.setValues(formData.value);
|
||||
// 加载商品和 SKU 信息
|
||||
// TODO @puhui999:if return,简化括号层级
|
||||
if (formData.value.spuId) {
|
||||
const spu = await getSpu(formData.value.spuId);
|
||||
if (spu) {
|
||||
spuId.value = spu.id;
|
||||
spuName.value = spu.name || '';
|
||||
// 回填 SKU 配置
|
||||
const products = formData.value.products || [];
|
||||
skuTableData.value =
|
||||
spu.skus
|
||||
?.filter((sku) => products.some((p) => p.skuId === sku.id))
|
||||
.map((sku) => {
|
||||
const product = products.find((p) => p.skuId === sku.id);
|
||||
return {
|
||||
skuId: sku.id!,
|
||||
skuName: sku.name || '',
|
||||
picUrl: sku.picUrl || spu.picUrl || '',
|
||||
price: sku.price || 0,
|
||||
combinationPrice: (product?.combinationPrice || 0) / 100, // 分转元
|
||||
};
|
||||
}) || [];
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
@@ -91,12 +179,80 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-3/5" :title="getTitle">
|
||||
<Form>
|
||||
<!-- 自定义插槽:商品选择 -->
|
||||
<template #spuId>
|
||||
<SpuShowcase v-model="formData.spuId" :limit="1" />
|
||||
</template>
|
||||
</Form>
|
||||
<Modal class="w-4/5" :title="getTitle">
|
||||
<div class="mx-4">
|
||||
<Form />
|
||||
|
||||
<!-- 商品选择区域 -->
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
<!-- TODO @puhui999:这里貌似和 element-plus 没对齐;;ps:是不是用 grid 组件呀?或者 vxe 组件
|
||||
图片
|
||||
商品条码
|
||||
销售价(元)
|
||||
市场价(元)
|
||||
成本价(元)
|
||||
库存
|
||||
拼团价格(元)
|
||||
-->
|
||||
<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">
|
||||
<!-- TODO @puhui999:是不是要使用 antd 的哈? -->
|
||||
<input
|
||||
v-model.number="sku.combinationPrice"
|
||||
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"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -57,24 +57,22 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
/** 打开弹窗 */
|
||||
async function open() {
|
||||
visible.value = true;
|
||||
// 重置查询条件并重新加载数据,与老组件行为一致
|
||||
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,
|
||||
|
||||
@@ -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]),
|
||||
});
|
||||
|
||||
@@ -126,7 +126,6 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
},
|
||||
// TODO @puhui999:商品图太大了。
|
||||
{
|
||||
fieldName: 'spuId',
|
||||
label: '活动商品',
|
||||
|
||||
@@ -180,7 +180,6 @@ const [Modal, modalApi] = useVbenModal({
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
// 重置表单数据(新增和编辑模式都需要)
|
||||
formData.value = undefined;
|
||||
spuList.value = [];
|
||||
spuPropertyList.value = [];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<!-- 积分商城活动橱窗组件:用于展示和选择积分商城活动 -->
|
||||
<!-- TODO @puhui999:不需要 point- 前缀 --->
|
||||
<script lang="ts" setup>
|
||||
import type { MallPointActivityApi } from '#/api/mall/promotion/point';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<!-- 积分商城活动选择弹窗组件 -->
|
||||
<!-- TODO @puhui999:不需要 point- 前缀 --->
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
@@ -32,7 +32,6 @@ const formData = ref<Partial<MallRewardActivityApi.RewardActivity>>({
|
||||
productScope: PromotionProductScopeEnum.ALL.scope,
|
||||
rules: [],
|
||||
});
|
||||
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['满减送'])
|
||||
@@ -76,6 +75,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
});
|
||||
// 设置 productScopeValues
|
||||
switch (data.productScope) {
|
||||
// TODO @puhui999:这里要不要搞成类似优惠劵的
|
||||
case PromotionProductScopeEnum.CATEGORY.scope: {
|
||||
const categoryIds = data.productCategoryIds;
|
||||
data.productScopeValues = Array.isArray(categoryIds)
|
||||
@@ -90,7 +90,6 @@ const [Modal, modalApi] = useVbenModal({
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await (data.id
|
||||
? updateRewardActivity(data as MallRewardActivityApi.RewardActivity)
|
||||
: createRewardActivity(data as MallRewardActivityApi.RewardActivity));
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<MallSeckillActivityApi.SeckillActivity>();
|
||||
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['秒杀活动'])
|
||||
@@ -90,12 +89,10 @@ const [Modal, modalApi] = useVbenModal({
|
||||
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,
|
||||
@@ -105,11 +102,10 @@ const [Modal, modalApi] = useVbenModal({
|
||||
return;
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
modalApi.lock();
|
||||
try {
|
||||
const values = await formApi.getValues();
|
||||
|
||||
// 构建提交数据
|
||||
const data: any = {
|
||||
...values,
|
||||
spuId: spuId.value,
|
||||
@@ -119,11 +115,10 @@ const [Modal, modalApi] = useVbenModal({
|
||||
seckillPrice: Math.round(sku.seckillPrice * 100), // 转换为分
|
||||
})),
|
||||
};
|
||||
|
||||
await (formData.value?.id
|
||||
? updateSeckillActivity(data)
|
||||
: createSeckillActivity(data));
|
||||
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
@@ -140,18 +135,18 @@ const [Modal, modalApi] = useVbenModal({
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
const data = modalApi.getData<MallSeckillActivityApi.SeckillActivity>();
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getSeckillActivity(data.id);
|
||||
await nextTick();
|
||||
await formApi.setValues(formData.value);
|
||||
|
||||
// 加载商品和 SKU 信息
|
||||
// TODO @puhui999:if return 简化括号层级
|
||||
if (formData.value.spuId) {
|
||||
const spu = await getSpu(formData.value.spuId);
|
||||
if (spu) {
|
||||
@@ -201,6 +196,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
|
||||
<!-- SKU 配置表格 -->
|
||||
<div v-if="skuTableData.length > 0" class="mt-4">
|
||||
<!-- TODO @puhui999:Grid?或者 VXETable 哇? -->
|
||||
<table class="w-full border-collapse border border-gray-300">
|
||||
<thead>
|
||||
<tr class="bg-gray-100">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { NewsType } from '@vben/constants';
|
||||
@@ -34,16 +34,6 @@ const emit = defineEmits<{
|
||||
(e: 'selectMaterial', item: any): void;
|
||||
}>();
|
||||
|
||||
// TODO @dlyan @AI:这里是不是应该都用 grid;
|
||||
const loading = ref(false); // 遮罩层
|
||||
const total = ref(0); // 总条数
|
||||
const list = ref<any[]>([]); // 数据列表
|
||||
const queryParams = reactive({
|
||||
accountId: props.accountId,
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
}); // 查询参数
|
||||
|
||||
const voiceGridColumns: VxeTableGridOptions<MpMaterialApi.Material>['columns'] =
|
||||
[
|
||||
{
|
||||
@@ -123,6 +113,99 @@ const videoGridColumns: VxeTableGridOptions<MpMaterialApi.Material>['columns'] =
|
||||
},
|
||||
];
|
||||
|
||||
// Image Grid
|
||||
const [ImageGrid, imageGridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: [],
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, { accountId }) => {
|
||||
const finalAccountId = accountId ?? props.accountId;
|
||||
if (!finalAccountId) {
|
||||
return { list: [], total: 0 };
|
||||
}
|
||||
return await getMaterialPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
accountId: finalAccountId,
|
||||
type: 'image',
|
||||
});
|
||||
},
|
||||
},
|
||||
autoLoad: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'mediaId',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
|
||||
// News Grid
|
||||
const [NewsGrid, newsGridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: [],
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, { accountId }) => {
|
||||
const finalAccountId = accountId ?? props.accountId;
|
||||
if (!finalAccountId) {
|
||||
return { list: [], total: 0 };
|
||||
}
|
||||
if (props.newsType === NewsType.Published) {
|
||||
const data = await getFreePublishPage({
|
||||
accountId: finalAccountId,
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
});
|
||||
data.list.forEach((item: any) => {
|
||||
const articles = item.content.newsItem;
|
||||
articles.forEach((article: any) => {
|
||||
article.picUrl = article.thumbUrl;
|
||||
});
|
||||
});
|
||||
return data;
|
||||
} else {
|
||||
const data = await getDraftPage({
|
||||
accountId: finalAccountId,
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
});
|
||||
data.list.forEach((draft: any) => {
|
||||
const articles = draft.content.newsItem;
|
||||
articles.forEach((article: any) => {
|
||||
article.picUrl = article.thumbUrl;
|
||||
});
|
||||
});
|
||||
return data;
|
||||
}
|
||||
},
|
||||
},
|
||||
autoLoad: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'mediaId',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
} as VxeTableGridOptions<any>,
|
||||
});
|
||||
|
||||
const [VoiceGrid, voiceGridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
border: true,
|
||||
@@ -136,7 +219,7 @@ const [VoiceGrid, voiceGridApi] = useVbenVxeGrid({
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, { accountId }) => {
|
||||
const finalAccountId = accountId ?? queryParams.accountId;
|
||||
const finalAccountId = accountId ?? props.accountId;
|
||||
if (!finalAccountId) {
|
||||
return { list: [], total: 0 };
|
||||
}
|
||||
@@ -172,7 +255,7 @@ const [VideoGrid, videoGridApi] = useVbenVxeGrid({
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, { accountId }) => {
|
||||
const finalAccountId = accountId ?? queryParams.accountId;
|
||||
const finalAccountId = accountId ?? props.accountId;
|
||||
if (finalAccountId === undefined || finalAccountId === null) {
|
||||
return { list: [], total: 0 };
|
||||
}
|
||||
@@ -195,91 +278,164 @@ const [VideoGrid, videoGridApi] = useVbenVxeGrid({
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
|
||||
// 从 Grid 获取数据
|
||||
const imageList = computed(() => {
|
||||
try {
|
||||
const tableData = imageGridApi.grid?.getTableData();
|
||||
return (tableData?.tableData as MpMaterialApi.Material[]) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const imageLoading = computed(() => {
|
||||
return imageGridApi.grid?.loading || false;
|
||||
});
|
||||
|
||||
const imageTotal = computed(() => {
|
||||
try {
|
||||
const proxyInfo = imageGridApi.grid?.getProxyInfo();
|
||||
return proxyInfo?.pager?.total || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const imageCurrentPage = computed({
|
||||
get: () => {
|
||||
try {
|
||||
return imageGridApi.grid?.pagerConfig?.currentPage || 1;
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
},
|
||||
set: (value: number) => {
|
||||
imageGridApi.grid?.commitProxy('page', { currentPage: value });
|
||||
},
|
||||
});
|
||||
|
||||
const imagePageSize = computed({
|
||||
get: () => {
|
||||
try {
|
||||
return imageGridApi.grid?.pagerConfig?.pageSize || 10;
|
||||
} catch {
|
||||
return 10;
|
||||
}
|
||||
},
|
||||
set: (value: number) => {
|
||||
imageGridApi.grid?.commitProxy('page', { pageSize: value, currentPage: 1 });
|
||||
},
|
||||
});
|
||||
|
||||
const newsList = computed(() => {
|
||||
try {
|
||||
const tableData = newsGridApi.grid?.getTableData();
|
||||
return (tableData?.tableData as any[]) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const newsLoading = computed(() => {
|
||||
return newsGridApi.grid?.loading || false;
|
||||
});
|
||||
|
||||
const newsTotal = computed(() => {
|
||||
try {
|
||||
const proxyInfo = newsGridApi.grid?.getProxyInfo();
|
||||
return proxyInfo?.pager?.total || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const newsCurrentPage = computed({
|
||||
get: () => {
|
||||
try {
|
||||
return newsGridApi.grid?.pagerConfig?.currentPage || 1;
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
},
|
||||
set: (value: number) => {
|
||||
newsGridApi.grid?.commitProxy('page', { currentPage: value });
|
||||
},
|
||||
});
|
||||
|
||||
const newsPageSize = computed({
|
||||
get: () => {
|
||||
try {
|
||||
return newsGridApi.grid?.pagerConfig?.pageSize || 10;
|
||||
} catch {
|
||||
return 10;
|
||||
}
|
||||
},
|
||||
set: (value: number) => {
|
||||
newsGridApi.grid?.commitProxy('page', { pageSize: value, currentPage: 1 });
|
||||
},
|
||||
});
|
||||
|
||||
function selectMaterialFun(item: any) {
|
||||
emit('selectMaterial', item);
|
||||
}
|
||||
|
||||
async function getMaterialPageFun() {
|
||||
const data = await getMaterialPage({
|
||||
...queryParams,
|
||||
type: props.type,
|
||||
});
|
||||
list.value = data.list;
|
||||
total.value = data.total;
|
||||
}
|
||||
|
||||
async function getFreePublishPageFun() {
|
||||
const data = await getFreePublishPage(queryParams);
|
||||
data.list.forEach((item: any) => {
|
||||
const articles = item.content.newsItem;
|
||||
articles.forEach((article: any) => {
|
||||
article.picUrl = article.thumbUrl;
|
||||
});
|
||||
});
|
||||
list.value = data.list;
|
||||
total.value = data.total;
|
||||
}
|
||||
|
||||
async function getDraftPageFun() {
|
||||
const data = await getDraftPage(queryParams);
|
||||
data.list.forEach((draft: any) => {
|
||||
const articles = draft.content.newsItem;
|
||||
articles.forEach((article: any) => {
|
||||
article.picUrl = article.thumbUrl;
|
||||
});
|
||||
});
|
||||
list.value = data.list;
|
||||
total.value = data.total;
|
||||
}
|
||||
|
||||
async function getPage() {
|
||||
if (props.type === 'voice') {
|
||||
await voiceGridApi.reload({ accountId: queryParams.accountId });
|
||||
return;
|
||||
}
|
||||
if (props.type === 'video') {
|
||||
await videoGridApi.reload({ accountId: queryParams.accountId });
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
if (props.type === 'news' && props.newsType === NewsType.Published) {
|
||||
await getFreePublishPageFun();
|
||||
} else if (props.type === 'news' && props.newsType === NewsType.Draft) {
|
||||
await getDraftPageFun();
|
||||
} else {
|
||||
await getMaterialPageFun();
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 accountId 变化
|
||||
watch(
|
||||
() => props.accountId,
|
||||
(accountId) => {
|
||||
queryParams.accountId = accountId;
|
||||
queryParams.pageNo = 1;
|
||||
getPage();
|
||||
switch (props.type) {
|
||||
case 'image': {
|
||||
imageGridApi.reload({ accountId });
|
||||
break;
|
||||
}
|
||||
case 'news': {
|
||||
newsGridApi.reload({ accountId });
|
||||
break;
|
||||
}
|
||||
case 'video': {
|
||||
videoGridApi.reload({ accountId });
|
||||
break;
|
||||
}
|
||||
case 'voice': {
|
||||
voiceGridApi.reload({ accountId });
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 监听 type 变化
|
||||
watch(
|
||||
() => props.type,
|
||||
() => {
|
||||
queryParams.pageNo = 1;
|
||||
getPage();
|
||||
switch (props.type) {
|
||||
case 'image': {
|
||||
imageGridApi.reload({ accountId: props.accountId });
|
||||
break;
|
||||
}
|
||||
case 'news': {
|
||||
newsGridApi.reload({ accountId: props.accountId });
|
||||
break;
|
||||
}
|
||||
case 'video': {
|
||||
videoGridApi.reload({ accountId: props.accountId });
|
||||
break;
|
||||
}
|
||||
case 'voice': {
|
||||
voiceGridApi.reload({ accountId: props.accountId });
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 监听 newsType 变化
|
||||
watch(
|
||||
() => props.newsType,
|
||||
() => {
|
||||
if (props.type === 'news') {
|
||||
queryParams.pageNo = 1;
|
||||
getPage();
|
||||
newsGridApi.reload({ accountId: props.accountId });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -289,42 +445,47 @@ watch(
|
||||
<Page :bordered="false" class="pb-8">
|
||||
<!-- 类型:image -->
|
||||
<template v-if="props.type === 'image'">
|
||||
<Spin :spinning="loading">
|
||||
<div
|
||||
class="mx-auto w-full columns-1 [column-gap:10px] md:columns-2 lg:columns-3 xl:columns-4 2xl:columns-5"
|
||||
>
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.mediaId"
|
||||
class="mb-2.5 h-72 break-inside-avoid border border-[#eaeaea] p-2.5"
|
||||
>
|
||||
<img
|
||||
class="h-48 w-full object-contain"
|
||||
:src="item.url"
|
||||
alt="素材图片"
|
||||
<div class="image-grid-wrapper">
|
||||
<ImageGrid>
|
||||
<template #default>
|
||||
<Spin :spinning="imageLoading">
|
||||
<div
|
||||
class="mx-auto w-full columns-1 [column-gap:10px] md:columns-2 lg:columns-3 xl:columns-4 2xl:columns-5"
|
||||
>
|
||||
<div
|
||||
v-for="item in imageList"
|
||||
:key="item.mediaId"
|
||||
class="mb-2.5 h-72 break-inside-avoid border border-[#eaeaea] p-2.5"
|
||||
>
|
||||
<img
|
||||
class="h-48 w-full object-contain"
|
||||
:src="item.url"
|
||||
alt="素材图片"
|
||||
/>
|
||||
<p class="truncate text-center text-xs leading-[30px]">
|
||||
{{ item.name }}
|
||||
</p>
|
||||
<Row class="flex justify-center pt-2.5">
|
||||
<Button type="primary" @click="selectMaterialFun(item)">
|
||||
选择
|
||||
<template #icon>
|
||||
<IconifyIcon icon="lucide:circle-check" />
|
||||
</template>
|
||||
</Button>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
<Pagination
|
||||
v-model:current="imageCurrentPage"
|
||||
v-model:page-size="imagePageSize"
|
||||
:total="imageTotal"
|
||||
class="mt-4"
|
||||
show-size-changer
|
||||
/>
|
||||
<p class="truncate text-center text-xs leading-[30px]">
|
||||
{{ item.name }}
|
||||
</p>
|
||||
<Row class="flex justify-center pt-2.5">
|
||||
<Button type="primary" @click="selectMaterialFun(item)">
|
||||
选择
|
||||
<template #icon>
|
||||
<IconifyIcon icon="lucide:circle-check" />
|
||||
</template>
|
||||
</Button>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
<Pagination
|
||||
v-model:current="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
class="mt-4"
|
||||
@change="getPage"
|
||||
@show-size-change="getPage"
|
||||
/>
|
||||
</template>
|
||||
</ImageGrid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 类型:voice -->
|
||||
@@ -363,37 +524,52 @@ watch(
|
||||
|
||||
<!-- 类型:news -->
|
||||
<template v-else-if="props.type === 'news'">
|
||||
<Spin :spinning="loading">
|
||||
<div
|
||||
class="mx-auto w-full columns-1 [column-gap:10px] md:columns-2 lg:columns-3 xl:columns-4 2xl:columns-5"
|
||||
>
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.mediaId"
|
||||
class="mb-2.5 break-inside-avoid border border-[#eaeaea] p-2.5"
|
||||
>
|
||||
<div v-if="item.content && item.content.newsItem">
|
||||
<WxNews :articles="item.content.newsItem" />
|
||||
<Row class="flex justify-center pt-2.5">
|
||||
<Button type="primary" @click="selectMaterialFun(item)">
|
||||
选择
|
||||
<template #icon>
|
||||
<IconifyIcon icon="lucide:circle-check" />
|
||||
</template>
|
||||
</Button>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
<Pagination
|
||||
v-model:current="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
class="mt-4"
|
||||
@change="getPage"
|
||||
@show-size-change="getPage"
|
||||
/>
|
||||
<div class="news-grid-wrapper">
|
||||
<NewsGrid>
|
||||
<template #default>
|
||||
<Spin :spinning="newsLoading">
|
||||
<div
|
||||
class="mx-auto w-full columns-1 [column-gap:10px] md:columns-2 lg:columns-3 xl:columns-4 2xl:columns-5"
|
||||
>
|
||||
<div
|
||||
v-for="item in newsList"
|
||||
:key="item.mediaId"
|
||||
class="mb-2.5 break-inside-avoid border border-[#eaeaea] p-2.5"
|
||||
>
|
||||
<div v-if="item.content && item.content.newsItem">
|
||||
<WxNews :articles="item.content.newsItem" />
|
||||
<Row class="flex justify-center pt-2.5">
|
||||
<Button type="primary" @click="selectMaterialFun(item)">
|
||||
选择
|
||||
<template #icon>
|
||||
<IconifyIcon icon="lucide:circle-check" />
|
||||
</template>
|
||||
</Button>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
<Pagination
|
||||
v-model:current="newsCurrentPage"
|
||||
v-model:page-size="newsPageSize"
|
||||
:total="newsTotal"
|
||||
class="mt-4"
|
||||
show-size-changer
|
||||
/>
|
||||
</template>
|
||||
</NewsGrid>
|
||||
</div>
|
||||
</template>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.image-grid-wrapper :deep(.vxe-grid--body-wrapper) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.news-grid-wrapper :deep(.vxe-grid--body-wrapper) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,79 +1,121 @@
|
||||
<script lang="ts" setup>
|
||||
import { provide, reactive, ref } from 'vue';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { provide, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { confirm, DocAlert, Page } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { Button, Card, Form, message, Pagination, Tabs } from 'ant-design-vue';
|
||||
import { Button, message, Tabs } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deletePermanentMaterial, getMaterialPage } from '#/api/mp/material';
|
||||
import { WxAccountSelect } from '#/views/mp/components';
|
||||
|
||||
import ImageTable from './modules/image-table.vue';
|
||||
import {
|
||||
useGridFormSchema,
|
||||
useImageGridColumns,
|
||||
useVideoGridColumns,
|
||||
useVoiceGridColumns,
|
||||
} from './modules/data';
|
||||
import { UploadType } from './modules/upload';
|
||||
import UploadFile from './modules/UploadFile.vue';
|
||||
import UploadVideo from './modules/UploadVideo.vue';
|
||||
import VideoTable from './modules/video-table.vue';
|
||||
import VoiceTable from './modules/voice-table.vue';
|
||||
import {$t} from '@vben/locales';
|
||||
|
||||
defineOptions({ name: 'MpMaterial' });
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
const type = ref<UploadType>(UploadType.Image); // 素材类型
|
||||
const loading = ref(false); // 遮罩层
|
||||
const list = ref<any[]>([]); // 数据列表
|
||||
const total = ref(0); // 总条数
|
||||
const showCreateVideo = ref(false); // 是否新建视频的弹窗
|
||||
|
||||
const accountId = ref(-1);
|
||||
provide('accountId', accountId);
|
||||
|
||||
// TODO @dlyan @AI:这里是不是应该都用 grid;类似 yudao-ui-admin-vben-v5/apps/web-ele/src/views/mp/autoReply/index.vue
|
||||
const queryParams = reactive({
|
||||
accountId,
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
permanent: true,
|
||||
}); // 查询参数
|
||||
const showCreateVideo = ref(false); // 是否新建视频的弹窗
|
||||
|
||||
/** 侦听公众号变化 */
|
||||
function onAccountChanged(id: number) {
|
||||
accountId.value = id;
|
||||
queryParams.accountId = id;
|
||||
queryParams.pageNo = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 查询列表 */
|
||||
async function getList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getMaterialPage({
|
||||
...queryParams,
|
||||
type: type.value,
|
||||
});
|
||||
list.value = data.list;
|
||||
total.value = data.total;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
// 根据类型获取对应的列配置
|
||||
const getColumnsByType = () => {
|
||||
switch (type.value) {
|
||||
case UploadType.Image: {
|
||||
return useImageGridColumns();
|
||||
}
|
||||
case UploadType.Video: {
|
||||
return useVideoGridColumns();
|
||||
}
|
||||
case UploadType.Voice: {
|
||||
return useVoiceGridColumns();
|
||||
}
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: getColumnsByType(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const finalAccountId = formValues?.accountId ?? accountId.value;
|
||||
if (!finalAccountId || finalAccountId === -1) {
|
||||
return { list: [], total: 0 };
|
||||
}
|
||||
return await getMaterialPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
type: type.value,
|
||||
permanent: true,
|
||||
accountId: finalAccountId,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
autoLoad: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
height: type.value === UploadType.Image ? 220 : 'auto',
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
|
||||
// 当 tab 切换时,更新 Grid 的 columns 和 rowConfig
|
||||
async function onTabChange() {
|
||||
const columns = getColumnsByType();
|
||||
gridApi.setGridOptions({
|
||||
columns,
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
height: type.value === UploadType.Image ? 220 : 'auto',
|
||||
},
|
||||
});
|
||||
await gridApi.reload();
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
function handleQuery() {
|
||||
queryParams.pageNo = 1;
|
||||
getList();
|
||||
async function handleAccountChange(id: number) {
|
||||
accountId.value = id;
|
||||
// 同步设置表单值
|
||||
await gridApi.formApi.setValues({ accountId: id });
|
||||
await gridApi.formApi.submitForm();
|
||||
}
|
||||
|
||||
/** 处理 tab 切换 */
|
||||
function onTabChange() {
|
||||
// 提前清空数据,避免 tab 切换后显示垃圾数据
|
||||
list.value = [];
|
||||
total.value = 0;
|
||||
// 从第一页开始查询
|
||||
handleQuery();
|
||||
async function handleRefresh() {
|
||||
await gridApi.query();
|
||||
}
|
||||
|
||||
/** 处理删除操作 */
|
||||
@@ -86,7 +128,7 @@ async function handleDelete(id: number) {
|
||||
try {
|
||||
await deletePermanentMaterial(id);
|
||||
message.success('删除成功');
|
||||
await getList();
|
||||
await handleRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
@@ -98,18 +140,12 @@ async function handleDelete(id: number) {
|
||||
<template #doc>
|
||||
<DocAlert title="公众号素材" url="https://doc.iocoder.cn/mp/material/" />
|
||||
</template>
|
||||
<div class="h-full">
|
||||
<!-- 搜索工作栏 -->
|
||||
<Card class="h-[10%]" :bordered="false">
|
||||
<Form :model="queryParams" layout="inline">
|
||||
<Form.Item label="公众号">
|
||||
<WxAccountSelect @change="onAccountChanged" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card :bordered="false" class="mt-4 h-auto">
|
||||
<Tabs v-model:active-key="type" @change="onTabChange">
|
||||
<Grid class="material-grid">
|
||||
<template #form-accountId>
|
||||
<WxAccountSelect @change="handleAccountChange" />
|
||||
</template>
|
||||
<template #toolbar-actions>
|
||||
<Tabs v-model:active-key="type" class="w-full" @change="onTabChange">
|
||||
<!-- tab 1:图片 -->
|
||||
<Tabs.TabPane :key="UploadType.Image">
|
||||
<template #tab>
|
||||
@@ -118,33 +154,6 @@ async function handleDelete(id: number) {
|
||||
图片
|
||||
</span>
|
||||
</template>
|
||||
<!-- 列表 -->
|
||||
<ImageTable
|
||||
:key="`image-${type}`"
|
||||
:list="list"
|
||||
:loading="loading"
|
||||
@delete="handleDelete"
|
||||
@refresh="getList"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<UploadFile
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
:type="UploadType.Image"
|
||||
@uploaded="getList"
|
||||
/>
|
||||
</template>
|
||||
</ImageTable>
|
||||
<!-- 分页组件 -->
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Pagination
|
||||
v-model:current="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
@change="getList"
|
||||
@show-size-change="getList"
|
||||
/>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<!-- tab 2:语音 -->
|
||||
@@ -155,33 +164,6 @@ async function handleDelete(id: number) {
|
||||
语音
|
||||
</span>
|
||||
</template>
|
||||
<!-- 列表 -->
|
||||
<VoiceTable
|
||||
:key="`voice-${type}`"
|
||||
:list="list"
|
||||
:loading="loading"
|
||||
@delete="handleDelete"
|
||||
@refresh="getList"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<UploadFile
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
:type="UploadType.Voice"
|
||||
@uploaded="getList"
|
||||
/>
|
||||
</template>
|
||||
</VoiceTable>
|
||||
<!-- 分页组件 -->
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Pagination
|
||||
v-model:current="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
@change="getList"
|
||||
@show-size-change="getList"
|
||||
/>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<!-- tab 3:视频 -->
|
||||
@@ -192,40 +174,81 @@ async function handleDelete(id: number) {
|
||||
视频
|
||||
</span>
|
||||
</template>
|
||||
<!-- 列表 -->
|
||||
<VideoTable
|
||||
:key="`video-${type}`"
|
||||
:list="list"
|
||||
:loading="loading"
|
||||
@delete="handleDelete"
|
||||
@refresh="getList"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<Button
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
type="primary"
|
||||
@click="showCreateVideo = true"
|
||||
>
|
||||
新建视频
|
||||
</Button>
|
||||
</template>
|
||||
</VideoTable>
|
||||
<!-- 新建视频的弹窗 -->
|
||||
<UploadVideo v-model:open="showCreateVideo" @uploaded="getList" />
|
||||
<!-- 分页组件 -->
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Pagination
|
||||
v-model:current="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
@change="getList"
|
||||
@show-size-change="getList"
|
||||
/>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
<template #toolbar-tools>
|
||||
<UploadFile
|
||||
v-if="
|
||||
hasAccessByCodes(['mp:material:upload-permanent']) &&
|
||||
type === UploadType.Image
|
||||
"
|
||||
:type="UploadType.Image"
|
||||
@uploaded="handleRefresh"
|
||||
/>
|
||||
<UploadFile
|
||||
v-if="
|
||||
hasAccessByCodes(['mp:material:upload-permanent']) &&
|
||||
type === UploadType.Voice
|
||||
"
|
||||
:type="UploadType.Voice"
|
||||
@uploaded="handleRefresh"
|
||||
/>
|
||||
<Button
|
||||
v-if="
|
||||
hasAccessByCodes(['mp:material:upload-permanent']) &&
|
||||
type === UploadType.Video
|
||||
"
|
||||
type="primary"
|
||||
@click="showCreateVideo = true"
|
||||
>
|
||||
新建视频
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- 图片列的 slot -->
|
||||
<template #image="{ row }">
|
||||
<div class="flex items-center justify-center" style="height: 192px">
|
||||
<img
|
||||
:src="row.url"
|
||||
class="object-contain"
|
||||
style="display: block; max-width: 100%; max-height: 192px"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 语音列的 slot -->
|
||||
<template #voice="{ row }">
|
||||
<audio :src="row.url" controls style="width: 160px"></audio>
|
||||
</template>
|
||||
|
||||
<!-- 视频列的 slot -->
|
||||
<template #video="{ row }">
|
||||
<video
|
||||
:src="row.url"
|
||||
controls
|
||||
style="width: 200px; height: 150px"
|
||||
></video>
|
||||
</template>
|
||||
|
||||
<!-- 操作列的 slot -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['mp:material:delete'],
|
||||
onClick: () => handleDelete(row.id!),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<!-- 新建视频的弹窗 -->
|
||||
<UploadVideo v-model:open="showCreateVideo" @uploaded="handleRefresh" />
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
@@ -131,3 +132,14 @@ export function useImageGridColumns(): VxeTableGridOptions<MpMaterialApi.Materia
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'accountId',
|
||||
label: '公众号',
|
||||
component: 'Input',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { nextTick, onMounted, watch } from 'vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
|
||||
import { useImageGridColumns } from './data';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
const props = defineProps<{
|
||||
list: MpMaterialApi.Material[];
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [v: number];
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
const columns = useImageGridColumns();
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid<MpMaterialApi.Material>({
|
||||
gridOptions: {
|
||||
border: true,
|
||||
columns,
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
height: 220,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
// 数据由父组件管理,触发刷新事件后返回当前数据
|
||||
emit('refresh');
|
||||
// 返回当前数据,避免覆盖
|
||||
return {
|
||||
list: Array.isArray(props.list) ? props.list : [],
|
||||
total: props.list?.length || 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
autoLoad: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function updateGridData(data: MpMaterialApi.Material[]) {
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
async (list: MpMaterialApi.Material[]) => {
|
||||
const data = Array.isArray(list) ? list : [];
|
||||
await nextTick();
|
||||
updateGridData(data);
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading: boolean) => {
|
||||
gridApi.setLoading(loading);
|
||||
},
|
||||
);
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
updateGridData(Array.isArray(props.list) ? props.list : []);
|
||||
gridApi.setLoading(props.loading);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Grid class="image-table-grid mt-4 pb-0">
|
||||
<template #toolbar-tools>
|
||||
<slot name="toolbar-tools"></slot>
|
||||
</template>
|
||||
<template #image="{ row }">
|
||||
<div class="flex items-center justify-center" style="height: 192px">
|
||||
<img
|
||||
:src="row.url"
|
||||
class="object-contain"
|
||||
style="display: block; max-width: 100%; max-height: 192px"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['mp:material:delete'],
|
||||
popConfirm: {
|
||||
title: '确定要删除该图片吗?',
|
||||
confirm: () => emit('delete', row.id!),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</template>
|
||||
@@ -1,120 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { nextTick, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { WxVideoPlayer } from '#/views/mp/components';
|
||||
|
||||
import { useVideoGridColumns } from './data';
|
||||
|
||||
const props = defineProps<{
|
||||
list: MpMaterialApi.Material[];
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [v: number];
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
const columns = useVideoGridColumns();
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
border: true,
|
||||
columns,
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
// 数据由父组件管理,触发刷新事件后返回当前数据
|
||||
emit('refresh');
|
||||
// 返回当前数据,避免覆盖
|
||||
return {
|
||||
list: Array.isArray(props.list) ? props.list : [],
|
||||
total: props.list?.length || 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
autoLoad: false,
|
||||
},
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
|
||||
function updateGridData(data: MpMaterialApi.Material[]) {
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
async (list: MpMaterialApi.Material[]) => {
|
||||
const data = Array.isArray(list) ? list : [];
|
||||
await nextTick();
|
||||
updateGridData(data);
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading: boolean) => {
|
||||
gridApi.setLoading(loading);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Grid class="mt-4">
|
||||
<template #toolbar-tools>
|
||||
<slot name="toolbar-tools"></slot>
|
||||
</template>
|
||||
<template #video="{ row }">
|
||||
<WxVideoPlayer v-if="row.url" :url="row.url" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '下载',
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.DOWNLOAD,
|
||||
onClick: () => openWindow(row.url),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['mp:material:delete'],
|
||||
popConfirm: {
|
||||
title: '确定要删除该视频吗?',
|
||||
confirm: () => emit('delete', row.id!),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</template>
|
||||
@@ -1,120 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { nextTick, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { WxVoicePlayer } from '#/views/mp/components';
|
||||
|
||||
import { useVoiceGridColumns } from './data';
|
||||
|
||||
const props = defineProps<{
|
||||
list: MpMaterialApi.Material[];
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [v: number];
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
const columns = useVoiceGridColumns();
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
border: true,
|
||||
columns,
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
// 数据由父组件管理,触发刷新事件后返回当前数据
|
||||
emit('refresh');
|
||||
// 返回当前数据,避免覆盖
|
||||
return {
|
||||
list: Array.isArray(props.list) ? props.list : [],
|
||||
total: props.list?.length || 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
autoLoad: false,
|
||||
},
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
|
||||
function updateGridData(data: MpMaterialApi.Material[]) {
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
async (list: MpMaterialApi.Material[]) => {
|
||||
const data = Array.isArray(list) ? list : [];
|
||||
await nextTick();
|
||||
updateGridData(data);
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading: boolean) => {
|
||||
gridApi.setLoading(loading);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Grid class="mt-4">
|
||||
<template #toolbar-tools>
|
||||
<slot name="toolbar-tools"></slot>
|
||||
</template>
|
||||
<template #voice="{ row }">
|
||||
<WxVoicePlayer v-if="row.url" :url="row.url" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '下载',
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.DOWNLOAD,
|
||||
onClick: () => openWindow(row.url),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['mp:material:delete'],
|
||||
popConfirm: {
|
||||
title: '确定要删除该语音吗?',
|
||||
confirm: () => emit('delete', row.id!),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</template>
|
||||
@@ -62,13 +62,6 @@ export namespace MallSpuApi {
|
||||
valueName?: string; // 属性值名称
|
||||
}
|
||||
|
||||
// TODO @puhui999:这个还要么?
|
||||
/** 优惠券模板 */
|
||||
export interface GiveCouponTemplate {
|
||||
id?: number; // 优惠券编号
|
||||
name?: string; // 优惠券名称
|
||||
}
|
||||
|
||||
/** 商品状态更新请求 */
|
||||
export interface SpuStatusUpdateReqVO {
|
||||
id: number; // 商品编号
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace MallBargainActivityApi {
|
||||
@@ -32,17 +30,6 @@ export namespace MallBargainActivityApi {
|
||||
bargainMinPrice: number; // 砍价底价
|
||||
stock: number; // 活动库存
|
||||
}
|
||||
|
||||
// TODO @puhui999:要不要删除?
|
||||
/** 扩展 SKU 配置 */
|
||||
export type SkuExtension = {
|
||||
productConfig: BargainProduct; // 砍价活动配置
|
||||
} & MallSpuApi.Sku;
|
||||
|
||||
/** 扩展 SPU 配置 */
|
||||
export interface SpuExtension extends MallSpuApi.Spu {
|
||||
skus: SkuExtension[]; // SKU 列表
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询砍价活动列表 */
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace MallCombinationActivityApi {
|
||||
@@ -23,25 +21,15 @@ export namespace MallCombinationActivityApi {
|
||||
limitDuration?: number; // 限制时长
|
||||
combinationPrice?: number; // 拼团价格
|
||||
products: CombinationProduct[]; // 商品列表
|
||||
picUrl?: any;
|
||||
}
|
||||
|
||||
// TODO @puhui999:要不要删除?
|
||||
/** 拼团活动所需属性 */
|
||||
export interface CombinationProduct {
|
||||
spuId: number; // 商品 SPU 编号
|
||||
skuId: number; // 商品 SKU 编号
|
||||
combinationPrice: number; // 拼团价格
|
||||
}
|
||||
|
||||
/** 扩展 SKU 配置 */
|
||||
export type SkuExtension = {
|
||||
productConfig: CombinationProduct; // 拼团活动配置
|
||||
} & MallSpuApi.Sku;
|
||||
|
||||
/** 扩展 SPU 配置 */
|
||||
export interface SpuExtension extends MallSpuApi.Spu {
|
||||
skus: SkuExtension[]; // SKU 列表
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询拼团活动列表 */
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace MallDiscountActivityApi {
|
||||
@@ -25,17 +23,6 @@ export namespace MallDiscountActivityApi {
|
||||
endTime?: Date; // 结束时间
|
||||
products?: DiscountProduct[]; // 商品列表
|
||||
}
|
||||
|
||||
// TODO @puhui999:要不要删除?
|
||||
/** 扩展 SKU 配置 */
|
||||
export type SkuExtension = {
|
||||
productConfig: DiscountProduct; // 限时折扣配置
|
||||
} & MallSpuApi.Sku;
|
||||
|
||||
/** 扩展 SPU 配置 */
|
||||
export interface SpuExtension extends MallSpuApi.Spu {
|
||||
skus: SkuExtension[]; // SKU 列表
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询限时折扣活动列表 */
|
||||
|
||||
@@ -36,17 +36,6 @@ export namespace MallPointActivityApi {
|
||||
price: number; // 兑换金额,单位:分
|
||||
}
|
||||
|
||||
// TODO @puhui999:这些还需要么?
|
||||
/** 扩展 SKU 配置 */
|
||||
export type SkuExtension = {
|
||||
productConfig: PointProduct; // 积分商城商品配置
|
||||
} & MallSpuApi.Sku;
|
||||
|
||||
/** 扩展 SPU 配置 */
|
||||
export interface SpuExtension extends MallSpuApi.Spu {
|
||||
skus: SkuExtension[]; // SKU 列表
|
||||
}
|
||||
|
||||
/** 扩展 SPU 配置(带积分信息) */
|
||||
export interface SpuExtensionWithPoint extends MallSpuApi.Spu {
|
||||
pointStock: number; // 积分商城活动库存
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace MallSeckillActivityApi {
|
||||
@@ -33,17 +31,7 @@ export namespace MallSeckillActivityApi {
|
||||
totalStock?: number; // 秒杀总库存
|
||||
seckillPrice?: number; // 秒杀价格
|
||||
products?: SeckillProduct[]; // 秒杀商品列表
|
||||
}
|
||||
|
||||
// TODO @puhui999:这些还需要么?
|
||||
/** 扩展 SKU 配置 */
|
||||
export type SkuExtension = {
|
||||
productConfig: SeckillProduct; // 秒杀商品配置
|
||||
} & MallSpuApi.Sku;
|
||||
|
||||
/** 扩展 SPU 配置 */
|
||||
export interface SpuExtension extends MallSpuApi.Spu {
|
||||
skus: SkuExtension[]; // SKU 列表
|
||||
picUrl?: any;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const routes: RouteRecordRaw[] = [
|
||||
title: '商品添加',
|
||||
activePath: '/mall/product/spu',
|
||||
},
|
||||
component: () => import('#/views/mall/product/spu/modules/form.vue'),
|
||||
component: () => import('#/views/mall/product/spu/form/index.vue'),
|
||||
},
|
||||
{
|
||||
path: String.raw`spu/edit/:id(\d+)`,
|
||||
@@ -27,25 +27,16 @@ const routes: RouteRecordRaw[] = [
|
||||
title: '商品编辑',
|
||||
activePath: '/mall/product/spu',
|
||||
},
|
||||
component: () => import('#/views/mall/product/spu/modules/form.vue'),
|
||||
component: () => import('#/views/mall/product/spu/form/index.vue'),
|
||||
},
|
||||
{
|
||||
path: String.raw`spu/detail/:id(\d+)`,
|
||||
name: 'ProductSpuDetail',
|
||||
meta: {
|
||||
title: '商品详情',
|
||||
activePath: '/crm/business',
|
||||
},
|
||||
component: () => import('#/views/mall/product/spu/modules/detail.vue'),
|
||||
},
|
||||
{
|
||||
path: '/product/spu',
|
||||
name: 'ProductSpu',
|
||||
meta: {
|
||||
title: '商品列表',
|
||||
activePath: '/mall/product/spu',
|
||||
},
|
||||
component: () => import('#/views/mall/product/spu/index.vue'),
|
||||
component: () => import('#/views/mall/product/spu/form/index.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -5,7 +5,10 @@ import { isEmpty } from '@vben/utils';
|
||||
|
||||
import { acceptHMRUpdate, defineStore } from 'pinia';
|
||||
|
||||
import * as KeFuConversationApi from '#/api/mall/promotion/kefu/conversation';
|
||||
import {
|
||||
getConversation,
|
||||
getConversationList,
|
||||
} from '#/api/mall/promotion/kefu/conversation';
|
||||
|
||||
interface MallKefuInfoVO {
|
||||
conversationList: MallKefuConversationApi.Conversation[]; // 会话列表
|
||||
@@ -41,9 +44,7 @@ export const useMallKefuStore = defineStore('mall-kefu', {
|
||||
// ======================= 会话相关 =======================
|
||||
/** 加载会话缓存列表 */
|
||||
async setConversationList() {
|
||||
// TODO @jave:idea linter 告警,修复下;
|
||||
// TODO @jave:不使用 KeFuConversationApi.,直接用 getConversationList
|
||||
this.conversationList = await KeFuConversationApi.getConversationList();
|
||||
this.conversationList = await getConversationList();
|
||||
this.conversationSort();
|
||||
},
|
||||
/** 更新会话缓存已读 */
|
||||
@@ -51,8 +52,11 @@ export const useMallKefuStore = defineStore('mall-kefu', {
|
||||
if (isEmpty(this.conversationList)) {
|
||||
return;
|
||||
}
|
||||
const conversation = this.conversationList.find(
|
||||
(item) => item.id === conversationId,
|
||||
const conversationList = this
|
||||
.conversationList as MallKefuConversationApi.Conversation[];
|
||||
const conversation = conversationList.find(
|
||||
(item: MallKefuConversationApi.Conversation) =>
|
||||
item.id === conversationId,
|
||||
);
|
||||
conversation && (conversation.adminUnreadMessageCount = 0);
|
||||
},
|
||||
@@ -62,10 +66,16 @@ export const useMallKefuStore = defineStore('mall-kefu', {
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation =
|
||||
await KeFuConversationApi.getConversation(conversationId);
|
||||
const conversation = await getConversation(conversationId);
|
||||
this.deleteConversation(conversationId);
|
||||
conversation && this.conversationList.push(conversation);
|
||||
if (conversation && this.conversationList) {
|
||||
const conversationList = this
|
||||
.conversationList as MallKefuConversationApi.Conversation[];
|
||||
this.conversationList = [
|
||||
...conversationList,
|
||||
conversation as MallKefuConversationApi.Conversation,
|
||||
];
|
||||
}
|
||||
this.conversationSort();
|
||||
},
|
||||
/** 删除会话缓存 */
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
// TODO @puhui999:这里和 yudao-ui-admin-vben-v5/apps/web-antd/src/views/mall/product/comment/modules/form.vue 存在差异,是不是还没迁移到呀。
|
||||
const formData = ref<MallCommentApi.Comment>();
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
export * from './property-util';
|
||||
export { default as SkuList } from './sku-list.vue';
|
||||
export { default as SkuTableSelect } from './sku-table-select.vue';
|
||||
export { default as SpuAndSkuList } from './spu-and-sku-list.vue';
|
||||
export { default as SpuSkuSelect } from './spu-select.vue';
|
||||
export { default as SpuShowcase } from './spu-showcase.vue';
|
||||
export { default as SpuTableSelect } from './spu-table-select.vue';
|
||||
export * from './type';
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||
import type { PropertyAndValues } from '#/views/mall/product/spu/components/type';
|
||||
|
||||
/** 获得商品的规格列表 - 商品相关的公共函数(被其它模块如 promotion 使用) */
|
||||
const getPropertyList = (spu: MallSpuApi.Spu): PropertyAndValues[] => {
|
||||
// 直接拿返回的 skus 属性逆向生成出 propertyList
|
||||
const properties: PropertyAndValues[] = [];
|
||||
// 只有是多规格才处理
|
||||
if (spu.specType) {
|
||||
spu.skus?.forEach((sku) => {
|
||||
sku.properties?.forEach(
|
||||
({ propertyId, propertyName, valueId, valueName }) => {
|
||||
// 添加属性
|
||||
if (!properties?.some((item) => item.id === propertyId)) {
|
||||
properties.push({
|
||||
id: propertyId!,
|
||||
name: propertyName!,
|
||||
values: [],
|
||||
});
|
||||
}
|
||||
// 添加属性值
|
||||
const index = properties?.findIndex((item) => item.id === propertyId);
|
||||
if (
|
||||
!properties[index]?.values?.some((value) => value.id === valueId)
|
||||
) {
|
||||
properties[index]?.values?.push({ id: valueId!, name: valueName! });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
return properties;
|
||||
};
|
||||
|
||||
export { getPropertyList };
|
||||
641
apps/web-ele/src/views/mall/product/spu/components/sku-list.vue
Normal file
641
apps/web-ele/src/views/mall/product/spu/components/sku-list.vue
Normal file
@@ -0,0 +1,641 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||
import type {
|
||||
PropertyAndValues,
|
||||
RuleConfig,
|
||||
} from '#/views/mall/product/spu/components';
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
copyValueToTarget,
|
||||
formatToFraction,
|
||||
getNestedValue,
|
||||
isEmpty,
|
||||
} from '@vben/utils';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElImage,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElMessage,
|
||||
} from 'element-plus';
|
||||
|
||||
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
|
||||
import { ImageUpload } from '#/components/upload';
|
||||
|
||||
defineOptions({ name: 'SkuList' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
isActivityComponent?: boolean; // 是否作为 sku 活动配置组件
|
||||
isBatch?: boolean; // 是否作为批量操作组件
|
||||
isComponent?: boolean; // 是否作为 sku 选择组件
|
||||
isDetail?: boolean; // 是否作为 sku 详情组件
|
||||
propertyList?: PropertyAndValues[];
|
||||
propFormData?: MallSpuApi.Spu;
|
||||
ruleConfig?: RuleConfig[];
|
||||
}>(),
|
||||
{
|
||||
propFormData: () => ({}) as MallSpuApi.Spu,
|
||||
propertyList: () => [],
|
||||
ruleConfig: () => [],
|
||||
isBatch: false,
|
||||
isDetail: false,
|
||||
isComponent: false,
|
||||
isActivityComponent: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'selectionChange', value: MallSpuApi.Sku[]): void;
|
||||
}>();
|
||||
|
||||
const { isBatch, isDetail, isComponent, isActivityComponent } = props;
|
||||
|
||||
const formData: Ref<MallSpuApi.Spu | undefined> = ref<MallSpuApi.Spu>();
|
||||
const tableHeaders = ref<{ label: string; prop: string }[]>([]);
|
||||
|
||||
/** 创建空 SKU 数据 */
|
||||
function createEmptySku(): MallSpuApi.Sku {
|
||||
return {
|
||||
price: 0,
|
||||
marketPrice: 0,
|
||||
costPrice: 0,
|
||||
barCode: '',
|
||||
picUrl: '',
|
||||
stock: 0,
|
||||
weight: 0,
|
||||
volume: 0,
|
||||
firstBrokeragePrice: 0,
|
||||
secondBrokeragePrice: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const skuList = ref<MallSpuApi.Sku[]>([createEmptySku()]);
|
||||
|
||||
/** 批量添加 */
|
||||
function batchAdd() {
|
||||
validateProperty();
|
||||
formData.value!.skus!.forEach((item: MallSpuApi.Sku) => {
|
||||
copyValueToTarget(item, skuList.value[0]);
|
||||
});
|
||||
}
|
||||
|
||||
/** 校验商品属性属性值 */
|
||||
function validateProperty() {
|
||||
// 校验商品属性属性值是否为空,有一个为空都不给过
|
||||
const warningInfo = '存在属性属性值为空,请先检查完善属性值后重试!!!';
|
||||
for (const item of props.propertyList as PropertyAndValues[]) {
|
||||
if (!item.values || isEmpty(item.values)) {
|
||||
ElMessage.warning(warningInfo);
|
||||
throw new Error(warningInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除 SKU */
|
||||
function deleteSku(row: MallSpuApi.Sku) {
|
||||
const index = formData.value!.skus!.findIndex(
|
||||
(sku: MallSpuApi.Sku) =>
|
||||
JSON.stringify(sku.properties) === JSON.stringify(row.properties),
|
||||
);
|
||||
if (index !== -1) {
|
||||
formData.value!.skus!.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验 SKU 数据:保存时,每个商品规格的表单要校验。例如:销售金额最低是 0.01 */
|
||||
function validateSku() {
|
||||
validateProperty();
|
||||
let warningInfo = '请检查商品各行相关属性配置,';
|
||||
let validate = true;
|
||||
|
||||
for (const sku of formData.value!.skus!) {
|
||||
for (const rule of props?.ruleConfig as RuleConfig[]) {
|
||||
const value = getNestedValue(sku, rule.name);
|
||||
if (!rule.rule(value)) {
|
||||
validate = false;
|
||||
warningInfo += rule.message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!validate) {
|
||||
ElMessage.warning(warningInfo);
|
||||
throw new Error(warningInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择时触发
|
||||
*
|
||||
* @param {object} param0 参数对象
|
||||
* @param {MallSpuApi.Sku[]} param0.records 传递过来的选中的 sku 是一个数组
|
||||
*/
|
||||
function handleSelectionChange({ records }: { records: MallSpuApi.Sku[] }) {
|
||||
emit('selectionChange', records);
|
||||
}
|
||||
|
||||
/** 将传进来的值赋值给 skuList */
|
||||
watch(
|
||||
() => props.propFormData,
|
||||
(data) => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
formData.value = data;
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
/** 生成表数据 */
|
||||
function generateTableData(propertyList: PropertyAndValues[]) {
|
||||
const propertyValues = propertyList.map((item: PropertyAndValues) =>
|
||||
(item.values || []).map((v: { id: number; name: string }) => ({
|
||||
propertyId: item.id,
|
||||
propertyName: item.name,
|
||||
valueId: v.id,
|
||||
valueName: v.name,
|
||||
})),
|
||||
);
|
||||
|
||||
const buildSkuList = build(propertyValues);
|
||||
|
||||
// 如果回显的 sku 属性和添加的属性不一致则重置 skus 列表
|
||||
if (!validateData(propertyList)) {
|
||||
formData.value!.skus = [];
|
||||
}
|
||||
|
||||
for (const item of buildSkuList) {
|
||||
const properties = Array.isArray(item) ? item : [item];
|
||||
const row = {
|
||||
...createEmptySku(),
|
||||
properties,
|
||||
};
|
||||
|
||||
// 如果存在属性相同的 sku 则不做处理
|
||||
const exists = formData.value!.skus!.some(
|
||||
(sku: MallSpuApi.Sku) =>
|
||||
JSON.stringify(sku.properties) === JSON.stringify(row.properties),
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
formData.value!.skus!.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成 skus 前置校验 */
|
||||
function validateData(propertyList: PropertyAndValues[]): boolean {
|
||||
const skuPropertyIds: number[] = [];
|
||||
formData.value!.skus!.forEach((sku: MallSpuApi.Sku) =>
|
||||
sku.properties
|
||||
?.map((property: MallSpuApi.Property) => property.propertyId)
|
||||
?.forEach((propertyId?: number) => {
|
||||
if (!skuPropertyIds.includes(propertyId!)) {
|
||||
skuPropertyIds.push(propertyId!);
|
||||
}
|
||||
}),
|
||||
);
|
||||
const propertyIds = propertyList.map((item: PropertyAndValues) => item.id);
|
||||
return skuPropertyIds.length === propertyIds.length;
|
||||
}
|
||||
|
||||
/** 构建所有排列组合 */
|
||||
function build(
|
||||
propertyValuesList: MallSpuApi.Property[][],
|
||||
): (MallSpuApi.Property | MallSpuApi.Property[])[] {
|
||||
if (propertyValuesList.length === 0) {
|
||||
return [];
|
||||
} else if (propertyValuesList.length === 1) {
|
||||
return propertyValuesList[0] || [];
|
||||
} else {
|
||||
const result: MallSpuApi.Property[][] = [];
|
||||
const rest = build(propertyValuesList.slice(1));
|
||||
const firstList = propertyValuesList[0];
|
||||
if (!firstList) {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const element of firstList) {
|
||||
for (const element_ of rest) {
|
||||
// 第一次不是数组结构,后面的都是数组结构
|
||||
if (Array.isArray(element_)) {
|
||||
result.push([element!, ...(element_ as MallSpuApi.Property[])]);
|
||||
} else {
|
||||
result.push([element!, element_ as MallSpuApi.Property]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听属性列表,生成相关参数和表头 */
|
||||
watch(
|
||||
() => props.propertyList as PropertyAndValues[],
|
||||
(propertyList: PropertyAndValues[]) => {
|
||||
// 如果不是多规格则结束
|
||||
if (!formData.value!.specType) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果当前组件作为批量添加数据使用,则重置表数据
|
||||
if (props.isBatch) {
|
||||
skuList.value = [createEmptySku()];
|
||||
}
|
||||
|
||||
// 判断代理对象是否为空
|
||||
if (JSON.stringify(propertyList) === '[]') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 重置并生成表头
|
||||
tableHeaders.value = propertyList.map((item, index) => ({
|
||||
prop: `name${index}`,
|
||||
label: item.name,
|
||||
}));
|
||||
|
||||
// 如果回显的 sku 属性和添加的属性一致则不处理
|
||||
if (validateData(propertyList)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加新属性没有属性值也不做处理
|
||||
if (propertyList.some((item) => !item.values || isEmpty(item.values))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成 table 数据,即 sku 列表
|
||||
generateTableData(propertyList);
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
const activitySkuListRef = ref();
|
||||
|
||||
/** 获取 SKU 表格引用 */
|
||||
function getSkuTableRef() {
|
||||
return activitySkuListRef.value;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
generateTableData,
|
||||
validateSku,
|
||||
getSkuTableRef,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<!-- 情况一:添加/修改 -->
|
||||
<VxeTable
|
||||
v-if="!isDetail && !isActivityComponent"
|
||||
:data="isBatch ? skuList : formData?.skus || []"
|
||||
border
|
||||
max-height="500"
|
||||
:column-config="{
|
||||
resizable: true,
|
||||
}"
|
||||
:resizable-config="{
|
||||
dragMode: 'fixed',
|
||||
}"
|
||||
size="small"
|
||||
>
|
||||
<VxeColumn align="center" title="图片" width="120" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<ImageUpload
|
||||
v-model:value="row.picUrl"
|
||||
:max-number="1"
|
||||
:max-size="2"
|
||||
:show-description="false"
|
||||
/>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<template v-if="formData?.specType && !isBatch">
|
||||
<!-- 根据商品属性动态添加 -->
|
||||
<VxeColumn
|
||||
v-for="(item, index) in tableHeaders"
|
||||
:key="index"
|
||||
:title="item.label"
|
||||
align="center"
|
||||
fixed="left"
|
||||
min-width="80"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span class="font-bold text-[#40aaff]">
|
||||
{{ row.properties?.[index]?.valueName }}
|
||||
</span>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
</template>
|
||||
<VxeColumn align="center" title="商品条码" width="168">
|
||||
<template #default="{ row }">
|
||||
<ElInput v-model="row.barCode" class="w-full" />
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="销售价" width="168">
|
||||
<template #default="{ row }">
|
||||
<ElInputNumber
|
||||
v-model="row.price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
class="w-full"
|
||||
controls-position="right"
|
||||
/>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="市场价" width="168">
|
||||
<template #default="{ row }">
|
||||
<ElInputNumber
|
||||
v-model="row.marketPrice"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
class="w-full"
|
||||
controls-position="right"
|
||||
/>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="成本价" width="168">
|
||||
<template #default="{ row }">
|
||||
<ElInputNumber
|
||||
v-model="row.costPrice"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
class="w-full"
|
||||
controls-position="right"
|
||||
/>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="库存" width="168">
|
||||
<template #default="{ row }">
|
||||
<ElInputNumber
|
||||
v-model="row.stock"
|
||||
:min="0"
|
||||
class="w-full"
|
||||
controls-position="right"
|
||||
/>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="重量(kg)" width="168">
|
||||
<template #default="{ row }">
|
||||
<ElInputNumber
|
||||
v-model="row.weight"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
class="w-full"
|
||||
controls-position="right"
|
||||
/>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="体积(m^3)" width="168">
|
||||
<template #default="{ row }">
|
||||
<ElInputNumber
|
||||
v-model="row.volume"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
class="w-full"
|
||||
controls-position="right"
|
||||
/>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<template v-if="formData?.subCommissionType">
|
||||
<VxeColumn align="center" title="一级返佣(元)" width="168">
|
||||
<template #default="{ row }">
|
||||
<ElInputNumber
|
||||
v-model="row.firstBrokeragePrice"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
class="w-full"
|
||||
controls-position="right"
|
||||
/>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="二级返佣(元)" width="168">
|
||||
<template #default="{ row }">
|
||||
<ElInputNumber
|
||||
v-model="row.secondBrokeragePrice"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
class="w-full"
|
||||
controls-position="right"
|
||||
/>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
</template>
|
||||
<VxeColumn
|
||||
v-if="formData?.specType"
|
||||
align="center"
|
||||
fixed="right"
|
||||
title="操作"
|
||||
width="100"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<ElButton
|
||||
v-if="isBatch"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="batchAdd"
|
||||
>
|
||||
批量添加
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-else
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
@click="deleteSku(row)"
|
||||
>
|
||||
删除
|
||||
</ElButton>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
</VxeTable>
|
||||
|
||||
<!-- 情况二:详情 -->
|
||||
<VxeTable
|
||||
v-if="isDetail"
|
||||
ref="activitySkuListRef"
|
||||
:data="formData?.skus || []"
|
||||
border
|
||||
max-height="500"
|
||||
size="small"
|
||||
:column-config="{
|
||||
resizable: true,
|
||||
}"
|
||||
:resizable-config="{
|
||||
dragMode: 'fixed',
|
||||
}"
|
||||
:checkbox-config="isComponent ? { reserve: true } : undefined"
|
||||
@checkbox-change="handleSelectionChange"
|
||||
@checkbox-all="handleSelectionChange"
|
||||
>
|
||||
<VxeColumn v-if="isComponent" type="checkbox" width="45" fixed="left" />
|
||||
<VxeColumn align="center" title="图片" max-width="140" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<ElImage
|
||||
v-if="row.picUrl"
|
||||
:src="row.picUrl"
|
||||
class="h-[50px] w-[50px] cursor-pointer"
|
||||
:preview-src-list="[row.picUrl]"
|
||||
fit="cover"
|
||||
/>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<template v-if="formData?.specType && !isBatch">
|
||||
<!-- 根据商品属性动态添加 -->
|
||||
<VxeColumn
|
||||
v-for="(item, index) in tableHeaders"
|
||||
:key="index"
|
||||
:title="item.label"
|
||||
align="center"
|
||||
max-width="80"
|
||||
fixed="left"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span class="font-bold text-[#40aaff]">
|
||||
{{ row.properties?.[index]?.valueName }}
|
||||
</span>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
</template>
|
||||
<VxeColumn align="center" title="商品条码" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.barCode }}
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="销售价(元)" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ row.price }}
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="市场价(元)" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ row.marketPrice }}
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="成本价(元)" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ row.costPrice }}
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="库存" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ row.stock }}
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="重量(kg)" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ row.weight }}
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="体积(m^3)" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ row.volume }}
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<template v-if="formData?.subCommissionType">
|
||||
<VxeColumn align="center" title="一级返佣(元)" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ row.firstBrokeragePrice }}
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="二级返佣(元)" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ row.secondBrokeragePrice }}
|
||||
</template>
|
||||
</VxeColumn>
|
||||
</template>
|
||||
</VxeTable>
|
||||
|
||||
<!-- 情况三:作为活动组件 -->
|
||||
<VxeTable
|
||||
v-if="isActivityComponent"
|
||||
:data="formData?.skus || []"
|
||||
border
|
||||
max-height="500"
|
||||
size="small"
|
||||
:column-config="{
|
||||
resizable: true,
|
||||
}"
|
||||
:resizable-config="{
|
||||
dragMode: 'fixed',
|
||||
}"
|
||||
>
|
||||
<VxeColumn v-if="isComponent" type="checkbox" width="45" fixed="left" />
|
||||
<VxeColumn align="center" title="图片" max-width="140" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<ElImage
|
||||
:src="row.picUrl"
|
||||
class="h-[60px] w-[60px] cursor-pointer"
|
||||
:preview-src-list="[row.picUrl]"
|
||||
fit="cover"
|
||||
/>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<template v-if="formData?.specType">
|
||||
<!-- 根据商品属性动态添加 -->
|
||||
<VxeColumn
|
||||
v-for="(item, index) in tableHeaders"
|
||||
:key="index"
|
||||
:title="item.label"
|
||||
align="center"
|
||||
width="80"
|
||||
fixed="left"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span class="font-bold text-[#40aaff]">
|
||||
{{ row.properties?.[index]?.valueName }}
|
||||
</span>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
</template>
|
||||
<VxeColumn align="center" title="商品条码" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.barCode }}
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="销售价(元)" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ formatToFraction(row.price) }}
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="市场价(元)" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ formatToFraction(row.marketPrice) }}
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="成本价(元)" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ formatToFraction(row.costPrice) }}
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" title="库存" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ row.stock }}
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<!-- 方便扩展每个活动配置的属性不一样 -->
|
||||
<slot name="extension"></slot>
|
||||
</VxeTable>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,16 +1,16 @@
|
||||
<!-- SKU 选择弹窗组件 -->
|
||||
<script lang="ts" setup>
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { fenToYuan } from '@vben/utils';
|
||||
import { ElDialog } from 'element-plus';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getSpu } from '#/api/mall/product/spu';
|
||||
|
||||
import { useSkuGridColumns } from './spu-select-data';
|
||||
|
||||
interface SpuData {
|
||||
spuId: number;
|
||||
}
|
||||
@@ -19,78 +19,29 @@ const emit = defineEmits<{
|
||||
change: [sku: MallSpuApi.Sku];
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const spuId = ref<number>();
|
||||
|
||||
/** 表格列配置 */
|
||||
const gridColumns = computed<VxeGridProps['columns']>(() => [
|
||||
{
|
||||
type: 'radio',
|
||||
width: 55,
|
||||
},
|
||||
{
|
||||
field: 'picUrl',
|
||||
title: '图片',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
name: 'CellImage',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'properties',
|
||||
title: '规格',
|
||||
minWidth: 120,
|
||||
align: 'center',
|
||||
formatter: ({ cellValue }) => {
|
||||
return (
|
||||
cellValue?.map((p: MallSpuApi.Property) => p.valueName)?.join(' ') ||
|
||||
'-'
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'price',
|
||||
title: '销售价(元)',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
formatter: ({ cellValue }) => {
|
||||
return fenToYuan(cellValue);
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
/** 处理选中 */
|
||||
function handleRadioChange() {
|
||||
const selectedRow = gridApi.grid.getRadioRecord() as MallSpuApi.Sku;
|
||||
if (selectedRow) {
|
||||
emit('change', selectedRow);
|
||||
modalApi.close();
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO @芋艿:要不要直接非 pager?
|
||||
// TODO @puhui999:这里的代码风格,对齐 antd 的;可以使用 idea 对比两个文件哈;
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: gridColumns.value,
|
||||
columns: useSkuGridColumns(),
|
||||
height: 400,
|
||||
border: true,
|
||||
showOverflow: true,
|
||||
radioConfig: {
|
||||
reserve: true,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
if (!spuId.value) {
|
||||
return { list: [], total: 0 };
|
||||
}
|
||||
const spu = await getSpu(spuId.value);
|
||||
return {
|
||||
list: spu.skus || [],
|
||||
total: spu.skus?.length || 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
gridEvents: {
|
||||
@@ -98,26 +49,43 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
},
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
onOpenChange: async (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
gridApi.grid.clearRadioRow();
|
||||
spuId.value = undefined;
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<SpuData>();
|
||||
if (!data?.spuId) {
|
||||
return;
|
||||
}
|
||||
spuId.value = data.spuId;
|
||||
await gridApi.query();
|
||||
},
|
||||
/** 关闭弹窗 */
|
||||
function closeModal() {
|
||||
visible.value = false;
|
||||
gridApi.grid.clearRadioRow();
|
||||
spuId.value = undefined;
|
||||
}
|
||||
|
||||
/** 打开弹窗 */
|
||||
async function openModal(data?: SpuData) {
|
||||
if (!data?.spuId) {
|
||||
return;
|
||||
}
|
||||
spuId.value = data.spuId;
|
||||
visible.value = true;
|
||||
if (!spuId.value) {
|
||||
gridApi.grid?.reloadData([]);
|
||||
return;
|
||||
}
|
||||
const spu = await getSpu(spuId.value);
|
||||
gridApi.grid?.reloadData(spu.skus || []);
|
||||
}
|
||||
|
||||
/** 对外暴露的方法 */
|
||||
defineExpose({
|
||||
open: openModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[700px]" title="选择规格">
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
title="选择规格"
|
||||
width="700px"
|
||||
:destroy-on-close="true"
|
||||
:append-to-body="true"
|
||||
@close="closeModal"
|
||||
>
|
||||
<Grid />
|
||||
</Modal>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<script generic="T extends MallSpuApi.Spu" lang="ts" setup>
|
||||
import type { MallSpuApi, RuleConfig, SpuProperty } from './type';
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { confirm } from '@vben/common-ui';
|
||||
import { formatToFraction } from '@vben/utils';
|
||||
|
||||
import { ElButton, ElImage } from 'element-plus';
|
||||
|
||||
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
|
||||
|
||||
import SkuList from './sku-list.vue';
|
||||
|
||||
defineOptions({ name: 'PromotionSpuAndSkuList' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
deletable?: boolean; // SPU 是否可删除
|
||||
ruleConfig: RuleConfig[];
|
||||
spuList: T[];
|
||||
spuPropertyListP: SpuProperty<T>[];
|
||||
}>(),
|
||||
{
|
||||
deletable: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete', spuId: number): void;
|
||||
}>();
|
||||
|
||||
const spuData = ref<MallSpuApi.Spu[]>([]); // spu 详情数据列表
|
||||
const skuListRef = ref<InstanceType<typeof SkuList> | undefined>(); // 商品属性列表Ref
|
||||
const spuPropertyList = ref<SpuProperty<T>[]>([]); // spuId 对应的 sku 的属性列表
|
||||
const expandRowKeys = ref<string[]>([]); // 控制展开行需要设置 row-key 属性才能使用,该属性为展开行的 keys 数组。
|
||||
|
||||
/**
|
||||
* 获取所有 sku 活动配置
|
||||
*
|
||||
* @param extendedAttribute 在 sku 上扩展的属性,例:秒杀活动 sku 扩展属性 productConfig 请参考 seckillActivity.ts
|
||||
*/
|
||||
function getSkuConfigs(extendedAttribute: string) {
|
||||
// 验证 SKU 数据(如果有 ref 的话)
|
||||
if (skuListRef.value) {
|
||||
skuListRef.value.validateSku();
|
||||
}
|
||||
const seckillProducts: unknown[] = [];
|
||||
spuPropertyList.value.forEach((item) => {
|
||||
item.spuDetail.skus?.forEach((sku) => {
|
||||
const extendedValue = (sku as Record<string, unknown>)[extendedAttribute];
|
||||
if (extendedValue) {
|
||||
seckillProducts.push(extendedValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
return seckillProducts;
|
||||
}
|
||||
|
||||
defineExpose({ getSkuConfigs }); // 暴露出给表单提交时使用
|
||||
|
||||
/** 多选时可以删除 SPU */
|
||||
async function deleteSpu(spuId: number) {
|
||||
await confirm(`是否删除商品编号为${spuId}的数据?`);
|
||||
const index = spuData.value.findIndex((item) => item.id === spuId);
|
||||
if (index !== -1) {
|
||||
spuData.value.splice(index, 1);
|
||||
emit('delete', spuId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 将传进来的值赋值给 spuData */
|
||||
watch(
|
||||
() => props.spuList,
|
||||
(data) => {
|
||||
if (!data) return;
|
||||
spuData.value = data as MallSpuApi.Spu[];
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
/** 将传进来的值赋值给 spuPropertyList */
|
||||
watch(
|
||||
() => props.spuPropertyListP,
|
||||
(data) => {
|
||||
if (!data) return;
|
||||
spuPropertyList.value = data as SpuProperty<T>[];
|
||||
// 解决如果之前选择的是单规格 spu 的话后面选择多规格 sku 多规格属性信息不展示的问题。解决方法:让 SkuList 组件重新渲染(行折叠会干掉包含的组件展开时会重新加载)
|
||||
setTimeout(() => {
|
||||
expandRowKeys.value = data.map((item) => String(item.spuId));
|
||||
}, 200);
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VxeTable
|
||||
:data="spuData"
|
||||
:expand-row-keys="expandRowKeys"
|
||||
:row-config="{
|
||||
keyField: 'id',
|
||||
}"
|
||||
>
|
||||
<VxeColumn type="expand" width="30">
|
||||
<template #content="{ row }">
|
||||
<SkuList
|
||||
ref="skuListRef"
|
||||
:is-activity-component="true"
|
||||
:prop-form-data="
|
||||
spuPropertyList.find((item) => item.spuId === row.id)?.spuDetail
|
||||
"
|
||||
:property-list="
|
||||
spuPropertyList.find((item) => item.spuId === row.id)?.propertyList
|
||||
"
|
||||
:rule-config="ruleConfig"
|
||||
>
|
||||
<template #extension>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</SkuList>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn field="id" align="center" title="商品编号" min-width="30" />
|
||||
<VxeColumn title="商品图" min-width="80">
|
||||
<template #default="{ row }">
|
||||
<ElImage
|
||||
v-if="row.picUrl"
|
||||
:src="row.picUrl"
|
||||
class="h-[30px] w-[30px] cursor-pointer"
|
||||
:preview-src-list="[row.picUrl]"
|
||||
fit="cover"
|
||||
/>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn
|
||||
field="name"
|
||||
title="商品名称"
|
||||
min-width="300"
|
||||
show-overflow="tooltip"
|
||||
/>
|
||||
<VxeColumn align="center" title="商品售价" min-width="90">
|
||||
<template #default="{ row }">
|
||||
{{ formatToFraction(row.price) }}
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn field="salesCount" align="center" title="销量" min-width="90" />
|
||||
<VxeColumn field="stock" align="center" title="库存" min-width="90" />
|
||||
<VxeColumn
|
||||
v-if="spuData.length > 1 && deletable"
|
||||
align="center"
|
||||
title="操作"
|
||||
min-width="90"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<ElButton type="danger" link @click="deleteSpu(row.id)">删除</ElButton>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
</VxeTable>
|
||||
</template>
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeGridProps, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MallCategoryApi } from '#/api/mall/product/category';
|
||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { fenToYuan } from '@vben/utils';
|
||||
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(
|
||||
categoryTreeList: Ref<MallCategoryApi.Category[] | unknown[]>,
|
||||
): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '商品名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入商品名称',
|
||||
clearable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'categoryId',
|
||||
label: '商品分类',
|
||||
component: 'TreeSelect',
|
||||
componentProps: {
|
||||
data: computed(() => categoryTreeList.value),
|
||||
props: {
|
||||
label: 'name',
|
||||
value: 'id',
|
||||
},
|
||||
checkStrictly: true,
|
||||
placeholder: '请选择商品分类',
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '创建时间',
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
...getRangePickerDefaultProps(),
|
||||
clearable: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(
|
||||
isSelectSku: boolean,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
type: 'expand',
|
||||
width: 30,
|
||||
visible: isSelectSku,
|
||||
slots: { content: 'expand_content' },
|
||||
},
|
||||
{ type: 'checkbox', width: 55 },
|
||||
{
|
||||
field: 'id',
|
||||
title: '商品编号',
|
||||
minWidth: 100,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'picUrl',
|
||||
title: '商品图',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
name: 'CellImage',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '商品名称',
|
||||
minWidth: 300,
|
||||
showOverflow: 'tooltip',
|
||||
},
|
||||
{
|
||||
field: 'price',
|
||||
title: '商品售价',
|
||||
minWidth: 90,
|
||||
align: 'center',
|
||||
formatter: 'formatAmount2',
|
||||
},
|
||||
{
|
||||
field: 'salesCount',
|
||||
title: '销量',
|
||||
minWidth: 90,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'stock',
|
||||
title: '库存',
|
||||
minWidth: 90,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'sort',
|
||||
title: '排序',
|
||||
minWidth: 70,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
width: 180,
|
||||
align: 'center',
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
] as VxeTableGridOptions['columns'];
|
||||
}
|
||||
|
||||
/** SKU 列表的字段 */
|
||||
export function useSkuGridColumns(): VxeGridProps['columns'] {
|
||||
return [
|
||||
{
|
||||
type: 'radio',
|
||||
width: 55,
|
||||
},
|
||||
{
|
||||
field: 'id',
|
||||
title: '商品编号',
|
||||
minWidth: 100,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
field: 'picUrl',
|
||||
title: '图片',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
name: 'CellImage',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'properties',
|
||||
title: '规格',
|
||||
minWidth: 120,
|
||||
align: 'center',
|
||||
formatter: ({ cellValue }) => {
|
||||
return (
|
||||
cellValue?.map((p: MallSpuApi.Property) => p.valueName)?.join(' ') ||
|
||||
'-'
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'price',
|
||||
title: '销售价(元)',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
formatter: ({ cellValue }) => {
|
||||
return fenToYuan(cellValue);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropertyAndValues } from './type';
|
||||
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MallCategoryApi } from '#/api/mall/product/category';
|
||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
|
||||
import { handleTree } from '@vben/utils';
|
||||
|
||||
import { ElDialog, ElMessage } from 'element-plus';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getCategoryList } from '#/api/mall/product/category';
|
||||
import { getSpu, getSpuPage } from '#/api/mall/product/spu';
|
||||
|
||||
import { getPropertyList } from './property-util';
|
||||
import SkuList from './sku-list.vue';
|
||||
import { useGridColumns, useGridFormSchema } from './spu-select-data';
|
||||
|
||||
defineOptions({ name: 'SpuSelect' });
|
||||
|
||||
const props = withDefaults(defineProps<SpuSelectProps>(), {
|
||||
isSelectSku: false,
|
||||
radio: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', spuId: number, skuIds?: number[]): void;
|
||||
}>();
|
||||
|
||||
interface SpuSelectProps {
|
||||
// 默认不需要(不需要的情况下只返回 spu,需要的情况下返回 选中的 spu 和 sku 列表)
|
||||
// 其它活动需要选择商品和商品属性导入此组件即可,需添加组件属性 :isSelectSku='true'
|
||||
isSelectSku?: boolean; // 是否需要选择 sku 属性
|
||||
radio?: boolean; // 是否单选 sku
|
||||
}
|
||||
|
||||
// ============ 数据状态 ============
|
||||
const categoryList = ref<MallCategoryApi.Category[]>([]); // 分类列表
|
||||
const categoryTreeList = ref<MallCategoryApi.Category[]>([]); // 分类树
|
||||
const propertyList = ref<PropertyAndValues[]>([]); // 商品属性列表
|
||||
const spuData = ref<MallSpuApi.Spu>(); // 当前展开的商品详情
|
||||
const isExpand = ref(false); // 控制 SKU 列表显示
|
||||
|
||||
// ============ 商品选择相关 ============
|
||||
const selectedSpuId = ref<number>(0); // 选中的商品 spuId
|
||||
const selectedSkuIds = ref<number[]>([]); // 选中的商品 skuIds
|
||||
const skuListRef = ref<InstanceType<typeof SkuList>>(); // 商品属性选择 Ref
|
||||
|
||||
/** 处理 SKU 选择变化 */
|
||||
function selectSku(val: MallSpuApi.Sku[]) {
|
||||
const skuTable = skuListRef.value?.getSkuTableRef();
|
||||
if (selectedSpuId.value === 0) {
|
||||
ElMessage.warning('请先选择商品再选择相应的规格!!!');
|
||||
skuTable?.clearSelection();
|
||||
return;
|
||||
}
|
||||
if (val.length === 0) {
|
||||
selectedSkuIds.value = [];
|
||||
return;
|
||||
}
|
||||
if (props.radio) {
|
||||
// 只选择一个
|
||||
const firstId = val[0]?.id;
|
||||
if (firstId !== undefined) {
|
||||
selectedSkuIds.value = [firstId];
|
||||
}
|
||||
// 如果大于1个
|
||||
if (val.length > 1) {
|
||||
// 清空选择
|
||||
skuTable?.clearSelection();
|
||||
// 变更为最后一次选择的
|
||||
const lastItem = val.pop();
|
||||
if (lastItem) {
|
||||
skuTable?.toggleRowSelection(lastItem, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selectedSkuIds.value = val
|
||||
.map((sku) => sku.id!)
|
||||
.filter((id): id is number => id !== undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理 SPU 选择变化 */
|
||||
function selectSpu(row: MallSpuApi.Spu) {
|
||||
if (!row) {
|
||||
selectedSpuId.value = 0;
|
||||
return;
|
||||
}
|
||||
selectedSpuId.value = row.id!;
|
||||
|
||||
// 切换选择 spu 如果有选择的 sku 则清空,确保选择的 sku 是对应的 spu 下面的
|
||||
if (selectedSkuIds.value.length > 0) {
|
||||
selectedSkuIds.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理行展开变化 */
|
||||
async function expandChange(
|
||||
row: MallSpuApi.Spu,
|
||||
expandedRows?: MallSpuApi.Spu[],
|
||||
) {
|
||||
// 判断需要展开的 spuId === 选择的 spuId。如果选择了 A 就展开 A 的 skuList。如果选择了 A 手动展开 B 则阻断
|
||||
// 目的:防止误选 sku
|
||||
if (selectedSpuId.value !== 0) {
|
||||
if (row.id !== selectedSpuId.value) {
|
||||
ElMessage.warning('你已选择商品请先取消');
|
||||
// 阻止展开,通过重新设置展开状态来保持当前选中行的展开
|
||||
if (row.id !== undefined) {
|
||||
const tableData = gridApi.grid.getTableData().fullData;
|
||||
const selectedRow = tableData.find(
|
||||
(item: MallSpuApi.Spu) => item.id === selectedSpuId.value,
|
||||
);
|
||||
if (selectedRow) {
|
||||
// 关闭当前行,重新展开选中行
|
||||
gridApi.grid.setRowExpand(selectedRow, true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 如果已展开 skuList 则选择此对应的 spu 不需要重新获取渲染 skuList
|
||||
if (isExpand.value && spuData.value?.id === row.id) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
spuData.value = undefined;
|
||||
propertyList.value = [];
|
||||
isExpand.value = false;
|
||||
if (expandedRows?.length === 0) {
|
||||
// 如果展开个数为 0,直接返回
|
||||
return;
|
||||
}
|
||||
// 获取 SPU 详情
|
||||
if (row.id === undefined) {
|
||||
return;
|
||||
}
|
||||
const res = (await getSpu(row.id)) as MallSpuApi.Spu;
|
||||
res.skus?.forEach((item) => {
|
||||
if (typeof item.price === 'number') {
|
||||
item.price = Math.round(item.price * 100);
|
||||
}
|
||||
if (typeof item.marketPrice === 'number') {
|
||||
item.marketPrice = Math.round(item.marketPrice * 100);
|
||||
}
|
||||
if (typeof item.costPrice === 'number') {
|
||||
item.costPrice = Math.round(item.costPrice * 100);
|
||||
}
|
||||
if (typeof item.firstBrokeragePrice === 'number') {
|
||||
item.firstBrokeragePrice = Math.round(item.firstBrokeragePrice * 100);
|
||||
}
|
||||
if (typeof item.secondBrokeragePrice === 'number') {
|
||||
item.secondBrokeragePrice = Math.round(item.secondBrokeragePrice * 100);
|
||||
}
|
||||
});
|
||||
propertyList.value = getPropertyList(res);
|
||||
spuData.value = res;
|
||||
isExpand.value = true;
|
||||
}
|
||||
|
||||
const formSchema = computed(() => useGridFormSchema(categoryTreeList)); // 搜索表单 Schema
|
||||
const gridColumns = computed<VxeTableGridOptions['columns']>(() => {
|
||||
const columns = useGridColumns(props.isSelectSku);
|
||||
// 将 checkbox 替换为 radio
|
||||
return columns?.map((col) => {
|
||||
if (col.type === 'checkbox') {
|
||||
return { ...col, type: 'radio' };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
}); // 表格列配置
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: formSchema.value,
|
||||
layout: 'horizontal',
|
||||
collapsed: false,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: gridColumns.value,
|
||||
height: 800,
|
||||
border: true,
|
||||
radioConfig: {
|
||||
reserve: true,
|
||||
highlight: true,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
expandConfig: {
|
||||
trigger: 'row',
|
||||
reserve: true,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
async query({ page }: any, formValues: any) {
|
||||
return await getSpuPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
tabType: 0,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
gridEvents: {
|
||||
radioChange: ({ row, $grid }: { $grid: any; row: MallSpuApi.Spu }) => {
|
||||
selectSpu(row);
|
||||
if (props.isSelectSku) {
|
||||
$grid.clearRowExpand();
|
||||
$grid.setRowExpand(row, true);
|
||||
expandChange(row, [row]);
|
||||
}
|
||||
},
|
||||
toggleRowExpand: ({
|
||||
row,
|
||||
expanded,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
row: unknown;
|
||||
}) => {
|
||||
if (expanded) {
|
||||
expandChange(row as MallSpuApi.Spu, [row as MallSpuApi.Spu]);
|
||||
} else {
|
||||
expandChange(row as MallSpuApi.Spu, []);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const visible = ref(false); // 弹窗显示状态
|
||||
|
||||
/** 打开弹窗 */
|
||||
async function openModal() {
|
||||
visible.value = true;
|
||||
// 等待 Grid 组件完全初始化后再查询数据
|
||||
await nextTick();
|
||||
if (gridApi.grid) {
|
||||
await gridApi.query();
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭弹窗 */
|
||||
function closeModal() {
|
||||
visible.value = false;
|
||||
selectedSpuId.value = 0;
|
||||
selectedSkuIds.value = [];
|
||||
spuData.value = undefined;
|
||||
propertyList.value = [];
|
||||
isExpand.value = false;
|
||||
}
|
||||
|
||||
/** 确认选择 */
|
||||
function handleConfirm() {
|
||||
if (selectedSpuId.value === 0) {
|
||||
ElMessage.warning('没有选择任何商品');
|
||||
return;
|
||||
}
|
||||
if (props.isSelectSku && selectedSkuIds.value.length === 0) {
|
||||
ElMessage.warning('没有选择任何商品属性');
|
||||
return;
|
||||
}
|
||||
// 返回各自 id 列表
|
||||
props.isSelectSku
|
||||
? emit('select', selectedSpuId.value, selectedSkuIds.value)
|
||||
: emit('select', selectedSpuId.value);
|
||||
|
||||
// 重置选中状态
|
||||
closeModal();
|
||||
}
|
||||
|
||||
/** 对外暴露的方法 */
|
||||
defineExpose({
|
||||
open: openModal,
|
||||
});
|
||||
|
||||
/** 初始化分类数据 */
|
||||
onMounted(async () => {
|
||||
categoryList.value = await getCategoryList({});
|
||||
categoryTreeList.value = handleTree(
|
||||
categoryList.value,
|
||||
'id',
|
||||
'parentId',
|
||||
) as MallCategoryApi.Category[];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
title="商品选择"
|
||||
width="70%"
|
||||
:destroy-on-close="true"
|
||||
@close="closeModal"
|
||||
>
|
||||
<Grid>
|
||||
<!-- 展开列内容(SKU 列表) -->
|
||||
<template v-if="isSelectSku" #expand_content="{ row }">
|
||||
<SkuList
|
||||
v-if="isExpand && spuData?.id === row.id"
|
||||
ref="skuListRef"
|
||||
:is-component="true"
|
||||
:is-detail="true"
|
||||
:prop-form-data="spuData"
|
||||
:property-list="propertyList"
|
||||
@selection-change="selectSku"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
<template #footer>
|
||||
<el-button @click="closeModal">取消</el-button>
|
||||
<el-button type="primary" @click="handleConfirm">确定</el-button>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
@@ -112,6 +112,7 @@ function emitSpuChange() {
|
||||
fit="cover"
|
||||
/>
|
||||
<!-- 删除按钮 -->
|
||||
<!-- TODO @puhui999:还是使用 IconifyIcon:使用自己的中立的图标,方便 antd 和 ele 共享 -->
|
||||
<IconifyIcon
|
||||
v-if="!disabled"
|
||||
icon="ep:circle-close-filled"
|
||||
@@ -128,6 +129,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 @puhui999:还是使用 IconifyIcon:使用自己的中立的图标,方便 antd 和 ele 共享 -->
|
||||
<IconifyIcon icon="ep:plus" class="text-xl text-gray-400" />
|
||||
</div>
|
||||
</ElTooltip>
|
||||
|
||||
@@ -5,11 +5,12 @@ import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { MallCategoryApi } from '#/api/mall/product/category';
|
||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { handleTree } from '@vben/utils';
|
||||
|
||||
import { ElDialog } from 'element-plus';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getCategoryList } from '#/api/mall/product/category';
|
||||
import { getSpuPage } from '#/api/mall/product/spu';
|
||||
@@ -30,12 +31,16 @@ const emit = defineEmits<{
|
||||
const categoryList = ref<MallCategoryApi.Category[]>([]); // 分类列表
|
||||
const categoryTreeList = ref<any[]>([]); // 分类树
|
||||
|
||||
/** 弹窗显示状态 */
|
||||
const visible = ref(false);
|
||||
const initData = ref<MallSpuApi.Spu | MallSpuApi.Spu[]>();
|
||||
|
||||
/** 单选:处理选中变化 */
|
||||
function handleRadioChange() {
|
||||
const selectedRow = gridApi.grid.getRadioRecord() as MallSpuApi.Spu;
|
||||
if (selectedRow) {
|
||||
emit('change', selectedRow);
|
||||
modalApi.close();
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,56 +158,65 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
},
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
showConfirmButton: props.multiple, // 特殊:radio 单选情况下,走 handleRadioChange 处理。
|
||||
onConfirm: () => {
|
||||
const selectedRows = gridApi.grid.getCheckboxRecords() as MallSpuApi.Spu[];
|
||||
emit('change', selectedRows);
|
||||
modalApi.close();
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
await gridApi.grid.clearCheckboxRow();
|
||||
await gridApi.grid.clearRadioRow();
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 先查询数据
|
||||
await gridApi.query();
|
||||
// 2. 设置已选中行
|
||||
const data = modalApi.getData<MallSpuApi.Spu | MallSpuApi.Spu[]>();
|
||||
if (props.multiple && Array.isArray(data) && data.length > 0) {
|
||||
setTimeout(() => {
|
||||
const tableData = gridApi.grid.getTableData().fullData;
|
||||
data.forEach((spu) => {
|
||||
const row = tableData.find(
|
||||
(item: MallSpuApi.Spu) => item.id === spu.id,
|
||||
);
|
||||
if (row) {
|
||||
gridApi.grid.setCheckboxRow(row, true);
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
} else if (!props.multiple && data && !Array.isArray(data)) {
|
||||
setTimeout(() => {
|
||||
const tableData = gridApi.grid.getTableData().fullData;
|
||||
/** 打开弹窗 */
|
||||
async function openModal(data?: MallSpuApi.Spu | MallSpuApi.Spu[]) {
|
||||
initData.value = data;
|
||||
visible.value = true;
|
||||
await nextTick();
|
||||
// 1. 查询数据
|
||||
await gridApi.query();
|
||||
// 2. 设置已选中行
|
||||
const tableData = gridApi.grid.getTableData().fullData;
|
||||
if (
|
||||
props.multiple &&
|
||||
Array.isArray(initData.value) &&
|
||||
initData.value.length > 0
|
||||
) {
|
||||
setTimeout(() => {
|
||||
(initData.value as unknown as MallSpuApi.Spu[])!.forEach((spu) => {
|
||||
const row = tableData.find(
|
||||
(item: MallSpuApi.Spu) => item.id === data.id,
|
||||
(item: MallSpuApi.Spu) => item.id === spu.id,
|
||||
);
|
||||
if (row) {
|
||||
gridApi.grid.setRadioRow(row);
|
||||
gridApi.grid.setCheckboxRow(row, true);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}, 300);
|
||||
} else if (
|
||||
!props.multiple &&
|
||||
initData.value &&
|
||||
!Array.isArray(initData.value)
|
||||
) {
|
||||
setTimeout(() => {
|
||||
const row = tableData.find(
|
||||
(item: MallSpuApi.Spu) =>
|
||||
item.id === (initData.value as MallSpuApi.Spu).id,
|
||||
);
|
||||
if (row) {
|
||||
gridApi.grid.setRadioRow(row);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭弹窗 */
|
||||
async function closeModal() {
|
||||
visible.value = false;
|
||||
await gridApi.grid.clearCheckboxRow();
|
||||
await gridApi.grid.clearRadioRow();
|
||||
initData.value = undefined;
|
||||
}
|
||||
|
||||
/** 确认选择(多选模式) */
|
||||
function handleConfirm() {
|
||||
const selectedRows = gridApi.grid.getCheckboxRecords() as MallSpuApi.Spu[];
|
||||
emit('change', selectedRows);
|
||||
closeModal();
|
||||
}
|
||||
|
||||
/** 对外暴露的方法 */
|
||||
defineExpose({
|
||||
open: (data?: MallSpuApi.Spu | MallSpuApi.Spu[]) => {
|
||||
modalApi.setData(data).open();
|
||||
},
|
||||
open: openModal,
|
||||
});
|
||||
|
||||
/** 初始化分类数据 */
|
||||
@@ -213,7 +227,18 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="选择商品" class="w-[950px]">
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
title="选择商品"
|
||||
width="950px"
|
||||
:destroy-on-close="true"
|
||||
:append-to-body="true"
|
||||
@close="closeModal"
|
||||
>
|
||||
<Grid />
|
||||
</Modal>
|
||||
<template v-if="props.multiple" #footer>
|
||||
<el-button @click="closeModal">取消</el-button>
|
||||
<el-button type="primary" @click="handleConfirm">确定</el-button>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
32
apps/web-ele/src/views/mall/product/spu/components/type.ts
Normal file
32
apps/web-ele/src/views/mall/product/spu/components/type.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** 商品属性及其值的树形结构(用于前端展示和操作) */
|
||||
export interface PropertyAndValues {
|
||||
id: number;
|
||||
name: string;
|
||||
values?: PropertyAndValues[];
|
||||
}
|
||||
|
||||
export interface RuleConfig {
|
||||
// 需要校验的字段
|
||||
// 例:name: 'name' 则表示校验 sku.name 的值
|
||||
// 例:name: 'productConfig.stock' 则表示校验 sku.productConfig.name 的值,此处 productConfig 表示我在 Sku 上扩展的属性
|
||||
name: string;
|
||||
// 校验规格为一个毁掉函数,其中 arg 为需要校验的字段的值。
|
||||
// 例:需要校验价格必须大于0.01
|
||||
// {
|
||||
// name:'price',
|
||||
// rule:(arg: number) => arg > 0.01
|
||||
// }
|
||||
rule: (arg: any) => boolean;
|
||||
// 校验不通过时的消息提示
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SpuProperty<T> {
|
||||
propertyList: PropertyAndValues[];
|
||||
spuDetail: T;
|
||||
spuId: number;
|
||||
}
|
||||
|
||||
// Re-export for use in generic constraint
|
||||
|
||||
export { type MallSpuApi } from '#/api/mall/product/spu';
|
||||
@@ -2,11 +2,17 @@ import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||
|
||||
import { handleTree } from '@vben/utils';
|
||||
import { fenToYuan, handleTree, treeToString } from '@vben/utils';
|
||||
|
||||
import { getCategoryList } from '#/api/mall/product/category';
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 关联数据 */
|
||||
let categoryList: any[] = [];
|
||||
getCategoryList({}).then((data) => {
|
||||
categoryList = handleTree(data, 'id', 'parentId', 'children');
|
||||
});
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
@@ -14,16 +20,19 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
||||
fieldName: 'name',
|
||||
label: '商品名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入商品名称',
|
||||
clearable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'categoryId',
|
||||
label: '商品分类',
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
api: async () => {
|
||||
const res = await getCategoryList({});
|
||||
return handleTree(res, 'id', 'parentId', 'children');
|
||||
},
|
||||
placeholder: '请选择商品分类',
|
||||
clearable: true,
|
||||
options: categoryList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
childrenField: 'children',
|
||||
@@ -49,16 +58,11 @@ export function useGridColumns(
|
||||
) => PromiseLike<boolean | undefined>,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
type: 'expand',
|
||||
width: 80,
|
||||
slots: { content: 'expand_content' },
|
||||
fixed: 'left',
|
||||
},
|
||||
{
|
||||
field: 'id',
|
||||
title: '商品编号',
|
||||
fixed: 'left',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
@@ -69,30 +73,23 @@ export function useGridColumns(
|
||||
{
|
||||
field: 'picUrl',
|
||||
title: '商品图片',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellImage',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'price',
|
||||
title: '价格',
|
||||
formatter: 'formatFenToYuanAmount',
|
||||
},
|
||||
{
|
||||
field: 'salesCount',
|
||||
title: '销量',
|
||||
},
|
||||
{
|
||||
field: 'stock',
|
||||
title: '库存',
|
||||
},
|
||||
{
|
||||
field: 'sort',
|
||||
title: '排序',
|
||||
field: 'categoryId',
|
||||
title: '商品分类',
|
||||
minWidth: 150,
|
||||
formatter: ({ row }) => {
|
||||
return treeToString(categoryList, row.categoryId);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '销售状态',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
attrs: { beforeChange: onStatusChange },
|
||||
name: 'CellSwitch',
|
||||
@@ -104,9 +101,57 @@ export function useGridColumns(
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'price',
|
||||
title: '价格',
|
||||
minWidth: 100,
|
||||
formatter: 'formatAmount2',
|
||||
},
|
||||
{
|
||||
field: 'marketPrice',
|
||||
title: '市场价',
|
||||
minWidth: 100,
|
||||
formatter: ({ row }) => {
|
||||
return `${fenToYuan(row.marketPrice)} 元`;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'costPrice',
|
||||
title: '成本价',
|
||||
minWidth: 100,
|
||||
formatter: ({ row }) => {
|
||||
return `${fenToYuan(row.costPrice)} 元`;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'salesCount',
|
||||
title: '销量',
|
||||
minWidth: 80,
|
||||
},
|
||||
{
|
||||
field: 'virtualSalesCount',
|
||||
title: '虚拟销量',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'stock',
|
||||
title: '库存',
|
||||
minWidth: 80,
|
||||
},
|
||||
{
|
||||
field: 'browseCount',
|
||||
title: '浏览量',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'sort',
|
||||
title: '排序',
|
||||
minWidth: 80,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 160,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,7 +4,6 @@ import { DeliveryTypeEnum, DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
import { handleTree } from '@vben/utils';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getSimpleBrandList } from '#/api/mall/product/brand';
|
||||
import { getCategoryList } from '#/api/mall/product/category';
|
||||
import { getSimpleTemplateList } from '#/api/mall/trade/delivery/expressTemplate';
|
||||
@@ -25,7 +24,7 @@ export function useInfoFormSchema(): VbenFormSchema[] {
|
||||
label: '商品名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
clearable: true,
|
||||
placeholder: '请输入商品名称',
|
||||
},
|
||||
rules: 'required',
|
||||
@@ -33,14 +32,15 @@ export function useInfoFormSchema(): VbenFormSchema[] {
|
||||
{
|
||||
fieldName: 'categoryId',
|
||||
label: '分类名称',
|
||||
// component: 'ApiCascader',
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
api: async () => {
|
||||
const data = await getCategoryList({});
|
||||
return handleTree(data);
|
||||
},
|
||||
fieldNames: { label: 'name', value: 'id', children: 'children' },
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
childrenField: 'children',
|
||||
placeholder: '请选择商品分类',
|
||||
},
|
||||
rules: 'required',
|
||||
@@ -53,7 +53,7 @@ export function useInfoFormSchema(): VbenFormSchema[] {
|
||||
api: getSimpleBrandList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
allowClear: true,
|
||||
clearable: true,
|
||||
placeholder: '请选择商品品牌',
|
||||
},
|
||||
rules: 'required',
|
||||
@@ -73,10 +73,10 @@ export function useInfoFormSchema(): VbenFormSchema[] {
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入商品简介',
|
||||
autoSize: { minRows: 2, maxRows: 2 },
|
||||
showCount: true,
|
||||
autosize: { minRows: 2, maxRows: 2 },
|
||||
showWordLimit: true,
|
||||
maxlength: 128,
|
||||
allowClear: true,
|
||||
clearable: true,
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
@@ -104,7 +104,10 @@ export function useInfoFormSchema(): VbenFormSchema[] {
|
||||
}
|
||||
|
||||
/** 价格库存的表单 */
|
||||
export function useSkuFormSchema(): VbenFormSchema[] {
|
||||
export function useSkuFormSchema(
|
||||
propertyList: any[] = [],
|
||||
isDetail: boolean = false,
|
||||
): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'id',
|
||||
@@ -119,7 +122,7 @@ export function useSkuFormSchema(): VbenFormSchema[] {
|
||||
label: '分销类型',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
clearable: true,
|
||||
options: [
|
||||
{
|
||||
label: '默认设置',
|
||||
@@ -138,7 +141,7 @@ export function useSkuFormSchema(): VbenFormSchema[] {
|
||||
label: '商品规格',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
clearable: true,
|
||||
options: [
|
||||
{
|
||||
label: '单规格',
|
||||
@@ -152,7 +155,51 @@ export function useSkuFormSchema(): VbenFormSchema[] {
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
// TODO @xingyu:待补充商品属性
|
||||
// 单规格时显示的 SkuList
|
||||
{
|
||||
fieldName: 'singleSkuList',
|
||||
label: '',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: ['specType'],
|
||||
// 当 specType 为 false(单规格)时显示
|
||||
show: (values) => values.specType === false,
|
||||
},
|
||||
},
|
||||
// 多规格时显示的商品属性(占位,实际通过插槽渲染)
|
||||
{
|
||||
fieldName: 'productAttributes',
|
||||
label: '商品属性',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: ['specType'],
|
||||
// 当 specType 为 true(多规格)时显示
|
||||
show: (values) => values.specType === true,
|
||||
},
|
||||
},
|
||||
// 多规格 - 批量设置
|
||||
{
|
||||
fieldName: 'batchSkuList',
|
||||
label: '批量设置',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: ['specType'],
|
||||
// 当 specType 为 true(多规格)且 propertyList 有数据时显示,且非详情模式
|
||||
show: (values) =>
|
||||
values.specType === true && propertyList.length > 0 && !isDetail,
|
||||
},
|
||||
},
|
||||
// 多规格 - 规格列表
|
||||
{
|
||||
fieldName: 'multiSkuList',
|
||||
label: '规格列表',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: ['specType'],
|
||||
// 当 specType 为 true(多规格)且 propertyList 有数据时显示
|
||||
show: (values) => values.specType === true && propertyList.length > 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -237,10 +284,8 @@ export function useOtherFormSchema(): VbenFormSchema[] {
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
controlsPosition: 'right',
|
||||
class: '!w-full',
|
||||
},
|
||||
rules: z.number().min(0).optional().default(0),
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'giveIntegral',
|
||||
@@ -248,10 +293,8 @@ export function useOtherFormSchema(): VbenFormSchema[] {
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
controlsPosition: 'right',
|
||||
class: '!w-full',
|
||||
},
|
||||
rules: z.number().min(0).optional().default(0),
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'virtualSalesCount',
|
||||
@@ -259,10 +302,8 @@ export function useOtherFormSchema(): VbenFormSchema[] {
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
controlsPosition: 'right',
|
||||
class: '!w-full',
|
||||
},
|
||||
rules: z.number().min(0).optional().default(0),
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
413
apps/web-ele/src/views/mall/product/spu/form/index.vue
Normal file
413
apps/web-ele/src/views/mall/product/spu/form/index.vue
Normal file
@@ -0,0 +1,413 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||
import type {
|
||||
PropertyAndValues,
|
||||
RuleConfig,
|
||||
} from '#/views/mall/product/spu/components';
|
||||
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { useTabs } from '@vben/hooks';
|
||||
import { convertToInteger, formatToFraction } from '@vben/utils';
|
||||
|
||||
import { ElButton, ElCard, ElMessage, ElTabPane, ElTabs } from 'element-plus';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createSpu, getSpu, updateSpu } from '#/api/mall/product/spu';
|
||||
import { getPropertyList, SkuList } from '#/views/mall/product/spu/components';
|
||||
|
||||
import {
|
||||
useDeliveryFormSchema,
|
||||
useDescriptionFormSchema,
|
||||
useInfoFormSchema,
|
||||
useOtherFormSchema,
|
||||
useSkuFormSchema,
|
||||
} from './data';
|
||||
import ProductAttributes from './modules/product-attributes.vue';
|
||||
import ProductPropertyAddForm from './modules/product-property-add-form.vue';
|
||||
|
||||
const spuId = ref<number>();
|
||||
const { params, name } = useRoute();
|
||||
const { closeCurrentTab } = useTabs();
|
||||
const activeTabName = ref('info');
|
||||
|
||||
const formLoading = ref(false); // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
||||
const isDetail = ref(name === 'ProductSpuDetail'); // 是否查看详情
|
||||
const skuListRef = ref(); // 商品属性列表 Ref
|
||||
|
||||
const formData = ref<MallSpuApi.Spu>({
|
||||
name: '',
|
||||
categoryId: undefined,
|
||||
keyword: '',
|
||||
picUrl: '',
|
||||
sliderPicUrls: [],
|
||||
introduction: '',
|
||||
deliveryTypes: [],
|
||||
deliveryTemplateId: undefined,
|
||||
brandId: undefined,
|
||||
specType: false,
|
||||
subCommissionType: false,
|
||||
skus: [
|
||||
{
|
||||
price: 0,
|
||||
marketPrice: 0,
|
||||
costPrice: 0,
|
||||
barCode: '',
|
||||
picUrl: '',
|
||||
stock: 0,
|
||||
weight: 0,
|
||||
volume: 0,
|
||||
firstBrokeragePrice: 0,
|
||||
secondBrokeragePrice: 0,
|
||||
},
|
||||
],
|
||||
description: '',
|
||||
sort: 0,
|
||||
giveIntegral: 0,
|
||||
virtualSalesCount: 0,
|
||||
}); // spu 表单数据
|
||||
const propertyList = ref<PropertyAndValues[]>([]); // 商品属性列表
|
||||
const ruleConfig: RuleConfig[] = [
|
||||
// TODO @puhui999:ele 这里都有 :number,antd 要不要加?
|
||||
{
|
||||
name: 'stock',
|
||||
rule: (arg: number) => arg >= 0,
|
||||
message: '商品库存必须大于等于 1 !!!',
|
||||
},
|
||||
{
|
||||
name: 'price',
|
||||
rule: (arg: number) => arg >= 0.01,
|
||||
message: '商品销售价格必须大于等于 0.01 元!!!',
|
||||
},
|
||||
{
|
||||
name: 'marketPrice',
|
||||
rule: (arg: number) => arg >= 0.01,
|
||||
message: '商品市场价格必须大于等于 0.01 元!!!',
|
||||
},
|
||||
{
|
||||
name: 'costPrice',
|
||||
rule: (arg: number) => arg >= 0.01,
|
||||
message: '商品成本价格必须大于等于 0.00 元!!!',
|
||||
},
|
||||
]; // sku 相关属性校验规则
|
||||
|
||||
const [InfoForm, infoFormApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 120,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useInfoFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [SkuForm, skuFormApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
labelWidth: 120,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useSkuFormSchema(propertyList.value, isDetail.value),
|
||||
showDefaultActions: false,
|
||||
handleValuesChange: (values, fieldsChanged) => {
|
||||
if (fieldsChanged.includes('subCommissionType')) {
|
||||
formData.value.subCommissionType = values.subCommissionType;
|
||||
handleChangeSubCommissionType();
|
||||
}
|
||||
if (fieldsChanged.includes('specType')) {
|
||||
formData.value.specType = values.specType;
|
||||
handleChangeSpec();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [ProductPropertyAddFormModal, productPropertyAddFormApi] = useVbenModal({
|
||||
connectedComponent: ProductPropertyAddForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [DeliveryForm, deliveryFormApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 120,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useDeliveryFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [DescriptionForm, descriptionFormApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 120,
|
||||
},
|
||||
layout: 'vertical',
|
||||
schema: useDescriptionFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [OtherForm, otherFormApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 120,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useOtherFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
/** tab 切换 */
|
||||
function handleTabChange(key: string) {
|
||||
activeTabName.value = key;
|
||||
}
|
||||
|
||||
/** 提交表单 */
|
||||
async function handleSubmit() {
|
||||
const values: MallSpuApi.Spu = await infoFormApi
|
||||
.merge(skuFormApi)
|
||||
.merge(deliveryFormApi)
|
||||
.merge(descriptionFormApi)
|
||||
.merge(otherFormApi)
|
||||
.submitAllForm(true);
|
||||
values.skus = formData.value.skus;
|
||||
if (values.skus) {
|
||||
try {
|
||||
// 校验 sku
|
||||
skuListRef.value.validateSku();
|
||||
} catch {
|
||||
ElMessage.error('【库存价格】不完善,请填写相关信息');
|
||||
return;
|
||||
}
|
||||
values.skus.forEach((item) => {
|
||||
// 金额转换:元转分
|
||||
item.price = convertToInteger(item.price);
|
||||
item.marketPrice = convertToInteger(item.marketPrice);
|
||||
item.costPrice = convertToInteger(item.costPrice);
|
||||
item.firstBrokeragePrice = convertToInteger(item.firstBrokeragePrice);
|
||||
item.secondBrokeragePrice = convertToInteger(item.secondBrokeragePrice);
|
||||
});
|
||||
}
|
||||
// 处理轮播图列表 TODO @puhui999:这个是必须的哇?
|
||||
const newSliderPicUrls: any[] = [];
|
||||
values.sliderPicUrls!.forEach((item: any) => {
|
||||
// 如果是前端选的图
|
||||
typeof item === 'object'
|
||||
? newSliderPicUrls.push(item.url)
|
||||
: newSliderPicUrls.push(item);
|
||||
});
|
||||
values.sliderPicUrls = newSliderPicUrls;
|
||||
|
||||
// 提交数据
|
||||
await (spuId.value ? updateSpu(values) : createSpu(values));
|
||||
}
|
||||
|
||||
/** 获得详情 */
|
||||
async function getDetail() {
|
||||
if (isDetail.value) {
|
||||
isDetail.value = true;
|
||||
infoFormApi.setDisabled(true);
|
||||
skuFormApi.setDisabled(true);
|
||||
deliveryFormApi.setDisabled(true);
|
||||
descriptionFormApi.setDisabled(true);
|
||||
otherFormApi.setDisabled(true);
|
||||
}
|
||||
// 将 SKU 的属性,整理成 PropertyAndValues 数组
|
||||
propertyList.value = getPropertyList(formData.value);
|
||||
formLoading.value = true;
|
||||
try {
|
||||
const res = await getSpu(spuId.value!);
|
||||
// 金额转换:元转分
|
||||
res.skus?.forEach((item) => {
|
||||
item.price = formatToFraction(item.price);
|
||||
item.marketPrice = formatToFraction(item.marketPrice);
|
||||
item.costPrice = formatToFraction(item.costPrice);
|
||||
item.firstBrokeragePrice = formatToFraction(item.firstBrokeragePrice);
|
||||
item.secondBrokeragePrice = formatToFraction(item.secondBrokeragePrice);
|
||||
});
|
||||
formData.value = res;
|
||||
// 初始化各表单值
|
||||
infoFormApi.setValues(res).then();
|
||||
skuFormApi.setValues(res).then();
|
||||
deliveryFormApi.setValues(res).then();
|
||||
descriptionFormApi.setValues(res).then();
|
||||
otherFormApi.setValues(res).then();
|
||||
// 将 SKU 的属性,整理成 PropertyAndValues 数组
|
||||
propertyList.value = getPropertyList(formData.value);
|
||||
} finally {
|
||||
formLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// =========== sku form 逻辑 ===========
|
||||
|
||||
/** 打开属性添加表单 */
|
||||
function openPropertyAddForm() {
|
||||
productPropertyAddFormApi.open();
|
||||
}
|
||||
|
||||
/** 调用 SkuList generateTableData 方法*/
|
||||
function generateSkus(propertyList: PropertyAndValues[]) {
|
||||
skuListRef.value.generateTableData(propertyList);
|
||||
}
|
||||
|
||||
/** 分销类型 */
|
||||
function handleChangeSubCommissionType() {
|
||||
// 默认为零,类型切换后也要重置为零
|
||||
for (const item of formData.value.skus!) {
|
||||
item.firstBrokeragePrice = 0;
|
||||
item.secondBrokeragePrice = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 选择规格 */
|
||||
function handleChangeSpec() {
|
||||
// 重置商品属性列表
|
||||
propertyList.value = [];
|
||||
// 重置 sku 列表
|
||||
formData.value.skus = [
|
||||
{
|
||||
price: 0,
|
||||
marketPrice: 0,
|
||||
costPrice: 0,
|
||||
barCode: '',
|
||||
picUrl: '',
|
||||
stock: 0,
|
||||
weight: 0,
|
||||
volume: 0,
|
||||
firstBrokeragePrice: 0,
|
||||
secondBrokeragePrice: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 监听 sku form schema 变化,更新表单 */
|
||||
watch(
|
||||
propertyList,
|
||||
() => {
|
||||
skuFormApi.updateSchema(
|
||||
useSkuFormSchema(propertyList.value, isDetail.value),
|
||||
);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
spuId.value = params.id as unknown as number;
|
||||
if (!spuId.value) {
|
||||
return;
|
||||
}
|
||||
await getDetail();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ProductPropertyAddFormModal :property-list="propertyList" />
|
||||
|
||||
<Page auto-content-height>
|
||||
<ElCard class="spu-form-card h-full w-full" v-loading="formLoading">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<ElTabs
|
||||
v-model="activeTabName"
|
||||
@tab-click="(tab: any) => handleTabChange(tab.paneName)"
|
||||
>
|
||||
<ElTabPane label="基础设置" name="info" />
|
||||
<ElTabPane label="价格库存" name="sku" />
|
||||
<ElTabPane label="物流设置" name="delivery" />
|
||||
<ElTabPane label="商品详情" name="description" />
|
||||
<ElTabPane label="其它设置" name="other" />
|
||||
</ElTabs>
|
||||
<div>
|
||||
<ElButton v-if="!isDetail" type="primary" @click="handleSubmit">
|
||||
保存
|
||||
</ElButton>
|
||||
<ElButton v-else @click="() => closeCurrentTab()">
|
||||
返回列表
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<InfoForm class="w-3/5" v-show="activeTabName === 'info'" />
|
||||
<SkuForm class="w-full" v-show="activeTabName === 'sku'">
|
||||
<template #singleSkuList>
|
||||
<SkuList
|
||||
ref="skuListRef"
|
||||
class="w-full"
|
||||
:is-detail="isDetail"
|
||||
:prop-form-data="formData"
|
||||
:property-list="propertyList"
|
||||
:rule-config="ruleConfig"
|
||||
/>
|
||||
</template>
|
||||
<template #productAttributes>
|
||||
<div>
|
||||
<ElButton class="mb-10px mr-15px" @click="openPropertyAddForm">
|
||||
添加属性
|
||||
</ElButton>
|
||||
<ProductAttributes
|
||||
:is-detail="isDetail"
|
||||
:property-list="propertyList"
|
||||
@success="generateSkus"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #batchSkuList>
|
||||
<SkuList
|
||||
:is-batch="true"
|
||||
:is-detail="isDetail"
|
||||
:prop-form-data="formData"
|
||||
:property-list="propertyList"
|
||||
/>
|
||||
</template>
|
||||
<template #multiSkuList>
|
||||
<SkuList
|
||||
ref="skuListRef"
|
||||
:is-detail="isDetail"
|
||||
:prop-form-data="formData"
|
||||
:property-list="propertyList"
|
||||
:rule-config="ruleConfig"
|
||||
/>
|
||||
</template>
|
||||
</SkuForm>
|
||||
<DeliveryForm class="w-3/5" v-show="activeTabName === 'delivery'" />
|
||||
<DescriptionForm
|
||||
class="w-3/5"
|
||||
v-show="activeTabName === 'description'"
|
||||
/>
|
||||
<OtherForm class="w-3/5" v-show="activeTabName === 'other'" />
|
||||
</div>
|
||||
</ElCard>
|
||||
</Page>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.spu-form-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.spu-form-card :deep(.el-card__body) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<!-- 商品发布 - 库存价格 - 属性列表 -->
|
||||
<script lang="ts" setup>
|
||||
import type { MallPropertyApi } from '#/api/mall/product/property';
|
||||
import type { PropertyAndValues } from '#/views/mall/product/spu/components';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
ElCol,
|
||||
ElDivider,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElTag,
|
||||
} from 'element-plus';
|
||||
|
||||
import {
|
||||
createPropertyValue,
|
||||
getPropertyValueSimpleList,
|
||||
} from '#/api/mall/product/property';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
defineOptions({ name: 'ProductAttributes' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
propertyList: () => [],
|
||||
isDetail: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
interface Props {
|
||||
propertyList?: PropertyAndValues[];
|
||||
isDetail?: boolean;
|
||||
}
|
||||
|
||||
const inputValue = ref<string[]>([]); // 输入框值(tags 模式使用数组)
|
||||
const attributeIndex = ref<null | number>(null); // 获取焦点时记录当前属性项的 index
|
||||
const inputVisible = computed(() => (index: number) => {
|
||||
if (attributeIndex.value === null) {
|
||||
return false;
|
||||
}
|
||||
if (attributeIndex.value === index) {
|
||||
return true;
|
||||
}
|
||||
}); // 输入框显隐控制
|
||||
|
||||
interface InputRefItem {
|
||||
inputRef?: {
|
||||
attributes: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
const inputRef = ref<InputRefItem[]>([]); // 标签输入框 Ref
|
||||
const attributeList = ref<PropertyAndValues[]>([]); // 商品属性列表
|
||||
const attributeOptions = ref<MallPropertyApi.PropertyValue[]>([]); // 商品属性值下拉框
|
||||
|
||||
/** 解决 ref 在 v-for 中的获取问题*/
|
||||
function setInputRef(el: any) {
|
||||
if (el === null || el === undefined) return;
|
||||
// 如果不存在 id 相同的元素才添加
|
||||
if (
|
||||
!inputRef.value.some(
|
||||
(item) => item.inputRef?.attributes.id === el.inputRef?.attributes.id,
|
||||
)
|
||||
) {
|
||||
inputRef.value.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.propertyList,
|
||||
(data) => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
attributeList.value = data;
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
/** 删除属性值 */
|
||||
function handleCloseValue(index: number, value: PropertyAndValues) {
|
||||
if (attributeList.value[index]?.values) {
|
||||
attributeList.value[index].values = attributeList.value[
|
||||
index
|
||||
].values?.filter((item) => item.id !== value.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除属性 */
|
||||
function handleCloseProperty(item: PropertyAndValues) {
|
||||
attributeList.value = attributeList.value.filter(
|
||||
(attribute) => attribute.id !== item.id,
|
||||
);
|
||||
emit('success', attributeList.value);
|
||||
}
|
||||
|
||||
/** 显示输入框并获取焦点 */
|
||||
async function showInput(index: number) {
|
||||
attributeIndex.value = index;
|
||||
inputRef.value?.[index]?.focus();
|
||||
// 获取属性下拉选项
|
||||
await getAttributeOptions(attributeList.value?.[index]?.id!);
|
||||
}
|
||||
|
||||
/** 定义 success 事件,用于操作成功后的回调 */
|
||||
async function handleInputConfirm(index: number, propertyId: number) {
|
||||
// 从数组中取最后一个输入的值(tags 模式下 inputValue 是数组)
|
||||
const currentValue = inputValue.value?.[inputValue.value.length - 1]?.trim();
|
||||
|
||||
if (currentValue) {
|
||||
// 1. 重复添加校验
|
||||
if (
|
||||
attributeList.value?.[index]?.values?.find(
|
||||
(item) => item.name === currentValue,
|
||||
)
|
||||
) {
|
||||
ElMessage.warning('已存在相同属性值,请重试');
|
||||
attributeIndex.value = null;
|
||||
inputValue.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// 2.1 情况一:属性值已存在,则直接使用并结束
|
||||
const existValue = attributeOptions.value.find(
|
||||
(item) => item.name === currentValue,
|
||||
);
|
||||
if (existValue) {
|
||||
attributeIndex.value = null;
|
||||
inputValue.value = [];
|
||||
attributeList.value?.[index]?.values?.push({
|
||||
id: existValue.id!,
|
||||
name: existValue.name,
|
||||
});
|
||||
emit('success', attributeList.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2.2 情况二:新属性值,则进行保存
|
||||
try {
|
||||
const id = await createPropertyValue({
|
||||
propertyId,
|
||||
name: currentValue,
|
||||
});
|
||||
attributeList.value?.[index]?.values?.push({
|
||||
id,
|
||||
name: currentValue,
|
||||
});
|
||||
ElMessage.success($t('ui.actionMessage.operationSuccess'));
|
||||
emit('success', attributeList.value);
|
||||
} catch {
|
||||
ElMessage.error($t('ui.actionMessage.operationFailed'));
|
||||
}
|
||||
}
|
||||
attributeIndex.value = null;
|
||||
inputValue.value = [];
|
||||
}
|
||||
|
||||
/** 获取商品属性下拉选项 */
|
||||
async function getAttributeOptions(propertyId: number) {
|
||||
attributeOptions.value = await getPropertyValueSimpleList(propertyId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCol v-for="(attribute, index) in attributeList" :key="index">
|
||||
<ElDivider class="my-3" />
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span class="mx-1">属性名:</span>
|
||||
<ElTag
|
||||
:closable="!isDetail"
|
||||
class="mx-1"
|
||||
type="success"
|
||||
@close="handleCloseProperty(attribute)"
|
||||
>
|
||||
{{ attribute.name }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span class="mx-1">属性值:</span>
|
||||
<ElTag
|
||||
v-for="(value, valueIndex) in attribute.values"
|
||||
:key="valueIndex"
|
||||
:closable="!isDetail"
|
||||
class="mx-1"
|
||||
@close="handleCloseValue(index, value)"
|
||||
>
|
||||
{{ value?.name }}
|
||||
</ElTag>
|
||||
<ElSelect
|
||||
v-show="inputVisible(index)"
|
||||
:id="`input${index}`"
|
||||
:ref="setInputRef"
|
||||
v-model="inputValue"
|
||||
clearable
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
:max-collapse-tags="1"
|
||||
size="small"
|
||||
style="width: 100px"
|
||||
@blur="handleInputConfirm(index, attribute.id)"
|
||||
@change="handleInputConfirm(index, attribute.id)"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item2 in attributeOptions"
|
||||
:key="item2.id"
|
||||
:value="item2.name"
|
||||
:label="item2.name"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElTag
|
||||
v-show="!inputVisible(index)"
|
||||
@click="showInput(index)"
|
||||
class="mx-1 cursor-pointer"
|
||||
effect="plain"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconifyIcon class="mr-2" icon="ep:plus" />
|
||||
添加
|
||||
</div>
|
||||
</ElTag>
|
||||
</div>
|
||||
</ElCol>
|
||||
</template>
|
||||
@@ -0,0 +1,146 @@
|
||||
<!-- 商品发布 - 库存价格 - 添加属性 -->
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { MallPropertyApi } from '#/api/mall/product/property';
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
createProperty,
|
||||
getPropertySimpleList,
|
||||
} from '#/api/mall/product/property';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
defineOptions({ name: 'ProductPropertyAddForm' });
|
||||
|
||||
const props = defineProps({
|
||||
propertyList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: [];
|
||||
}>();
|
||||
|
||||
const attributeList = ref<any[]>([]); // 商品属性列表
|
||||
const attributeOptions = ref<MallPropertyApi.Property[]>([]); // 商品属性名称下拉框
|
||||
|
||||
watch(
|
||||
() => props.propertyList,
|
||||
(data) => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
attributeList.value = data as any[];
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
const formSchema: VbenFormSchema[] = [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '属性名称',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: async () => {
|
||||
const data = await getPropertySimpleList();
|
||||
attributeOptions.value = data;
|
||||
return data.map((item: MallPropertyApi.Property) => ({
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
}));
|
||||
},
|
||||
filterable: true,
|
||||
placeholder: '请选择属性名称。如果不存在,可手动输入选择',
|
||||
multiple: true,
|
||||
allowCreate: true,
|
||||
clearable: true,
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 80,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: formSchema,
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
const values = await formApi.getValues();
|
||||
// name 为数组,遍历数组,进行重复添加校验
|
||||
const names = values.name;
|
||||
for (const name of names) {
|
||||
// 重复添加校验
|
||||
for (const attrItem of attributeList.value) {
|
||||
if (attrItem.name === name) {
|
||||
ElMessage.error('该属性已存在,请勿重复添加');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of names) {
|
||||
const existProperty = attributeOptions.value.find(
|
||||
(item: MallPropertyApi.Property) => item.name === name,
|
||||
);
|
||||
if (existProperty) {
|
||||
// 情况一:如果属性已存在,则直接使用并结束
|
||||
attributeList.value.push({
|
||||
id: existProperty.id,
|
||||
name,
|
||||
values: [],
|
||||
});
|
||||
} else {
|
||||
// 情况二:如果是不存在的属性,则需要执行新增
|
||||
const propertyId = await createProperty({ name });
|
||||
attributeList.value.push({
|
||||
id: propertyId,
|
||||
name,
|
||||
values: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
ElMessage.success($t('ui.actionMessage.operationSuccess'));
|
||||
modalApi.unlock();
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
await formApi.resetForm();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="添加商品属性">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -7,17 +7,11 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { confirm, DocAlert, Page } from '@vben/common-ui';
|
||||
import { ProductSpuStatusEnum } from '@vben/constants';
|
||||
import {
|
||||
downloadFileFromBlobPart,
|
||||
fenToYuan,
|
||||
handleTree,
|
||||
treeToString,
|
||||
} from '@vben/utils';
|
||||
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||
|
||||
import { ElDescriptions, ElLoading, ElMessage, ElTabs } from 'element-plus';
|
||||
import { ElLoading, ElMessage, ElTabs } from 'element-plus';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getCategoryList } from '#/api/mall/product/category';
|
||||
import {
|
||||
deleteSpu,
|
||||
exportSpu,
|
||||
@@ -30,11 +24,8 @@ import { $t } from '#/locales';
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
|
||||
const { push } = useRouter();
|
||||
const tabType = ref(0);
|
||||
const route = useRoute();
|
||||
const categoryList = ref();
|
||||
|
||||
// tabs 数据
|
||||
const tabType = ref('0');
|
||||
const tabsData = ref([
|
||||
{
|
||||
name: '出售中',
|
||||
@@ -69,13 +60,19 @@ async function handleRefresh() {
|
||||
await getTabCount();
|
||||
}
|
||||
|
||||
/** 导出表格 */
|
||||
async function handleExport() {
|
||||
const data = await exportSpu(await gridApi.formApi.getValues());
|
||||
downloadFileFromBlobPart({ fileName: '商品.xls', source: data });
|
||||
}
|
||||
|
||||
/** 获得每个 Tab 的数量 */
|
||||
async function getTabCount() {
|
||||
const res = await getTabsCount();
|
||||
for (const objName in res) {
|
||||
const index = Number(objName);
|
||||
if (tabsData.value[index]) {
|
||||
tabsData.value[index].count = res[objName] as number;
|
||||
tabsData.value[index].count = res[objName]!;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,12 +82,6 @@ function handleCreate() {
|
||||
push({ name: 'ProductSpuAdd' });
|
||||
}
|
||||
|
||||
/** 导出表格 */
|
||||
async function handleExport() {
|
||||
const data = await exportSpu(await gridApi.formApi.getValues());
|
||||
downloadFileFromBlobPart({ fileName: '商品.xls', source: data });
|
||||
}
|
||||
|
||||
/** 编辑商品 */
|
||||
function handleEdit(row: MallSpuApi.Spu) {
|
||||
push({ name: 'ProductSpuEdit', params: { id: row.id } });
|
||||
@@ -102,7 +93,7 @@ async function handleDelete(row: MallSpuApi.Spu) {
|
||||
text: $t('ui.actionMessage.deleting', [row.name]),
|
||||
});
|
||||
try {
|
||||
await deleteSpu(row.id as number);
|
||||
await deleteSpu(row.id!);
|
||||
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
await handleRefresh();
|
||||
} finally {
|
||||
@@ -110,19 +101,6 @@ async function handleDelete(row: MallSpuApi.Spu) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 添加到仓库 / 回收站的状态 */
|
||||
async function handleStatus02Change(row: MallSpuApi.Spu, newStatus: number) {
|
||||
// 二次确认
|
||||
const text =
|
||||
newStatus === ProductSpuStatusEnum.RECYCLE.status
|
||||
? '加入到回收站'
|
||||
: '恢复到仓库';
|
||||
await confirm(`确认要"${row.name}"${text}吗?`);
|
||||
await updateStatus({ id: row.id as number, status: newStatus });
|
||||
ElMessage.success(`${text}成功`);
|
||||
await handleRefresh();
|
||||
}
|
||||
|
||||
/** 更新状态 */
|
||||
async function handleStatusChange(
|
||||
newStatus: number,
|
||||
@@ -130,14 +108,14 @@ async function handleStatusChange(
|
||||
): Promise<boolean | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 二次确认
|
||||
const text = row.status ? '上架' : '下架';
|
||||
const text = newStatus ? '上架' : '下架';
|
||||
confirm({
|
||||
content: `确认要${text + row.name}吗?`,
|
||||
})
|
||||
.then(async () => {
|
||||
// 更新状态
|
||||
await updateStatus({
|
||||
id: row.id as number,
|
||||
id: row.id!,
|
||||
status: newStatus,
|
||||
});
|
||||
// 提示并返回成功
|
||||
@@ -150,6 +128,27 @@ async function handleStatusChange(
|
||||
});
|
||||
}
|
||||
|
||||
/** 添加到仓库 / 回收站的状态 */
|
||||
async function handleStatus02Change(row: MallSpuApi.Spu, newStatus: number) {
|
||||
const text =
|
||||
newStatus === ProductSpuStatusEnum.RECYCLE.status
|
||||
? '加入到回收站'
|
||||
: '恢复到仓库';
|
||||
await confirm({
|
||||
content: `确认要"${row.name}"${text}吗?`,
|
||||
});
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: `正在${text}中...`,
|
||||
});
|
||||
try {
|
||||
await updateStatus({ id: row.id!, status: newStatus });
|
||||
ElMessage.success(`${text}成功`);
|
||||
await handleRefresh();
|
||||
} finally {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 查看商品详情 */
|
||||
function handleDetail(row: MallSpuApi.Spu) {
|
||||
push({ name: 'ProductSpuDetail', params: { id: row.id } });
|
||||
@@ -162,12 +161,6 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: useGridColumns(handleStatusChange),
|
||||
height: 'auto',
|
||||
cellConfig: {
|
||||
height: 80,
|
||||
},
|
||||
expandConfig: {
|
||||
height: 100,
|
||||
},
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
@@ -175,7 +168,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
return await getSpuPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
tabType: tabType.value,
|
||||
tabType: Number(tabType.value),
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
@@ -183,7 +176,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
resizable: true,
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
@@ -192,8 +185,8 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
} as VxeTableGridOptions<MallSpuApi.Spu>,
|
||||
});
|
||||
|
||||
function onChangeTab(key: any) {
|
||||
tabType.value = Number(key);
|
||||
function onChangeTab(key: number | string) {
|
||||
tabType.value = key.toString();
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
@@ -204,9 +197,8 @@ onMounted(async () => {
|
||||
categoryId: Number(route.query.categoryId),
|
||||
});
|
||||
}
|
||||
// 获得每个 Tab 的数量
|
||||
await getTabCount();
|
||||
const categoryRes = await getCategoryList({});
|
||||
categoryList.value = handleTree(categoryRes, 'id', 'parentId', 'children');
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -221,12 +213,12 @@ onMounted(async () => {
|
||||
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<ElTabs class="w-full" @tab-change="onChangeTab" :stretch="true">
|
||||
<ElTabs v-model="tabType" class="w-full" @tab-change="onChangeTab">
|
||||
<ElTabs.TabPane
|
||||
v-for="item in tabsData"
|
||||
:key="item.type"
|
||||
:label="`${item.name} (${item.count})`"
|
||||
:name="item.type"
|
||||
:name="String(item.type)"
|
||||
/>
|
||||
</ElTabs>
|
||||
</template>
|
||||
@@ -250,39 +242,6 @@ onMounted(async () => {
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<!-- TODO @霖:展开的样子,不展示信息 -->
|
||||
<template #expand_content="{ row }">
|
||||
<ElDescriptions
|
||||
:column="4"
|
||||
class="mt-4"
|
||||
:label-style="{
|
||||
width: '100px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '14px',
|
||||
}"
|
||||
:content-style="{ width: '100px', fontSize: '14px' }"
|
||||
>
|
||||
<ElDescriptions.Item label="商品分类">
|
||||
{{ treeToString(categoryList, row.categoryId || 0) }}
|
||||
</ElDescriptions.Item>
|
||||
<ElDescriptions.Item label="商品名称">
|
||||
{{ row.name }}
|
||||
</ElDescriptions.Item>
|
||||
|
||||
<ElDescriptions.Item label="市场价">
|
||||
{{ fenToYuan(row.marketPrice || 0) }} 元
|
||||
</ElDescriptions.Item>
|
||||
<ElDescriptions.Item label="成本价">
|
||||
{{ fenToYuan(row.costPrice || 0) }} 元
|
||||
</ElDescriptions.Item>
|
||||
<ElDescriptions.Item label="浏览量">
|
||||
{{ row.browseCount || 0 }}
|
||||
</ElDescriptions.Item>
|
||||
<ElDescriptions.Item label="虚拟销量">
|
||||
{{ row.virtualSalesCount || 0 }}
|
||||
</ElDescriptions.Item>
|
||||
</ElDescriptions>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
@@ -307,7 +266,7 @@ onMounted(async () => {
|
||||
link: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['product:spu:delete'],
|
||||
ifShow: () => tabType === 4,
|
||||
ifShow: () => tabType === '4',
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
@@ -319,7 +278,6 @@ onMounted(async () => {
|
||||
link: true,
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['product:spu:update'],
|
||||
ifShow: () => tabType === 4,
|
||||
onClick: handleStatus02Change.bind(
|
||||
null,
|
||||
row,
|
||||
@@ -332,7 +290,6 @@ onMounted(async () => {
|
||||
link: true,
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['product:spu:update'],
|
||||
ifShow: () => tabType !== 4,
|
||||
onClick: handleStatus02Change.bind(
|
||||
null,
|
||||
row,
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DictDataType } from '@vben/hooks';
|
||||
|
||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { floatToFixed2 } from '@vben/utils';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElCarousel,
|
||||
ElCarouselItem,
|
||||
ElDescriptions,
|
||||
ElDescriptionsItem,
|
||||
ElEmpty,
|
||||
ElImage,
|
||||
ElTabPane,
|
||||
ElTabs,
|
||||
ElTag,
|
||||
} from 'element-plus';
|
||||
|
||||
import * as ProductBrandApi from '#/api/mall/product/brand';
|
||||
import * as ProductCategoryApi from '#/api/mall/product/category';
|
||||
import * as ProductSpuApi from '#/api/mall/product/spu';
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
children?: Category[];
|
||||
}
|
||||
|
||||
interface Brand {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const { push } = useRouter();
|
||||
const { params } = useRoute();
|
||||
|
||||
const formLoading = ref(false); // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
||||
const activeTab = ref('basic'); // 当前激活的标签页
|
||||
const categoryList = ref<Category[]>([]); // 商品分类列表
|
||||
const brandList = ref<Brand[]>([]); // 商品品牌列表
|
||||
const deliveryTypeDict = ref<DictDataType[]>([]); // 配送方式字典
|
||||
|
||||
// SPU 表单数据
|
||||
const formData = ref<MallSpuApi.Spu>({
|
||||
name: '', // 商品名称
|
||||
categoryId: undefined, // 商品分类
|
||||
keyword: '', // 关键字
|
||||
picUrl: '', // 商品封面图
|
||||
sliderPicUrls: [], // 商品轮播图
|
||||
introduction: '', // 商品简介
|
||||
deliveryTypes: [], // 配送方式数组
|
||||
deliveryTemplateId: undefined, // 运费模版
|
||||
brandId: undefined, // 商品品牌
|
||||
specType: false, // 商品规格
|
||||
subCommissionType: false, // 分销类型
|
||||
skus: [
|
||||
{
|
||||
price: 0, // 商品价格
|
||||
marketPrice: 0, // 市场价
|
||||
costPrice: 0, // 成本价
|
||||
barCode: '', // 商品条码
|
||||
picUrl: '', // 图片地址
|
||||
stock: 0, // 库存
|
||||
weight: 0, // 商品重量
|
||||
volume: 0, // 商品体积
|
||||
firstBrokeragePrice: 0, // 一级分销的佣金
|
||||
secondBrokeragePrice: 0, // 二级分销的佣金
|
||||
},
|
||||
],
|
||||
description: '', // 商品详情
|
||||
sort: 0, // 商品排序
|
||||
giveIntegral: 0, // 赠送积分
|
||||
virtualSalesCount: 0, // 虚拟销量
|
||||
});
|
||||
|
||||
/** 获取配送方式字典 */
|
||||
const getDeliveryTypeDict = async () => {
|
||||
try {
|
||||
deliveryTypeDict.value = await getDictOptions(
|
||||
DICT_TYPE.TRADE_DELIVERY_TYPE,
|
||||
'number',
|
||||
);
|
||||
deliveryTypeDict.value = await getDictOptions(
|
||||
DICT_TYPE.TRADE_DELIVERY_TYPE,
|
||||
'number',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('获取配送方式字典失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
/** 获取商品分类列表 */
|
||||
const getCategoryList = async () => {
|
||||
try {
|
||||
const data = await ProductCategoryApi.getCategorySimpleList();
|
||||
categoryList.value = data as Category[];
|
||||
} catch (error) {
|
||||
console.error('获取商品分类失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
/** 获取商品品牌列表 */
|
||||
const getBrandList = async () => {
|
||||
try {
|
||||
const data = await ProductBrandApi.getSimpleBrandList();
|
||||
brandList.value = data as Brand[];
|
||||
} catch (error) {
|
||||
console.error('获取商品品牌失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
/** 根据ID获取分类名称 */
|
||||
const getCategoryNameById = (id: number | undefined) => {
|
||||
if (!id || !categoryList.value || categoryList.value.length === 0)
|
||||
return '未知分类';
|
||||
const category = categoryList.value.find((item) => item.id === id);
|
||||
return category ? category.name : '未知分类';
|
||||
};
|
||||
|
||||
/** 根据ID获取品牌名称 */
|
||||
const getBrandNameById = (id: number | undefined) => {
|
||||
if (!id || !brandList.value || brandList.value.length === 0)
|
||||
return '未知品牌';
|
||||
const brand = brandList.value.find((item) => item.id === id);
|
||||
return brand ? brand.name : '未知品牌';
|
||||
};
|
||||
|
||||
/** 根据值获取配送方式名称 */
|
||||
const getDeliveryTypeName = (value: number) => {
|
||||
if (!deliveryTypeDict.value || deliveryTypeDict.value.length === 0)
|
||||
return `${value}`;
|
||||
const dict = deliveryTypeDict.value.find((item) => item.value === value);
|
||||
return dict ? dict.label : `${value}`;
|
||||
};
|
||||
|
||||
/** 获得详情 */
|
||||
const getDetail = async () => {
|
||||
const id = params.id as unknown as number;
|
||||
if (id) {
|
||||
formLoading.value = true;
|
||||
try {
|
||||
const res = (await ProductSpuApi.getSpu(id)) as MallSpuApi.Spu;
|
||||
res.skus?.forEach((item: MallSpuApi.Sku) => {
|
||||
item.price = floatToFixed2(item.price);
|
||||
item.marketPrice = floatToFixed2(item.marketPrice);
|
||||
item.costPrice = floatToFixed2(item.costPrice);
|
||||
item.firstBrokeragePrice = floatToFixed2(item.firstBrokeragePrice);
|
||||
item.secondBrokeragePrice = floatToFixed2(item.secondBrokeragePrice);
|
||||
});
|
||||
formData.value = res;
|
||||
} finally {
|
||||
formLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** 返回列表 */
|
||||
const back = () => {
|
||||
push({ name: 'ProductSpu' });
|
||||
};
|
||||
|
||||
/** 编辑商品 */
|
||||
const editProduct = () => {
|
||||
push({ name: 'ProductSpuForm', params: { id: params.id } });
|
||||
};
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
await Promise.all([getCategoryList(), getBrandList(), getDeliveryTypeDict()]);
|
||||
await getDetail();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height :loading="formLoading">
|
||||
<template #title>
|
||||
<span class="text-lg font-bold">商品详情</span>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<div class="flex gap-2">
|
||||
<ElButton type="primary" @click="editProduct">
|
||||
<IconifyIcon icon="ep:edit" class="mr-1" />
|
||||
编辑商品
|
||||
</ElButton>
|
||||
<ElButton @click="back">
|
||||
<IconifyIcon icon="ep:back" class="mr-1" />
|
||||
返回列表
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElCard shadow="hover" class="mb-4">
|
||||
<div class="mb-4 flex flex-col gap-4 md:flex-row md:items-center">
|
||||
<ElImage
|
||||
:src="formData.picUrl"
|
||||
fit="contain"
|
||||
style="width: 120px; height: 120px"
|
||||
class="rounded border"
|
||||
/>
|
||||
<div class="flex-grow">
|
||||
<h1 class="mb-2 text-xl font-bold">{{ formData.name }}</h1>
|
||||
<div class="mb-2 text-gray-500">
|
||||
{{ formData.introduction || '暂无简介' }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ElTag v-if="formData.specType" type="success">多规格</ElTag>
|
||||
<ElTag v-else type="info">单规格</ElTag>
|
||||
<ElTag v-if="formData.subCommissionType" type="warning">分销</ElTag>
|
||||
<ElTag type="danger">
|
||||
库存:
|
||||
{{
|
||||
formData.skus?.reduce(
|
||||
(sum, sku) => sum + (sku.stock || 0),
|
||||
0,
|
||||
) || 0
|
||||
}}
|
||||
</ElTag>
|
||||
<ElTag type="info">
|
||||
分类: {{ getCategoryNameById(formData.categoryId) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElTabs v-model="activeTab" type="border-card">
|
||||
<ElTabPane name="basic" label="基本信息">
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- 基本信息 -->
|
||||
<ElCard shadow="never" header="商品信息" class="h-full">
|
||||
<ElDescriptions :column="1" border>
|
||||
<ElDescriptionsItem label="商品名称">
|
||||
{{ formData.name }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="商品分类">
|
||||
<ElTag type="success">
|
||||
{{ getCategoryNameById(formData.categoryId) }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="商品品牌">
|
||||
<ElTag type="primary">
|
||||
{{ getBrandNameById(formData.brandId) }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="关键字">
|
||||
<ElTag type="danger" />{{ formData.keyword || '无' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="赠送积分">
|
||||
{{ formData.giveIntegral }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="虚拟销量">
|
||||
{{ formData.virtualSalesCount }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="排序">
|
||||
{{ formData.sort }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="规格类型">
|
||||
<ElTag :type="formData.specType ? 'success' : 'info'">
|
||||
{{ formData.specType ? '多规格' : '单规格' }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="分销类型">
|
||||
<ElTag
|
||||
:type="formData.subCommissionType ? 'warning' : 'info'"
|
||||
>
|
||||
{{ formData.subCommissionType ? '单独设置' : '默认设置' }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
|
||||
<!-- 配送信息 -->
|
||||
<ElCard shadow="never" header="配送信息" class="h-full">
|
||||
<ElDescriptions :column="1" border>
|
||||
<ElDescriptionsItem label="配送方式">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ElTag
|
||||
v-for="(type, index) in formData.deliveryTypes"
|
||||
:key="index"
|
||||
:type="
|
||||
(deliveryTypeDict.find((dict) => dict.value === type)
|
||||
?.colorType as
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'info'
|
||||
| 'danger'
|
||||
| 'primary'
|
||||
| undefined) || undefined
|
||||
"
|
||||
>
|
||||
{{ getDeliveryTypeName(type) }}
|
||||
</ElTag>
|
||||
<span
|
||||
v-if="
|
||||
!formData.deliveryTypes ||
|
||||
formData.deliveryTypes.length === 0
|
||||
"
|
||||
class="text-gray-400"
|
||||
>
|
||||
暂无配送方式
|
||||
</span>
|
||||
</div>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="运费模板">
|
||||
{{ formData.deliveryTemplateId || '未设置' }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
|
||||
<ElTabPane name="images" label="商品图片">
|
||||
<ElCard shadow="never" header="商品轮播图">
|
||||
<div
|
||||
v-if="formData.sliderPicUrls && formData.sliderPicUrls.length > 0"
|
||||
>
|
||||
<ElCarousel
|
||||
height="400px"
|
||||
:interval="4000"
|
||||
indicator-position="outside"
|
||||
arrow="always"
|
||||
>
|
||||
<ElCarouselItem
|
||||
v-for="(item, index) in formData.sliderPicUrls"
|
||||
:key="index"
|
||||
>
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<ElImage
|
||||
:src="item"
|
||||
fit="contain"
|
||||
class="max-h-full"
|
||||
:preview-src-list="formData.sliderPicUrls"
|
||||
:initial-index="index"
|
||||
/>
|
||||
</div>
|
||||
</ElCarouselItem>
|
||||
</ElCarousel>
|
||||
<div class="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<div
|
||||
v-for="(item, index) in formData.sliderPicUrls"
|
||||
:key="index"
|
||||
class="cursor-pointer rounded border p-1"
|
||||
>
|
||||
<ElImage
|
||||
:src="item"
|
||||
fit="cover"
|
||||
style="width: 80px; height: 80px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-else description="暂无轮播图" />
|
||||
</ElCard>
|
||||
</ElTabPane>
|
||||
|
||||
<ElTabPane name="sku" label="SKU信息">
|
||||
<div v-if="formData.skus && formData.skus.length > 0">
|
||||
<div
|
||||
v-for="(sku, index) in formData.skus"
|
||||
:key="index"
|
||||
class="mb-6"
|
||||
>
|
||||
<ElCard
|
||||
shadow="hover"
|
||||
:header="`规格 ${index + 1}${sku.properties && sku.properties.length > 0 ? ` - ${sku.properties.map((p) => p.valueName).join('/')}` : ''}`"
|
||||
>
|
||||
<div class="flex flex-col gap-4 md:flex-row">
|
||||
<ElImage
|
||||
:src="sku.picUrl || formData.picUrl"
|
||||
fit="contain"
|
||||
style="width: 120px; height: 120px"
|
||||
class="flex-shrink-0 rounded border"
|
||||
/>
|
||||
|
||||
<div class="grid flex-grow grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<!-- 价格信息 -->
|
||||
<div class="rounded bg-gray-50 p-4">
|
||||
<h3 class="mb-2 border-b pb-2 font-bold text-gray-700">
|
||||
价格信息
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="text-gray-500">销售价:</div>
|
||||
<div class="font-bold text-red-500">
|
||||
¥{{ sku.price }}
|
||||
</div>
|
||||
<div class="text-gray-500">市场价:</div>
|
||||
<div>¥{{ sku.marketPrice }}</div>
|
||||
<div class="text-gray-500">成本价:</div>
|
||||
<div>¥{{ sku.costPrice }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 库存信息 -->
|
||||
<div class="rounded bg-gray-50 p-4">
|
||||
<h3 class="mb-2 border-b pb-2 font-bold text-gray-700">
|
||||
库存信息
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="text-gray-500">库存:</div>
|
||||
<div class="font-bold">{{ sku.stock }} 件</div>
|
||||
<div class="text-gray-500">条码:</div>
|
||||
<div>{{ sku.barCode || '未设置' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 物流信息 -->
|
||||
<div class="rounded bg-gray-50 p-4">
|
||||
<h3 class="mb-2 border-b pb-2 font-bold text-gray-700">
|
||||
物流信息
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="text-gray-500">重量:</div>
|
||||
<div>{{ sku.weight }} kg</div>
|
||||
<div class="text-gray-500">体积:</div>
|
||||
<div>{{ sku.volume }} m³</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分销佣金 -->
|
||||
<div
|
||||
v-if="formData.subCommissionType"
|
||||
class="mt-4 rounded bg-yellow-50 p-4"
|
||||
>
|
||||
<h3 class="mb-2 border-b pb-2 font-bold text-gray-700">
|
||||
分销佣金
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div class="text-gray-500">一级佣金:</div>
|
||||
<div class="font-bold">¥{{ sku.firstBrokeragePrice }}</div>
|
||||
<div class="text-gray-500">二级佣金:</div>
|
||||
<div class="font-bold">¥{{ sku.secondBrokeragePrice }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 规格属性 -->
|
||||
<div
|
||||
v-if="sku.properties && sku.properties.length > 0"
|
||||
class="mt-4"
|
||||
>
|
||||
<h3 class="mb-2 font-bold text-gray-700">规格属性</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ElTag
|
||||
v-for="(prop, propIndex) in sku.properties"
|
||||
:key="propIndex"
|
||||
effect="dark"
|
||||
class="text-sm"
|
||||
>
|
||||
{{ prop.propertyName }}: {{ prop.valueName }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-else description="暂无SKU信息" />
|
||||
</ElTabPane>
|
||||
|
||||
<ElTabPane name="detail" label="商品详情">
|
||||
<ElCard shadow="never" body-style="padding: 0;">
|
||||
<div v-if="formData.description" class="product-description">
|
||||
<div v-html="formData.description"></div>
|
||||
</div>
|
||||
<ElEmpty v-else description="暂无商品详情" />
|
||||
</ElCard>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</ElCard>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-description {
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.product-description :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.product-description :deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.product-description :deep(table td) {
|
||||
padding: 8px;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
</style>
|
||||
@@ -1,177 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { ContentWrap, Page } from '@vben/common-ui';
|
||||
import { convertToInteger, formatToFraction } from '@vben/utils';
|
||||
|
||||
import { ElButton, ElTabs } from 'element-plus';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createSpu, getSpu, updateSpu } from '#/api/mall/product/spu';
|
||||
|
||||
import {
|
||||
useDeliveryFormSchema,
|
||||
useDescriptionFormSchema,
|
||||
useInfoFormSchema,
|
||||
useOtherFormSchema,
|
||||
useSkuFormSchema,
|
||||
} from './form-data';
|
||||
|
||||
const spuId = ref<number>();
|
||||
const { params } = useRoute();
|
||||
|
||||
const activeTab = ref('info');
|
||||
|
||||
const [InfoForm, infoFormApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 120,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useInfoFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [SkuForm, skuFormApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 120,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useSkuFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [DeliveryForm, deliveryFormApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 120,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useDeliveryFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [DescriptionForm, descriptionFormApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 120,
|
||||
},
|
||||
layout: 'vertical',
|
||||
schema: useDescriptionFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [OtherForm, otherFormApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 120,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useOtherFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
const values: MallSpuApi.Spu = await infoFormApi
|
||||
.merge(skuFormApi)
|
||||
.merge(deliveryFormApi)
|
||||
.merge(descriptionFormApi)
|
||||
.merge(otherFormApi)
|
||||
.submitAllForm(true);
|
||||
|
||||
if (values.skus) {
|
||||
values.skus.forEach((item) => {
|
||||
// sku相关价格元转分
|
||||
item.price = convertToInteger(item.price);
|
||||
item.marketPrice = convertToInteger(item.marketPrice);
|
||||
item.costPrice = convertToInteger(item.costPrice);
|
||||
item.firstBrokeragePrice = convertToInteger(item.firstBrokeragePrice);
|
||||
item.secondBrokeragePrice = convertToInteger(item.secondBrokeragePrice);
|
||||
});
|
||||
}
|
||||
// 处理轮播图列表
|
||||
const newSliderPicUrls: any[] = [];
|
||||
values.sliderPicUrls!.forEach((item: any) => {
|
||||
// 如果是前端选的图
|
||||
typeof item === 'object'
|
||||
? newSliderPicUrls.push(item.url)
|
||||
: newSliderPicUrls.push(item);
|
||||
});
|
||||
values.sliderPicUrls = newSliderPicUrls;
|
||||
|
||||
await (spuId.value ? updateSpu(values) : createSpu(values));
|
||||
}
|
||||
|
||||
async function initDate() {
|
||||
spuId.value = params.id as unknown as number;
|
||||
if (!spuId.value) {
|
||||
return;
|
||||
}
|
||||
const res = await getSpu(spuId.value);
|
||||
if (res.skus) {
|
||||
res.skus.forEach((item) => {
|
||||
// 回显价格分转元
|
||||
item.price = formatToFraction(item.price);
|
||||
item.marketPrice = formatToFraction(item.marketPrice);
|
||||
item.costPrice = formatToFraction(item.costPrice);
|
||||
item.firstBrokeragePrice = formatToFraction(item.firstBrokeragePrice);
|
||||
item.secondBrokeragePrice = formatToFraction(item.secondBrokeragePrice);
|
||||
});
|
||||
}
|
||||
infoFormApi.setValues(res);
|
||||
skuFormApi.setValues(res);
|
||||
deliveryFormApi.setValues(res);
|
||||
descriptionFormApi.setValues(res);
|
||||
otherFormApi.setValues(res);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await initDate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<ContentWrap class="h-full w-full pb-8">
|
||||
<template #extra>
|
||||
<ElButton type="primary" @click="onSubmit"> 保存 </ElButton>
|
||||
</template>
|
||||
<ElTabs v-model="activeTab">
|
||||
<ElTabs.TabPane label="基础设置" name="info">
|
||||
<InfoForm class="w-3/5" />
|
||||
</ElTabs.TabPane>
|
||||
<ElTabs.TabPane label="价格库存" name="sku">
|
||||
<SkuForm class="w-3/5" />
|
||||
</ElTabs.TabPane>
|
||||
<ElTabs.TabPane label="物流设置" name="delivery">
|
||||
<DeliveryForm class="w-3/5" />
|
||||
</ElTabs.TabPane>
|
||||
<ElTabs.TabPane label="商品详情" name="description">
|
||||
<DescriptionForm class="w-3/5" />
|
||||
</ElTabs.TabPane>
|
||||
<ElTabs.TabPane label="其它设置" name="other">
|
||||
<OtherForm class="w-3/5" />
|
||||
</ElTabs.TabPane>
|
||||
</ElTabs>
|
||||
</ContentWrap>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -24,6 +24,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
placeholder: '请输入活动名称',
|
||||
},
|
||||
rules: 'required',
|
||||
// TODO @puhui999:这里和 antd 里的不太一样,可以看看。
|
||||
},
|
||||
{
|
||||
fieldName: 'startTime',
|
||||
@@ -222,12 +223,14 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
field: 'bargainFirstPrice',
|
||||
title: '起始价格',
|
||||
minWidth: 100,
|
||||
// TODO @puhui999:这里和 antd 不太一样,得看看
|
||||
formatter: 'formatFenToYuanAmount',
|
||||
},
|
||||
{
|
||||
field: 'bargainMinPrice',
|
||||
title: '砍价底价',
|
||||
minWidth: 100,
|
||||
// TODO @puhui999:这里和 antd 不太一样,得看看
|
||||
formatter: 'formatFenToYuanAmount',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -42,9 +42,16 @@ function handleEdit(row: MallBargainActivityApi.BargainActivity) {
|
||||
/** 关闭砍价活动 */
|
||||
async function handleClose(row: MallBargainActivityApi.BargainActivity) {
|
||||
await confirm('确定关闭该砍价活动吗?');
|
||||
await closeBargainActivity(row.id as number);
|
||||
ElMessage.success('关闭成功');
|
||||
handleRefresh();
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: '关闭中...',
|
||||
});
|
||||
try {
|
||||
await closeBargainActivity(row.id!);
|
||||
ElMessage.success('关闭成功');
|
||||
handleRefresh();
|
||||
} finally {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除砍价活动 */
|
||||
@@ -53,7 +60,7 @@ async function handleDelete(row: MallBargainActivityApi.BargainActivity) {
|
||||
text: $t('ui.actionMessage.deleting', [row.name]),
|
||||
});
|
||||
try {
|
||||
await deleteBargainActivity(row.id as number);
|
||||
await deleteBargainActivity(row.id!);
|
||||
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallBargainActivityApi } from '#/api/mall/promotion/bargain/bargainActivity';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { getSpu } from '#/api/mall/product/spu';
|
||||
import {
|
||||
createBargainActivity,
|
||||
getBargainActivity,
|
||||
updateBargainActivity,
|
||||
} from '#/api/mall/promotion/bargain/bargainActivity';
|
||||
import { $t } from '#/locales';
|
||||
import { SpuSkuSelect } from '#/views/mall/product/spu/components';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
@@ -28,14 +30,55 @@ const getTitle = computed(() => {
|
||||
: $t('ui.actionTitle.create', ['砍价活动']);
|
||||
});
|
||||
|
||||
// ================= 商品选择相关 =================
|
||||
const spuId = ref<number>();
|
||||
const skuId = ref<number>();
|
||||
const spuName = ref<string>('');
|
||||
const skuInfo = ref<{
|
||||
picUrl: string;
|
||||
price: number;
|
||||
skuName: string;
|
||||
}>();
|
||||
|
||||
const spuSkuSelectRef = ref(); // 商品选择弹窗 Ref
|
||||
|
||||
/** 打开商品选择弹窗 */
|
||||
const handleSelectProduct = () => {
|
||||
spuSkuSelectRef.value?.open();
|
||||
};
|
||||
|
||||
/** 选择商品后的回调 */
|
||||
async function handleSpuSelected(selectedSpuId: number, skuIds?: number[]) {
|
||||
const spu = await getSpu(selectedSpuId);
|
||||
if (!spu) return;
|
||||
|
||||
spuId.value = spu.id;
|
||||
spuName.value = spu.name || '';
|
||||
|
||||
// 砍价活动只选择一个 SKU
|
||||
if (skuIds && skuIds.length > 0) {
|
||||
const selectedSku = spu.skus?.find((sku) => sku.id === skuIds[0]);
|
||||
if (selectedSku) {
|
||||
skuId.value = selectedSku.id;
|
||||
skuInfo.value = {
|
||||
skuName: selectedSku.name || '',
|
||||
picUrl: selectedSku.picUrl || spu.picUrl || '',
|
||||
price: Number(selectedSku.price) || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================= end =================
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 120,
|
||||
labelWidth: 130,
|
||||
},
|
||||
wrapperClass: 'grid-cols-2',
|
||||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
@@ -47,11 +90,34 @@ const [Modal, modalApi] = useVbenModal({
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
|
||||
// 验证商品和 SKU 选择
|
||||
if (!spuId.value) {
|
||||
ElMessage.error('请选择砍价商品');
|
||||
return;
|
||||
}
|
||||
if (!skuId.value) {
|
||||
ElMessage.error('请选择商品 SKU');
|
||||
return;
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const data =
|
||||
(await formApi.getValues()) as MallBargainActivityApi.BargainActivity;
|
||||
modalApi.lock();
|
||||
try {
|
||||
const values = await formApi.getValues();
|
||||
const data = {
|
||||
...values,
|
||||
spuId: spuId.value,
|
||||
skuId: skuId.value,
|
||||
bargainFirstPrice: Math.round((values.bargainFirstPrice || 0) * 100),
|
||||
bargainMinPrice: Math.round((values.bargainMinPrice || 0) * 100),
|
||||
randomMinPrice: values.randomMinPrice
|
||||
? Math.round(values.randomMinPrice * 100)
|
||||
: undefined,
|
||||
randomMaxPrice: values.randomMaxPrice
|
||||
? Math.round(values.randomMaxPrice * 100)
|
||||
: undefined,
|
||||
} as MallBargainActivityApi.BargainActivity;
|
||||
await (formData.value?.id
|
||||
? updateBargainActivity(data)
|
||||
: createBargainActivity(data));
|
||||
@@ -66,9 +132,13 @@ const [Modal, modalApi] = useVbenModal({
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
spuId.value = undefined;
|
||||
skuId.value = undefined;
|
||||
spuName.value = '';
|
||||
skuInfo.value = undefined;
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
// 加载表单数据
|
||||
const data = modalApi.getData<MallBargainActivityApi.BargainActivity>();
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
@@ -76,8 +146,40 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getBargainActivity(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
await nextTick();
|
||||
// 设置表单值时,价格字段从分转换为元
|
||||
await formApi.setValues({
|
||||
...formData.value,
|
||||
bargainFirstPrice: (formData.value.bargainFirstPrice || 0) / 100,
|
||||
bargainMinPrice: (formData.value.bargainMinPrice || 0) / 100,
|
||||
randomMinPrice: formData.value.randomMinPrice
|
||||
? formData.value.randomMinPrice / 100
|
||||
: undefined,
|
||||
randomMaxPrice: formData.value.randomMaxPrice
|
||||
? formData.value.randomMaxPrice / 100
|
||||
: undefined,
|
||||
});
|
||||
// 加载商品和 SKU 信息
|
||||
if (formData.value.spuId) {
|
||||
const spu = await getSpu(formData.value.spuId);
|
||||
if (spu) {
|
||||
spuId.value = spu.id;
|
||||
spuName.value = spu.name || '';
|
||||
if (formData.value.skuId) {
|
||||
const selectedSku = spu.skus?.find(
|
||||
(sku) => sku.id === formData.value?.skuId,
|
||||
);
|
||||
if (selectedSku) {
|
||||
skuId.value = selectedSku.id;
|
||||
skuInfo.value = {
|
||||
skuName: selectedSku.name || '',
|
||||
picUrl: selectedSku.picUrl || spu.picUrl || '',
|
||||
price: Number(selectedSku.price) || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
@@ -86,7 +188,74 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-2/5" :title="getTitle">
|
||||
<Form class="mx-4" />
|
||||
<Modal class="w-3/5" :title="getTitle">
|
||||
<div class="mx-4">
|
||||
<Form />
|
||||
|
||||
<!-- 商品选择区域 -->
|
||||
<div class="mt-4">
|
||||
<div class="mb-2 flex items-center">
|
||||
<span class="text-sm font-medium">砍价活动商品:</span>
|
||||
<el-button class="ml-2" type="primary" @click="handleSelectProduct">
|
||||
选择商品
|
||||
</el-button>
|
||||
<span v-if="spuName" class="ml-4 text-sm text-gray-600">
|
||||
已选择: {{ spuName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- SKU 信息展示 -->
|
||||
<div v-if="skuInfo" class="mt-4">
|
||||
<table class="w-full border-collapse border border-gray-300">
|
||||
<!-- TODO @puhui999:和 element-plus 有点差别哈;ps:是不是用 grid 组件呀?或者 vxe 组件
|
||||
图片
|
||||
颜色
|
||||
版本
|
||||
商品条码
|
||||
销售价(元)
|
||||
市场价(元)
|
||||
成本价(元)
|
||||
库存
|
||||
砍价起始价格(元)
|
||||
砍价底价(元)
|
||||
活动库存
|
||||
-->
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center">
|
||||
<img
|
||||
v-if="skuInfo.picUrl"
|
||||
:src="skuInfo.picUrl"
|
||||
alt="商品图片"
|
||||
class="h-16 w-16 object-cover"
|
||||
/>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2">
|
||||
{{ skuInfo.skuName }}
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center">
|
||||
¥{{ (skuInfo.price / 100).toFixed(2) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- 商品选择器弹窗(单选模式) -->
|
||||
<SpuSkuSelect
|
||||
ref="spuSkuSelectRef"
|
||||
:is-select-sku="true"
|
||||
:radio="true"
|
||||
@select="handleSpuSelected"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -75,12 +75,14 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
field: 'activity.bargainMinPrice',
|
||||
title: '最低价',
|
||||
minWidth: 100,
|
||||
// TODO @puhui999:这里和 antd 不同
|
||||
formatter: 'formatFenToYuanAmount',
|
||||
},
|
||||
{
|
||||
field: 'bargainPrice',
|
||||
title: '当前价',
|
||||
minWidth: 100,
|
||||
// TODO @puhui999:这里和 antd 不同
|
||||
formatter: 'formatFenToYuanAmount',
|
||||
},
|
||||
{
|
||||
@@ -152,6 +154,7 @@ export function useHelpGridColumns(): VxeTableGridOptions['columns'] {
|
||||
field: 'reducePrice',
|
||||
title: '砍价金额',
|
||||
minWidth: 100,
|
||||
// TODO @puhui999:这里和 antd 不同
|
||||
formatter: 'formatFenToYuanAmount',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -115,12 +115,6 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
|
||||
},
|
||||
},
|
||||
{
|
||||
// TODO
|
||||
fieldName: 'spuId',
|
||||
label: '拼团商品',
|
||||
component: 'Input',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -43,10 +43,17 @@ function handleEdit(row: MallCombinationActivityApi.CombinationActivity) {
|
||||
async function handleClose(
|
||||
row: MallCombinationActivityApi.CombinationActivity,
|
||||
) {
|
||||
await confirm('确定关闭该拼团活动吗?');
|
||||
await closeCombinationActivity(row.id as number);
|
||||
ElMessage.success('关闭成功');
|
||||
handleRefresh();
|
||||
await confirm('确认关闭该拼团活动吗?');
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: '关闭中...',
|
||||
});
|
||||
try {
|
||||
await closeCombinationActivity(row.id!);
|
||||
ElMessage.success('关闭成功');
|
||||
handleRefresh();
|
||||
} finally {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除拼团活动 */
|
||||
@@ -57,7 +64,7 @@ async function handleDelete(
|
||||
text: $t('ui.actionMessage.deleting', [row.name]),
|
||||
});
|
||||
try {
|
||||
await deleteCombinationActivity(row.id as number);
|
||||
await deleteCombinationActivity(row.id!);
|
||||
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallCombinationActivityApi } from '#/api/mall/promotion/combination/combinationActivity';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
|
||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { getSpu } from '#/api/mall/product/spu';
|
||||
import {
|
||||
createCombinationActivity,
|
||||
getCombinationActivity,
|
||||
updateCombinationActivity,
|
||||
} from '#/api/mall/promotion/combination/combinationActivity';
|
||||
import { $t } from '#/locales';
|
||||
import { SpuSkuSelect } from '#/views/mall/product/spu/components';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
@@ -20,12 +22,50 @@ defineOptions({ name: 'CombinationActivityForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<MallCombinationActivityApi.CombinationActivity>();
|
||||
|
||||
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 spuSkuSelectRef = ref(); // 商品选择弹窗 Ref
|
||||
|
||||
/** 打开商品选择弹窗 */
|
||||
const handleSelectProduct = () => {
|
||||
spuSkuSelectRef.value?.open();
|
||||
};
|
||||
|
||||
/** 选择商品后的回调 */
|
||||
async function handleSpuSelected(selectedSpuId: number, skuIds?: number[]) {
|
||||
const spu = await getSpu(selectedSpuId);
|
||||
if (!spu) return;
|
||||
|
||||
spuId.value = spu.id;
|
||||
spuName.value = spu.name || '';
|
||||
|
||||
// 筛选指定的 SKU
|
||||
const selectedSkus = skuIds
|
||||
? spu.skus?.filter((sku) => skuIds.includes(sku.id!))
|
||||
: spu.skus;
|
||||
|
||||
skuTableData.value =
|
||||
selectedSkus?.map((sku) => ({
|
||||
skuId: sku.id!,
|
||||
skuName: sku.name || '',
|
||||
picUrl: sku.picUrl || spu.picUrl || '',
|
||||
price: sku.price || 0,
|
||||
combinationPrice: 0,
|
||||
})) || [];
|
||||
}
|
||||
|
||||
// ================= end =================
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
@@ -45,11 +85,37 @@ const [Modal, modalApi] = useVbenModal({
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
|
||||
// 验证商品和 SKU 配置
|
||||
if (!spuId.value) {
|
||||
ElMessage.error('请选择拼团商品');
|
||||
return;
|
||||
}
|
||||
if (skuTableData.value.length === 0) {
|
||||
ElMessage.error('请至少配置一个 SKU');
|
||||
return;
|
||||
}
|
||||
// 验证 SKU 配置
|
||||
const hasInvalidSku = skuTableData.value.some(
|
||||
(sku) => sku.combinationPrice < 0.01,
|
||||
);
|
||||
if (hasInvalidSku) {
|
||||
ElMessage.error('请正确配置 SKU 的拼团价格(≥0.01)');
|
||||
return;
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const data =
|
||||
(await formApi.getValues()) as MallCombinationActivityApi.CombinationActivity;
|
||||
modalApi.lock();
|
||||
try {
|
||||
const values = await formApi.getValues();
|
||||
const data: any = {
|
||||
...values,
|
||||
spuId: spuId.value,
|
||||
products: skuTableData.value.map((sku) => ({
|
||||
skuId: sku.skuId,
|
||||
combinationPrice: Math.round(sku.combinationPrice * 100), // 转换为分
|
||||
})),
|
||||
};
|
||||
await (formData.value?.id
|
||||
? updateCombinationActivity(data)
|
||||
: createCombinationActivity(data));
|
||||
@@ -64,6 +130,9 @@ const [Modal, modalApi] = useVbenModal({
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
spuId.value = undefined;
|
||||
spuName.value = '';
|
||||
skuTableData.value = [];
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
@@ -75,8 +144,32 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getCombinationActivity(data.id);
|
||||
// 设置到 values
|
||||
await nextTick();
|
||||
await formApi.setValues(formData.value);
|
||||
// 加载商品和 SKU 信息
|
||||
// TODO @puhui999:if return,简化括号层级
|
||||
if (formData.value.spuId) {
|
||||
const spu = await getSpu(formData.value.spuId);
|
||||
if (spu) {
|
||||
spuId.value = spu.id;
|
||||
spuName.value = spu.name || '';
|
||||
// 回填 SKU 配置
|
||||
const products = formData.value.products || [];
|
||||
skuTableData.value =
|
||||
spu.skus
|
||||
?.filter((sku) => products.some((p) => p.skuId === sku.id))
|
||||
.map((sku) => {
|
||||
const product = products.find((p) => p.skuId === sku.id);
|
||||
return {
|
||||
skuId: sku.id!,
|
||||
skuName: sku.name || '',
|
||||
picUrl: sku.picUrl || spu.picUrl || '',
|
||||
price: sku.price || 0,
|
||||
combinationPrice: (product?.combinationPrice || 0) / 100, // 分转元
|
||||
};
|
||||
}) || [];
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
@@ -85,7 +178,80 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-3/5" :title="getTitle">
|
||||
<Form />
|
||||
<Modal class="w-4/5" :title="getTitle">
|
||||
<div class="mx-4">
|
||||
<Form />
|
||||
|
||||
<!-- 商品选择区域 -->
|
||||
<div class="mt-4">
|
||||
<div class="mb-2 flex items-center">
|
||||
<span class="text-sm font-medium">拼团活动商品:</span>
|
||||
<ElButton class="ml-2" type="primary" @click="handleSelectProduct">
|
||||
选择商品
|
||||
</ElButton>
|
||||
<span v-if="spuName" class="ml-4 text-sm text-gray-600">
|
||||
已选择: {{ spuName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
<!-- TODO @puhui999:这里貌似和 element-plus 没对齐;;ps:是不是用 grid 组件呀?或者 vxe 组件
|
||||
图片
|
||||
商品条码
|
||||
销售价(元)
|
||||
市场价(元)
|
||||
成本价(元)
|
||||
库存
|
||||
拼团价格(元)
|
||||
-->
|
||||
<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">
|
||||
<!-- TODO @puhui999:是不是要使用 antd 的哈? -->
|
||||
<input
|
||||
v-model.number="sku.combinationPrice"
|
||||
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"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -111,7 +111,6 @@ function emitActivityChange() {
|
||||
>
|
||||
<ElTooltip :content="activity.name">
|
||||
<div class="relative h-full w-full">
|
||||
<!-- TODO @芋艿 -->
|
||||
<ElImage
|
||||
:src="activity.picUrl"
|
||||
class="h-full w-full rounded-lg object-cover"
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { ElDialog } from 'element-plus';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getCouponTemplatePage } from '#/api/mall/promotion/coupon/couponTemplate';
|
||||
@@ -15,8 +17,11 @@ const props = defineProps<{
|
||||
takeType?: number; // 领取方式
|
||||
}>();
|
||||
|
||||
// TODO @puhui999:这个也要调整,和 antd 保持统一。
|
||||
const emit = defineEmits(['success']);
|
||||
const emit = defineEmits<{
|
||||
(e: 'change', v: MallCouponTemplateApi.CouponTemplate[]): void;
|
||||
}>();
|
||||
|
||||
const visible = ref(false); // 弹窗显示状态
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
@@ -49,19 +54,44 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
} as VxeTableGridOptions<MallCouponTemplateApi.CouponTemplate>,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
// 从 gridApi 获取选中的记录
|
||||
const selectedRecords = (gridApi.grid?.getCheckboxRecords() ||
|
||||
[]) as MallCouponTemplateApi.CouponTemplate[];
|
||||
await modalApi.close();
|
||||
emit('success', selectedRecords);
|
||||
},
|
||||
/** 打开弹窗 */
|
||||
async function open() {
|
||||
visible.value = true;
|
||||
await gridApi.query();
|
||||
}
|
||||
|
||||
/** 确认选择 */
|
||||
function handleConfirm() {
|
||||
const selectedRecords = (gridApi.grid?.getCheckboxRecords() ||
|
||||
[]) as MallCouponTemplateApi.CouponTemplate[];
|
||||
emit('change', selectedRecords);
|
||||
closeModal();
|
||||
}
|
||||
|
||||
/** 关闭弹窗 */
|
||||
function closeModal() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
/** 对外暴露的方法 */
|
||||
defineExpose({
|
||||
open,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="选择优惠券" class="w-2/3">
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
title="选择优惠券"
|
||||
width="65%"
|
||||
:destroy-on-close="true"
|
||||
:append-to-body="true"
|
||||
@close="closeModal"
|
||||
>
|
||||
<Grid />
|
||||
</Modal>
|
||||
<template #footer>
|
||||
<el-button @click="closeModal">取消</el-button>
|
||||
<el-button type="primary" @click="handleConfirm">确定</el-button>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
@@ -73,8 +73,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
:actions="[
|
||||
{
|
||||
label: '发送',
|
||||
type: 'primary',
|
||||
link: true,
|
||||
type: 'text',
|
||||
auth: ['promotion:coupon:send'],
|
||||
onClick: () => handleSendCoupon(row),
|
||||
},
|
||||
|
||||
@@ -70,7 +70,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
rows: 4,
|
||||
},
|
||||
},
|
||||
// TODO
|
||||
// TODO @puhui999:这里和 antd 对应的不太一样;
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -41,11 +41,17 @@ function handleEdit(row: MallDiscountActivityApi.DiscountActivity) {
|
||||
|
||||
/** 关闭满减活动 */
|
||||
async function handleClose(row: MallDiscountActivityApi.DiscountActivity) {
|
||||
await confirm('确定关闭该限时折扣活动吗?');
|
||||
await closeDiscountActivity(row.id as number);
|
||||
ElMessage.success('关闭成功');
|
||||
handleRefresh();
|
||||
}
|
||||
await confirm('确认关闭该限时折扣活动吗?');
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: '正在关闭中...',
|
||||
});
|
||||
try {
|
||||
await closeDiscountActivity(row.id as number);
|
||||
ElMessage.success('关闭成功');
|
||||
handleRefresh();
|
||||
} finally {
|
||||
loadingInstance.close();
|
||||
}
|
||||
|
||||
/** 删除满减活动 */
|
||||
async function handleDelete(row: MallDiscountActivityApi.DiscountActivity) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'DiscountActivityForm' });
|
||||
|
||||
// TODO @puhui999:这里和 yudao-ui-admin-vben-v5/apps/web-antd/src/views/mall/promotion/discountActivity/modules/form.vue 不太一样
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<MallDiscountActivityApi.DiscountActivity>();
|
||||
const getTitle = computed(() => {
|
||||
@@ -69,6 +70,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
// TODO @puhui999:这里和 yudao-ui-admin-vben-v5/apps/web-antd/src/views/mall/promotion/discountActivity/modules/form.vue 不太一样
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
@@ -91,6 +93,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
|
||||
<template>
|
||||
<Modal class="w-3/5" :title="getTitle">
|
||||
<!-- TODO @puhui999:这里和 yudao-ui-admin-vben-v5/apps/web-antd/src/views/mall/promotion/discountActivity/modules/form.vue 不太一样 -->
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -3,50 +3,7 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
/** 表单配置 */
|
||||
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,
|
||||
controlsPosition: 'right',
|
||||
class: '!w-full',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入备注',
|
||||
rows: 4,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
import { fenToYuan } from '@vben/utils';
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
@@ -56,15 +13,15 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
||||
label: '活动状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
placeholder: '请选择活动状态',
|
||||
clearable: true,
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
/** 列表的表格列 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
@@ -78,6 +35,9 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
minWidth: 80,
|
||||
cellRender: {
|
||||
name: 'CellImage',
|
||||
props: {
|
||||
height: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -89,18 +49,7 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
field: 'marketPrice',
|
||||
title: '原价',
|
||||
minWidth: 100,
|
||||
formatter: 'formatFenToYuanAmount',
|
||||
},
|
||||
{
|
||||
field: 'point',
|
||||
title: '兑换积分',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'price',
|
||||
title: '兑换金额',
|
||||
minWidth: 100,
|
||||
formatter: 'formatFenToYuanAmount',
|
||||
formatter: ({ row }) => `¥${fenToYuan(row.marketPrice)}`,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
@@ -125,7 +74,9 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
field: 'redeemedQuantity',
|
||||
title: '已兑换数量',
|
||||
minWidth: 100,
|
||||
slots: { default: 'redeemedQuantity' },
|
||||
formatter: ({ row }) => {
|
||||
return (row.totalStock || 0) - (row.stock || 0);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
@@ -141,3 +92,47 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 新增/修改的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
placeholder: '请输入排序',
|
||||
controlsPosition: 'right',
|
||||
class: '!w-full',
|
||||
},
|
||||
defaultValue: 0,
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入备注',
|
||||
rows: 4,
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
},
|
||||
{
|
||||
fieldName: 'spuId',
|
||||
label: '活动商品',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
formItemClass: 'col-span-2',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<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 { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { ElLoading, ElMessage } from 'element-plus';
|
||||
|
||||
@@ -17,51 +14,52 @@ 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' });
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/** 创建积分活动 */
|
||||
/** 创建积分商城活动 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/** 编辑积分活动 */
|
||||
function handleEdit(row: MallPointActivityApi.PointActivity) {
|
||||
/** 编辑积分商城活动 */
|
||||
function handleEdit(row: any) {
|
||||
formModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 关闭积分活动 */
|
||||
async function handleClose(row: MallPointActivityApi.PointActivity) {
|
||||
await confirm('确认关闭该积分商城活动吗?');
|
||||
await closePointActivity(row.id);
|
||||
ElMessage.success('关闭成功');
|
||||
handleRefresh();
|
||||
/** 关闭积分商城活动 */
|
||||
async function handleClose(row: any) {
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: '正在关闭中...',
|
||||
});
|
||||
try {
|
||||
await closePointActivity(row.id);
|
||||
ElMessage.success('关闭成功');
|
||||
handleRefresh();
|
||||
} finally {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除积分活动 */
|
||||
async function handleDelete(row: MallPointActivityApi.PointActivity) {
|
||||
/** 删除积分商城活动 */
|
||||
async function handleDelete(row: any) {
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: $t('ui.actionMessage.deleting', [row.spuName]),
|
||||
});
|
||||
try {
|
||||
await deletePointActivity(row.id);
|
||||
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.spuName]));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
loadingInstance.close();
|
||||
@@ -95,21 +93,13 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
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>
|
||||
|
||||
<FormModal @success="handleRefresh" />
|
||||
|
||||
<Grid table-title="积分商城活动列表">
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
@@ -124,9 +114,6 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #redeemedQuantity="{ row }">
|
||||
{{ getRedeemedQuantity(row) }}
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
@@ -142,17 +129,21 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
label: '关闭',
|
||||
type: 'danger',
|
||||
link: true,
|
||||
auth: ['promotion:point-activity:close'],
|
||||
icon: ACTION_ICON.CLOSE,
|
||||
ifShow: row.status === 0,
|
||||
onClick: handleClose.bind(null, row),
|
||||
auth: ['promotion:point-activity:close'],
|
||||
popConfirm: {
|
||||
title: '确认关闭该积分商城活动吗?',
|
||||
confirm: handleClose.bind(null, row),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'danger',
|
||||
link: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['promotion:point-activity:delete'],
|
||||
ifShow: row.status !== 0,
|
||||
auth: ['promotion:point-activity:delete'],
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.spuName]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||
import type { MallPointActivityApi } from '#/api/mall/promotion/point';
|
||||
import type {
|
||||
RuleConfig,
|
||||
SpuProperty,
|
||||
} from '#/views/mall/product/spu/components/type';
|
||||
|
||||
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 { ElMessage } from 'element-plus';
|
||||
import { ElButton, ElInputNumber, ElMessage } from 'element-plus';
|
||||
|
||||
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 {
|
||||
SpuAndSkuList,
|
||||
SpuSkuSelect,
|
||||
} from '#/views/mall/product/spu/components';
|
||||
// TODO @puhui999:getPropertyList 在 antd 和 el 导入的路径不同;是不是要统一下哈;
|
||||
import { getPropertyList } from '#/views/mall/product/spu/components/property-util';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
@@ -29,13 +44,112 @@ const [Form, formApi] = useVbenForm({
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
labelWidth: 120,
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 100,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
// ================= 商品选择相关 =================
|
||||
|
||||
const spuSkuSelectRef = ref(); // 商品和属性选择 Ref
|
||||
const spuAndSkuListRef = ref(); // SPU 和 SKU 列表组件 Ref
|
||||
|
||||
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 !!!',
|
||||
},
|
||||
]; // SKU 规则配置
|
||||
|
||||
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?: MallPointActivityApi.PointProduct[],
|
||||
) {
|
||||
const res = await getSpu(spuId);
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO @puhui999:这里的 spuList,是不是直接放到 145 行就 ok 啦;(尾部);
|
||||
spuList.value = [];
|
||||
|
||||
// 筛选指定的 SKU
|
||||
const selectSkus =
|
||||
skuIds === undefined
|
||||
? res.skus
|
||||
: res.skus?.filter((sku) => skuIds.includes(sku.id!));
|
||||
// 为每个 SKU 配置积分商城相关的配置
|
||||
selectSkus?.forEach((sku) => {
|
||||
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;
|
||||
}
|
||||
// 动态添加 productConfig 属性到 SKU
|
||||
(
|
||||
sku as MallSpuApi.Sku & {
|
||||
productConfig: MallPointActivityApi.PointProduct;
|
||||
}
|
||||
).productConfig = config;
|
||||
});
|
||||
res.skus = selectSkus;
|
||||
|
||||
// TODO @puhui999:这里的逻辑,是不是放到 147 行(尾部);
|
||||
const spuProperties: SpuProperty<MallSpuApi.Spu>[] = [
|
||||
{
|
||||
spuId: res.id!,
|
||||
spuDetail: res,
|
||||
propertyList: getPropertyList(res),
|
||||
},
|
||||
]; // 构建 SPU 属性列表
|
||||
|
||||
// 直接赋值,因为每次只选择一个 SPU
|
||||
spuList.value = [res];
|
||||
spuPropertyList.value = spuProperties;
|
||||
}
|
||||
|
||||
// ================= end =================
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
@@ -43,19 +157,19 @@ const [Modal, modalApi] = useVbenModal({
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data =
|
||||
(await formApi.getValues()) as MallPointActivityApi.PointActivity;
|
||||
|
||||
// 确保必要的默认值
|
||||
if (!data.products) {
|
||||
data.products = [];
|
||||
}
|
||||
if (!data.sort) {
|
||||
data.sort = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取积分商城商品配置(深拷贝避免直接修改原对象)
|
||||
const products: MallPointActivityApi.PointProduct[] = cloneDeep(
|
||||
spuAndSkuListRef.value?.getSkuConfigs('productConfig') || [],
|
||||
);
|
||||
// 价格需要转为分
|
||||
products.forEach((item) => {
|
||||
item.price = convertToInteger(item.price);
|
||||
});
|
||||
// 提交表单
|
||||
const data =
|
||||
(await formApi.getValues()) as MallPointActivityApi.PointActivity;
|
||||
data.products = products;
|
||||
await (formData.value?.id
|
||||
? updatePointActivity(data)
|
||||
: createPointActivity(data));
|
||||
@@ -70,16 +184,25 @@ const [Modal, modalApi] = useVbenModal({
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
spuList.value = [];
|
||||
spuPropertyList.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
const data = modalApi.getData<MallPointActivityApi.PointActivity>();
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getPointActivity(data.id);
|
||||
await getSpuDetails(
|
||||
formData.value.spuId,
|
||||
formData.value.products?.map((sku) => sku.skuId),
|
||||
formData.value.products,
|
||||
);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
@@ -90,16 +213,85 @@ 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>
|
||||
<div>
|
||||
<Modal :title="getTitle" class="w-[70%]">
|
||||
<Form class="mx-4">
|
||||
<!-- 商品选择 -->
|
||||
<template #spuId>
|
||||
<div class="w-full">
|
||||
<ElButton
|
||||
v-if="!formData?.id"
|
||||
type="primary"
|
||||
@click="openSpuSelect"
|
||||
>
|
||||
选择商品
|
||||
</ElButton>
|
||||
|
||||
<!-- 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 }">
|
||||
<ElInputNumber
|
||||
v-model="sku.productConfig.stock"
|
||||
:max="sku.stock"
|
||||
:min="0"
|
||||
class="w-full"
|
||||
controls-position="right"
|
||||
/>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" min-width="168" title="可兑换次数">
|
||||
<template #default="{ row: sku }">
|
||||
<ElInputNumber
|
||||
v-model="sku.productConfig.count"
|
||||
:min="0"
|
||||
class="w-full"
|
||||
controls-position="right"
|
||||
/>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" min-width="168" title="所需积分">
|
||||
<template #default="{ row: sku }">
|
||||
<ElInputNumber
|
||||
v-model="sku.productConfig.point"
|
||||
:min="0"
|
||||
class="w-full"
|
||||
controls-position="right"
|
||||
/>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
<VxeColumn align="center" min-width="168" title="所需金额(元)">
|
||||
<template #default="{ row: sku }">
|
||||
<ElInputNumber
|
||||
v-model="sku.productConfig.price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
class="w-full"
|
||||
controls-position="right"
|
||||
/>
|
||||
</template>
|
||||
</VxeColumn>
|
||||
</template>
|
||||
</SpuAndSkuList>
|
||||
</div>
|
||||
</template>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<!-- 商品选择器弹窗 -->
|
||||
<SpuSkuSelect
|
||||
ref="spuSkuSelectRef"
|
||||
:is-select-sku="true"
|
||||
@select="handleSpuSelected"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default as PointShowcase } from './showcase.vue';
|
||||
export { default as PointTableSelect } from './table-select.vue';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<!-- 积分商城活动橱窗组件:用于展示和选择积分商城活动 -->
|
||||
<!-- TODO @puhui999:antd 多了一个,看着 ele 要迁移下? --->
|
||||
<script lang="ts" setup>
|
||||
import type { MallPointActivityApi } from '#/api/mall/promotion/point';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<!-- 积分商城活动选择弹窗组件 -->
|
||||
<!-- TODO @puhui999:antd 多了一个,看着 ele 要迁移下? --->
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
@@ -1,84 +1,16 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import {
|
||||
DICT_TYPE,
|
||||
PromotionConditionTypeEnum,
|
||||
PromotionProductScopeEnum,
|
||||
} 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',
|
||||
class: '!w-full',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'endTime',
|
||||
label: '结束时间',
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
placeholder: '请选择结束时间',
|
||||
showTime: true,
|
||||
valueFormat: 'x',
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
class: '!w-full',
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
import { z } from '#/adapter/form';
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
@@ -89,7 +21,7 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入活动名称',
|
||||
clearable: true,
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -97,9 +29,9 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
||||
label: '活动状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '请选择活动状态',
|
||||
clearable: true,
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
placeholder: '请选择活动状态',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -107,26 +39,25 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
||||
label: '活动时间',
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
placeholder: ['活动开始日期', '活动结束日期'],
|
||||
clearable: true,
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
...getRangePickerDefaultProps(),
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
/** 列表的表格列 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'name',
|
||||
title: '活动名称',
|
||||
minWidth: 140,
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'productScope',
|
||||
title: '活动范围',
|
||||
minWidth: 100,
|
||||
minWidth: 120,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.PROMOTION_PRODUCT_SCOPE },
|
||||
@@ -161,9 +92,160 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 180,
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 新增/修改的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'id',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '活动名称',
|
||||
component: 'Input',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
placeholder: '请输入活动名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入备注',
|
||||
rows: 4,
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'startAndEndTime',
|
||||
label: '活动时间',
|
||||
component: 'RangePicker',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
showTime: true,
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
placeholder: [
|
||||
$t('utils.rangePicker.beginTime'),
|
||||
$t('utils.rangePicker.endTime'),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'conditionType',
|
||||
label: '条件类型',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.PROMOTION_CONDITION_TYPE, 'number'),
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: z.number().default(PromotionConditionTypeEnum.PRICE.type),
|
||||
},
|
||||
{
|
||||
fieldName: 'productScope',
|
||||
label: '活动范围',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.PROMOTION_PRODUCT_SCOPE, 'number'),
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: z.number().default(PromotionProductScopeEnum.ALL.scope),
|
||||
},
|
||||
{
|
||||
fieldName: 'productSpuIds',
|
||||
label: '选择商品',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: ['productScope', 'productScopeValues'],
|
||||
show: (values) => {
|
||||
return values.productScope === PromotionProductScopeEnum.SPU.scope;
|
||||
},
|
||||
trigger(values, form) {
|
||||
// 当加载已有数据时,根据 productScopeValues 设置 productSpuIds
|
||||
if (
|
||||
values.productScope === PromotionProductScopeEnum.SPU.scope &&
|
||||
values.productScopeValues
|
||||
) {
|
||||
form.setFieldValue('productSpuIds', values.productScopeValues);
|
||||
}
|
||||
},
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'productCategoryIds',
|
||||
label: '选择分类',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: ['productScope', 'productScopeValues'],
|
||||
show: (values) => {
|
||||
return (
|
||||
values.productScope === PromotionProductScopeEnum.CATEGORY.scope
|
||||
);
|
||||
},
|
||||
trigger(values, form) {
|
||||
// 当加载已有数据时,根据 productScopeValues 设置 productCategoryIds
|
||||
if (
|
||||
values.productScope === PromotionProductScopeEnum.CATEGORY.scope &&
|
||||
values.productScopeValues
|
||||
) {
|
||||
const categoryIds = values.productScopeValues;
|
||||
// 单选时使用数组不能反显,取第一个元素
|
||||
form.setFieldValue(
|
||||
'productCategoryIds',
|
||||
Array.isArray(categoryIds) && categoryIds.length > 0
|
||||
? categoryIds[0]
|
||||
: categoryIds,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'rules',
|
||||
label: '优惠设置',
|
||||
component: 'Input',
|
||||
formItemClass: 'items-start',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'productScopeValues', // 隐藏字段:用于自动同步 productScopeValues
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: ['productScope', 'productSpuIds', 'productCategoryIds'],
|
||||
show: () => false,
|
||||
trigger(values, form) {
|
||||
switch (values.productScope) {
|
||||
case PromotionProductScopeEnum.CATEGORY.scope: {
|
||||
const categoryIds = Array.isArray(values.productCategoryIds)
|
||||
? values.productCategoryIds
|
||||
: [values.productCategoryIds];
|
||||
form.setFieldValue('productScopeValues', categoryIds);
|
||||
break;
|
||||
}
|
||||
case PromotionProductScopeEnum.SPU.scope: {
|
||||
form.setFieldValue('productScopeValues', values.productSpuIds);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
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 { CommonStatusEnum } from '@vben/constants';
|
||||
|
||||
import { ElLoading, ElMessage } from 'element-plus';
|
||||
|
||||
@@ -15,12 +16,12 @@ 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' });
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: RewardActivityForm,
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
@@ -36,24 +37,29 @@ function handleCreate() {
|
||||
|
||||
/** 编辑满减送活动 */
|
||||
function handleEdit(row: MallRewardActivityApi.RewardActivity) {
|
||||
formModalApi.setData(row).open();
|
||||
formModalApi.setData({ id: row.id }).open();
|
||||
}
|
||||
|
||||
/** 关闭活动 */
|
||||
/** 关闭满减送活动 */
|
||||
async function handleClose(row: MallRewardActivityApi.RewardActivity) {
|
||||
await confirm('确认关闭该满减送活动吗?');
|
||||
await closeRewardActivity(row.id as number);
|
||||
ElMessage.success('关闭成功');
|
||||
handleRefresh();
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: $t('ui.actionMessage.closing', [row.name]),
|
||||
});
|
||||
try {
|
||||
await closeRewardActivity(row.id!);
|
||||
ElMessage.success($t('ui.actionMessage.closeSuccess', [row.name]));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除活动 */
|
||||
async function handleDelete(row: MallRewardActivityApi.RewardActivity) {
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: $t('ui.actionMessage.deleting', [row.name]),
|
||||
});
|
||||
try {
|
||||
await deleteRewardActivity(row.id as number);
|
||||
await deleteRewardActivity(row.id!);
|
||||
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
@@ -94,21 +100,13 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<template #doc>
|
||||
<DocAlert
|
||||
title="【营销】满减送"
|
||||
url="https://doc.iocoder.cn/mall/promotion-record/"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<FormModal @success="handleRefresh" />
|
||||
|
||||
<Grid table-title="满减送活动列表">
|
||||
<Grid table-title="满减送活动">
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('ui.actionTitle.create', ['满减送活动']),
|
||||
label: $t('ui.actionTitle.create', ['活动']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['promotion:reward-activity:create'],
|
||||
@@ -122,28 +120,30 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'primary',
|
||||
link: true,
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['promotion:reward-activity:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
// TODO @puhui999:下面两个按钮,type、danger 属性无效,应该是 el 不是这个哈。
|
||||
{
|
||||
label: '关闭',
|
||||
type: 'danger',
|
||||
link: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.CLOSE,
|
||||
auth: ['promotion:reward-activity:close'],
|
||||
ifShow: row.status === 0,
|
||||
onClick: handleClose.bind(null, row),
|
||||
ifShow: row.status === CommonStatusEnum.ENABLE,
|
||||
popConfirm: {
|
||||
title: '确认关闭该满减送活动吗?',
|
||||
confirm: handleClose.bind(null, row),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'danger',
|
||||
link: true,
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['promotion:reward-activity:delete'],
|
||||
ifShow: row.status !== 0,
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
|
||||
@@ -3,25 +3,39 @@ import type { MallRewardActivityApi } from '#/api/mall/promotion/reward/rewardAc
|
||||
|
||||
import { computed, 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 { ElMessage } from 'element-plus';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
createRewardActivity,
|
||||
getReward,
|
||||
updateRewardActivity,
|
||||
} from '#/api/mall/promotion/reward/rewardActivity';
|
||||
import { $t } from '#/locales';
|
||||
import { ProductCategorySelect } from '#/views/mall/product/category/components';
|
||||
import { SpuShowcase } from '#/views/mall/product/spu/components';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
import RewardRule from './reward-rule.vue';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<MallRewardActivityApi.RewardActivity>();
|
||||
|
||||
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', ['满减送活动'])
|
||||
: $t('ui.actionTitle.create', ['满减送活动']);
|
||||
? $t('ui.actionTitle.edit', ['满减送'])
|
||||
: $t('ui.actionTitle.create', ['满减送']);
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
@@ -31,7 +45,6 @@ const [Form, formApi] = useVbenForm({
|
||||
},
|
||||
labelWidth: 100,
|
||||
},
|
||||
wrapperClass: 'grid-cols-2',
|
||||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
@@ -45,18 +58,39 @@ const [Modal, modalApi] = useVbenModal({
|
||||
}
|
||||
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 values = await formApi.getValues();
|
||||
const data = { ...formData.value, ...values };
|
||||
if (data.startAndEndTime && Array.isArray(data.startAndEndTime)) {
|
||||
data.startTime = data.startAndEndTime[0];
|
||||
data.endTime = data.startAndEndTime[1];
|
||||
delete data.startAndEndTime;
|
||||
}
|
||||
data.rules?.forEach((item: any) => {
|
||||
item.discountPrice = convertToInteger(item.discountPrice || 0);
|
||||
if (data.conditionType === PromotionConditionTypeEnum.PRICE.type) {
|
||||
item.limit = convertToInteger(item.limit || 0);
|
||||
}
|
||||
});
|
||||
switch (data.productScope) {
|
||||
// TODO @puhui999:是不是也可以类似优惠劵的处理策略哈;
|
||||
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));
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
@@ -67,7 +101,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
formData.value = {};
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
@@ -77,9 +111,17 @@ const [Modal, modalApi] = useVbenModal({
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getReward(data.id);
|
||||
const result = await getReward(data.id);
|
||||
result.startAndEndTime = [result.startTime, result.endTime] as any[];
|
||||
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;
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
await formApi.setValues(result);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
@@ -88,16 +130,20 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-4/5" :title="getTitle">
|
||||
<Form />
|
||||
|
||||
<!-- 简化说明 -->
|
||||
<div class="mt-4 rounded bg-blue-50 p-4">
|
||||
<p class="text-sm text-blue-600">
|
||||
<strong>说明:</strong> 当前为简化版本的满减送活动表单。
|
||||
复杂的商品选择、优惠规则配置等功能已简化,仅保留基础字段配置。
|
||||
如需完整功能,请参考原始 Element UI 版本的实现。
|
||||
</p>
|
||||
</div>
|
||||
<Modal :title="getTitle" class="w-2/3">
|
||||
<Form class="mx-6">
|
||||
<!-- 自定义插槽:优惠规则 -->
|
||||
<template #rules>
|
||||
<RewardRule v-model="formData" />
|
||||
</template>
|
||||
<!-- 自定义插槽:商品选择 -->
|
||||
<template #productSpuIds>
|
||||
<SpuShowcase v-model="formData.productSpuIds" />
|
||||
</template>
|
||||
<!-- 自定义插槽:分类选择 -->
|
||||
<template #productCategoryIds>
|
||||
<ProductCategorySelect v-model="formData.productCategoryIds" multiple />
|
||||
</template>
|
||||
</Form>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
|
||||
import type { MallRewardActivityApi } from '#/api/mall/promotion/reward/rewardActivity';
|
||||
|
||||
import { nextTick, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { CouponTemplateTakeTypeEnum, DICT_TYPE } from '@vben/constants';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { ElButton, ElInputNumber } from 'element-plus';
|
||||
|
||||
import { getCouponTemplateList } from '#/api/mall/promotion/coupon/couponTemplate';
|
||||
import { DictTag } from '#/components/dict-tag';
|
||||
import { CouponSelect } from '#/views/mall/promotion/coupon/components';
|
||||
import { discountFormat } from '#/views/mall/promotion/coupon/formatter';
|
||||
|
||||
defineOptions({ name: 'RewardRuleCouponSelect' });
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: MallRewardActivityApi.RewardRule;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', v: any): void;
|
||||
}>();
|
||||
|
||||
/** 选择赠送的优惠类型拓展 */
|
||||
interface GiveCoupon extends MallCouponTemplateApi.CouponTemplate {
|
||||
giveCount?: number;
|
||||
}
|
||||
|
||||
const rewardRule = useVModel(props, 'modelValue', emits);
|
||||
const list = ref<GiveCoupon[]>([]); // 选择的优惠劵列表
|
||||
|
||||
/** 选择优惠券 */
|
||||
const selectRef = ref<InstanceType<typeof CouponSelect>>();
|
||||
function handleSelect() {
|
||||
selectRef.value?.open();
|
||||
}
|
||||
|
||||
/** 选择优惠券后的回调 */
|
||||
function handleChange(val: any[]) {
|
||||
for (const item of val) {
|
||||
if (list.value.some((v) => v.id === item.id)) {
|
||||
continue;
|
||||
}
|
||||
list.value.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除优惠券 */
|
||||
function handleDelete(index: number) {
|
||||
list.value.splice(index, 1);
|
||||
}
|
||||
|
||||
/** 初始化赠送的优惠券列表 */
|
||||
async function initGiveCouponList() {
|
||||
// 校验优惠券存在
|
||||
if (
|
||||
!rewardRule.value ||
|
||||
!rewardRule.value.giveCouponTemplateCounts ||
|
||||
Object.keys(rewardRule.value.giveCouponTemplateCounts).length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const tempLateIds = Object.keys(
|
||||
rewardRule.value.giveCouponTemplateCounts,
|
||||
) as unknown as number[];
|
||||
const data = await getCouponTemplateList(tempLateIds);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
data.forEach((coupon) => {
|
||||
list.value.push({
|
||||
...coupon,
|
||||
giveCount: rewardRule.value.giveCouponTemplateCounts![coupon.id],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 监听 list 变化,自动同步到 rewardRule */
|
||||
watch(
|
||||
list,
|
||||
(val) => {
|
||||
if (!rewardRule.value) {
|
||||
return;
|
||||
}
|
||||
// 核心:清空 giveCouponTemplateCounts,解决删除不生效的问题
|
||||
rewardRule.value.giveCouponTemplateCounts = {};
|
||||
// 设置优惠券和其数量的对应
|
||||
val.forEach((item) => {
|
||||
rewardRule.value.giveCouponTemplateCounts![item.id] = item.giveCount!;
|
||||
});
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
await initGiveCouponList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 shrink-0 items-center gap-2">
|
||||
<span class="text-gray-500">送</span>
|
||||
<ElInputNumber
|
||||
v-model="item.giveCount"
|
||||
class="!w-20"
|
||||
:min="0"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="text-gray-500">张</span>
|
||||
<ElButton
|
||||
type="danger"
|
||||
text
|
||||
size="small"
|
||||
@click="handleDelete(index)"
|
||||
>
|
||||
删除
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加按钮 -->
|
||||
<ElButton link class="!pl-0" @click="handleSelect">+ 添加优惠券</ElButton>
|
||||
|
||||
<!-- 优惠券选择弹窗 -->
|
||||
<CouponSelect
|
||||
ref="selectRef"
|
||||
:take-type="CouponTemplateTakeTypeEnum.ADMIN.type"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallRewardActivityApi } from '#/api/mall/promotion/reward/rewardActivity';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { PromotionConditionTypeEnum } from '@vben/constants';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import {
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElCol,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInputNumber,
|
||||
ElRow,
|
||||
ElSwitch,
|
||||
ElTag,
|
||||
} from 'element-plus';
|
||||
|
||||
import RewardRuleCouponSelect from './reward-rule-coupon-select.vue';
|
||||
|
||||
defineOptions({ name: 'RewardRule' });
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Partial<MallRewardActivityApi.RewardActivity>;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', v: any): void;
|
||||
}>();
|
||||
|
||||
const formData = useVModel(props, 'modelValue', emits);
|
||||
|
||||
const isPriceCondition = computed(() => {
|
||||
return (
|
||||
formData.value?.conditionType === PromotionConditionTypeEnum.PRICE.type
|
||||
);
|
||||
});
|
||||
|
||||
/** 处理新增 */
|
||||
function handleAdd() {
|
||||
if (!formData.value.rules) {
|
||||
formData.value.rules = [];
|
||||
}
|
||||
formData.value.rules.push({
|
||||
limit: 0,
|
||||
discountPrice: 0,
|
||||
freeDelivery: false,
|
||||
point: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/** 处理删除 */
|
||||
function handleDelete(ruleIndex: number) {
|
||||
formData.value.rules?.splice(ruleIndex, 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- TODO @puhui999:这里报错了;idea -->
|
||||
<ElRow :gutter="[16, 16]">
|
||||
<template v-if="formData.rules">
|
||||
<ElCol v-for="(rule, index) in formData.rules" :key="index" :span="24">
|
||||
<ElCard size="small" class="rounded-lg">
|
||||
<!-- 规则标题 -->
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base font-medium">
|
||||
活动层级 {{ index + 1 }}
|
||||
</span>
|
||||
<ElButton
|
||||
v-if="index !== 0"
|
||||
type="danger"
|
||||
text
|
||||
size="small"
|
||||
@click="handleDelete(index)"
|
||||
>
|
||||
删除
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElForm :model="rule" label-position="left">
|
||||
<!-- 优惠门槛 -->
|
||||
<ElFormItem label="优惠门槛:" class="mb-3">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md bg-gray-50 px-3 py-2"
|
||||
>
|
||||
<span>满</span>
|
||||
<ElInputNumber
|
||||
v-if="isPriceCondition"
|
||||
v-model="rule.limit"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
class="!w-40"
|
||||
placeholder="请输入金额"
|
||||
/>
|
||||
<ElInputNumber
|
||||
v-else
|
||||
v-model="rule.limit"
|
||||
:min="0"
|
||||
:step="1"
|
||||
class="!w-40"
|
||||
placeholder="请输入数量"
|
||||
/>
|
||||
<span>{{ isPriceCondition ? '元' : '件' }}</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="优惠内容:" 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>
|
||||
<ElInputNumber
|
||||
v-model="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>
|
||||
<ElSwitch v-model="rule.freeDelivery" />
|
||||
</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>
|
||||
<ElInputNumber
|
||||
v-model="rule.point"
|
||||
:min="0"
|
||||
:step="1"
|
||||
class="!w-32"
|
||||
placeholder="请输入积分"
|
||||
/>
|
||||
<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>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
</ElCol>
|
||||
</template>
|
||||
|
||||
<!-- 添加规则按钮 -->
|
||||
<Col :span="24" class="mt-2">
|
||||
<Button type="primary" @click="handleAdd">+ 添加优惠规则</Button>
|
||||
</Col>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<ElCol :span="24" class="mt-2">
|
||||
<ElTag type="warning">
|
||||
提示:赠送积分为 0 时不赠送;未选择优惠券时不赠送。
|
||||
</ElTag>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</template>
|
||||
@@ -4,6 +4,9 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getSimpleSeckillConfigList } from '#/api/mall/promotion/seckill/seckillConfig';
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
@@ -29,6 +32,114 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** 新增/编辑的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '秒杀活动名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入活动名称',
|
||||
},
|
||||
rules: 'required',
|
||||
formItemClass: 'col-span-2',
|
||||
},
|
||||
{
|
||||
fieldName: 'startTime',
|
||||
label: '活动开始时间',
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
placeholder: '请选择活动开始时间',
|
||||
showTime: false,
|
||||
format: 'YYYY-MM-DD',
|
||||
valueFormat: 'x',
|
||||
class: 'w-full',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'endTime',
|
||||
label: '活动结束时间',
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
placeholder: '请选择活动结束时间',
|
||||
showTime: false,
|
||||
format: 'YYYY-MM-DD',
|
||||
valueFormat: 'x',
|
||||
class: 'w-full',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'configIds',
|
||||
label: '秒杀时段',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
placeholder: '请选择秒杀时段',
|
||||
mode: 'multiple',
|
||||
api: getSimpleSeckillConfigList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
class: 'w-full',
|
||||
},
|
||||
rules: 'required',
|
||||
formItemClass: 'col-span-2',
|
||||
},
|
||||
{
|
||||
fieldName: 'totalLimitCount',
|
||||
label: '总限购数量',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入总限购数量',
|
||||
min: 0,
|
||||
class: 'w-full',
|
||||
},
|
||||
rules: z.number().min(0).default(0),
|
||||
},
|
||||
{
|
||||
fieldName: 'singleLimitCount',
|
||||
label: '单次限购数量',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入单次限购数量',
|
||||
min: 0,
|
||||
class: 'w-full',
|
||||
},
|
||||
rules: z.number().min(0).default(0),
|
||||
},
|
||||
{
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入排序',
|
||||
min: 0,
|
||||
class: 'w-full',
|
||||
},
|
||||
rules: z.number().min(0).default(0),
|
||||
},
|
||||
{
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入备注',
|
||||
rows: 4,
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
|
||||
@@ -9,7 +9,7 @@ export function setConfigList(list: any[]) {
|
||||
}
|
||||
|
||||
/** 格式化配置名称 */
|
||||
export function formatConfigNames(configId: number): string {
|
||||
export function formatConfigNames(configId: number | string): string {
|
||||
const config = configList.find((item) => item.id === configId);
|
||||
return config === null || config === undefined
|
||||
? ''
|
||||
@@ -27,8 +27,11 @@ export function formatSeckillPrice(products: any[]): string {
|
||||
|
||||
/** 格式化活动时间范围 */
|
||||
export function formatTimeRange(
|
||||
startTime: Date | string,
|
||||
endTime: Date | string,
|
||||
startTime: Date | string | undefined,
|
||||
endTime: Date | string | undefined,
|
||||
): string {
|
||||
return `${formatDate(startTime, 'YYYY-MM-DD')} ~ ${formatDate(endTime, 'YYYY-MM-DD')}`;
|
||||
if (startTime && endTime) {
|
||||
return `${formatDate(startTime, 'YYYY-MM-DD')} ~ ${formatDate(endTime, 'YYYY-MM-DD')}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
},
|
||||
},
|
||||
},
|
||||
// TODO @puhui999:antd 没有,是不是要统一哈。
|
||||
cellConfig: {
|
||||
height: 250,
|
||||
},
|
||||
@@ -164,6 +165,7 @@ onMounted(async () => {
|
||||
link: true,
|
||||
auth: ['promotion:seckill-activity:close'],
|
||||
ifShow: row.status === 0,
|
||||
// TODO @puhui999:antd 这里是 popConfirm,看看要不要统一
|
||||
onClick: handleClose.bind(null, row),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,12 +8,16 @@ import { useVbenModal } from '@vben/common-ui';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { getSpu } from '#/api/mall/product/spu';
|
||||
import {
|
||||
createSeckillActivity,
|
||||
getSeckillActivity,
|
||||
updateSeckillActivity,
|
||||
} from '#/api/mall/promotion/seckill/seckillActivity';
|
||||
import { $t } from '#/locales';
|
||||
import { SpuSkuSelect } from '#/views/mall/product/spu/components';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<MallSeckillActivityApi.SeckillActivity>();
|
||||
@@ -23,51 +27,54 @@ const getTitle = computed(() => {
|
||||
: $t('ui.actionTitle.create', ['秒杀活动']);
|
||||
});
|
||||
|
||||
// 简化的表单配置,实际项目中应该有完整的字段配置
|
||||
const formSchema = [
|
||||
{
|
||||
fieldName: 'id',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '活动名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入活动名称',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '活动状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '请选择活动状态',
|
||||
options: [
|
||||
{ label: '开启', value: 0 },
|
||||
{ label: '关闭', value: 1 },
|
||||
],
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
// ================= 商品选择相关 =================
|
||||
const spuId = ref<number>();
|
||||
const spuName = ref<string>('');
|
||||
const skuTableData = ref<any[]>([]);
|
||||
|
||||
const spuSkuSelectRef = ref(); // 商品选择弹窗 Ref
|
||||
|
||||
/** 打开商品选择弹窗 */
|
||||
const handleSelectProduct = () => {
|
||||
spuSkuSelectRef.value?.open();
|
||||
};
|
||||
|
||||
/** 选择商品后的回调 */
|
||||
async function handleSpuSelected(selectedSpuId: number, skuIds?: number[]) {
|
||||
const spu = await getSpu(selectedSpuId);
|
||||
if (!spu) return;
|
||||
|
||||
spuId.value = spu.id;
|
||||
spuName.value = spu.name || '';
|
||||
|
||||
// 筛选指定的 SKU
|
||||
const selectedSkus = skuIds
|
||||
? spu.skus?.filter((sku) => skuIds.includes(sku.id!))
|
||||
: spu.skus;
|
||||
|
||||
skuTableData.value =
|
||||
selectedSkus?.map((sku) => ({
|
||||
skuId: sku.id!,
|
||||
skuName: sku.name || '',
|
||||
picUrl: sku.picUrl || spu.picUrl || '',
|
||||
price: sku.price || 0,
|
||||
stock: 0,
|
||||
seckillPrice: 0,
|
||||
})) || [];
|
||||
}
|
||||
|
||||
// ================= end =================
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 80,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: formSchema,
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'grid-cols-2',
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
@@ -76,11 +83,38 @@ const [Modal, modalApi] = useVbenModal({
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
|
||||
// 验证商品和 SKU 配置
|
||||
if (!spuId.value) {
|
||||
ElMessage.error('请选择秒杀商品');
|
||||
return;
|
||||
}
|
||||
if (skuTableData.value.length === 0) {
|
||||
ElMessage.error('请至少配置一个 SKU');
|
||||
return;
|
||||
}
|
||||
// 验证 SKU 配置
|
||||
const hasInvalidSku = skuTableData.value.some(
|
||||
(sku) => sku.stock < 1 || sku.seckillPrice < 0.01,
|
||||
);
|
||||
if (hasInvalidSku) {
|
||||
ElMessage.error('请正确配置 SKU 的秒杀库存(≥1)和秒杀价格(≥0.01)');
|
||||
return;
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const data =
|
||||
(await formApi.getValues()) as MallSeckillActivityApi.SeckillActivity;
|
||||
modalApi.lock();
|
||||
try {
|
||||
const values = await formApi.getValues();
|
||||
const data: any = {
|
||||
...values,
|
||||
spuId: spuId.value,
|
||||
products: skuTableData.value.map((sku) => ({
|
||||
skuId: sku.skuId,
|
||||
stock: sku.stock,
|
||||
seckillPrice: Math.round(sku.seckillPrice * 100), // 转换为分
|
||||
})),
|
||||
};
|
||||
await (formData.value?.id
|
||||
? updateSeckillActivity(data)
|
||||
: createSeckillActivity(data));
|
||||
@@ -95,8 +129,12 @@ const [Modal, modalApi] = useVbenModal({
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
spuId.value = undefined;
|
||||
spuName.value = '';
|
||||
skuTableData.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
const data = modalApi.getData<MallSeckillActivityApi.SeckillActivity>();
|
||||
if (!data || !data.id) {
|
||||
@@ -105,8 +143,33 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getSeckillActivity(data.id);
|
||||
// 设置到 values
|
||||
// TODO @puhui999:这里需要 nextTick 么?因为 antd 有
|
||||
await formApi.setValues(formData.value);
|
||||
// 加载商品和 SKU 信息
|
||||
// TODO @puhui999:if return 简化括号层级
|
||||
if (formData.value.spuId) {
|
||||
const spu = await getSpu(formData.value.spuId);
|
||||
if (spu) {
|
||||
spuId.value = spu.id;
|
||||
spuName.value = spu.name || '';
|
||||
// 回填 SKU 配置
|
||||
const products = formData.value.products || [];
|
||||
skuTableData.value =
|
||||
spu.skus
|
||||
?.filter((sku) => products.some((p) => p.skuId === sku.id))
|
||||
.map((sku) => {
|
||||
const product = products.find((p) => p.skuId === sku.id);
|
||||
return {
|
||||
skuId: sku.id!,
|
||||
skuName: sku.name || '',
|
||||
picUrl: sku.picUrl || spu.picUrl || '',
|
||||
price: sku.price || 0,
|
||||
stock: product?.stock || 0,
|
||||
seckillPrice: (product?.seckillPrice || 0) / 100, // 分转元
|
||||
};
|
||||
}) || [];
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
@@ -115,7 +178,81 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-2/5" :title="getTitle">
|
||||
<Form class="mx-4" />
|
||||
<Modal class="w-4/5" :title="getTitle">
|
||||
<div class="mx-4">
|
||||
<Form />
|
||||
|
||||
<!-- 商品选择区域 -->
|
||||
<div class="mt-4">
|
||||
<div class="mb-2 flex items-center">
|
||||
<span class="text-sm font-medium">秒杀活动商品:</span>
|
||||
<!-- TODO @puhui999:使用 ElButton 这种哈 -->
|
||||
<el-button class="ml-2" type="primary" @click="handleSelectProduct">
|
||||
选择商品
|
||||
</el-button>
|
||||
<span v-if="spuName" class="ml-4 text-sm text-gray-600">
|
||||
已选择: {{ spuName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- SKU 配置表格 -->
|
||||
<div v-if="skuTableData.length > 0" class="mt-4">
|
||||
<!-- TODO @puhui999:Grid?或者 VXETable 哇? -->
|
||||
<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"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -110,7 +110,6 @@ function emitActivityChange() {
|
||||
>
|
||||
<ElTooltip :content="activity.name">
|
||||
<div class="relative h-full w-full">
|
||||
<!-- TODO @芋艿 -->
|
||||
<ElImage
|
||||
:src="activity.picUrl"
|
||||
class="h-full w-full rounded-lg object-cover"
|
||||
|
||||
@@ -12,7 +12,9 @@ import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
/** 关联数据 */
|
||||
const userStore = useUserStore();
|
||||
const pickUpStoreList = ref<MallDeliveryPickUpStoreApi.PickUpStore[]>([]);
|
||||
const pickUpStoreList = ref<MallDeliveryPickUpStoreApi.DeliveryPickUpStore[]>(
|
||||
[],
|
||||
);
|
||||
getSimpleDeliveryPickUpStoreList().then((res) => {
|
||||
pickUpStoreList.value = res;
|
||||
// 移除自己无法核销的门店
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
|
||||
// TODO @芋艿:风格和 antd 不一致;
|
||||
const summary = ref<MallOrderApi.OrderSummary>();
|
||||
|
||||
/** 刷新表格 */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
import { NewsType } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
@@ -33,16 +33,6 @@ const emit = defineEmits<{
|
||||
(e: 'selectMaterial', item: any): void;
|
||||
}>();
|
||||
|
||||
const loading = ref(false); // 遮罩层
|
||||
const total = ref(0); // 总条数
|
||||
const list = ref<any[]>([]); // 数据列表
|
||||
// TODO @dlyan @AI:这里是不是应该都用 grid;
|
||||
const queryParams = reactive({
|
||||
accountId: props.accountId,
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
}); // 查询参数
|
||||
|
||||
const voiceGridColumns: VxeTableGridOptions<MpMaterialApi.Material>['columns'] =
|
||||
[
|
||||
{
|
||||
@@ -122,6 +112,99 @@ const videoGridColumns: VxeTableGridOptions<MpMaterialApi.Material>['columns'] =
|
||||
},
|
||||
];
|
||||
|
||||
// Image Grid
|
||||
const [ImageGrid, imageGridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: [],
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, { accountId }) => {
|
||||
const finalAccountId = accountId ?? props.accountId;
|
||||
if (!finalAccountId) {
|
||||
return { list: [], total: 0 };
|
||||
}
|
||||
return await getMaterialPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
accountId: finalAccountId,
|
||||
type: 'image',
|
||||
});
|
||||
},
|
||||
},
|
||||
autoLoad: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'mediaId',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
|
||||
// News Grid
|
||||
const [NewsGrid, newsGridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: [],
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, { accountId }) => {
|
||||
const finalAccountId = accountId ?? props.accountId;
|
||||
if (!finalAccountId) {
|
||||
return { list: [], total: 0 };
|
||||
}
|
||||
if (props.newsType === NewsType.Published) {
|
||||
const data = await getFreePublishPage({
|
||||
accountId: finalAccountId,
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
});
|
||||
data.list.forEach((item: any) => {
|
||||
const articles = item.content.newsItem;
|
||||
articles.forEach((article: any) => {
|
||||
article.picUrl = article.thumbUrl;
|
||||
});
|
||||
});
|
||||
return data;
|
||||
} else {
|
||||
const data = await getDraftPage({
|
||||
accountId: finalAccountId,
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
});
|
||||
data.list.forEach((draft: any) => {
|
||||
const articles = draft.content.newsItem;
|
||||
articles.forEach((article: any) => {
|
||||
article.picUrl = article.thumbUrl;
|
||||
});
|
||||
});
|
||||
return data;
|
||||
}
|
||||
},
|
||||
},
|
||||
autoLoad: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'mediaId',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
} as VxeTableGridOptions<any>,
|
||||
});
|
||||
|
||||
const [VoiceGrid, voiceGridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
border: true,
|
||||
@@ -135,7 +218,7 @@ const [VoiceGrid, voiceGridApi] = useVbenVxeGrid({
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, { accountId }) => {
|
||||
const finalAccountId = accountId ?? queryParams.accountId;
|
||||
const finalAccountId = accountId ?? props.accountId;
|
||||
if (!finalAccountId) {
|
||||
return { list: [], total: 0 };
|
||||
}
|
||||
@@ -171,7 +254,7 @@ const [VideoGrid, videoGridApi] = useVbenVxeGrid({
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, { accountId }) => {
|
||||
const finalAccountId = accountId ?? queryParams.accountId;
|
||||
const finalAccountId = accountId ?? props.accountId;
|
||||
if (finalAccountId === undefined || finalAccountId === null) {
|
||||
return { list: [], total: 0 };
|
||||
}
|
||||
@@ -194,91 +277,164 @@ const [VideoGrid, videoGridApi] = useVbenVxeGrid({
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
|
||||
// 从 Grid 获取数据
|
||||
const imageList = computed(() => {
|
||||
try {
|
||||
const tableData = imageGridApi.grid?.getTableData();
|
||||
return (tableData?.tableData as MpMaterialApi.Material[]) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const imageLoading = computed(() => {
|
||||
return imageGridApi.grid?.loading || false;
|
||||
});
|
||||
|
||||
const imageTotal = computed(() => {
|
||||
try {
|
||||
const proxyInfo = imageGridApi.grid?.getProxyInfo();
|
||||
return proxyInfo?.pager?.total || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const imageCurrentPage = computed({
|
||||
get: () => {
|
||||
try {
|
||||
return imageGridApi.grid?.pagerConfig?.currentPage || 1;
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
},
|
||||
set: (value: number) => {
|
||||
imageGridApi.grid?.commitProxy('page', { currentPage: value });
|
||||
},
|
||||
});
|
||||
|
||||
const imagePageSize = computed({
|
||||
get: () => {
|
||||
try {
|
||||
return imageGridApi.grid?.pagerConfig?.pageSize || 10;
|
||||
} catch {
|
||||
return 10;
|
||||
}
|
||||
},
|
||||
set: (value: number) => {
|
||||
imageGridApi.grid?.commitProxy('page', { pageSize: value, currentPage: 1 });
|
||||
},
|
||||
});
|
||||
|
||||
const newsList = computed(() => {
|
||||
try {
|
||||
const tableData = newsGridApi.grid?.getTableData();
|
||||
return (tableData?.tableData as any[]) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const newsLoading = computed(() => {
|
||||
return newsGridApi.grid?.loading || false;
|
||||
});
|
||||
|
||||
const newsTotal = computed(() => {
|
||||
try {
|
||||
const proxyInfo = newsGridApi.grid?.getProxyInfo();
|
||||
return proxyInfo?.pager?.total || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const newsCurrentPage = computed({
|
||||
get: () => {
|
||||
try {
|
||||
return newsGridApi.grid?.pagerConfig?.currentPage || 1;
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
},
|
||||
set: (value: number) => {
|
||||
newsGridApi.grid?.commitProxy('page', { currentPage: value });
|
||||
},
|
||||
});
|
||||
|
||||
const newsPageSize = computed({
|
||||
get: () => {
|
||||
try {
|
||||
return newsGridApi.grid?.pagerConfig?.pageSize || 10;
|
||||
} catch {
|
||||
return 10;
|
||||
}
|
||||
},
|
||||
set: (value: number) => {
|
||||
newsGridApi.grid?.commitProxy('page', { pageSize: value, currentPage: 1 });
|
||||
},
|
||||
});
|
||||
|
||||
function selectMaterialFun(item: any) {
|
||||
emit('selectMaterial', item);
|
||||
}
|
||||
|
||||
async function getMaterialPageFun() {
|
||||
const data = await getMaterialPage({
|
||||
...queryParams,
|
||||
type: props.type,
|
||||
});
|
||||
list.value = data.list;
|
||||
total.value = data.total;
|
||||
}
|
||||
|
||||
async function getFreePublishPageFun() {
|
||||
const data = await getFreePublishPage(queryParams);
|
||||
data.list.forEach((item: any) => {
|
||||
const articles = item.content.newsItem;
|
||||
articles.forEach((article: any) => {
|
||||
article.picUrl = article.thumbUrl;
|
||||
});
|
||||
});
|
||||
list.value = data.list;
|
||||
total.value = data.total;
|
||||
}
|
||||
|
||||
async function getDraftPageFun() {
|
||||
const data = await getDraftPage(queryParams);
|
||||
data.list.forEach((draft: any) => {
|
||||
const articles = draft.content.newsItem;
|
||||
articles.forEach((article: any) => {
|
||||
article.picUrl = article.thumbUrl;
|
||||
});
|
||||
});
|
||||
list.value = data.list;
|
||||
total.value = data.total;
|
||||
}
|
||||
|
||||
async function getPage() {
|
||||
if (props.type === 'voice') {
|
||||
await voiceGridApi.reload({ accountId: queryParams.accountId });
|
||||
return;
|
||||
}
|
||||
if (props.type === 'video') {
|
||||
await videoGridApi.reload({ accountId: queryParams.accountId });
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
if (props.type === 'news' && props.newsType === NewsType.Published) {
|
||||
await getFreePublishPageFun();
|
||||
} else if (props.type === 'news' && props.newsType === NewsType.Draft) {
|
||||
await getDraftPageFun();
|
||||
} else {
|
||||
await getMaterialPageFun();
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 accountId 变化
|
||||
watch(
|
||||
() => props.accountId,
|
||||
(accountId) => {
|
||||
queryParams.accountId = accountId;
|
||||
queryParams.pageNo = 1;
|
||||
getPage();
|
||||
switch (props.type) {
|
||||
case 'image': {
|
||||
imageGridApi.reload({ accountId });
|
||||
break;
|
||||
}
|
||||
case 'news': {
|
||||
newsGridApi.reload({ accountId });
|
||||
break;
|
||||
}
|
||||
case 'video': {
|
||||
videoGridApi.reload({ accountId });
|
||||
break;
|
||||
}
|
||||
case 'voice': {
|
||||
voiceGridApi.reload({ accountId });
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 监听 type 变化
|
||||
watch(
|
||||
() => props.type,
|
||||
() => {
|
||||
queryParams.pageNo = 1;
|
||||
getPage();
|
||||
switch (props.type) {
|
||||
case 'image': {
|
||||
imageGridApi.reload({ accountId: props.accountId });
|
||||
break;
|
||||
}
|
||||
case 'news': {
|
||||
newsGridApi.reload({ accountId: props.accountId });
|
||||
break;
|
||||
}
|
||||
case 'video': {
|
||||
videoGridApi.reload({ accountId: props.accountId });
|
||||
break;
|
||||
}
|
||||
case 'voice': {
|
||||
voiceGridApi.reload({ accountId: props.accountId });
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 监听 newsType 变化
|
||||
watch(
|
||||
() => props.newsType,
|
||||
() => {
|
||||
if (props.type === 'news') {
|
||||
queryParams.pageNo = 1;
|
||||
getPage();
|
||||
newsGridApi.reload({ accountId: props.accountId });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -287,38 +443,41 @@ watch(
|
||||
<template>
|
||||
<div class="pb-30px">
|
||||
<!-- 类型:image -->
|
||||
<div v-if="props.type === 'image'">
|
||||
<div
|
||||
class="mx-auto w-full columns-1 [column-gap:10px] md:columns-2 lg:columns-3 xl:columns-4 2xl:columns-5"
|
||||
v-loading="loading"
|
||||
>
|
||||
<div
|
||||
class="mb-2.5 break-inside-avoid border border-[#eaeaea] p-2.5"
|
||||
v-for="item in list"
|
||||
:key="item.mediaId"
|
||||
>
|
||||
<img class="w-full" :src="item.url" />
|
||||
<p class="truncate text-center text-xs leading-[30px]">
|
||||
{{ item.name }}
|
||||
</p>
|
||||
<ElRow class="flex justify-center pt-2.5">
|
||||
<ElButton type="success" @click="selectMaterialFun(item)">
|
||||
选择
|
||||
<IconifyIcon icon="lucide:circle-check" />
|
||||
</ElButton>
|
||||
</ElRow>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 分页组件 -->
|
||||
<ElPagination
|
||||
background
|
||||
layout="prev, pager, next, sizes, total"
|
||||
:total="total"
|
||||
v-model:current-page="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
@current-change="getMaterialPageFun"
|
||||
@size-change="getMaterialPageFun"
|
||||
/>
|
||||
<div v-if="props.type === 'image'" class="image-grid-wrapper">
|
||||
<ImageGrid>
|
||||
<template #default>
|
||||
<div
|
||||
class="mx-auto w-full columns-1 [column-gap:10px] md:columns-2 lg:columns-3 xl:columns-4 2xl:columns-5"
|
||||
v-loading="imageLoading"
|
||||
>
|
||||
<div
|
||||
class="mb-2.5 break-inside-avoid border border-[#eaeaea] p-2.5"
|
||||
v-for="item in imageList"
|
||||
:key="item.mediaId"
|
||||
>
|
||||
<img class="w-full" :src="item.url" />
|
||||
<p class="truncate text-center text-xs leading-[30px]">
|
||||
{{ item.name }}
|
||||
</p>
|
||||
<ElRow class="flex justify-center pt-2.5">
|
||||
<ElButton type="success" @click="selectMaterialFun(item)">
|
||||
选择
|
||||
<IconifyIcon icon="lucide:circle-check" />
|
||||
</ElButton>
|
||||
</ElRow>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 分页组件 -->
|
||||
<ElPagination
|
||||
background
|
||||
layout="prev, pager, next, sizes, total"
|
||||
:total="imageTotal"
|
||||
v-model:current-page="imageCurrentPage"
|
||||
v-model:page-size="imagePageSize"
|
||||
class="mt-4"
|
||||
/>
|
||||
</template>
|
||||
</ImageGrid>
|
||||
</div>
|
||||
<!-- 类型:voice -->
|
||||
<div v-else-if="props.type === 'voice'">
|
||||
@@ -334,6 +493,7 @@ watch(
|
||||
</template>
|
||||
</VoiceGrid>
|
||||
</div>
|
||||
|
||||
<!-- 类型:video -->
|
||||
<div v-else-if="props.type === 'video'">
|
||||
<VideoGrid>
|
||||
@@ -348,38 +508,52 @@ watch(
|
||||
</template>
|
||||
</VideoGrid>
|
||||
</div>
|
||||
|
||||
<!-- 类型:news -->
|
||||
<div v-else-if="props.type === 'news'">
|
||||
<div
|
||||
class="mx-auto w-full columns-1 [column-gap:10px] md:columns-2 lg:columns-3 xl:columns-4 2xl:columns-5"
|
||||
v-loading="loading"
|
||||
>
|
||||
<div
|
||||
class="mb-2.5 break-inside-avoid border border-[#eaeaea] p-2.5"
|
||||
v-for="item in list"
|
||||
:key="item.mediaId"
|
||||
>
|
||||
<div v-if="item.content && item.content.newsItem">
|
||||
<WxNews :articles="item.content.newsItem" />
|
||||
<ElRow class="flex justify-center pt-2.5">
|
||||
<ElButton type="success" @click="selectMaterialFun(item)">
|
||||
选择
|
||||
<IconifyIcon icon="lucide:circle-check" />
|
||||
</ElButton>
|
||||
</ElRow>
|
||||
<div v-else-if="props.type === 'news'" class="news-grid-wrapper">
|
||||
<NewsGrid>
|
||||
<template #default>
|
||||
<div
|
||||
class="mx-auto w-full columns-1 [column-gap:10px] md:columns-2 lg:columns-3 xl:columns-4 2xl:columns-5"
|
||||
v-loading="newsLoading"
|
||||
>
|
||||
<div
|
||||
class="mb-2.5 break-inside-avoid border border-[#eaeaea] p-2.5"
|
||||
v-for="item in newsList"
|
||||
:key="item.mediaId"
|
||||
>
|
||||
<div v-if="item.content && item.content.newsItem">
|
||||
<WxNews :articles="item.content.newsItem" />
|
||||
<ElRow class="flex justify-center pt-2.5">
|
||||
<ElButton type="success" @click="selectMaterialFun(item)">
|
||||
选择
|
||||
<IconifyIcon icon="lucide:circle-check" />
|
||||
</ElButton>
|
||||
</ElRow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 分页组件 -->
|
||||
<ElPagination
|
||||
background
|
||||
layout="prev, pager, next, sizes, total"
|
||||
:total="total"
|
||||
v-model:current-page="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
@current-change="getMaterialPageFun"
|
||||
@size-change="getMaterialPageFun"
|
||||
/>
|
||||
<!-- 分页组件 -->
|
||||
<ElPagination
|
||||
background
|
||||
layout="prev, pager, next, sizes, total"
|
||||
:total="newsTotal"
|
||||
v-model:current-page="newsCurrentPage"
|
||||
v-model:page-size="newsPageSize"
|
||||
class="mt-4"
|
||||
/>
|
||||
</template>
|
||||
</NewsGrid>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.image-grid-wrapper :deep(.vxe-grid--body-wrapper) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.news-grid-wrapper :deep(.vxe-grid--body-wrapper) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { provide, reactive, ref } from 'vue';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { provide, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { confirm, DocAlert, Page } from '@vben/common-ui';
|
||||
@@ -7,83 +10,117 @@ import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElLoading,
|
||||
ElMessage,
|
||||
ElPagination,
|
||||
ElTabPane,
|
||||
ElTabs,
|
||||
} from 'element-plus';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deletePermanentMaterial, getMaterialPage } from '#/api/mp/material';
|
||||
import { WxAccountSelect } from '#/views/mp/components';
|
||||
|
||||
import ImageTable from './modules/image-table.vue';
|
||||
import {
|
||||
useGridFormSchema,
|
||||
useImageGridColumns,
|
||||
useVideoGridColumns,
|
||||
useVoiceGridColumns,
|
||||
} from './modules/data';
|
||||
import { UploadType } from './modules/upload';
|
||||
import UploadFile from './modules/UploadFile.vue';
|
||||
import UploadVideo from './modules/UploadVideo.vue';
|
||||
import VideoTable from './modules/video-table.vue';
|
||||
import VoiceTable from './modules/voice-table.vue';
|
||||
|
||||
defineOptions({ name: 'MpMaterial' });
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
const type = ref<UploadType>(UploadType.Image); // 素材类型
|
||||
const loading = ref(false); // 遮罩层
|
||||
const list = ref<any[]>([]); // 数据列表
|
||||
const total = ref(0); // 总条数
|
||||
const showCreateVideo = ref(false); // 是否新建视频的弹窗
|
||||
|
||||
const accountId = ref(-1);
|
||||
provide('accountId', accountId);
|
||||
|
||||
// TODO @dlyan @AI:这里是不是应该都用 grid;类似 yudao-ui-admin-vben-v5/apps/web-ele/src/views/mp/autoReply/index.vue
|
||||
const queryParams = reactive({
|
||||
accountId,
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
permanent: true,
|
||||
}); // 查询参数
|
||||
const showCreateVideo = ref(false); // 是否新建视频的弹窗
|
||||
|
||||
/** 侦听公众号变化 */
|
||||
function onAccountChanged(id: number) {
|
||||
accountId.value = id;
|
||||
queryParams.accountId = id;
|
||||
queryParams.pageNo = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 查询列表 */
|
||||
async function getList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getMaterialPage({
|
||||
...queryParams,
|
||||
type: type.value,
|
||||
});
|
||||
list.value = data.list;
|
||||
total.value = data.total;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
// 根据类型获取对应的列配置
|
||||
const getColumnsByType = () => {
|
||||
switch (type.value) {
|
||||
case UploadType.Image: {
|
||||
return useImageGridColumns();
|
||||
}
|
||||
case UploadType.Video: {
|
||||
return useVideoGridColumns();
|
||||
}
|
||||
case UploadType.Voice: {
|
||||
return useVoiceGridColumns();
|
||||
}
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: getColumnsByType(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const finalAccountId = formValues?.accountId ?? accountId.value;
|
||||
if (!finalAccountId || finalAccountId === -1) {
|
||||
return { list: [], total: 0 };
|
||||
}
|
||||
return await getMaterialPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
type: type.value,
|
||||
permanent: true,
|
||||
accountId: finalAccountId,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
autoLoad: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
height: type.value === UploadType.Image ? 220 : 'auto',
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
|
||||
// 当 tab 切换时,更新 Grid 的 columns 和 rowConfig
|
||||
async function onTabChange() {
|
||||
const columns = getColumnsByType();
|
||||
gridApi.setGridOptions({
|
||||
columns,
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
height: type.value === UploadType.Image ? 220 : 'auto',
|
||||
},
|
||||
});
|
||||
await gridApi.reload();
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
function handleQuery() {
|
||||
queryParams.pageNo = 1;
|
||||
getList();
|
||||
async function handleAccountChange(id: number) {
|
||||
accountId.value = id;
|
||||
// 同步设置表单值
|
||||
await gridApi.formApi.setValues({ accountId: id });
|
||||
await gridApi.formApi.submitForm();
|
||||
}
|
||||
|
||||
/** 处理 tab 切换 */
|
||||
function onTabChange() {
|
||||
// 提前清空数据,避免 tab 切换后显示垃圾数据
|
||||
list.value = [];
|
||||
total.value = 0;
|
||||
// 从第一页开始查询
|
||||
handleQuery();
|
||||
async function handleRefresh() {
|
||||
await gridApi.query();
|
||||
}
|
||||
|
||||
/** 处理删除操作 */
|
||||
@@ -96,24 +133,11 @@ async function handleDelete(id: number) {
|
||||
try {
|
||||
await deletePermanentMaterial(id);
|
||||
ElMessage.success('删除成功');
|
||||
await getList();
|
||||
await handleRefresh();
|
||||
} finally {
|
||||
loadingInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 分页改变事件 */
|
||||
function handlePageChange(page: number) {
|
||||
queryParams.pageNo = page;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 每页条数改变事件 */
|
||||
function handleSizeChange(pageSize: number) {
|
||||
queryParams.pageSize = pageSize;
|
||||
queryParams.pageNo = 1;
|
||||
getList();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -121,18 +145,12 @@ function handleSizeChange(pageSize: number) {
|
||||
<template #doc>
|
||||
<DocAlert title="公众号素材" url="https://doc.iocoder.cn/mp/material/" />
|
||||
</template>
|
||||
<div class="h-full">
|
||||
<!-- 搜索工作栏 -->
|
||||
<ElCard class="h-[10%]" shadow="never">
|
||||
<ElForm :model="queryParams" :inline="true">
|
||||
<ElFormItem label="公众号" class="w-52">
|
||||
<WxAccountSelect @change="onAccountChanged" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
|
||||
<ElCard shadow="never" class="mt-4 h-auto">
|
||||
<ElTabs v-model="type" @tab-change="onTabChange">
|
||||
<Grid class="material-grid">
|
||||
<template #form-accountId>
|
||||
<WxAccountSelect @change="handleAccountChange" />
|
||||
</template>
|
||||
<template #toolbar-actions>
|
||||
<ElTabs v-model="type" class="w-full" @tab-change="onTabChange">
|
||||
<!-- tab 1:图片 -->
|
||||
<ElTabPane :name="UploadType.Image">
|
||||
<template #label>
|
||||
@@ -141,34 +159,6 @@ function handleSizeChange(pageSize: number) {
|
||||
图片
|
||||
</span>
|
||||
</template>
|
||||
<!-- 列表 -->
|
||||
<ImageTable
|
||||
:key="`image-${type}`"
|
||||
:list="list"
|
||||
:loading="loading"
|
||||
@delete="handleDelete"
|
||||
@refresh="getList"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<UploadFile
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
:type="UploadType.Image"
|
||||
@uploaded="getList"
|
||||
/>
|
||||
</template>
|
||||
</ImageTable>
|
||||
<!-- 分页组件 -->
|
||||
<div class="mt-4 flex justify-end">
|
||||
<ElPagination
|
||||
v-model:current-page="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- tab 2:语音 -->
|
||||
@@ -179,34 +169,6 @@ function handleSizeChange(pageSize: number) {
|
||||
语音
|
||||
</span>
|
||||
</template>
|
||||
<!-- 列表 -->
|
||||
<VoiceTable
|
||||
:key="`voice-${type}`"
|
||||
:list="list"
|
||||
:loading="loading"
|
||||
@delete="handleDelete"
|
||||
@refresh="getList"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<UploadFile
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
:type="UploadType.Voice"
|
||||
@uploaded="getList"
|
||||
/>
|
||||
</template>
|
||||
</VoiceTable>
|
||||
<!-- 分页组件 -->
|
||||
<div class="mt-4 flex justify-end">
|
||||
<ElPagination
|
||||
v-model:current-page="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- tab 3:视频 -->
|
||||
@@ -217,41 +179,81 @@ function handleSizeChange(pageSize: number) {
|
||||
视频
|
||||
</span>
|
||||
</template>
|
||||
<!-- 列表 -->
|
||||
<VideoTable
|
||||
:key="`video-${type}`"
|
||||
:list="list"
|
||||
:loading="loading"
|
||||
@delete="handleDelete"
|
||||
@refresh="getList"
|
||||
>
|
||||
<template #toolbar-tools>
|
||||
<ElButton
|
||||
v-if="hasAccessByCodes(['mp:material:upload-permanent'])"
|
||||
type="primary"
|
||||
@click="showCreateVideo = true"
|
||||
>
|
||||
新建视频
|
||||
</ElButton>
|
||||
</template>
|
||||
</VideoTable>
|
||||
<!-- 新建视频的弹窗 -->
|
||||
<UploadVideo v-model:open="showCreateVideo" @uploaded="getList" />
|
||||
<!-- 分页组件 -->
|
||||
<div class="mt-4 flex justify-end">
|
||||
<ElPagination
|
||||
v-model:current-page="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
<template #toolbar-tools>
|
||||
<UploadFile
|
||||
v-if="
|
||||
hasAccessByCodes(['mp:material:upload-permanent']) &&
|
||||
type === UploadType.Image
|
||||
"
|
||||
:type="UploadType.Image"
|
||||
@uploaded="handleRefresh"
|
||||
/>
|
||||
<UploadFile
|
||||
v-if="
|
||||
hasAccessByCodes(['mp:material:upload-permanent']) &&
|
||||
type === UploadType.Voice
|
||||
"
|
||||
:type="UploadType.Voice"
|
||||
@uploaded="handleRefresh"
|
||||
/>
|
||||
<ElButton
|
||||
v-if="
|
||||
hasAccessByCodes(['mp:material:upload-permanent']) &&
|
||||
type === UploadType.Video
|
||||
"
|
||||
type="primary"
|
||||
@click="showCreateVideo = true"
|
||||
>
|
||||
新建视频
|
||||
</ElButton>
|
||||
</template>
|
||||
|
||||
<!-- 图片列的 slot -->
|
||||
<template #image="{ row }">
|
||||
<div class="flex items-center justify-center" style="height: 192px">
|
||||
<img
|
||||
:src="row.url"
|
||||
class="object-contain"
|
||||
style="display: block; max-width: 100%; max-height: 192px"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 语音列的 slot -->
|
||||
<template #voice="{ row }">
|
||||
<audio :src="row.url" controls style="width: 160px"></audio>
|
||||
</template>
|
||||
|
||||
<!-- 视频列的 slot -->
|
||||
<template #video="{ row }">
|
||||
<video
|
||||
:src="row.url"
|
||||
controls
|
||||
style="width: 200px; height: 150px"
|
||||
></video>
|
||||
</template>
|
||||
|
||||
<!-- 操作列的 slot -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'danger',
|
||||
link: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['mp:material:delete'],
|
||||
onClick: () => handleDelete(row.id!),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<!-- 新建视频的弹窗 -->
|
||||
<UploadVideo v-model:open="showCreateVideo" @uploaded="handleRefresh" />
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
@@ -131,3 +132,14 @@ export function useImageGridColumns(): VxeTableGridOptions<MpMaterialApi.Materia
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'accountId',
|
||||
label: '公众号',
|
||||
component: 'Input',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { nextTick, onMounted, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
|
||||
import { useImageGridColumns } from './data';
|
||||
|
||||
const props = defineProps<{
|
||||
list: MpMaterialApi.Material[];
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [v: number];
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
const columns = useImageGridColumns();
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid<MpMaterialApi.Material>({
|
||||
gridOptions: {
|
||||
border: true,
|
||||
columns,
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
height: 220,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
// 数据由父组件管理,触发刷新事件后返回当前数据
|
||||
emit('refresh');
|
||||
// 返回当前数据,避免覆盖
|
||||
return {
|
||||
list: Array.isArray(props.list) ? props.list : [],
|
||||
total: props.list?.length || 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
autoLoad: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function updateGridData(data: MpMaterialApi.Material[]) {
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
async (list: MpMaterialApi.Material[]) => {
|
||||
const data = Array.isArray(list) ? list : [];
|
||||
await nextTick();
|
||||
updateGridData(data);
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading: boolean) => {
|
||||
gridApi.setLoading(loading);
|
||||
},
|
||||
);
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
updateGridData(Array.isArray(props.list) ? props.list : []);
|
||||
gridApi.setLoading(props.loading);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Grid class="image-table-grid mt-4 pb-0">
|
||||
<template #toolbar-tools>
|
||||
<slot name="toolbar-tools"></slot>
|
||||
</template>
|
||||
<template #image="{ row }">
|
||||
<div class="flex items-center justify-center" style="height: 192px">
|
||||
<img
|
||||
:src="row.url"
|
||||
class="object-contain"
|
||||
style="display: block; max-width: 100%; max-height: 192px"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'danger',
|
||||
link: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['mp:material:delete'],
|
||||
popConfirm: {
|
||||
title: '确定要删除该图片吗?',
|
||||
confirm: () => emit('delete', row.id!),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</template>
|
||||
@@ -1,121 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { nextTick, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { WxVideoPlayer } from '#/views/mp/components';
|
||||
|
||||
import { useVideoGridColumns } from './data';
|
||||
|
||||
const props = defineProps<{
|
||||
list: MpMaterialApi.Material[];
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [v: number];
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
const columns = useVideoGridColumns();
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
border: true,
|
||||
columns,
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
// 数据由父组件管理,触发刷新事件后返回当前数据
|
||||
emit('refresh');
|
||||
// 返回当前数据,避免覆盖
|
||||
return {
|
||||
list: Array.isArray(props.list) ? props.list : [],
|
||||
total: props.list?.length || 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
autoLoad: false,
|
||||
},
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
|
||||
function updateGridData(data: MpMaterialApi.Material[]) {
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
async (list: MpMaterialApi.Material[]) => {
|
||||
const data = Array.isArray(list) ? list : [];
|
||||
await nextTick();
|
||||
updateGridData(data);
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading: boolean) => {
|
||||
gridApi.setLoading(loading);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Grid class="mt-4">
|
||||
<template #toolbar-tools>
|
||||
<slot name="toolbar-tools"></slot>
|
||||
</template>
|
||||
<template #video="{ row }">
|
||||
<WxVideoPlayer v-if="row.url" :url="row.url" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '下载',
|
||||
type: 'primary',
|
||||
link: true,
|
||||
icon: ACTION_ICON.DOWNLOAD,
|
||||
onClick: () => openWindow(row.url),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'danger',
|
||||
link: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['mp:material:delete'],
|
||||
popConfirm: {
|
||||
title: '确定要删除该视频吗?',
|
||||
confirm: () => emit('delete', row.id!),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</template>
|
||||
@@ -1,121 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { MpMaterialApi } from '#/api/mp/material';
|
||||
|
||||
import { nextTick, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { WxVoicePlayer } from '#/views/mp/components';
|
||||
|
||||
import { useVoiceGridColumns } from './data';
|
||||
|
||||
const props = defineProps<{
|
||||
list: MpMaterialApi.Material[];
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [v: number];
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
const columns = useVoiceGridColumns();
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
border: true,
|
||||
columns,
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
},
|
||||
showOverflow: 'tooltip',
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
// 数据由父组件管理,触发刷新事件后返回当前数据
|
||||
emit('refresh');
|
||||
// 返回当前数据,避免覆盖
|
||||
return {
|
||||
list: Array.isArray(props.list) ? props.list : [],
|
||||
total: props.list?.length || 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
autoLoad: false,
|
||||
},
|
||||
} as VxeTableGridOptions<MpMaterialApi.Material>,
|
||||
});
|
||||
|
||||
function updateGridData(data: MpMaterialApi.Material[]) {
|
||||
if (gridApi.grid?.loadData) {
|
||||
gridApi.grid.loadData(data);
|
||||
} else {
|
||||
gridApi.setGridOptions({ data });
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
async (list: MpMaterialApi.Material[]) => {
|
||||
const data = Array.isArray(list) ? list : [];
|
||||
await nextTick();
|
||||
updateGridData(data);
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading: boolean) => {
|
||||
gridApi.setLoading(loading);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Grid class="mt-4">
|
||||
<template #toolbar-tools>
|
||||
<slot name="toolbar-tools"></slot>
|
||||
</template>
|
||||
<template #voice="{ row }">
|
||||
<WxVoicePlayer v-if="row.url" :url="row.url" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '下载',
|
||||
type: 'primary',
|
||||
link: true,
|
||||
icon: ACTION_ICON.DOWNLOAD,
|
||||
onClick: () => openWindow(row.url),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'danger',
|
||||
link: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['mp:material:delete'],
|
||||
popConfirm: {
|
||||
title: '确定要删除该语音吗?',
|
||||
confirm: () => emit('delete', row.id!),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</template>
|
||||
@@ -1,2 +1 @@
|
||||
// TODO @xingyu:【待讨论】是不是把 user select 放到 user 目录的 components 下,dept select 放到 dept 目录的 components 下
|
||||
export { default as DeptSelectModal } from './dept-select-modal.vue';
|
||||
export { default as DeptSelectModal } from './select-modal.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// TODO @芋艿:是否有更好的组织形式?!
|
||||
<script lang="ts" setup>
|
||||
// TODO @jason:看看这个有没办法,整体代码的文件顺序,注释风格等,和 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/views/system/dept/components/select-modal.vue 一致。原因是:好维护~
|
||||
import type { SystemDeptApi } from '#/api/system/dept';
|
||||
|
||||
import { nextTick, ref } from 'vue';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user