feat: 更新商品详情和表单组件,优化数据获取逻辑,移除未使用的格式化函数

pull/171/head
吃货 2025-07-07 08:24:09 +08:00
parent 3326c25a0d
commit 2464bf8e8f
3 changed files with 506 additions and 25 deletions

View File

@ -6,6 +6,5 @@ export * from './rangePickerProps';
export * from './routerHelper';
export * from './validator';
export * from './tree';
export * from './formatNum';
export * from './is';
export * from './bean';

View File

@ -1,3 +1,499 @@
<script lang="ts" setup></script>
<script lang="ts" setup>
import { useRouter, useRoute } from 'vue-router';
import { ref, onMounted } from 'vue';
import { floatToFixed2 } from '@vben/utils';
import * as ProductSpuApi from '#/api/mall/product/spu';
import type { MallSpuApi } from '#/api/mall/product/spu';
import * as ProductCategoryApi from '#/api/mall/product/category';
import * as ProductBrandApi from '#/api/mall/product/brand';
import { Page } from '@vben/common-ui';
import { getIntDictOptions, DICT_TYPE } from '#/utils/dict';
import {
ElCard,
ElDescriptions,
ElDescriptionsItem,
ElCarousel,
ElCarouselItem,
ElImage,
ElDivider,
ElButton,
ElTabs,
ElTabPane,
ElTag,
ElBadge,
ElEmpty,
} from 'element-plus';
import { IconifyIcon } from '@vben/icons';
<template>detail</template>
interface Category {
id: number;
name: string;
children?: Category[];
}
interface Brand {
id: number;
name: string;
}
interface DictData {
value: number | string;
label: string;
colorType?: string;
cssClass?: string;
}
const { push } = useRouter(); //
const { params } = useRoute(); //
const formLoading = ref(false); // 12
const activeTab = ref('basic'); //
const categoryList = ref<Category[]>([]); //
const brandList = ref<Brand[]>([]); //
const deliveryTypeDict = ref<DictData[]>([]); //
// 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 getIntDictOptions(
DICT_TYPE.TRADE_DELIVERY_TYPE,
);
} 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"></ElTag
>{{ 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 }} </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>

View File

@ -4,7 +4,7 @@ import { onMounted, ref, unref } from 'vue';
import { cloneDeep } from '@vben/utils';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { useRouter, useRoute } from 'vue-router';
import { floatToFixed2, formatToFraction, convertToInteger } from '@vben/utils';
import { formatToFraction, convertToInteger } from '@vben/utils';
import * as ProductSpuApi from '#/api/mall/product/spu';
import { ElMessage } from 'element-plus';
@ -51,36 +51,22 @@ const formData = ref<MallSpuApi.Spu>({
});
const formLoading = ref(false); // 12
const isDetail = ref(false); //
const { push } = useRouter(); //
const { params, name } = useRoute(); //
const { params } = useRoute(); //
/** 获得详情 */
const getDetail = async () => {
if ('ProductSpuDetail' === name) {
isDetail.value = true;
}
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) => {
if (isDetail.value) {
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);
} else {
//
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,
);
}
//
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;
} finally {