feat: 商品详情

This commit is contained in:
zc 2025-10-24 23:34:45 +08:00
parent 263fbb3633
commit 3f18c2ea0e
4 changed files with 380 additions and 70 deletions

View File

@ -2,21 +2,41 @@
<div class="max-w-1/2">
<dl v-for="item in list" :key="item.typeId" class="flex mb-1 items-center">
<dt>
<el-upload
ref="uploadRef"
:auto-upload="false"
:limit="1"
:on-change="handleFileChange"
:on-exceed="handleExceed"
accept="image/*"
list-type="picture-card"
class="w-10 h-10"
>
<el-icon><Plus /></el-icon>
<template #file="{ file }">
<div class="relative w-10 h-10">
<img class="w-10 h-10" :src="file.url" alt="" />
</div>
</template>
</el-upload>
<el-button type="primary" plain size="small" class="mr-2">{{ item.name }}</el-button>
</dt>
<dd class="flex">
<img :src="item.img" alt="" />
<p>
<span class="text-xs text-[#666]">数量</span
><el-input v-model="item.num" size="small" class="w-20 mr-2"></el-input>
<span class="text-xs text-[#666]">库存</span
><el-input v-model="item.stock" size="small" class="w-20 mr-2"></el-input>
</p>
<p>
<span class="text-xs text-[#666]">成本价</span
><el-input v-model="item.originPrice" size="small" class="w-20 mr-2"></el-input>
</p>
<p>
<span class="text-xs text-[#666]">原价</span
><el-input v-model="item.sale" size="small" class="w-20 mr-2"></el-input>
><el-input v-model="item.salePrice" size="small" class="w-20 mr-2"></el-input>
</p>
<p>
<span class="text-xs text-[#666]">售价</span
><el-input v-model="item.amount" size="small" class="w-20 mr-2"></el-input>
><el-input v-model="item.promotionPrice" size="small" class="w-20 mr-2"></el-input>
</p>
</dd>
</dl>
@ -24,5 +44,65 @@
</template>
<script setup lang="ts">
const props = defineProps<{ list: any[] }>()
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
interface Props {
list: any[]
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const props = defineProps<Props>()
const uploadRef = ref()
//
const handleFileChange = (file: any, fileList: any[]) => {
//
if (fileList.length > 1) {
fileList.splice(0, 1)
}
}
//
const handleExceed = () => {
ElMessage.warning('只能选择一张图片')
}
//
const uploadImage = async (item: any) => {
if (!uploadRef.value) return
const uploadInstance = uploadRef.value
const fileList = uploadInstance.fileList
if (fileList.length === 0) {
ElMessage.warning('请选择要上传的图片')
return
}
try {
// FormData
const form = new FormData()
form.append('files', fileList[0].raw)
// API
const res = await (globalThis as any).api.resource.uploadFile.post!(form)
if (res.data && res.data.length > 0) {
// URL
item.imageUrl = res.data[0].url
ElMessage.success('图片上传成功')
//
uploadInstance.clearFiles()
} else {
ElMessage.error('图片上传失败')
}
} catch (error) {
console.error('上传失败:', error)
ElMessage.error('图片上传失败')
}
}
</script>

View File

@ -55,31 +55,41 @@
</form-wrap>
</el-card>
<el-card class="mt-2 sku-info">
<h4 class="font-bold mb-2">SKU信息:</h4>
<el-form-item label="后台类目">
<el-tree-select
show-checkbox
v-model="$dialog.data.categoryId"
:props="defaultAdminCategoryTreeProps"
:render-after-expand="false"
@check="
(checkedNode: any, checkedStatus: any) =>
handleCheckChange(checkedNode, checkedStatus, 'admin')
"
:load="handleLoadNode"
lazy
style="width: 240px"
>
</el-tree-select>
</el-form-item>
<h4 class="font-bold mb-2">属性配置:</h4>
<el-form inline>
<el-form-item label="后台类目">
<el-tree-select
show-checkbox
v-model="$dialog.data.categoryId"
:props="defaultAdminCategoryTreeProps"
:render-after-expand="false"
@check="
(checkedNode: any, checkedStatus: any) =>
handleCheckChange(checkedNode, checkedStatus, 'admin')
"
:load="handleLoadNode"
lazy
style="width: 240px"
>
</el-tree-select>
</el-form-item>
<el-form-item label="测试商品">
<el-switch v-model="isTest" active-value="1" inactive-value="0" />
</el-form-item>
</el-form>
<div ref="dragRef">
<dl v-for="(item, index) in categoryData" :key="item.typeId" class="flex items-center">
<dt class="text-sm text-[#666] mb-2">{{ item.typeName }}</dt>
<dl v-for="(item, index) in categoryData" :key="item.id" class="flex items-center">
<dt class="text-sm text-[#666] mb-2">{{ item.categoryPropertyName }}</dt>
<dd class="flex mb-2">
<div v-for="child in item.options" :key="item.typeId + '-' + child.id">
<el-tag closable effect="plain" :type="colors[index % 4]" class="mr-1">{{
child.name
}}</el-tag>
<div v-for="child in item.vvPropertyValueList" :key="item.id + '-' + child.id">
<el-tag
closable
effect="plain"
:type="colors[index % 4]"
class="mr-1"
@close="onDeletePropertyValue(index, child.id)"
>{{ child.categoryPropertyValue }}</el-tag
>
</div>
<el-input
v-if="inputVisibles[index]"
@ -87,14 +97,14 @@
v-model="inputValue"
class="w-20"
size="small"
@keyup.enter="handleInputConfirm(index)"
@keyup.enter="handleInputConfirm(index, skuList)"
@blur="handleInputConfirm(index)"
/>
<el-button
v-else
class="button-new-tag ml-1"
size="small"
@click="onAddCategoryDataOption(index)"
@click="onAddPropertyValue(index)"
>
+ 新增
</el-button>
@ -102,13 +112,13 @@
</dl>
</div>
<h4 class="my-1">价格配置</h4>
<h4 class="my-1">sku配置</h4>
<div
class="flex w-full flex-wrap justify-between p-2 rounded-[5px]"
style="border: 1px dashed #bbb"
>
<category-config :list="list.slice(0, Math.floor(list.length / 2))" />
<category-config :list="list.slice(Math.floor(list.length / 2))" />
<category-config :list="skuList.slice(0, Math.floor(skuList.length / 2))" />
<category-config :list="skuList.slice(Math.floor(skuList.length / 2))" />
</div>
</el-card>
<el-card>
@ -126,10 +136,10 @@
<script setup lang="ts">
import { initConfig } from './config'
import { categoryDataMock, categoryOptionsMock } from './mock'
import { categoryDataMock } from './mock'
import {
generateTargetData,
useCategoryAddOption,
generateSkuList,
usePropertyValue,
colors,
useDrag,
handleLoadNode,
@ -161,18 +171,27 @@ const {
onClickChooseResourceBtn
} = useImportFile()
const categoryOptions123 = ref(categoryOptionsMock)
const isTest = ref(0)
const categoryData = ref(categoryDataMock)
const { inputValue, InputRefs, inputVisibles, onAddCategoryDataOption, handleInputConfirm } =
useCategoryAddOption(categoryData)
const {
inputValue,
InputRefs,
inputVisibles,
onAddPropertyValue,
onClosePropertyValue,
handleInputConfirm
} = usePropertyValue(categoryData.value)
const { dragRef, createDrag } = useDrag(categoryData)
createDrag()
const list = computed(() => generateTargetData(categoryData.value))
console.warn('----- my data is list.value: ', list.value)
const skuList = generateSkuList(categoryData.value)
const htmlContent = ref('自定义')
const onDeletePropertyValue = (index: number, id: number) =>
onClosePropertyValue(index, id, skuList)
const onSubmit = () => {
console.warn('----- my data is htmlContent: ', htmlContent.value)
}

View File

@ -37,7 +37,7 @@ export const categoryOptionsMock = [
]
}
]
/*
// 类目配置项
export const categoryDataMock = [
{
@ -66,6 +66,145 @@ export const categoryDataMock = [
{ id: 33, name: '黄' }
]
}
] */
// 类目配置项
export const categoryDataMock = [
{
id: 1,
isDelete: 0,
createTime: 1756023528000,
modifyTime: 1756023528000,
categoryId: 16,
categoryName: '项链',
categoryPropertyName: '珍珠直径',
defaultSort: -1,
vvPropertyValueList: [
{
id: 1,
isDelete: 0,
createTime: 1756015260000,
modifyTime: 1756015260000,
categoryPropertyId: 1,
categoryPropertyValue: '6-7mm',
defaultSort: -1
},
{
id: 3,
isDelete: 0,
createTime: 1756015260000,
modifyTime: 1756015260000,
categoryPropertyId: 1,
categoryPropertyValue: '8-9mm',
defaultSort: 2
},
{
id: 4,
isDelete: 0,
createTime: 1756015260000,
modifyTime: 1756015260000,
categoryPropertyId: 1,
categoryPropertyValue: '9-10mm',
defaultSort: 3
},
{
id: 2,
isDelete: 0,
createTime: 1756015260000,
modifyTime: 1756015260000,
categoryPropertyId: 1,
categoryPropertyValue: '7-8mm',
defaultSort: 4
},
{
id: 5,
isDelete: 0,
createTime: 1756015260000,
modifyTime: 1756015260000,
categoryPropertyId: 1,
categoryPropertyValue: '10-11mm',
defaultSort: 5
},
{
id: 6,
isDelete: 0,
createTime: 1756015260000,
modifyTime: 1756015260000,
categoryPropertyId: 1,
categoryPropertyValue: '11-12mm',
defaultSort: 6
}
]
},
{
id: 3,
isDelete: 0,
createTime: 1756023528000,
modifyTime: 1756023528000,
categoryId: 16,
categoryName: '项链',
categoryPropertyName: '颜色分类',
defaultSort: 2,
vvPropertyValueList: [
{
id: 12,
isDelete: 0,
createTime: 1756015420000,
modifyTime: 1756015420000,
categoryPropertyId: 3,
categoryPropertyValue: '极光正圆【配鉴定证书】',
defaultSort: 1
}
]
},
{
id: 2,
isDelete: 0,
createTime: 1756023528000,
modifyTime: 1756023528000,
categoryId: 16,
categoryName: '项链',
categoryPropertyName: '项链长度',
defaultSort: 3,
vvPropertyValueList: [
{
id: 8,
isDelete: 0,
createTime: 1756015381000,
modifyTime: 1756015381000,
categoryPropertyId: 2,
categoryPropertyValue: '43cm',
defaultSort: 1
},
{
id: 9,
isDelete: 0,
createTime: 1756015381000,
modifyTime: 1756015381000,
categoryPropertyId: 2,
categoryPropertyValue: '45cm',
defaultSort: 2
},
{
id: 10,
isDelete: 0,
createTime: 1756015381000,
modifyTime: 1756015381000,
categoryPropertyId: 2,
categoryPropertyValue: '47cm',
defaultSort: 3
},
{
id: 11,
isDelete: 0,
createTime: 1756015381000,
modifyTime: 1756015381000,
categoryPropertyId: 2,
categoryPropertyValue: '50cm',
defaultSort: 4
}
]
}
]
//

View File

@ -2,43 +2,49 @@ import { useDraggable } from 'vue-draggable-plus'
// 定义源数据的结构类型
interface Option {
id: number
name: string
categoryPropertyValue: string
isAdd?: boolean
}
interface Category {
typeName: string
typeId: number
options: Option[]
vvPropertyValueList: Option[]
}
type CategoryDataMock = Category[]
// 定义目标数据的结构类型
interface TargetData {
interface TSkuList {
serialNo: string
name: string
sale: string
img: string
num: number
amount: string
salePrice: string
imageUrl: string
stock: number
originPrice: number
promotionPrice: string
vvSkuPropertyValueList: any[]
}
export const generateTargetData = (categoryData: CategoryDataMock): TargetData[] => {
const result: TargetData[] = []
const optionGroups: Option[][] = categoryData.map((category) => category.options)
// 创建sku列表
export const generateSkuList = (categoryData: CategoryDataMock): Ref<TSkuList[]> => {
const result: TSkuList[] = []
const optionGroups: Option[][] = categoryData.map((category) => category.vvPropertyValueList)
// 使用递归生成所有可能的组合
const combine = (index: number, current: Option[]) => {
if (index === optionGroups.length) {
const serialNo = current.map((opt) => opt.id.toString()).join('-')
const name = current.map((opt) => opt.name).join('-')
const name = current.map((opt) => opt.categoryPropertyValue).join('-')
result.push({
serialNo,
name,
sale: '',
num: 0,
img: '',
amount: ''
salePrice: '',
stock: 0,
originPrice: 0,
imageUrl: '',
promotionPrice: '',
vvSkuPropertyValueList: current
})
return
}
@ -48,33 +54,99 @@ export const generateTargetData = (categoryData: CategoryDataMock): TargetData[]
}
combine(0, [])
return result
return ref(result)
}
// 添加类目
export const useCategoryAddOption = (categoryData: any) => {
const inputVisibles = ref(Array.from({ length: categoryData.value.length }, (_) => false))
// 增加属性、删除属性
export const usePropertyValue = (categoryData: CategoryDataMock) => {
const inputVisibles = ref(Array.from({ length: categoryData.length }, (_) => false))
const InputRefs = ref()
const inputValue = ref('')
const onAddCategoryDataOption = (index: number) => {
const onAddPropertyValue = (index: number) => {
inputVisibles.value[index] = true
nextTick(() => {
InputRefs.value[index].input!.focus()
})
}
const handleInputConfirm = (index: number) => {
const onClosePropertyValue = (index: number, id: number, skuList: Ref<TSkuList[]>) => {
const i = categoryData[index].vvPropertyValueList.findIndex((item: any) => item.id === id)
categoryData[index].vvPropertyValueList.splice(i, 1)
const deleteIndexs: number[] = []
skuList.value.forEach((item: TSkuList, skuIndex: number) => {
if (item.vvSkuPropertyValueList.find((it) => it.id === id)) {
deleteIndexs.push(skuIndex)
}
})
skuList.value = skuList.value.filter(
(_: TSkuList, index: number) => !deleteIndexs.includes(index)
)
}
// 新增回车确认
const handleInputConfirm = (lineIndex: number, skuList: TSkuList[] = []) => {
if (inputValue.value) {
categoryData.value[index].options.push({
id: inputValue.value,
name: inputValue.value,
const id = Math.random()
categoryData[lineIndex].vvPropertyValueList.push({
id,
categoryPropertyValue: inputValue.value,
isAdd: true
})
skuList.push(
...generateAddSkuList(
lineIndex,
{ id, categoryPropertyValue: inputValue.value },
categoryData
)
)
}
inputVisibles.value[index] = false
inputVisibles.value[lineIndex] = false
inputValue.value = ''
}
return { inputVisibles, InputRefs, inputValue, onAddCategoryDataOption, handleInputConfirm }
// 新增属性的时候创建数据
const generateAddSkuList = (
lineIndex: number,
addData: Option,
categoryData: CategoryDataMock
): TSkuList[] => {
const result: TSkuList[] = []
const optionGroups: Option[][] = categoryData
.filter((_, index) => index !== lineIndex)
.map((category) => category.vvPropertyValueList)
// 使用递归生成所有可能的组合
const combine = (index: number, current: Option[]) => {
if (index === categoryData.length) {
const serialNo = current.map((opt) => opt.id.toString()).join('-')
const name = current.map((opt) => opt.categoryPropertyValue).join('-')
result.push({
serialNo,
name,
salePrice: '',
stock: 0,
originPrice: 0,
imageUrl: '',
promotionPrice: '',
vvSkuPropertyValueList: current
})
return
}
for (const option of optionGroups[index]) {
combine(index + 1, [...current, option])
}
}
combine(1, [addData])
return result
}
return {
inputVisibles,
InputRefs,
inputValue,
onAddPropertyValue,
onClosePropertyValue,
handleInputConfirm
}
}
export const colors = ['primary', 'success', 'warning', 'danger', 'info']
@ -120,7 +192,7 @@ export const handleLoadNode = (node: any, resolve: any) => {
})
}
// 树结构懒加载后台类目
// 树结构懒加载app类目
export const handleLoadNode2 = (node: any, resolve: any) => {
api.commodity.getAppCategoryList.post!<any>({ parentId: node.data.value || 0 }).then((res) => {
resolve(