This commit is contained in:
xingyu4j
2025-10-23 09:40:16 +08:00
26 changed files with 256 additions and 251 deletions

View File

@@ -95,7 +95,7 @@ export function getOtaTaskRecordStatusStatistics(
taskId?: number,
) {
return requestClient.get<Record<string, number>>(
'/iot/ota/task/record/status-statistics',
'/iot/ota/task/record/get-status-statistics',
{ params: { firmwareId, taskId } },
);
}

View File

@@ -18,8 +18,7 @@ const routes: RouteRecordRaw[] = [
title: '产品详情',
activePath: '/iot/device/product',
},
component: () =>
import('#/views/iot/product/product/modules/detail/index.vue'),
component: () => import('#/views/iot/product/product/modules/detail/index.vue'),
},
{
path: 'device/detail/:id',
@@ -28,11 +27,20 @@ const routes: RouteRecordRaw[] = [
title: '设备详情',
activePath: '/iot/device/device',
},
component: () =>
import('#/views/iot/device/device/modules/detail/index.vue'),
component: () => import('#/views/iot/device/device/modules/detail/index.vue'),
},
{
path: 'ota/firmware/detail/:id',
name: 'IoTOtaFirmwareDetail',
meta: {
title: '固件详情',
activePath: '/iot/ota',
},
component: () => import('#/views/iot/ota/modules/firmware-detail/index.vue'),
},
],
},
];
export default routes;

View File

@@ -76,7 +76,7 @@ export function useFormSchema(): VbenFormSchema[] {
label: '接收的用户',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
api: () => getSimpleUserList(),
labelField: 'nickname',
valueField: 'id',
mode: 'multiple',

View File

@@ -17,7 +17,7 @@ export function useGridFormSchema(): VbenFormSchema[] {
label: '告警配置',
component: 'ApiSelect',
componentProps: {
api: getSimpleAlertConfigList,
api: () => getSimpleAlertConfigList(),
labelField: 'name',
valueField: 'id',
placeholder: '请选择告警配置',
@@ -40,7 +40,7 @@ export function useGridFormSchema(): VbenFormSchema[] {
label: '产品',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductList,
api: () => getSimpleProductList(),
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',
@@ -53,7 +53,7 @@ export function useGridFormSchema(): VbenFormSchema[] {
label: '设备',
component: 'ApiSelect',
componentProps: {
api: getSimpleDeviceList,
api: () => getSimpleDeviceList(),
labelField: 'deviceName',
valueField: 'id',
placeholder: '请选择设备',

View File

@@ -28,7 +28,7 @@ export function useFormSchema(): VbenFormSchema[] {
label: '产品',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductList,
api: () => getSimpleProductList(),
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',
@@ -89,7 +89,7 @@ export function useFormSchema(): VbenFormSchema[] {
label: '设备分组',
component: 'ApiSelect',
componentProps: {
api: getSimpleDeviceGroupList,
api: () => getSimpleDeviceGroupList(),
labelField: 'name',
valueField: 'id',
mode: 'multiple',
@@ -156,7 +156,7 @@ export function useGroupFormSchema(): VbenFormSchema[] {
label: '设备分组',
component: 'ApiSelect',
componentProps: {
api: getSimpleDeviceGroupList,
api: () => getSimpleDeviceGroupList(),
labelField: 'name',
valueField: 'id',
mode: 'multiple',
@@ -199,7 +199,7 @@ export function useGridFormSchema(): VbenFormSchema[] {
label: '产品',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductList,
api: () => getSimpleProductList(),
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',
@@ -249,7 +249,7 @@ export function useGridFormSchema(): VbenFormSchema[] {
label: '设备分组',
component: 'ApiSelect',
componentProps: {
api: getSimpleDeviceGroupList,
api: () => getSimpleDeviceGroupList(),
labelField: 'name',
valueField: 'id',
placeholder: '请选择设备分组',

View File

@@ -481,6 +481,10 @@ onMounted(async () => {
</template>
<style scoped>
:deep(.vxe-toolbar div) {
z-index: 1;
}
/* 隐藏 VxeGrid 自带的搜索表单区域 */
:deep(.vxe-grid--form-wrapper) {
display: none !important;

View File

@@ -7,7 +7,6 @@ import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from 'ant-design-vue';
import { importDeviceTemplate } from '#/api/iot/device/device';
import { $t } from '#/locales';
import { useImportFormSchema } from '../data';
@@ -65,7 +64,7 @@ const [Modal, modalApi] = useVbenModal({
const result = await response.json();
if (result.code !== 0) {
message.error(result.msg || $t('ui.actionMessage.operationFailed'));
message.error(result.msg || '导入失败');
return;
}
@@ -95,7 +94,7 @@ const [Modal, modalApi] = useVbenModal({
await modalApi.close();
emit('success');
} catch (error: any) {
message.error(error.message || $t('ui.actionMessage.operationFailed'));
message.error(error.message || '导入失败');
} finally {
modalApi.unlock();
}

View File

@@ -34,7 +34,7 @@ export function useFormSchema(): VbenFormSchema[] {
label: '父级分组',
component: 'ApiTreeSelect',
componentProps: {
api: getSimpleDeviceGroupList,
api: () => getSimpleDeviceGroupList(),
labelField: 'name',
valueField: 'id',
placeholder: '请选择父级分组',

View File

@@ -29,7 +29,7 @@ export function useFormSchema(): VbenFormSchema[] {
label: '所属产品',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductList,
api: () => getSimpleProductList(),
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',
@@ -85,7 +85,7 @@ export function useGridFormSchema(): VbenFormSchema[] {
label: '产品',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductList,
api: () => getSimpleProductList(),
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',

View File

@@ -29,7 +29,7 @@ export function useFormSchema(): VbenFormSchema[] {
label: '所属产品',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductList,
api: () => getSimpleProductList(),
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',
@@ -86,7 +86,7 @@ export function useGridFormSchema(): VbenFormSchema[] {
label: '产品',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductList,
api: () => getSimpleProductList(),
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',

View File

@@ -4,6 +4,7 @@ import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
import { Page, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
@@ -16,6 +17,8 @@ import { useGridColumns, useGridFormSchema } from './data';
defineOptions({ name: 'IoTOtaFirmware' });
const { push } = useRouter();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
@@ -36,10 +39,6 @@ function handleEdit(row: IoTOtaFirmwareApi.Firmware) {
formModalApi.setData({ type: 'update', id: row.id }).open();
}
/** 查看固件详情 */
function handleDetail(row: IoTOtaFirmwareApi.Firmware) {
formModalApi.setData({ type: 'view', id: row.id }).open();
}
/** 删除固件 */
async function handleDelete(row: IoTOtaFirmwareApi.Firmware) {
@@ -58,6 +57,11 @@ async function handleDelete(row: IoTOtaFirmwareApi.Firmware) {
}
}
/** 查看固件详情 */
function handleDetail(row: IoTOtaFirmwareApi.Firmware) {
push({ name: 'IoTOtaFirmwareDetail', params: { id: row.id } });
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
@@ -113,18 +117,19 @@ const [Grid, gridApi] = useVbenVxeGrid({
<!-- 固件文件列 -->
<template #fileUrl="{ row }">
<a
v-if="row.fileUrl"
:href="row.fileUrl"
target="_blank"
download
class="text-primary cursor-pointer hover:underline"
>
<IconifyIcon icon="ant-design:download-outlined" class="mr-1" />
下载固件
</a>
<span v-else class="text-gray-400">无文件</span>
</template>
<div v-if="row.fileUrl" class="inline-flex items-center gap-1.5 align-middle leading-none">
<IconifyIcon icon="ant-design:download-outlined" class="shrink-0 text-base align-middle text-primary" />
<a
:href="row.fileUrl"
target="_blank"
download
class="text-primary cursor-pointer hover:underline align-middle"
>
下载固件
</a>
</div>
<span v-else class="text-gray-400">无文件</span>
</template>
<!-- 操作列 -->
<template #actions="{ row }">

View File

@@ -59,8 +59,8 @@ onMounted(() => {
<template>
<div class="p-4">
<!-- 固件信息 -->
<Card title="固件信息" class="mb-5" :loading="firmwareLoading">
<Descriptions :column="3" bordered>
<Card title="固件信息" class="mb-3" :loading="firmwareLoading">
<Descriptions :column="3" bordered size="small">
<Descriptions.Item label="固件名称">
{{ firmware?.name }}
</Descriptions.Item>
@@ -86,15 +86,15 @@ onMounted(() => {
<!-- 升级设备统计 -->
<Card
title="升级设备统计"
class="mb-5"
class="mb-3"
:loading="firmwareStatisticsLoading"
>
<Row :gutter="20" class="py-5">
<Row :gutter="20" class="py-3">
<Col :span="6">
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
class="rounded border border-solid border-gray-200 bg-gray-50 p-3 text-center"
>
<div class="mb-2 text-3xl font-bold text-blue-500">
<div class="mb-1 text-3xl font-bold text-blue-500">
{{
Object.values(firmwareStatistics).reduce(
(sum: number, count) => sum + (count || 0),
@@ -107,9 +107,9 @@ onMounted(() => {
</Col>
<Col :span="3">
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
class="rounded border border-solid border-gray-200 bg-gray-50 p-3 text-center"
>
<div class="mb-2 text-3xl font-bold text-gray-400">
<div class="mb-1 text-3xl font-bold text-gray-400">
{{
firmwareStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] ||
0
@@ -120,9 +120,9 @@ onMounted(() => {
</Col>
<Col :span="3">
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
class="rounded border border-solid border-gray-200 bg-gray-50 p-3 text-center"
>
<div class="mb-2 text-3xl font-bold text-blue-400">
<div class="mb-1 text-3xl font-bold text-blue-400">
{{
firmwareStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0
}}
@@ -132,9 +132,9 @@ onMounted(() => {
</Col>
<Col :span="3">
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
class="rounded border border-solid border-gray-200 bg-gray-50 p-3 text-center"
>
<div class="mb-2 text-3xl font-bold text-yellow-500">
<div class="mb-1 text-3xl font-bold text-yellow-500">
{{
firmwareStatistics[
IoTOtaTaskRecordStatusEnum.UPGRADING.value
@@ -146,9 +146,9 @@ onMounted(() => {
</Col>
<Col :span="3">
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
class="rounded border border-solid border-gray-200 bg-gray-50 p-3 text-center"
>
<div class="mb-2 text-3xl font-bold text-green-500">
<div class="mb-1 text-3xl font-bold text-green-500">
{{
firmwareStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] ||
0
@@ -159,9 +159,9 @@ onMounted(() => {
</Col>
<Col :span="3">
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
class="rounded border border-solid border-gray-200 bg-gray-50 p-3 text-center"
>
<div class="mb-2 text-3xl font-bold text-red-500">
<div class="mb-1 text-3xl font-bold text-red-500">
{{
firmwareStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] ||
0
@@ -172,9 +172,9 @@ onMounted(() => {
</Col>
<Col :span="3">
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
class="rounded border border-solid border-gray-200 bg-gray-50 p-3 text-center"
>
<div class="mb-2 text-3xl font-bold text-gray-400">
<div class="mb-1 text-3xl font-bold text-gray-400">
{{
firmwareStatistics[IoTOtaTaskRecordStatusEnum.CANCELED.value] ||
0

View File

@@ -34,7 +34,7 @@ export function useFormSchema(): VbenFormSchema[] {
label: '父级分类',
component: 'ApiTreeSelect',
componentProps: {
api: getSimpleProductCategoryList,
api: () => getSimpleProductCategoryList(),
labelField: 'name',
valueField: 'id',
placeholder: '请选择父级分类',

View File

@@ -93,7 +93,7 @@ export function useFormSchema(formApi?: any): VbenFormSchema[] {
label: '产品分类',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductCategoryList,
api: () => getSimpleProductCategoryList(),
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品分类',
@@ -246,7 +246,7 @@ export function useBasicFormSchema(formApi?: any): VbenFormSchema[] {
label: '产品分类',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductCategoryList,
api: () => getSimpleProductCategoryList(),
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品分类',

View File

@@ -333,6 +333,10 @@ onMounted(() => {
</Page>
</template>
<style scoped>
:deep(.vxe-toolbar div) {
z-index: 1;
}
/* 隐藏 VxeGrid 自带的搜索表单区域 */
:deep(.vxe-grid--form-wrapper) {
display: none !important;

View File

@@ -24,7 +24,7 @@ export function useGridFormSchema(): VbenFormSchema[] {
label: '产品',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductList,
api: () => getSimpleProductList(),
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',

View File

@@ -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';
@@ -33,7 +32,6 @@ export function useInfoFormSchema(): VbenFormSchema[] {
{
fieldName: 'categoryId',
label: '分类名称',
// component: 'ApiCascader',
component: 'ApiTreeSelect',
componentProps: {
api: async () => {
@@ -285,7 +283,7 @@ export function useOtherFormSchema(): VbenFormSchema[] {
componentProps: {
min: 0,
},
rules: z.number().min(0).optional().default(0),
rules: 'required',
},
{
fieldName: 'giveIntegral',
@@ -294,7 +292,7 @@ export function useOtherFormSchema(): VbenFormSchema[] {
componentProps: {
min: 0,
},
rules: z.number().min(0).optional().default(0),
rules: 'required',
},
{
fieldName: 'virtualSalesCount',
@@ -303,7 +301,7 @@ export function useOtherFormSchema(): VbenFormSchema[] {
componentProps: {
min: 0,
},
rules: z.number().min(0).optional().default(0),
rules: 'required',
},
];
}

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { MallSpuApi } from '#/api/mall/product/spu';
// TODO @puhui999这个是不是 api 后端有定义类似的?如果是,是不是放到 api 哈?
export interface PropertyAndValues {
id: number;
name: string;
@@ -23,12 +24,8 @@ export interface RuleConfig {
message: string;
}
/**
* 获得商品的规格列表 - 商品相关的公共函数
*
* @param spu
* @return PropertyAndValues 规格列表
*/
// TODO @puhui999这个是只有 index.ts 在用么?还是别的模块也会用
/** 获得商品的规格列表 - 商品相关的公共函数 */
const getPropertyList = (spu: MallSpuApi.Spu): PropertyAndValues[] => {
// 直接拿返回的 skus 属性逆向生成出 propertyList
const properties: PropertyAndValues[] = [];
@@ -62,4 +59,5 @@ const getPropertyList = (spu: MallSpuApi.Spu): PropertyAndValues[] => {
export { getPropertyList };
// 导出组件
// TODO @puhui999如果 sku-list.vue 要对外,可以考虑在 spu 下面,搞个 components 模块目前看别的模块应该会用到哈。modules 是当前模块用到的components 是跨模块要用到的。
export { default as SkuList } from './modules/sku-list.vue';

View File

@@ -8,7 +8,7 @@ import { useRoute } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { convertToInteger, floatToFixed2, formatToFraction } from '@vben/utils';
import { convertToInteger, formatToFraction } from '@vben/utils';
import { Button, Card, message } from 'ant-design-vue';
@@ -31,11 +31,6 @@ const spuId = ref<number>();
const { params, name } = useRoute();
const { closeCurrentTab } = useTabs();
const activeTabName = ref('info');
function onTabChange(key: string) {
activeTabName.value = key;
}
const tabList = ref([
{
key: 'info',
@@ -58,44 +53,43 @@ const tabList = ref([
tab: '其它设置',
},
]);
// 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 propertyList = ref<PropertyAndValues[]>([]); // 商品属性列表
const formLoading = ref(true); // 表单的加载中1修改时的数据加载2提交的按钮禁用
const isDetail = ref(false); // 是否查看详情
const formLoading = ref(false); // 表单的加载中1修改时的数据加载2提交的按钮禁用
const isDetail = ref(name === 'ProductSpuDetail'); // 是否查看详情
const skuListRef = ref(); // 商品属性列表 Ref
// sku 相关属性校验规则
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[] = [
{
name: 'stock',
@@ -117,7 +111,7 @@ const ruleConfig: RuleConfig[] = [
rule: (arg) => arg >= 0.01,
message: '商品成本价格必须大于等于 0.00 元!!!',
},
];
]; // sku 相关属性校验规则
const [InfoForm, infoFormApi] = useVbenForm({
commonConfig: {
@@ -146,11 +140,11 @@ const [SkuForm, skuFormApi] = useVbenForm({
handleValuesChange: (values, fieldsChanged) => {
if (fieldsChanged.includes('subCommissionType')) {
formData.value.subCommissionType = values.subCommissionType;
changeSubCommissionType();
handleChangeSubCommissionType();
}
if (fieldsChanged.includes('specType')) {
formData.value.specType = values.specType;
onChangeSpec();
handleChangeSpec();
}
},
});
@@ -199,7 +193,13 @@ const [OtherForm, otherFormApi] = useVbenForm({
showDefaultActions: false,
});
async function onSubmit() {
/** tab 切换 */
function handleTabChange(key: string) {
activeTabName.value = key;
}
/** 提交表单 */
async function handleSubmit() {
const values: MallSpuApi.Spu = await infoFormApi
.merge(skuFormApi)
.merge(deliveryFormApi)
@@ -216,7 +216,7 @@ async function onSubmit() {
return;
}
values.skus.forEach((item) => {
// sku相关价格元转分
// 金额转换:元转分
item.price = convertToInteger(item.price);
item.marketPrice = convertToInteger(item.marketPrice);
item.costPrice = convertToInteger(item.costPrice);
@@ -224,7 +224,7 @@ async function onSubmit() {
item.secondBrokeragePrice = convertToInteger(item.secondBrokeragePrice);
});
}
// 处理轮播图列表
// 处理轮播图列表 TODO @puhui999这个是必须的哇
const newSliderPicUrls: any[] = [];
values.sliderPicUrls!.forEach((item: any) => {
// 如果是前端选的图
@@ -234,12 +234,13 @@ async function onSubmit() {
});
values.sliderPicUrls = newSliderPicUrls;
// 提交数据
await (spuId.value ? updateSpu(values) : createSpu(values));
}
/** 获得详情 */
async function getDetail() {
if (name === 'ProductSpuDetail') {
if (isDetail.value) {
isDetail.value = true;
infoFormApi.setDisabled(true);
skuFormApi.setDisabled(true);
@@ -247,45 +248,36 @@ async function getDetail() {
descriptionFormApi.setDisabled(true);
otherFormApi.setDisabled(true);
}
const id = params.id as unknown as number;
if (id) {
try {
const res = await getSpu(spuId.value!);
res.skus?.forEach((item) => {
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,
);
}
});
formData.value = res;
// 初始化各表单值(异步)
infoFormApi.setValues(res);
skuFormApi.setValues(res);
deliveryFormApi.setValues(res);
descriptionFormApi.setValues(res);
otherFormApi.setValues(res);
} finally {
formLoading.value = false;
}
}
// 将 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();
}
@@ -296,7 +288,7 @@ function generateSkus(propertyList: any[]) {
}
/** 分销类型 */
function changeSubCommissionType() {
function handleChangeSubCommissionType() {
// 默认为零,类型切换后也要重置为零
for (const item of formData.value.skus!) {
item.firstBrokeragePrice = 0;
@@ -305,10 +297,10 @@ function changeSubCommissionType() {
}
/** 选择规格 */
function onChangeSpec() {
function handleChangeSpec() {
// 重置商品属性列表
propertyList.value = [];
// 重置sku列表
// 重置 sku 列表
formData.value.skus = [
{
price: 0,
@@ -325,7 +317,7 @@ function onChangeSpec() {
];
}
// 监听 sku form schema 变化,更新表单
/** 监听 sku form schema 变化,更新表单 */
watch(
propertyList,
() => {
@@ -336,6 +328,7 @@ watch(
{ deep: true },
);
/** 初始化 */
onMounted(async () => {
spuId.value = params.id as unknown as number;
if (!spuId.value) {
@@ -355,18 +348,18 @@ onMounted(async () => {
:loading="formLoading"
:tab-list="tabList"
:active-key="activeTabName"
@tab-change="onTabChange"
@tab-change="handleTabChange"
>
<template #tabBarExtraContent>
<Button type="primary" v-if="!isDetail" @click="onSubmit">
<Button type="primary" v-if="!isDetail" @click="handleSubmit">
保存
</Button>
<Button type="default" v-else @click="() => closeCurrentTab()">
返回列表
</Button>
</template>
<InfoForm class="w-3/5" v-show="activeTabName === 'info'" />
<InfoForm class="w-3/5" v-show="activeTabName === 'info'" />
<SkuForm class="w-full" v-show="activeTabName === 'sku'">
<template #singleSkuList>
<SkuList
@@ -418,6 +411,7 @@ onMounted(async () => {
</div>
</template>
<style lang="scss" scoped>
// TODO @puhui999这个样式是必须的哇
:deep(.ant-tabs-tab-btn) {
font-size: 14px !important;
}

View File

@@ -21,7 +21,6 @@ const props = withDefaults(defineProps<Props>(), {
isDetail: false,
});
/** 输入框失去焦点或点击回车时触发 */
const emit = defineEmits(['success']);
interface Props {
@@ -30,12 +29,15 @@ interface Props {
}
const inputValue = ref<string[]>([]); // 输入框值tags 模式使用数组)
const attributeIndex = ref<null | number>(null); // 获取焦点时记录当前属性项的index
// 输入框显隐控制
const attributeIndex = ref<null | number>(null); // 获取焦点时记录当前属性项的 index
const inputVisible = computed(() => (index: number) => {
if (attributeIndex.value === null) return false;
if (attributeIndex.value === index) return true;
});
if (attributeIndex.value === null) {
return false;
}
if (attributeIndex.value === index) {
return true;
}
}); // 输入框显隐控制
interface InputRefItem {
inputRef?: {
@@ -46,7 +48,10 @@ interface InputRefItem {
focus: () => void;
}
const inputRef = ref<InputRefItem[]>([]); // 标签输入框Ref
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;
@@ -59,13 +64,13 @@ function setInputRef(el: any) {
inputRef.value.push(el);
}
}
const attributeList = ref<PropertyAndValues[]>([]); // 商品属性列表
const attributeOptions = ref<MallPropertyApi.PropertyValue[]>([]); // 商品属性值下拉框
watch(
() => props.propertyList,
(data) => {
if (!data) return;
if (!data) {
return;
}
attributeList.value = data;
},
{
@@ -74,12 +79,12 @@ watch(
},
);
/** 删除属性值*/
/** 删除属性值 */
function handleCloseValue(index: number, valueIndex: number) {
attributeList.value?.[index]?.values?.splice(valueIndex, 1);
}
/** 删除属性*/
/** 删除属性 */
function handleCloseProperty(index: number) {
attributeList.value?.splice(index, 1);
emit('success', attributeList.value);
@@ -93,7 +98,7 @@ async function showInput(index: number) {
await getAttributeOptions(attributeList.value?.[index]?.id!);
}
// 定义 success 事件,用于操作成功后的回调
/** 定义 success 事件,用于操作成功后的回调 */
async function handleInputConfirm(index: number, propertyId: number) {
// 从数组中取最后一个输入的值tags 模式下 inputValue 是数组)
const currentValue = inputValue.value?.[inputValue.value.length - 1]?.trim();
@@ -154,6 +159,7 @@ async function getAttributeOptions(propertyId: number) {
<template>
<Col v-for="(item, index) in attributeList" :key="index">
<!-- TODO @puhui9991间隙可以看看2)vue3 + element-plus 添加属性这个按钮是和属性名在一排感觉更好看点 -->
<div>
<span class="mx-1">属性名</span>
<Tag
@@ -174,6 +180,7 @@ async function getAttributeOptions(propertyId: number) {
class="mx-1"
@close="handleCloseValue(index, valueIndex)"
>
<!-- TODO @puhui999这里貌似爆红idea -->
{{ value.name }}
</Tag>
<Select

View File

@@ -35,7 +35,9 @@ const attributeOptions = ref<MallPropertyApi.Property[]>([]); // 商品属性名
watch(
() => props.propertyList,
(data) => {
if (!data) return;
if (!data) {
return;
}
attributeList.value = data as any[];
},
{
@@ -44,7 +46,6 @@ watch(
},
);
// 表单配置
const formSchema: VbenFormSchema[] = [
{
fieldName: 'name',
@@ -62,7 +63,6 @@ const formSchema: VbenFormSchema[] = [
showSearch: true,
filterOption: true,
placeholder: '请选择属性名称。如果不存在,可手动输入选择',
// 支持手动输入新选项
mode: 'tags',
maxTagCount: 1,
allowClear: true,
@@ -71,7 +71,6 @@ const formSchema: VbenFormSchema[] = [
},
];
// 初始化表单
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
@@ -85,16 +84,15 @@ const [Form, formApi] = useVbenForm({
showDefaultActions: false,
});
// 初始化弹窗
const [Modal, modalApi] = useVbenModal({
destroyOnClose: true,
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
if (!valid) {
return;
}
const values = await formApi.getValues();
const name = Array.isArray(values.name) ? values.name[0] : values.name;
// 重复添加校验
for (const attrItem of attributeList.value) {
if (attrItem.name === name) {
@@ -103,6 +101,8 @@ const [Modal, modalApi] = useVbenModal({
}
}
// TODO @puhui999modalApi.lock(); 这种写法;
// 情况一:属性名已存在,则直接使用
const existProperty = attributeOptions.value.find(
(item: MallPropertyApi.Property) => item.name === name,
@@ -113,6 +113,7 @@ const [Modal, modalApi] = useVbenModal({
name,
values: [],
});
// TODO @puhui999这里要不 if else这样 await modalApi.close(); emit('success'); 可以复用另外感觉甚至可以情况二add 后,成为 existProperty可以进一步简化
await modalApi.close();
emit('success');
return;
@@ -132,7 +133,6 @@ const [Modal, modalApi] = useVbenModal({
await modalApi.close();
emit('success');
} catch (error) {
// 发生错误时不关闭弹窗
console.error('添加属性失败:', error);
}
},
@@ -140,7 +140,6 @@ const [Modal, modalApi] = useVbenModal({
if (!isOpen) {
return;
}
// 重置表单
await formApi.resetForm();
},
});

View File

@@ -46,16 +46,16 @@ const { isBatch, isDetail, isComponent, isActivityComponent } = props;
const formData: Ref<MallSpuApi.Spu | undefined> = ref<MallSpuApi.Spu>(); // 表单数据
const skuList = ref<MallSpuApi.Sku[]>([
{
price: 0, // 商品价格
marketPrice: 0, // 市场价
costPrice: 0, // 成本价
barCode: '', // 商品条码
picUrl: '', // 图片地址
stock: 0, // 库存
weight: 0, // 商品重量
volume: 0, // 商品体积
firstBrokeragePrice: 0, // 一级分销的佣金
secondBrokeragePrice: 0, // 二级分销的佣金
price: 0,
marketPrice: 0,
costPrice: 0,
barCode: '',
picUrl: '',
stock: 0,
weight: 0,
volume: 0,
firstBrokeragePrice: 0,
secondBrokeragePrice: 0,
},
]); // 批量添加时的临时数据
@@ -91,9 +91,7 @@ function deleteSku(row: MallSpuApi.Sku) {
const tableHeaders = ref<{ label: string; prop: string }[]>([]); // 多属性表头
/**
* 保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。
*/
/** 保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种 */
function validateSku() {
validateProperty();
let warningInfo = '请检查商品各行相关属性配置,';
@@ -116,6 +114,7 @@ function validateSku() {
}
}
// TODO @puhui999是不是可以通过 getNestedValue 简化?
function getValue(obj: any, arg: string): unknown {
const keys = arg.split('.');
let value: any = obj;
@@ -132,19 +131,20 @@ function getValue(obj: any, arg: string): unknown {
/**
* 选择时触发
*
* @param records 传递过来的选中的 sku 是一个数组
*/
function handleSelectionChange({ records }: { records: MallSpuApi.Sku[] }) {
emit('selectionChange', records);
}
/**
* 将传进来的值赋值给 skuList
*/
/** 将传进来的值赋值给 skuList */
watch(
() => props.propFormData,
(data) => {
if (!data) return;
if (!data) {
return;
}
formData.value = data;
},
{
@@ -196,9 +196,7 @@ function generateTableData(propertyList: PropertyAndValues[]) {
}
}
/**
* 生成 skus 前置校验
*/
/** 生成 skus 前置校验 */
function validateData(propertyList: PropertyAndValues[]): boolean {
const skuPropertyIds: number[] = [];
formData.value!.skus!.forEach((sku: MallSpuApi.Sku) =>
@@ -302,13 +300,13 @@ function getSkuTableRef() {
return activitySkuListRef.value;
}
// 暴露出生成 sku 方法,给添加属性成功时调用
defineExpose({ generateTableData, validateSku, getSkuTableRef });
</script>
<template>
<div>
<!-- 情况一添加/修改 -->
<!-- TODO @puhui999有可以通过 grid 来做么主要考虑这样不直接使用 vxe 标签抽象程度更高 -->
<VxeTable
v-if="!isDetail && !isActivityComponent"
:data="isBatch ? skuList : formData?.skus || []"
@@ -328,7 +326,7 @@ defineExpose({ generateTableData, validateSku, getSkuTableRef });
</template>
</VxeColumn>
<template v-if="formData?.specType && !isBatch">
<!-- 根据商品属性动态添加 -->
<!-- 根据商品属性动态添加 -->
<VxeColumn
v-for="(item, index) in tableHeaders"
:key="index"
@@ -481,7 +479,7 @@ defineExpose({ generateTableData, validateSku, getSkuTableRef });
</template>
</VxeColumn>
<template v-if="formData?.specType && !isBatch">
<!-- 根据商品属性动态添加 -->
<!-- 根据商品属性动态添加 -->
<VxeColumn
v-for="(item, index) in tableHeaders"
:key="index"
@@ -565,7 +563,7 @@ defineExpose({ generateTableData, validateSku, getSkuTableRef });
</template>
</VxeColumn>
<template v-if="formData?.specType">
<!-- 根据商品属性动态添加 -->
<!-- 根据商品属性动态添加 -->
<VxeColumn
v-for="(item, index) in tableHeaders"
:key="index"
@@ -605,7 +603,7 @@ defineExpose({ generateTableData, validateSku, getSkuTableRef });
{{ row.stock }}
</template>
</VxeColumn>
<!-- 方便扩展每个活动配置的属性不一样 -->
<!-- 方便扩展每个活动配置的属性不一样 -->
<slot name="extension"></slot>
</VxeTable>
</div>

View File

@@ -24,7 +24,8 @@ const emit = defineEmits<{
const selectedSkuId = ref<number>();
const spuId = ref<number>();
// 配置列
/** 配置列 */
// TODO @puhui999貌似列太宽了
const gridColumns = computed<VxeGridProps['columns']>(() => [
{
field: 'id',
@@ -65,7 +66,6 @@ const gridColumns = computed<VxeGridProps['columns']>(() => [
},
]);
// 初始化表格
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: gridColumns.value,
@@ -95,14 +95,13 @@ const [Grid, gridApi] = useVbenVxeGrid({
},
});
// 处理选中
/** 处理选中 */
function handleSelected(row: MallSpuApi.Sku) {
emit('change', row);
modalApi.close();
selectedSkuId.value = undefined;
}
// 初始化弹窗
const [Modal, modalApi] = useVbenModal({
destroyOnClose: true,
onOpenChange: async (isOpen: boolean) => {
@@ -111,8 +110,8 @@ const [Modal, modalApi] = useVbenModal({
spuId.value = undefined;
return;
}
const data = modalApi.getData<SpuData>();
// TODO @puhui999这里要不 if return让括号的层级简单点。
if (data?.spuId) {
spuId.value = data.spuId;
// 触发数据查询
@@ -125,7 +124,6 @@ const [Modal, modalApi] = useVbenModal({
<template>
<Modal class="w-[700px]" title="选择规格">
<Grid>
<!-- 单选列 -->
<template #radio-column="{ row }">
<Input
v-model="selectedSkuId"

View File

@@ -1,5 +1,6 @@
<!-- SPU 商品选择弹窗组件 -->
<script lang="ts" setup>
// TODO @puhui999这个是不是可以放到 components 里?,和商品发布,关系不大
import type { CheckboxChangeEvent } from 'ant-design-vue/es/checkbox/interface';
import type { VbenFormSchema } from '#/adapter/form';
@@ -30,33 +31,15 @@ const emit = defineEmits<{
change: [spu: MallSpuApi.Spu | MallSpuApi.Spu[]];
}>();
// 单选:选中的 SPU ID
const selectedSpuId = ref<number>();
// 多选:选中状态 map
const checkedStatus = ref<Record<number, boolean>>({});
// 多选:选中的 SPU 列表
const checkedSpus = ref<MallSpuApi.Spu[]>([]);
// 多选:全选状态
const isCheckAll = ref(false);
// 多选:半选状态
const isIndeterminate = ref(false);
const selectedSpuId = ref<number>(); // 单选:选中的 SPU ID
const checkedStatus = ref<Record<number, boolean>>({}); // 多选:选中状态 map
const checkedSpus = ref<MallSpuApi.Spu[]>([]); // 多选:选中的 SPU 列表
const isCheckAll = ref(false); // 多选:全选状态
const isIndeterminate = ref(false); // 多选:半选状态
// 分类列表(扁平)
const categoryList = ref<any[]>([]);
// 分类树
const categoryTreeList = ref<any[]>([]);
const categoryList = ref<any[]>([]); // 分类列表(扁平)
const categoryTreeList = ref<any[]>([]); // 分类树
// 初始化分类数据
onMounted(async () => {
try {
categoryList.value = await getCategoryList({});
categoryTreeList.value = handleTree(categoryList.value, 'id', 'parentId');
} catch (error) {
console.error('加载分类数据失败:', error);
}
});
// 搜索表单配置
const formSchema = computed<VbenFormSchema[]>(() => {
return [
{
@@ -97,7 +80,6 @@ const formSchema = computed<VbenFormSchema[]>(() => {
];
});
// 列配置
const gridColumns = computed<VxeGridProps['columns']>(() => {
const columns: VxeGridProps['columns'] = [];
@@ -121,7 +103,7 @@ const gridColumns = computed<VxeGridProps['columns']>(() => {
});
}
// 其
// 其
columns.push(
{
field: 'id',
@@ -157,7 +139,6 @@ const gridColumns = computed<VxeGridProps['columns']>(() => {
return columns;
});
// 初始化表格
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: formSchema.value,
@@ -172,6 +153,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
proxyConfig: {
ajax: {
async query({ page }: any, formValues: any) {
// TODO @puhui999这里是不是不 try catch
try {
const params = {
pageNo: page.currentPage,
@@ -182,6 +164,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
createTime: formValues.createTime || undefined,
};
// TODO @puhui999一次性的是不是不声明 params直接放到 getSpuPage 里?
const data = await getSpuPage(params);
// 初始化多选状态
@@ -208,14 +191,15 @@ const [Grid, gridApi] = useVbenVxeGrid({
},
});
// 单选:处理选中
// TODO @puhui999如下的选中方法可以因为 Grid 做简化么?
/** 单选:处理选中 */
function handleSingleSelected(row: MallSpuApi.Spu) {
selectedSpuId.value = row.id;
emit('change', row);
modalApi.close();
}
// 多选:全选/全不选
/** 多选:全选/全不选 */
function handleCheckAll(e: CheckboxChangeEvent) {
const checked = e.target.checked;
isCheckAll.value = checked;
@@ -228,7 +212,7 @@ function handleCheckAll(e: CheckboxChangeEvent) {
calculateIsCheckAll();
}
// 多选:选中单个
/** 多选:选中单个 */
function handleCheckOne(
checked: boolean,
spu: MallSpuApi.Spu,
@@ -255,7 +239,7 @@ function handleCheckOne(
}
}
// 多选:计算全选状态
/** 多选:计算全选状态 */
function calculateIsCheckAll() {
const currentList = gridApi.grid.getData();
if (currentList.length === 0) {
@@ -272,7 +256,6 @@ function calculateIsCheckAll() {
isIndeterminate.value = checkedCount > 0 && checkedCount < currentList.length;
}
// 初始化弹窗
const [Modal, modalApi] = useVbenModal({
destroyOnClose: true,
// 多选模式时显示确认按钮
@@ -284,7 +267,7 @@ const [Modal, modalApi] = useVbenModal({
: undefined,
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
// 关闭时清理状态
// TODO @puhui999是不是直接清理不要判断 selectedSpuId.value
if (!props.multiple) {
selectedSpuId.value = undefined;
}
@@ -300,7 +283,6 @@ const [Modal, modalApi] = useVbenModal({
checkedStatus.value = {};
isCheckAll.value = false;
isIndeterminate.value = false;
// 恢复已选中的数据
if (Array.isArray(data) && data.length > 0) {
checkedSpus.value = [...data];
@@ -316,9 +298,16 @@ const [Modal, modalApi] = useVbenModal({
}
// 触发查询
// TODO @puhui999貌似不用这里再查询一次100% 会查询的,记忆中是;
await gridApi.query();
},
});
/** 初始化分类数据 */
onMounted(async () => {
categoryList.value = await getCategoryList({});
categoryTreeList.value = handleTree(categoryList.value, 'id', 'parentId');
});
</script>
<template>

View File

@@ -145,7 +145,9 @@ const [Modal, modalApi] = useVbenModal({
// "确认"按钮的回调
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
if (!valid) {
return;
}
modalApi.lock();
try {

View File

@@ -53,7 +53,9 @@ const rewardRuleRef = ref<InstanceType<typeof RewardRule>>();
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
if (!valid) {
return;
}
modalApi.lock();
try {