Files
guilixu-admin/src/views/shop/shopGoods/components/shopGoodsEdit.vue
T
gxwebsoft b5c67988a6 feat(shop): 新增首页专区管理功能
- 在商品模型中添加所属专区字段 sectionIds,支持逗号分隔字符串
- 商品编辑界面增加所属专区多选下拉框,联动专区数据
- 新增首页专区相关接口,包括分页查询、增删改查及权限校验等
- 实现首页专区列表页面,支持专区的增删改查及状态切换
- 新增专区白名单用户管理弹窗,支持多选用户设置白名单
- 实现专区商品列表抽屉,支持分页查看专区内商品
- 首页专区编辑弹窗支持标题、副标题、banner等多字段编辑及上传功能
- 完善专区管理界面交互和表格展示,支持搜索和状态筛选功能
2026-08-15 03:30:48 +08:00

2140 lines
70 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 编辑弹窗 -->
<template>
<a-drawer
width="70%"
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:title="isUpdate ? '编辑商品' : '添加商品'"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
@ok="save"
>
<template #extra>
<a-button type="primary" style="margin-right: 8px" @click="save">保存</a-button>
</template>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="styleResponsive ? { md: 3, sm: 5, xs: 24 } : { flex: '90px' }"
:wrapper-col="
styleResponsive ? { md: 24, sm: 24, xs: 24 } : { flex: '1' }
"
>
<a-tabs type="card" v-model:active-key="active" @change="onChange">
<a-tab-pane tab="基本信息" key="base">
<a-form-item label="商品名称" name="name">
<a-input
allow-clear
style="width: 558px"
placeholder="请输入商品名称"
v-model:value="form.name"
/>
</a-form-item>
<a-form-item label="商品分类" name="categoryIds">
<a-tree-select
allow-clear
multiple
:max-tag-count="3"
:tree-data="navigationList"
tree-default-expand-all
style="width: 320px"
placeholder="请选择分类(可多选)"
:value="form.categoryIds && form.categoryIds.length ? form.categoryIds : undefined"
:listHeight="700"
:dropdown-style="{ overflow: 'auto' }"
@update:value="(value?: number[]) => (form.categoryIds = value || [])"
@change="onCategoryIds"
/>
</a-form-item>
<a-form-item label="所属专区" name="sectionIds">
<a-select
v-model:value="sectionIdList"
mode="multiple"
:max-tag-count="3"
:options="zoneOptions"
style="width: 320px"
placeholder="请选择所属专区(可多选)"
allow-clear
/>
</a-form-item>
<a-form-item label="商品编码" name="code">
<a-input
allow-clear
style="width: 558px"
placeholder="请输入商品编码"
v-model:value="form.code"
/>
</a-form-item>
<a-form-item label="商品卖点" name="comments">
<a-textarea
:rows="1"
:maxlength="100"
style="width: 558px"
show-count
placeholder="此款商品美观大方 性价比较高 不容错过"
v-model:value="form.comments"
/>
</a-form-item>
<a-form-item label="单位名称" name="unitName">
<a-input
allow-clear
style="width: 240px"
placeholder="单位名称,如(个)"
v-model:value="form.unitName"
/>
</a-form-item>
<a-form-item label="商城价" name="price">
<a-space>
<a-input-number
:placeholder="`商城价`"
style="width: 240px"
:min="0.01"
v-model:value="form.price"
/>
</a-space>
</a-form-item>
<a-form-item label="市场价" name="salePrice">
<a-input-number
:placeholder="`市场价`"
style="width: 240px"
:min="0.01"
v-model:value="form.salePrice"
/>
</a-form-item>
<a-form-item label="会员价" name="originPrice">
<a-input-number
:placeholder="`会员价`"
style="width: 240px"
:min="0.01"
v-model:value="form.dealerPrice"
/>
</a-form-item>
<a-form-item label="进货价格" name="originPrice">
<a-input-number
:placeholder="`进货价格`"
style="width: 240px"
:min="0.01"
v-model:value="form.buyingPrice"
/>
</a-form-item>
<a-form-item :label="form.canExpress === 1 ? '运费模板' : '运费模板'" v-if="!merchantId" :required="form.canExpress === 1">
<a-select
v-model:value="form.expressTemplateId"
style="width: 240px"
:placeholder="form.canExpress === 1 ? '请选择运费模板' : '请选择运费模板'"
>
<a-select-option
v-for="(item, index) in expressTemplateList"
:key="index"
:value="item.id"
>{{ item.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="商品图片(400x400)" name="image">
<!-- <SelectFile-->
<!-- :placeholder="`请选择视频文件`"-->
<!-- :limit="1"-->
<!-- :data="images"-->
<!-- @done="chooseImage"-->
<!-- @del="onDeleteItem"-->
<!-- />-->
<!-- 视频预览 -->
<div
v-if="form.image && isVideo(form.image)"
class="relative"
style="width: 100px; height: 100px; margin-right: 10px; border-radius: 6px; overflow: hidden; border: 1px solid #434343; background: #1a1a1a;"
>
<video
:src="form.image"
style="width: 100%; height: 100%; object-fit: cover;"
preload="metadata"
muted
/>
<!-- 播放图标覆盖层 -->
<div
class="absolute inset-0 flex items-center justify-center"
style="background: rgba(0,0,0,0.25);"
>
<PlayCircleOutlined style="font-size: 28px; color: rgba(255,255,255,0.85);" />
</div>
</div>
<!-- 图片预览 -->
<a-image
v-else-if="form.image"
width="100px"
height="100px"
:src="form.image"
style="margin-right: 10px"
/>
<a-upload
:show-upload-list="false"
:customRequest="onUploadImage"
ref="imageRef"
>
<a-button class="ele-btn-icon">
<template #icon>
<UploadOutlined/>
</template>
<span>上传</span>
</a-button>
</a-upload>
<div class="ele-text-placeholder">
支持上传视频mp4格式),视频时长不超过60秒视频大小不超过200M
</div>
</a-form-item>
<a-form-item label="轮播图" name="files">
<div class="flex flex-wrap justify-start items-start">
<div
v-for="(item, index) in files"
:key="index"
style="margin-right: 10px; margin-bottom: 10px"
class="relative"
>
<img style="width: 100px; height: 100px" :src="item.url"/>
<div
class="absolute right-0 top-0 w-6 h-6 bg-red-400 flex justify-center items-center rounded-bl-lg"
@click="onDeleteFile(index)"
>
<delete-outlined style="color: white"/>
</div>
</div>
</div>
<a-upload
:show-upload-list="false"
:customRequest="onUploadSwiper"
multiple
:max-count="9"
ref="swiperRef"
>
<a-button class="ele-btn-icon">
<template #icon>
<UploadOutlined/>
</template>
<span>上传轮播图</span>
</a-button>
</a-upload>
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="form.status">
<a-radio :value="0">上架</a-radio>
<a-radio :value="1">下架</a-radio>
</a-radio-group>
</a-form-item>
</a-tab-pane>
<a-tab-pane tab="商品详情" key="content">
<RichTextEditor
v-if="active === 'content'"
v-model="content"
:height="520"
placeholder="请输入商品详情,支持图文混排..."
/>
</a-tab-pane>
<a-tab-pane tab="商品规格" key="spec">
<a-form-item label="规格类型" name="specs">
<a-space>
<a-radio-group v-model:value="form.specs">
<a-radio :value="0">单规格</a-radio>
<a-radio :value="1">多规格</a-radio>
</a-radio-group>
</a-space>
</a-form-item>
<a-form-item name="multipleSpec" v-if="form.specs == 1">
<div class="w-[300px] ml-10 flex items-center">
<SelectSpec
placeholder="选择规格"
:width="130"
v-model:value="form.specName"
@done="onSpec"
/>
<a-button type="link" size="small" @click="openSpecManage">
<template #icon><SettingOutlined /></template>
规格管理
</a-button>
</div>
</a-form-item>
<a-form-item name="specValue" v-if="form.specs == 1">
<a-space direction="vertical" class="ml-[40px]">
<template v-for="(item, index) in spec" :key="index">
<div
class="text-left flex items-center leading-10 text-gray-400"
>
<div class="mr-2">{{ item.value }} :</div>
<CloseCircleOutlined
class="cursor-pointer"
@click="onClose(index)"
/>
</div>
<ele-edit-tag
v-model:data="item.detail"
size="middle"
shape="round"
/>
</template>
<a-card class="ml-[40px]" v-if="showSpecForm">
<a-form-item name="name">
<a-input
allow-clear
placeholder="请输入规格,如:颜色"
v-model:value="name"
/>
</a-form-item>
<a-form-item name="value">
<a-input
allow-clear
placeholder="请输入规格值,如:红色"
v-model:value="value"
/>
</a-form-item>
<a-space>
<a-button type="primary" @click="addSpecValue">确定</a-button>
<a-button @click="openSpecForm">取消</a-button>
</a-space>
</a-card>
<a-space>
<a-button type="primary" class="mt-5" @click="openSpecForm"
>添加新规格
</a-button>
<a-button type="primary" class="mt-5" @click="generateSku" v-if="spec.length > 0"
>生成SKU
</a-button>
<a-tooltip title="方案1(推荐):编辑旧商品时,重新点击此按钮即可自动将规格数据转换为新格式显示">
<InfoCircleOutlined class="mt-5 ml-1 text-gray-400 cursor-help" />
</a-tooltip>
</a-space>
</a-space>
</a-form-item>
<a-form-item name="oneSpec" v-if="form.specs == 1">
<div class="w-full">
<!-- 批量设置 -->
<div class="batch-set-bar flex items-center gap-3 mb-4 p-3 bg-gray-50 rounded">
<span class="text-sm font-medium text-gray-600 whitespace-nowrap">批量设置</span>
<a-input-number
v-model:value="batchPrice"
:precision="2"
:min="0"
style="width: 110px"
placeholder="商城价"
/>
<a-input-number
v-model:value="batchSalePrice"
:precision="2"
:min="0"
style="width: 110px"
placeholder="划线价格"
/>
<a-input-number
v-model:value="batchBuyingPrice"
:precision="2"
:min="0"
style="width: 110px"
placeholder="会员价"
/>
<a-input-number
v-model:value="batchStock"
:min="0"
style="width: 100px"
placeholder="库存"
/>
<a-input
v-model:value="batchSkuNo"
style="width: 120px"
placeholder="SKU编码"
/>
<a-button type="primary" @click="batchApply">立即设置</a-button>
</div>
<div class="sku-table">
<a-table
:pagination="false"
:dataSource="skuList"
:columns="columns"
:scroll="{ y: 500 }"
>
<template #bodyCell="{ record, column, index }">
<template v-if="column.key === 'line'">
{{ index + 1 }}
</template>
<template v-if="column.key === 'image'">
<SelectFile
:placeholder="`请选择商品图片`"
:limit="1"
:data="record.images || []"
:index="index"
@done="chooseSkuImage"
@del="() => onDeleteSkuItem(index)"
/>
</template>
<template v-if="column.key === 'price'">
<a-input
:placeholder="`成本价`"
v-model:value="skuList[index].price"
/>
</template>
<template v-if="column.key === 'salePrice'">
<a-input
:placeholder="`市场价`"
v-model:value="record.salePrice"
/>
</template>
<template v-if="column.key === 'buyingPrice'">
<a-input
:placeholder="`会员价`"
v-model:value="record.buyingPrice"
/>
</template>
<template v-if="column.key === 'stock'">
<a-input
:placeholder="`库存`"
v-model:value="record.stock"
/>
</template>
<template v-if="column.key === 'skuNo'">
<a-input
:placeholder="`编码`"
v-model:value="record.skuNo"
/>
</template>
</template>
</a-table>
</div>
</div>
</a-form-item>
</a-tab-pane>
<a-tab-pane tab="营销设置" key="coupon">
<a-form-item label="商品重量" name="goodsWeight">
<a-input
allow-clear
style="width: 250px"
placeholder="请输入商品重量"
v-model:value="form.goodsWeight"
/>
</a-form-item>
<a-form-item label="销量" name="sales">
<a-input-number
allow-clear
style="width: 250px"
placeholder="请输入销量"
v-model:value="form.sales"
/>
</a-form-item>
<a-form-item label="库存" name="stock">
<a-input-number
allow-clear
style="width: 250px"
placeholder="请输入库存"
v-model:value="form.stock"
/>
</a-form-item>
<a-form-item label="获取积分" name="gainIntegral">
<a-input-number
:placeholder="`消费获取的积分`"
style="width: 250px"
v-model:value="form.gainIntegral"
/>
</a-form-item>
<a-form-item label="库存计算方式" name="deductStockType">
<a-radio-group v-model:value="form.deductStockType">
<a-radio :value="20">付款减库存</a-radio>
<a-radio :value="10">下单减库存</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="货架" name="position">
<a-input
allow-clear
style="width: 250px"
placeholder="请输入货架"
v-model:value="form.position"
/>
</a-form-item>
<a-form-item label="排序号" name="sortNumber">
<a-input-number
:min="0"
:max="9999"
style="width: 250px"
placeholder="请输入排序号"
v-model:value="form.sortNumber"
/>
</a-form-item>
<a-form-item label="是否可以快递配送">
<a-switch
size="small"
v-model:checked="form.canExpress"
:checked-value="1"
:un-checked-value="0"
/>
</a-form-item>
<a-form-item label="是否新品">
<a-switch
size="small"
v-model:checked="form.isNew"
:checked-value="1"
:un-checked-value="0"
/>
</a-form-item>
<a-form-item label="活动方式" name="activityType">
<a-radio-group v-model:value="form.activityType">
<a-radio :value="0">全平台新老用户均可</a-radio>
<a-radio :value="1">新用户专享仅新用户可购</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="配送方式" name="deliveryMode">
<a-radio-group v-model:value="form.deliveryMode">
<a-radio :value="0">快递配送</a-radio>
<a-radio :value="1">限自提</a-radio>
</a-radio-group>
</a-form-item>
<!-- <a-form-item label="是否开启分红功能" v-if="!merchantId">-->
<!-- <a-switch-->
<!-- size="small"-->
<!-- v-model:checked="form.commissionRole"-->
<!-- :checked-value="1"-->
<!-- :un-checked-value="0"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- <a-form-item label="角色分红配置" v-if="form.commissionRole === 1">-->
<!-- <a-space>-->
<!-- <a-input-->
<!-- v-model:value="form.goodsRoleCommission[index].amount"-->
<!-- v-for="(item, index) in form.goodsRoleCommission"-->
<!-- :key="index"-->
<!-- >-->
<!-- <template #addonBefore>{{ item.roleName }}</template>-->
<!-- <template #addonAfter></template>-->
<!-- </a-input>-->
<!-- </a-space>-->
<!-- </a-form-item>-->
<a-divider orientation="left">分销设置</a-divider>
<a-form-item label="是否开启分销" name="isOpenCommission">
<a-switch
size="small"
v-model:checked="form.isOpenCommission"
:checked-value="1"
:un-checked-value="0"
/>
</a-form-item>
<template v-if="form.isOpenCommission === 1">
<a-form-item label="分佣类型" name="commissionType">
<a-radio-group v-model:value="form.commissionType">
<a-radio :value="10">固定金额</a-radio>
<a-radio :value="20">百分比</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item
:label="form.commissionType === 20 ? '一级佣金(%)' : '一级佣金(元)'"
name="firstMoney"
>
<a-input-number
v-model:value="form.firstMoney"
:min="0"
:max="form.commissionType === 20 ? 100 : undefined"
:precision="2"
style="width: 250px"
:placeholder="
form.commissionType === 20
? '请输入一级佣金百分比'
: '请输入一级佣金金额'
"
>
<template #addonAfter>{{
form.commissionType === 20 ? '%' : '元'
}}
</template>
</a-input-number>
</a-form-item>
<a-form-item
:label="form.commissionType === 20 ? '二级佣金(%)' : '二级佣金(元)'"
name="secondMoney"
>
<a-input-number
v-model:value="form.secondMoney"
:min="0"
:max="form.commissionType === 20 ? 100 : undefined"
:precision="2"
style="width: 250px"
:placeholder="
form.commissionType === 20
? '请输入二级佣金百分比'
: '请输入二级佣金金额'
"
>
<template #addonAfter>{{
form.commissionType === 20 ? '%' : '元'
}}
</template>
</a-input-number>
</a-form-item>
<!-- <a-form-item-->
<!-- :label="form.commissionType === 20 ? '三级佣金(%)' : '三级佣金(元)'"-->
<!-- name="thirdMoney"-->
<!-- >-->
<!-- <a-input-number-->
<!-- v-model:value="form.thirdMoney"-->
<!-- :min="0"-->
<!-- :max="form.commissionType === 20 ? 100 : undefined"-->
<!-- :precision="2"-->
<!-- style="width: 250px"-->
<!-- :placeholder="-->
<!-- form.commissionType === 20-->
<!-- ? '请输入三级佣金百分比'-->
<!-- : '请输入三级佣金金额'-->
<!-- "-->
<!-- >-->
<!-- <template #addonAfter>{{-->
<!-- form.commissionType === 20 ? '%' : '元'-->
<!-- }}</template>-->
<!-- </a-input-number>-->
<!-- </a-form-item>-->
<a-form-item label="一级分润" name="firstDividend">
<a-input-number
v-model:value="form.firstDividend"
:min="0"
:max="form.commissionType === 20 ? 100 : undefined"
:precision="2"
style="width: 250px"
placeholder="请输入一级分润"
>
<template #addonAfter>{{
form.commissionType === 20 ? '%' : '元'
}}
</template>
</a-input-number>
</a-form-item>
<a-form-item label="二级分润" name="secondDividend">
<a-input-number
v-model:value="form.secondDividend"
:min="0"
:max="form.commissionType === 20 ? 100 : undefined"
:precision="2"
style="width: 250px"
placeholder="请输入二级分润"
>
<template #addonAfter>{{
form.commissionType === 20 ? '%' : '元'
}}
</template>
</a-input-number>
</a-form-item>
<a-form-item label="配送费奖金" name="deliveryMoney">
<a-input-number
v-model:value="form.deliveryMoney"
:min="0"
:max="20"
:precision="2"
:step="0.01"
style="width: 250px"
placeholder="请输入配送费奖金"
>
<template #addonAfter></template>
</a-input-number>
</a-form-item>
</template>
<template v-if="form.type === 1 || merchantId">
<a-form-item label="可用日期">
<a-select v-model:value="canUseDate" mode="multiple">
<a-select-option
v-for="(item, index) in dayList"
:key="index"
:value="index"
>{{ item }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="服务保障">
<div>
<a-space>
<a-tag
v-for="item in ensureTag"
:key="item"
closable
@close="onDeleteEnsureTag(item)"
>{{ item }}
</a-tag>
<a-input v-model:value="ensureTagItem"></a-input>
<a-button @click="addEnsureTag()" :disabled="!ensureTagItem"
>添加
</a-button
>
</a-space>
</div>
<div class="mt-2">
<a-space>
<span @click="addEnsureTag('免预约')">免预约</span>
<span @click="addEnsureTag('随时退')">随时退</span>
<span @click="addEnsureTag('过期自动退')">过期自动退</span>
</a-space>
</div>
</a-form-item>
<a-form-item label="有效期限">
<a-input-number
:min="0"
:max="9999"
style="width: 250px"
placeholder="请输入有效期限"
v-model:value="form.expiredDay"
/>
</a-form-item>
</template>
</a-tab-pane>
</a-tabs>
</a-form>
</a-drawer>
</template>
<script lang="ts" setup>
import {
CloseCircleOutlined,
DeleteOutlined,
InfoCircleOutlined,
PlayCircleOutlined,
SettingOutlined,
UploadOutlined
} from '@ant-design/icons-vue';
import {ref, reactive, watch} from 'vue';
import {useRouter} from 'vue-router';
import {Form, message} from 'ant-design-vue';
import {assignObject, messageLoading, uuid} from 'ele-admin-pro';
import {addShopGoods, updateShopGoods} from '@/api/shop/shopGoods';
import {ShopGoods} from '@/api/shop/shopGoods/model';
import {useThemeStore} from '@/store/modules/theme';
import {storeToRefs} from 'pinia';
import {ItemType} from 'ele-admin-pro/es/ele-image-upload/types';
import {FormInstance, RuleObject} from 'ant-design-vue/es/form';
import {ShopMerchant} from '@/api/shop/shopMerchant/model';
import {uploadFile, uploadOss} from '@/api/system/file';
import {FileRecord} from '@/api/system/file/model';
import RichTextEditor from '@/components/RichTextEditor.vue';
import markdownit from 'markdown-it';
import {ShopSpec} from '@/api/shop/shopSpec/model';
import {ShopGoodsSku} from '@/api/shop/shopGoodsSku/model';
import {ShopGoodsSpec} from '@/api/shop/shopGoodsSpec/model';
import {listShopGoodsSku} from '@/api/shop/shopGoodsSku';
import {listShopGoodsSpec} from '@/api/shop/shopGoodsSpec';
import {getShopSpec} from '@/api/shop/shopSpec';
import {ShopGoodsCategory} from '@/api/shop/shopGoodsCategory/model';
import {getMerchantId} from '@/utils/merchant';
import {ShopExpressTemplate} from '@/api/shop/shopExpressTemplate/model';
import {listShopExpressTemplate} from '@/api/shop/shopExpressTemplate';
import {listShopCommissionRole} from '@/api/shop/shopCommissionRole';
import {listShopGoodsRoleCommission} from '@/api/shop/shopGoodsRoleCommission';
import {listHomeSections} from '@/api/shop/shopZone';
// 是否是修改
const isUpdate = ref(false);
const useForm = Form.useForm;
// 是否开启响应式布局
const themeStore = useThemeStore();
const {styleResponsive} = storeToRefs(themeStore);
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: ShopGoods | null;
// 分类数据
navigationList?: ShopGoodsCategory[];
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
// 提交状态
const loading = ref(false);
// 是否显示最大化切换按钮
const maxable = ref(true);
// 表格选中数据
const formRef = ref<FormInstance | null>(null);
const images = ref<ItemType[]>([]);
const content = ref('');
// Markdown -> HTML 转换(编辑回显旧数据时,把 Markdown 内容转成富文本可识别的 HTML)
const md = markdownit();
/**
* 把后端 content 转为富文本编辑器可用的 HTML:
* - 已是 HTML(含标签)则原样返回,避免二次转义;
* - 否则按 Markdown 渲染为 HTML。
*/
const toEditorContent = (raw?: string): string => {
if (!raw) return '';
const s = raw.trim();
if (/<[a-z][\s\S]*>/i.test(s)) return raw;
return md.render(s);
};
// 当前选项卡
const active = ref('base');
// 规格数据结构:{ value: 规格名, detail: 规格值数组 }
interface SpecItem {
value: string;
detail: string[];
}
/**
* 统一规范化 detail 为数组
*/
const normalizeDetail = (detail: any): string[] => {
if (Array.isArray(detail)) {
return detail.map(String);
}
if (typeof detail === 'string') {
return detail.split(',').map(s => s.trim()).filter(Boolean);
}
return [];
};
/**
* 统一解析规格值,支持多种存储格式
* @param specValue 规格值(JSON字符串或已解析对象)
* @returns 统一格式的 SpecItem[]
*/
const parseSpecValue = (specValue: string | any): SpecItem[] => {
if (!specValue) return [];
try {
let parsed: any;
if (typeof specValue === 'string') {
parsed = JSON.parse(specValue);
// 处理双层编码:parse 后仍是字符串(说明原始值是 "=\"...\" 格式)
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch (_) { /* ignore */ }
}
} else {
parsed = specValue;
}
// 标准数组格式: [{value: "颜色", detail: ["红色"]}]
if (Array.isArray(parsed)) {
return parsed
.filter((item: any) => item && (item.value !== undefined || typeof item === 'string') && (Array.isArray(item.detail) || typeof item.detail === 'string'))
.map((item: any) => {
if (typeof item === 'string') {
return { value: '', detail: [item] };
}
return {
value: item.value !== undefined ? String(item.value) : '',
detail: normalizeDetail(item.detail)
};
})
.filter((item: SpecItem) => item.detail.length > 0);
}
// 对象格式: {"颜色": ["红色", "蓝色"], "尺码": ["S", "M"]}
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return Object.entries(parsed).map(([key, value]: [string, any]) => ({
value: key,
detail: normalizeDetail(value)
}));
}
// 单个字符串规格值(旧格式):specValue = "\"红色\""
if (typeof parsed === 'string' && parsed.trim()) {
return [{ value: '', detail: [parsed.trim()] }];
}
return [];
} catch (e) {
console.error('解析规格值失败:', e, '原始值:', specValue);
return [];
}
};
/**
* 将后端多条 ShopGoodsSpec 记录合并为前端 spec.value 格式
* 每条记录 specValue 为简单字符串,按 specName 分组
*/
const mergeGoodsSpecs = (goodsSpecs: ShopGoodsSpec[]): SpecItem[] => {
if (!goodsSpecs || goodsSpecs.length === 0) return [];
const map = new Map<string, string[]>();
goodsSpecs.forEach((gs) => {
const name = gs.specName || '';
let val = gs.specValue || '';
// 去掉 JSON 引号:数据库存的是 "\"红色\"",需要解析成 红色
try { val = JSON.parse(val); } catch (_) { /* 不是 JSON 格式,保持原样 */ }
if (!map.has(name)) {
map.set(name, []);
}
if (val) map.get(name)!.push(val);
});
return Array.from(map.entries()).map(([value, detail]) => ({ value, detail }));
};
const spec = ref<SpecItem[]>([]);
const showSpecForm = ref(false);
const name = ref();
const value = ref();
const skuList = ref<ShopGoodsSku[]>([]);
const files = ref<ItemType[]>([]);
const goodsSpec = ref<ShopGoodsSpec>();
const category = ref<string[]>([]);
// 所属专区(多选)
const sectionIdList = ref<number[]>([]);
const zoneOptions = ref<{ label: string; value: number }[]>([]);
// 批量设置
const batchPrice = ref<number | undefined>(undefined);
const batchSalePrice = ref<number | undefined>(undefined);
const batchBuyingPrice = ref<number | undefined>(undefined);
const batchStock = ref<number | undefined>(undefined);
const batchSkuNo = ref<string>('');
const takeaway = ref<ShopGoodsCategory[]>([]);
const merchantId = getMerchantId();
const router = useRouter();
const openSpecManage = () => {
router.push('/shop/shopSpec');
};
const columns = [
{
title: '图片',
dataIndex: 'image',
key: 'image',
align: 'center'
},
{
title: 'SKU',
dataIndex: 'sku',
align: 'center'
},
{
title: '商城价',
dataIndex: 'price',
key: 'price',
align: 'center'
},
{
title: '划线价',
dataIndex: 'salePrice',
key: 'salePrice',
align: 'center'
},
{
title: '会员价',
dataIndex: 'buyingPrice',
key: 'buyingPrice',
align: 'center'
},
{
title: '库存',
dataIndex: 'stock',
key: 'stock',
align: 'center'
},
{
title: '商品编号',
dataIndex: 'skuNo',
key: 'skuNo',
align: 'center'
}
];
const dayList = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const canUseDate = ref([]);
const ensureTag = ref<string[]>([]);
const ensureTagItem = ref('');
const addEnsureTag = (item = '') => {
const tag = item || ensureTagItem.value;
if (ensureTag.value.findIndex((item) => item === tag) !== -1)
return message.error('请勿重复添加');
ensureTag.value.push(tag);
ensureTagItem.value = '';
};
// 选择栏目(方案B:支持多选,主分类取列表首个,向后兼容旧字段)
const onCategoryIds = (ids: number[]) => {
form.categoryIds = ids || [];
form.categoryId = ids && ids.length ? ids[0] : undefined;
// 💾 在新增模式下,用户手动选择栏目时也保存到本地存储
if (!isUpdate.value && ids && ids.length) {
saveLastCategory(ids[0]);
}
};
// 💾 保存和恢复分类选择的功能
const LAST_CATEGORY_KEY = 'shop_goods_last_category';
// 保存最后选择的分类到本地存储
const saveLastCategory = (categoryId: number | undefined) => {
if (categoryId) {
localStorage.setItem(LAST_CATEGORY_KEY, categoryId.toString());
}
};
// 从本地存储获取最后选择的分类
const getLastCategory = (): number | undefined => {
const saved = localStorage.getItem(LAST_CATEGORY_KEY);
return saved ? parseInt(saved) : undefined;
};
const onDeleteEnsureTag = (tag: string) => {
const index = ensureTag.value.findIndex((item) => item === tag);
ensureTag.value.splice(index, 1);
};
// 🎨 智能一键排版 - 人性化设计
const handleAutoFormat = (editor: any) => {
try {
// 1. 检查内容
const content = editor.getContent();
if (!content || content.trim() === '' || content === '<p><br></p>' || content === '<p></p>') {
message.warning({
content: '📝 请先输入一些内容,然后再使用一键排版功能',
duration: 3
});
return;
}
// 2. 显示友好的加载提示
const loadingMsg = message.loading({
content: '✨ 正在为您的文章进行智能排版优化...',
duration: 0
});
// 3. 延迟执行,让用户看到加载效果
setTimeout(() => {
try {
const optimizedContent = smartFormatContent(content);
editor.setContent(optimizedContent);
loadingMsg();
// 4. 显示成功提示
message.success({
content: '🎉 排版优化完成!您的文章现在看起来更专业了',
duration: 4
});
// 5. 可选:显示优化统计
showOptimizationStats(content, optimizedContent);
} catch (error) {
loadingMsg();
console.error('排版优化失败:', error);
message.error({
content: '😅 排版优化遇到了问题,请检查文章内容后重试',
duration: 4
});
}
}, 800); // 给用户一个良好的反馈体验
} catch (error) {
console.error('一键排版功能错误:', error);
message.error({
content: '🔧 功能暂时不可用,请刷新页面后重试',
duration: 4
});
}
};
// 📊 显示优化统计信息
const showOptimizationStats = (originalContent: string, optimizedContent: string) => {
const stats = analyzeOptimization(originalContent, optimizedContent);
if (stats.optimizations.length > 0) {
message.info({
content: `📈 本次优化: ${stats.optimizations.join('、')}`,
duration: 6
});
}
};
// 🔍 分析优化效果
const analyzeOptimization = (original: string, optimized: string) => {
const optimizations: string[] = [];
// 检查各种优化项目
if ((optimized.match(/<h[1-6][^>]*style/g) || []).length > (original.match(/<h[1-6][^>]*style/g) || []).length) {
optimizations.push('标题样式');
}
if ((optimized.match(/<p[^>]*style/g) || []).length > (original.match(/<p[^>]*style/g) || []).length) {
optimizations.push('段落格式');
}
if ((optimized.match(/<img[^>]*style/g) || []).length > (original.match(/<img[^>]*style/g) || []).length) {
optimizations.push('图片布局');
}
if ((optimized.match(/<ul[^>]*style|<ol[^>]*style/g) || []).length > (original.match(/<ul[^>]*style|<ol[^>]*style/g) || []).length) {
optimizations.push('列表格式');
}
return {optimizations};
};
// 🎨 智能排版核心函数 - 简单而强大
const smartFormatContent = (content: string): string => {
let optimized = content;
// 1. 🏷️ 标题优化 - 让标题更有层次感
optimized = optimized.replace(/<h1([^>]*)>/g, '<h1$1 style="font-size: 28px; font-weight: 700; margin: 24px 0 16px 0; line-height: 1.3; color: #1a1a1a; border-bottom: 2px solid #e8e8e8; padding-bottom: 10px;">');
optimized = optimized.replace(/<h2([^>]*)>/g, '<h2$1 style="font-size: 24px; font-weight: 600; margin: 20px 0 14px 0; line-height: 1.4; color: #2c2c2c;">');
optimized = optimized.replace(/<h3([^>]*)>/g, '<h3$1 style="font-size: 20px; font-weight: 600; margin: 18px 0 12px 0; line-height: 1.4; color: #3c3c3c;">');
optimized = optimized.replace(/<h4([^>]*)>/g, '<h4$1 style="font-size: 16px; font-weight: 600; margin: 14px 0 8px 0; line-height: 1.4; color: #4c4c4c;">');
optimized = optimized.replace(/<h5([^>]*)>/g, '<h5$1 style="font-size: 14px; font-weight: 600; margin: 12px 0 6px 0; line-height: 1.4; color: #5c5c5c;">');
optimized = optimized.replace(/<h6([^>]*)>/g, '<h6$1 style="font-size: 13px; font-weight: 600; margin: 10px 0 5px 0; line-height: 1.4; color: #6c6c6c;">');
// 2. 📝 段落优化 - 让阅读更舒适
optimized = optimized.replace(/<p([^>]*)>/g, (match, attrs) => {
if (!attrs.includes('style=')) {
return `<p${attrs} style="line-height: 1.8; margin: 16px 0; text-indent: 2em; color: #333;">`;
}
return match;
});
// 3. 🖼️ 图片优化 - 让图片更美观
optimized = optimized.replace(/<img([^>]*?)>/g, (match, attrs) => {
if (!attrs.includes('style=')) {
const hasAlt = attrs.includes('alt=');
return `<img${attrs} style="max-width: 100%; height: auto; margin: 20px auto; display: block; border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,0.1);"${!hasAlt ? ' alt="图片"' : ''}>`;
}
return match;
});
// 4. 📋 列表优化 - 让列表更清晰
optimized = optimized.replace(/<ul([^>]*)>/g, '<ul$1 style="margin: 16px 0; padding-left: 24px; line-height: 1.6;">');
optimized = optimized.replace(/<ol([^>]*)>/g, '<ol$1 style="margin: 16px 0; padding-left: 24px; line-height: 1.6;">');
optimized = optimized.replace(/<li([^>]*)>/g, '<li$1 style="margin: 8px 0; color: #333;">');
// 5. 💬 引用优化 - 让引用更突出
optimized = optimized.replace(/<blockquote([^>]*)>/g, '<blockquote$1 style="margin: 20px 0; padding: 16px 20px; border-left: 4px solid #1890ff; background: linear-gradient(90deg, #f6f8fa 0%, #ffffff 100%); font-style: italic; color: #555;">');
// 6. 💻 代码优化 - 让代码更专业
optimized = optimized.replace(/<code([^>]*)>/g, '<code$1 style="background-color: #f1f3f4; padding: 2px 6px; border-radius: 4px; font-family: \'Fira Code\', Consolas, Monaco, monospace; font-size: 0.9em; color: #d73a49;">');
optimized = optimized.replace(/<pre([^>]*)>/g, '<pre$1 style="margin: 20px 0; padding: 20px; background-color: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; overflow-x: auto; font-family: \'Fira Code\', Consolas, Monaco, monospace; font-size: 14px; line-height: 1.5;">');
// 7. 📊 表格优化 - 让表格更美观
optimized = optimized.replace(/<table([^>]*)>/g, '<table$1 style="width: 100%; border-collapse: collapse; margin: 20px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.1); border-radius: 8px; overflow: hidden;">');
optimized = optimized.replace(/<th([^>]*)>/g, '<th$1 style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 12px; text-align: left; font-weight: 600;">');
optimized = optimized.replace(/<td([^>]*)>/g, '<td$1 style="padding: 12px; border-bottom: 1px solid #eee; color: #333;">');
// 8. 🔗 链接优化 - 让链接更友好
optimized = optimized.replace(/<a([^>]*)>/g, '<a$1 style="color: #1890ff; text-decoration: none; border-bottom: 1px solid transparent; transition: border-bottom 0.2s ease;" onmouseover="this.style.borderBottom=\'1px solid #1890ff\'" onmouseout="this.style.borderBottom=\'1px solid transparent\'">');
// 9. ➖ 分隔线优化 - 让分隔线更优雅
optimized = optimized.replace(/<hr([^>]*)>/g, '<hr$1 style="border: none; height: 2px; background: linear-gradient(90deg, transparent, #e8e8e8, transparent); margin: 30px 0;">');
// 10. 🧹 清理多余空白
optimized = optimized.replace(/\s+/g, ' '); // 清理多余空格
optimized = optimized.replace(/<p[^>]*>\s*<\/p>/g, ''); // 清理空段落
optimized = optimized.replace(/(<\/[^>]+>)\s+(<[^>]+>)/g, '$1$2'); // 清理标签间空白
return optimized;
};
// 🔄 段落首行缩进切换功能
const toggleParagraphIndent = (editor: any) => {
try {
const content = editor.getContent();
if (!content || content.trim() === '' || content === '<p><br></p>' || content === '<p></p>') {
message.warning({
content: '📝 请先输入一些段落内容,然后再切换首行缩进',
duration: 3
});
return;
}
// 检查当前是否有首行缩进
const hasIndent = content.includes('text-indent: 2em') || content.includes('text-indent:2em');
let newContent: string;
let actionText: string;
if (hasIndent) {
// 移除首行缩进
newContent = removeIndentFromParagraphs(content);
actionText = '已移除段落首行缩进';
} else {
// 添加首行缩进
newContent = addIndentToParagraphs(content);
actionText = '已添加段落首行缩进';
}
editor.setContent(newContent);
message.success({
content: `📐 ${actionText}`,
duration: 3
});
} catch (error) {
console.error('首行缩进切换失败:', error);
message.error({
content: '🔧 首行缩进切换失败,请重试',
duration: 3
});
}
};
// 为段落添加首行缩进
const addIndentToParagraphs = (content: string): string => {
return content.replace(/<p([^>]*)>/g, (match, attrs) => {
// 如果已经有 style 属性
if (attrs.includes('style=')) {
// 检查是否已经有 text-indent
if (attrs.includes('text-indent')) {
// 更新现有的 text-indent
return match.replace(/text-indent:\s*[^;]+;?/g, 'text-indent: 2em;');
} else {
// 在现有 style 中添加 text-indent
return match.replace(/style="([^"]*)"/, 'style="$1 text-indent: 2em;"');
}
} else {
// 添加新的 style 属性
return `<p${attrs} style="text-indent: 2em;">`;
}
});
};
// 从段落移除首行缩进
const removeIndentFromParagraphs = (content: string): string => {
return content.replace(/<p([^>]*)>/g, (match, attrs) => {
if (attrs.includes('text-indent')) {
// 移除 text-indent 属性
let newAttrs = attrs.replace(/text-indent:\s*[^;]+;?\s*/g, '');
// 如果 style 属性变空了,移除整个 style 属性
newAttrs = newAttrs.replace(/style="\s*"/g, '');
newAttrs = newAttrs.replace(/style=''\s*/g, '');
return `<p${newAttrs}>`;
}
return match;
});
};
// 用户信息
const form = reactive<ShopGoods>({
goodsId: undefined,
type: 1,
code: undefined,
name: undefined,
goodsName: undefined,
image: undefined,
video: undefined,
content: undefined,
canExpress: 1,
unitName: '',
categoryId: undefined,
categoryIds: [],
parentName: undefined,
categoryName: undefined,
specs: 0,
commissionRole: 0,
// 分销佣金(新字段,后端保持 snake_case
isOpenCommission: 0,
commissionType: 10,
firstMoney: 0,
secondMoney: 0,
thirdMoney: 0,
firstDividend: 0,
secondDividend: 0,
deliveryMoney: 0,
position: undefined,
price: undefined,
originPrice: undefined,
salePrice: undefined,
buyingPrice: undefined,
dealerPrice: undefined,
priceGift: undefined,
dealerGift: undefined,
priceGiftNum: undefined,
priceGiftName: undefined,
dealerGiftNum: undefined,
files: undefined,
sales: 0,
isNew: 0,
activityType: 0,
deliveryMode: 0,
gainIntegral: 0,
goodsWeight: undefined,
recommend: undefined,
merchantId: undefined,
merchantName: undefined,
supplierMerchantId: undefined,
supplierName: undefined,
chainStorePrice: undefined,
memberStorePrice: undefined,
memberMarketPrice: undefined,
chainStoreRate: undefined,
memberStoreRate: undefined,
memberMarketRate: undefined,
memberStoreCommission: undefined,
supplierCommission: undefined,
coopCommission: undefined,
expressTemplateId: undefined,
canUseDate: undefined,
ensureTag: undefined,
expiredDay: undefined,
stock: 1000,
deductStockType: 20,
isShow: undefined,
status: 0,
comments: '',
sortNumber: 100,
specName: '',
goodsRoleCommission: []
});
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
// 表单验证规则
const rules = reactive({
type: [
{
required: true,
message: '请选择商品类型',
type: 'number',
trigger: 'blur'
}
],
image: [
{
required: true,
message: '请上传图片',
type: 'string',
trigger: 'blur'
}
],
files: [
{
required: true,
message: '请上传轮播图',
type: 'string',
trigger: 'blur',
validator: async (_rule: RuleObject, value: string) => {
if (form.files?.length == 0) {
return Promise.reject('选择上传轮播图');
}
return Promise.resolve();
}
}
],
specs: [
{
required: true,
message: '请选择规格类型',
type: 'number',
trigger: 'blur'
}
],
price: [
{
required: true,
message: '请填写商城价',
type: 'number',
trigger: 'blur'
}
],
salePrice: [
{
required: true,
message: '请填写市场价',
type: 'number',
trigger: 'blur'
}
],
buyingPrice: [
{
required: true,
message: '请填写进货价',
type: 'number',
trigger: 'blur'
}
],
stock: [
{
required: true,
message: '请填写商品库存',
type: 'number',
trigger: 'blur'
}
],
// supplierMerchantId: [
// {
// required: true,
// message: '请选择供应商',
// type: 'number',
// trigger: 'blur'
// }
// ],
// categoryId: [
// {
// required: true,
// type: 'string',
// message: '选择商品分类',
// trigger: 'blur',
// validator: async (_rule: RuleObject, value: string) => {
// if (!form.categoryId) {
// return Promise.reject('选择商品分类');
// }
// return Promise.resolve();
// }
// }
// ],
name: [
{
required: true,
message: '请选择商品名称',
type: 'string',
trigger: 'blur'
}
],
goodsName: [
{
required: true,
message: '请选择商品名称',
type: 'string',
trigger: 'blur'
}
],
sortNumber: [
{
required: true,
message: '请输入排序号',
type: 'number',
trigger: 'blur'
}
],
chainStorePrice: [
{
required: true,
message: '请输入供应价',
type: 'number',
trigger: 'blur'
}
],
memberStorePrice: [
{
required: true,
message: '请输入会员店价格',
type: 'number',
trigger: 'blur'
}
],
memberMarketPrice: [
{
required: true,
message: '请输入会员超市价格',
type: 'number',
trigger: 'blur'
}
],
memberStoreCommission: [
{
required: true,
message: '请输入直推收益分配',
type: 'number',
trigger: 'blur'
}
],
supplierCommission: [
{
required: true,
message: '请输入仓储费',
type: 'number',
trigger: 'blur'
}
]
});
const onType = (index: number) => {
form.type = index;
};
/* 搜索 */
const chooseMerchantId = (item: ShopMerchant) => {
form.merchantName = item.merchantName;
form.merchantId = item.merchantId;
};
const clearMerchant = () => {
form.merchantName = '';
form.merchantId = 0;
};
const chooseSupplier = (item: ShopMerchant) => {
form.supplierName = item.merchantName;
form.supplierMerchantId = item.merchantId;
};
const chooseGoodsCategory = (item: ShopGoodsCategory, value: any) => {
const cid = value[1].value as number;
form.categoryId = cid;
form.categoryIds = [cid];
form.parentName = value[0].label;
form.categoryName = value[1].label;
};
const chooseTakeawayCategory = (item: ShopGoodsCategory, value: any) => {
const cid = item[0] as number;
form.categoryParent = '店铺分类';
form.categoryChildren = value[0].label;
form.categoryId = cid;
form.categoryIds = [cid];
};
const chooseImage = (data: FileRecord) => {
images.value.push({
uid: data.id,
url: data.path,
status: 'done'
});
form.image = data.path;
};
const chooseSkuImage = (data: FileRecord) => {
const index = data?.index;
skuList.value[index].images?.push({
uid: uuid(),
url: data.path,
status: 'done'
});
skuList.value[index].image = data.path;
};
const onDeleteSkuItem = (index: number) => {
skuList.value[index].images = [];
skuList.value[index].image = '';
};
const videos = ref<ItemType[]>([]);
const chooseVideo = (data: FileRecord) => {
videos.value = [
{
uid: uuid(),
url: data.path,
status: 'done'
}
];
form.video = data.path;
};
const onDeleteVideo = (index: number) => {
images.value.splice(index, 1);
form.video = undefined;
};
const onChange = (text: string) => {
// 加载商品多规格
if (text == 'spec') {
const goodsId = props.data?.goodsId;
if (goodsId) {
listShopGoodsSpec({goodsId}).then((data) => {
if (data.length > 0) {
// 多条记录合并为 spec.value;兼容旧格式
const merged = mergeGoodsSpecs(data);
spec.value = merged.length > 0 ? merged : parseSpecValue(data[0].specValue);
goodsSpec.value = {
specId: data[0].specId,
specName: data[0].specName,
specValue: JSON.stringify(spec.value)
};
form.specName = data[0].specName || '';
}
});
listShopGoodsSku({goodsId}).then((data) => {
skuList.value = data.map((d) => ({
...d,
images: d.image ? [{uid: uuid(), url: d.image, status: 'done' as const}] : []
}));
});
}
}
};
const onDeleteItem = (index: number) => {
images.value.splice(index, 1);
form.image = '';
};
const onClose = (index: number) => {
spec.value.splice(index, 1);
// 同步更新 goodsSpec 的 specValue
if (goodsSpec.value) {
goodsSpec.value.specValue = JSON.stringify(spec.value);
}
};
const openSpecForm = () => {
showSpecForm.value = !showSpecForm.value;
};
const onSpec = (row: any) => {
form.specName = row.specName;
const rawSpecValue = row.specValue || row.value;
// 如果 specValue 为空,则从后端重新拉取完整规格数据
if (!rawSpecValue && row.specId) {
getShopSpec(row.specId).then((fullSpec: any) => {
const v = fullSpec.specValue || fullSpec.value;
goodsSpec.value = {
specId: row.specId,
specName: row.specName,
specValue: v
};
spec.value = parseSpecValue(v);
console.log('[onSpec] 重新拉取规格值:', v, '解析结果:', spec.value);
}).catch((e: any) => {
message.error('获取规格值失败: ' + (e.message || e));
});
} else {
goodsSpec.value = {
specId: row.specId,
specName: row.specName,
specValue: rawSpecValue
};
spec.value = parseSpecValue(rawSpecValue);
}
console.log('[onSpec] 选择规格模板:', row.specName, '原始值:', rawSpecValue, '解析结果:', spec.value);
skuList.value = [];
};
// 新增规格
const addSpecValue = () => {
if (!name.value || !value.value) {
message.error(`请输入规格和规格值`);
return false;
}
const findIndex = spec.value.findIndex((d) => d.value == name.value);
if (findIndex !== -1) {
message.error(`${name.value}已存在`);
return false;
}
spec.value.push({
value: name.value,
detail: [value.value]
});
// 同步更新 goodsSpec 的 specValue
if (goodsSpec.value) {
goodsSpec.value.specValue = JSON.stringify(spec.value);
}
name.value = '';
value.value = '';
openSpecForm();
};
const chooseFile = (data: FileRecord) => {
files.value.push({
uid: data.id,
url: data.path,
status: 'done'
});
};
const onDeleteFile = (index: number) => {
files.value.splice(index, 1);
};
/**
* 生成商品SKU列表
*/
const generateSku = () => {
if (spec.value.length === 0) {
message.warning('请先添加规格');
return;
}
// 过滤掉没有规格值的项
const specs = spec.value.filter(s => s.detail.length > 0);
if (specs.length === 0) {
message.warning('请先添加规格值');
return;
}
// 前端笛卡尔积生成 SKU 组合
const cartesian = (lists: string[][]): string[][] => {
if (lists.length === 0) return [[]];
if (lists.length === 1) return lists[0].map(v => [v]);
const [first, ...rest] = lists;
const sub = cartesian(rest);
return first.flatMap(v => sub.map(s => [v, ...s]));
};
const detailLists = specs.map(s => s.detail);
const names = specs.map(s => s.value);
const combos = cartesian(detailLists);
skuList.value = combos.map((combo, i) => {
const skuStr = combo.join('|');
// 保留已有行的价格等数据,避免重复生成时丢失
const existing = skuList.value[i] || {};
return {
sku: skuStr,
price: existing.price || 0,
salePrice: existing.salePrice || 0,
buyingPrice: existing.buyingPrice || 0,
stock: existing.stock || 0,
skuNo: existing.skuNo || '',
images: existing.images || []
};
});
// 同步更新 goodsSpec 的 specValue(每条记录一个值,JSON 格式)
if (goodsSpec.value) {
// 把 spec.value 拆成多条记录对应的 specValue 格式
const firstSpec = spec.value[0];
goodsSpec.value.specName = firstSpec?.value || '';
// specValue 在 save 时会被拆成多条记录,这里仅同步 specName
}
message.success(`已生成 ${combos.length} 条SKU`);
};
/**
* 批量设置SKU字段值
*/
const batchApply = () => {
if (skuList.value.length === 0) {
message.warning('请先生成SKU');
return;
}
let changed = 0;
skuList.value.forEach((item) => {
if (batchPrice.value !== undefined && batchPrice.value !== null) {
item.price = batchPrice.value;
changed++;
}
if (batchSalePrice.value !== undefined && batchSalePrice.value !== null) {
item.salePrice = batchSalePrice.value;
changed++;
}
if (batchBuyingPrice.value !== undefined && batchBuyingPrice.value !== null) {
item.buyingPrice = batchBuyingPrice.value;
changed++;
}
if (batchStock.value !== undefined && batchStock.value !== null) {
item.stock = batchStock.value;
changed++;
}
if (batchSkuNo.value) {
item.skuNo = batchSkuNo.value;
changed++;
}
});
if (changed > 0) {
message.success(`已批量设置 ${skuList.value.length} 条SKU`);
} else {
message.warning('请至少填写一个批量设置值');
}
};
const {resetFields} = useForm(form, rules);
const COMMISSION_PERCENT_FIELDS = [
'firstMoney',
'secondMoney',
'thirdMoney',
'firstDividend',
'secondDividend'
] as const;
type CommissionPercentField = (typeof COMMISSION_PERCENT_FIELDS)[number];
const toDbPercent = (val: unknown) => {
if (val === null || val === undefined) return val as any;
const num = Number(val);
if (!Number.isFinite(num)) return val as any;
return num / 100;
};
// Back-end stores percent as a fraction (e.g. 0.1 = 10%); UI inputs percent (e.g. 10 = 10%).
const toUiPercent = (val: unknown) => {
if (val === null || val === undefined) return val as any;
const num = Number(val);
if (!Number.isFinite(num)) return val as any;
// If historical data is already stored as 0~1, convert to 0~100 for display.
if (num <= 1) return Number((num * 100).toFixed(2));
return num;
};
/* 保存编辑 */
const save = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
// 快递配送时运费模板必选(后端也有此校验,前端提前拦截给出友好提示)
if (form.canExpress === 1 && !form.expressTemplateId && !merchantId) {
loading.value = false;
return message.error('请选择运费模板');
}
if (form.priceGift && !form.priceGiftNum)
return message.error('请输入会员赠品数量');
if (ensureTag.value.length) form.ensureTag = ensureTag.value.join();
if (canUseDate.value.length) form.canUseDate = canUseDate.value.join();
// 分销关闭时,避免把历史值一并保存到后端
if (form.isOpenCommission !== 1) {
form.commissionType = 10;
form.firstMoney = 0;
form.secondMoney = 0;
form.thirdMoney = 0;
form.firstDividend = 0;
form.secondDividend = 0;
form.deliveryMoney = 0;
}
if (form.isOpenCommission === 1 && form.commissionType === 20) {
// UI 输入:0~100;保存到后端:0~1(例如 10% => 0.1
const invalidPercent = COMMISSION_PERCENT_FIELDS.some((k) => {
const v = (form as any)[k] ?? 0;
return v < 0 || v > 100;
});
if (invalidPercent) {
return message.error('佣金百分比需在 0~100 之间');
}
}
// if (form.dealerGift && !form.dealerGiftNum) return message.error('请输入经销商赠品数量');
if (form.commissionRole === 1) {
for (let i = 0; i < form.goodsRoleCommission.length; i++) {
if (
form.goodsRoleCommission[i].amount === undefined ||
form.goodsRoleCommission[i].amount === '' ||
form.goodsRoleCommission[i].amount === null
) {
return message.error(
'请输入' + form.goodsRoleCommission[i].roleName + '的分红金额'
);
}
}
}
// 多规格模式下,保存前确保 goodsSpec.specValue 与当前 spec 数组同步
if (form.specs === 1 && goodsSpec.value && spec.value.length) {
goodsSpec.value.specValue = JSON.stringify(spec.value);
}
// 方案B:主分类兜底取多分类第一个,保证旧字段/旧逻辑兼容
if (form.categoryIds && form.categoryIds.length && !form.categoryId) {
form.categoryId = form.categoryIds[0];
}
const formData: any = {
...form,
// 所属专区:数字数组 -> 逗号分隔字符串
sectionIds: sectionIdList.value.length
? sectionIdList.value.join(',')
: '',
content: content.value,
category: JSON.stringify(category.value),
files: JSON.stringify(files.value),
type: getMerchantId() ? 1 : 0
};
// 多规格数据: goodsSpecs 为数组(每条记录一个规格值),goodsSkus 为数组
if (form.specs === 1) {
// 把 spec.value 拆成多条记录,每条 specValue 存单个字符串值(JSON 格式)
const goodsSpecs: ShopGoodsSpec[] = [];
spec.value.forEach((s) => {
s.detail.forEach((d) => {
goodsSpecs.push({
specId: goodsSpec.value?.specId,
specName: s.value,
specValue: JSON.stringify(d) // JSON 格式存单个字符串,如 "\"红色\""
});
});
});
formData.goodsSpecs = goodsSpecs;
formData.goodsSkus = skuList.value.map((item) => ({
...item,
// 提取图片URL(images 是前端上传组件格式,转为单图片URL)
image: item.images?.[0]?.url || ''
}));
} else {
formData.goodsSpecs = [];
formData.goodsSkus = [];
}
if (isUpdate.value) {
formData.type = props.data.type;
}
if (formData.isOpenCommission === 1 && formData.commissionType === 20) {
// Convert percent fields for persistence: 10 => 0.1
COMMISSION_PERCENT_FIELDS.forEach((k) => {
(formData as any)[k as CommissionPercentField] = toDbPercent(
(formData as any)[k]
);
});
}
const saveOrUpdate = isUpdate.value ? updateShopGoods : addShopGoods;
saveOrUpdate(formData)
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {
});
};
const expressTemplateList = ref<ShopExpressTemplate[]>([]);
const getExpressTemplateList = async () => {
expressTemplateList.value = await listShopExpressTemplate();
};
const getRoleList = async () => {
const roleList = await listShopCommissionRole();
form.goodsRoleCommission = [];
roleList.forEach((d) => {
form.goodsRoleCommission.push({
roleId: d.id,
roleName: d.title,
amount: 0
});
});
if (props?.data?.goodsId) {
const commissionList = await listShopGoodsRoleCommission({
goodsId: props?.data?.goodsId
});
form.goodsRoleCommission = form.goodsRoleCommission.map((d) => {
const find = commissionList.find((c) => c.roleId == d.roleId);
return {
...d,
amount: find?.amount || 0
};
});
}
};
// 判断是否为视频文件(支持 mp4/webm/mov/avi 等常见格式)
const isVideo = (url: string): boolean => {
if (!url) return false;
const videoExts = ['.mp4', '.webm', '.mov', '.avi', '.m3u8', '.ogg'];
const lowerUrl = url.toLowerCase().split('?')[0]; // 去掉查询参数
return videoExts.some((ext) => lowerUrl.endsWith(ext));
};
const onUploadImage = (item) => {
const {file} = item;
if (file.size / 1024 / 1024 > 10) {
message.error('大小不能超过 10MB');
return;
}
const hide = messageLoading({
content: '上传中..',
duration: 0,
mask: true
});
uploadOss(file)
.then((res) => {
hide();
form.image = res.path;
})
.catch((e) => {
message.error(e.message);
hide();
});
};
const onUploadSwiper = (item) => {
const {file} = item;
if (file.size / 1024 / 1024 > 10) {
message.error('大小不能超过 10MB');
return;
}
const hide = messageLoading({
content: '上传中..',
duration: 0,
mask: true
});
uploadOss(file)
.then((data) => {
hide();
files.value.push({
uid: data.id,
url: data.path,
status: 'done'
});
setPicker();
})
.catch((e) => {
message.error(e.message);
hide();
});
};
const swiperRef = ref();
const imageRef = ref();
const setPicker = () => {
const swiperEl = swiperRef.value?.$el?.querySelector('input[type="file"]');
if (swiperEl) {
swiperEl.removeAttribute('accept'); // 移除 accept 属性
swiperEl.removeAttribute('capture'); // 移除 capture 属性
swiperEl.setAttribute('name', 'file'); // 添加 name 属性
}
const imageEl = imageRef.value?.$el?.querySelector('input[type="file"]');
if (imageEl) {
imageEl.removeAttribute('accept'); // 移除 accept 属性
imageEl.removeAttribute('capture'); // 移除 capture 属性
imageEl.setAttribute('name', 'file'); // 添加 name 属性
}
};
watch(
() => props.visible,
async (visible) => {
if (visible) {
await getExpressTemplateList();
// 加载所属专区选项
listHomeSections({})
.then((list) => {
zoneOptions.value = (list || []).map((z) => ({
label: z.title || '',
value: z.sectionId as number
}));
})
.catch(() => {
zoneOptions.value = [];
});
images.value = [];
sectionIdList.value = [];
category.value = [];
files.value = [];
videos.value = [];
canUseDate.value = [];
ensureTag.value = [];
ensureTagItem.value = '';
if (props.data) {
assignObject(form, props.data);
// 所属专区:逗号分隔字符串 -> 数字数组
sectionIdList.value = form.sectionIds
? form.sectionIds
.split(',')
.map((s) => Number(s.trim()))
.filter((n) => !Number.isNaN(n))
: [];
if (form.commissionType === undefined || form.commissionType === null) {
form.commissionType = 10;
}
if (form.isOpenCommission === 1 && form.commissionType === 20) {
// Convert historical DB values (0~1) to UI percent (0~100).
COMMISSION_PERCENT_FIELDS.forEach((k) => {
(form as any)[k as CommissionPercentField] = toUiPercent(
(form as any)[k]
);
});
}
if (props.data.image) {
images.value.push({
uid: uuid(),
url: props.data.image,
status: 'done'
});
}
if (props.data.files) {
files.value = JSON.parse(props.data.files);
}
if (props.data.goodsSpecs && props.data.goodsSpecs.length > 0) {
if (props.data.specs == 1) {
// 多条记录合并为 spec.value;兼容旧格式(单条存 JSON 数组)
const merged = mergeGoodsSpecs(props.data.goodsSpecs);
spec.value = merged.length > 0 ? merged : parseSpecValue(props.data.goodsSpecs[0].specValue);
goodsSpec.value = {
specId: props.data.goodsSpecs[0].specId,
specName: props.data.goodsSpecs[0].specName,
specValue: JSON.stringify(spec.value)
};
form.specName = props.data.goodsSpecs[0].specName || '';
}
}
if (props.data.goodsSkus && props.data.goodsSkus.length > 0) {
skuList.value = props.data.goodsSkus.map((d) => ({
...d,
images: d.image ? [{uid: uuid(), url: d.image, status: 'done' as const}] : []
}));
}
// 多规格商品(specs == 1)从 API 加载规格和 SKU 数据
// 注意:列表 API 通常不返回 goodsSpecs/goodsSkus,需要单独查询
if (props.data.specs == 1 && props.data.goodsId) {
listShopGoodsSpec({goodsId: props.data.goodsId}).then((data) => {
if (data.length > 0) {
// 多条记录合并为 spec.value;兼容旧格式
const merged = mergeGoodsSpecs(data);
spec.value = merged.length > 0 ? merged : parseSpecValue(data[0].specValue);
goodsSpec.value = {
specId: data[0].specId,
specName: data[0].specName,
specValue: JSON.stringify(spec.value)
};
form.specName = data[0].specName || '';
}
});
listShopGoodsSku({goodsId: props.data.goodsId}).then((data) => {
skuList.value = data.map((d) => ({
...d,
images: d.image ? [{uid: uuid(), url: d.image, status: 'done' as const}] : []
}));
});
}
if (props.data.video) {
videos.value = [
{
uid: uuid(),
url: props.data.video,
status: 'done'
}
];
}
// 商品分类
if (props.data.parentName) {
category.value.push(props.data.parentName);
}
if (props.data.category) {
category.value = JSON.parse(props.data.category);
}
if (props.data.content) {
content.value = toEditorContent(props.data.content);
}
isUpdate.value = true;
if (form.ensureTag) {
ensureTag.value = form.ensureTag.split(',');
}
// 🎯 优先级设置栏目(方案B:支持多分类回填):
// 1. 如果传入了 categoryIds,使用多分类
// 2. 否则如果传入了 categoryId,兼容单分类
// 3. 否则使用上次保存的栏目
if (props.data.categoryIds && props.data.categoryIds.length) {
form.categoryIds = props.data.categoryIds;
form.categoryId = props.data.categoryId ?? props.data.categoryIds[0];
} else if (props.data.categoryId) {
form.categoryId = props.data?.categoryId;
form.categoryIds = [props.data.categoryId];
} else {
const lastCategory = getLastCategory();
if (lastCategory) {
form.categoryId = lastCategory;
form.categoryIds = [lastCategory];
}
}
await getRoleList();
} else {
await getRoleList();
spec.value = [];
goodsSpec.value = undefined;
skuList.value = [];
isUpdate.value = false;
}
setPicker();
} else {
resetFields();
}
},
{immediate: true}
);
</script>
<style scoped>
</style>