商品SKU多规格组件主要用于电商平台的商品详情页,帮助买家在选择商品时能够根据不同的规格(如颜色、尺码、容量等)进行筛选和购买。这种组件的设计旨在提升用户体验,简化购买流程,并确保买家能够准确地选择到自己需要的商品规格。以下是对商品SKU多规格组件的详细解析:
1. 定义与功能
- SKU(Stock Keeping Unit)定义:SKU即库存量单位,是指宝贝的销售属性集合,供买家在下单时点选,如“规格”、“颜色分类”、“尺码”等。部分SKU的属性值可以由卖家自定义编辑,部分则不可编辑。
- 功能:商品SKU多规格组件允许买家在商品详情页直接查看并选择商品的多种规格,同时展示每种规格对应的库存数量、价格等信息。这有助于买家快速了解商品的各种选项,并做出购买决策。
2. 组件特点
数据驱动:现代的商品SKU多规格组件通常采用数据驱动的方式,即组件从服务器获取商品规格数据,并根据这些数据动态渲染出可供买家选择的规格选项。这种方式提高了组件的灵活性和可扩展性。
用户友好:组件的设计注重用户体验,通过清晰的界面布局和交互设计,使买家能够轻松理解和选择商品的规格。
实时更新:组件能够实时从服务器获取商品的库存信息和价格信息,确保买家在选择规格时能够看到最新的数据。
3. 实现方式
前端开发:商品SKU多规格组件通常使用前端技术进行开发。这里使用了Vue3现代前端框架中,可以通过组件化的方式实现商品SKU多规格组件的复用和灵活配置。
后端支持:组件需要后端服务的支持,以获取商品的规格数据、库存信息、价格信息等。后端服务可以通过API接口向前端组件提供这些数据。
数据交互:前端组件与后端服务之间通过HTTP请求和响应进行数据交互。前端组件发送请求获取数据,后端服务处理请求并返回数据给前端组件进行展示。
4. 应用场景
电商平台:商品SKU多规格组件广泛应用于各类电商平台,如淘宝、京东、天猫等。这些平台上的商品种类繁多,规格多样,使用SKU多规格组件可以大大提升买家的购物体验。
零售店铺:对于拥有线上销售渠道的零售店铺来说,商品SKU多规格组件也是一个不可或缺的工具。它可以帮助店铺更好地展示商品信息,提高销售额。
5. 组件实现
<template> <div class="diygw-col-24"> <div v-if="specs.length > 0"> <VueDraggable v-bind="{ animation: 200, disabled: false, ghostClass: 'ghost' }" v-model="specs" class="list-group" ghost-class="ghost"> <div v-for="(element, itemIndex) in specs" class="box sku-spec flex flex-direction-column margin-bottom"> <div class="flex margin-bottom-xs align-center justify-between"> <el-input style="width: 200px !important; flex: unset" placeholder="请输入规格名称" v-model="element.title"> </el-input> <div class="toolbar"> <i class="diyicon handle diyicon-yidongxuanze" /> <el-tooltip content="上移" placement="bottom"> <i class="diy-icon-top" @click.stop="move(itemIndex, 'up')" /> </el-tooltip> <el-tooltip content="下移" placement="bottom"> <i class="diy-icon-down" @click.stop="move(itemIndex, 'down')" /> </el-tooltip> <el-tooltip content="新增规格组" placement="bottom"> <i class="diy-icon-add" @click.stop="addSpec()" /> </el-tooltip> <el-tooltip content="删除规格组" placement="bottom"> <i class="diy-icon-close" @click.stop="removeSpec(itemIndex)" /> </el-tooltip> </div> </div> <div class="flex flex-wrap align-center"> <VueDraggable v-bind="{ animation: 200, disabled: false, ghostClass: 'ghost' }" v-model="element.datas" class="flex flex-wrap align-center sku-spec-item" ghost-class="ghost" > <div v-for="(subitem, subindex) in element.datas" class="margin-right-xs spec-item margin-bottom-xs" style="width: 150px"> <el-input v-model="subitem.title"> </el-input> <div class="spec-toolbar"> <el-tooltip content="删除规格值" placement="bottom"> <i class="diy-icon-close" @click.stop="removeSpecItem(itemIndex, subindex)" /> </el-tooltip> </div> </div> </VueDraggable> <div class="margin-right-xs margin-bottom-xs text-xs" @click="addSpecIndex(itemIndex)">新增规格值</div> </div> </div> </VueDraggable> </div> <el-button class="diygw-col-24 margin-bottom-xs" @click="addSpec">新增规格</el-button> <DiySkutable v-model="skus" :specs="specs" :columns="columns" ref="skuref"></DiySkutable> </div> </template> <script setup name="DiySku"> import { ElMessage } from 'element-plus'; import { useVModels } from '@vueuse/core'; import { VueDraggable } from 'vue-draggable-plus'; import DiySkutable from './skutable.vue'; const props = defineProps({ skus: { type: Array, default: () => { return []; }, }, specs: { //规格配置 type: Array, default: () => { return []; }, }, columns: { //自定义sku属性 type: Array, default: () => { return []; }, }, filterable: { //是否开启sku搜索 type: Boolean, default: true, }, }); const emit = defineEmits(['update:specs', 'update:skus']); const { skus, specs } = useVModels(props, emit); const removeSpec = (index) => { specs.value.splice(index, 1); }; const removeSpecItem = (index, subindex) => { specs.value[index].datas.splice(subindex, 1); }; const addSpecIndex = (index) => { let id = specs.value[index].datas.length + specs.value[index].id; specs.value[index].datas.push({ title: '', id: id + '' + new Date().getTime(), }); }; const addSpec = () => { let id = (specs.value.length + 1) * 1000; specs.value.push({ id: id, title: '', datas: [ { title: '', id: id + '' + new Date().getTime(), }, ], }); }; const move = (itemIndex, dir) => { let comps = specs.value; let item = comps[itemIndex]; if (dir == 'up') { if (itemIndex == 0) { ElMessage.error('已经是第一个了!'); return; } const swap = comps[itemIndex - 1]; const tmp = swap; comps[itemIndex - 1] = item; comps[itemIndex] = tmp; } else { if (itemIndex == comps.length - 1) { ElMessage.error('已经是最一个了!'); return; } else { const swap = comps[itemIndex + 1]; const tmp = swap; comps[itemIndex + 1] = item; comps[itemIndex] = tmp; } } }; </script> <style lang="scss"> .sku-spec { padding: 6px; border-radius: 5px; border: 1px solid #ebeef5; .sku-spec-item { padding-left: 30px; padding-right: 5px; .spec-item { position: relative; .spec-toolbar { top: -2px; right: -3px; position: absolute; display: none; } &:hover { .spec-toolbar { display: block; } } } } } </style>
<template> <div class="sku-table" v-if="specs.length > 0"> <table class="diy-table diy-skutable"> <tr> <th v-for="(item, colIndex) in specs" :key="colIndex"> {{ item.title }} </th> <th v-for="(item, colIndex) in columns" :style="{ width: item.type == 'number' ? '100px' : 'auto' }" :key="colIndex + specs.length + 1"> {{ item.title }} </th> </tr> <tr> <td v-for="(item, colIndex) in specs" :key="colIndex"> <el-select v-if="filterable" @change="resetTable" v-model="headerFilterParams[item.title]" placeholder="过滤查询" clearable> <el-option label="全部" value=""></el-option> <el-option v-for="(child, index2) in item.datas" :key="index2" :label="child.title" :value="child.title"></el-option> </el-select> <span v-else class="spec-title"> {{ item.title }} </span> </td> <td v-for="(item, colIndex) in columns" :key="colIndex + specs.length + 1"> <span v-if="item.readOnly">{{ item.title }}</span> <diy-uploadinput class="diygw-col-24" v-else-if="item.type == 'img'" @blur=" () => { onBatchEdit(item.id); } " placeholder="批量修改" v-model="columnsValue[item.id]" /> <el-input-number :min="0" :precision="item.precision ? item.precision : 0" :step="item.precision ? 0.1 : 1" v-else-if="item.type == 'number'" v-model="columnsValue[item.id]" controls-position="right" :controls="false" class="sku-input-number" @blur=" () => { onBatchEdit(item.id); } " @keyup.native.enter=" () => { onBatchEdit(item.id); } " placeholder="批量修改" /> <el-input v-else class="diygw-col-24" v-model="columnsValue[item.id]" @blur=" () => { onBatchEdit(item.id); } " @keyup.native.enter=" () => { onBatchEdit(item.id); } " placeholder="批量修改" /> </td> </tr> <tr v-for="(row, rowIndex) in renderTableRows" :key="row.id"> <td v-for="(child, colIndex) in row.columns" :class="[ child.shouldSetRowSpan ? '' : 'hide', rowIndex === rowLastCanSpan[colIndex] ? 'col-last-rowspan' : '', colIndex < specs.length - 1 ? 'row-span-style' : '', ]" :rowspan="child.shouldSetRowSpan ? assignRule[colIndex] : ''" :key="colIndex" > <span>{{ child.showValue }}</span> </td> <td v-for="(child, colIndex) in columns" :key="colIndex + columns.length + 1"> <span v-if="child.readOnly">{{ row[child.id] }}</span> <diy-uploadinput v-else-if="child.type == 'img'" @change=" (value) => { checkValue(value, child, row); } " :placeholder="child.title" v-model="row[child.id]" ></diy-uploadinput> <el-input-number :min="0" :precision="child.precision ? child.precision : 0" :step="child.precision ? 0.1 : 1" v-else-if="child.type == 'number'" v-model="row[child.id]" controls-position="right" :controls="false" class="sku-input-number" @change=" (value) => { checkValue(value, child, row); } " :placeholder="child.title" /> <el-input v-else v-model="row[child.id]" @change=" (value) => { checkValue(value, child, row); } " :placeholder="child.title" /> </td> </tr> </table> </div> </template> <script setup name="DiySkutable"> import { uuid } from '@/utils'; import { ElMessageBox } from 'element-plus'; import { onMounted, ref, watch, computed } from 'vue'; import DiyUploadinput from '@/components/upload/uploadinput.vue'; import { useVModel } from '@vueuse/core'; const props = defineProps({ modelValue: { type: Array, default: () => { return []; }, }, specs: { //规格配置 type: Array, default: () => { return []; }, }, columns: { //自定义sku属性 type: Array, default: () => { return []; }, }, filterable: { //是否开启sku搜索 type: Boolean, default: true, }, }); const emit = defineEmits(['update:modelValue']); const modeldata = useVModel(props, 'modelValue', emit); const headerFilterParams = ref({}); const columnsValue = ref({}); const originTableRows = ref([]); const renderTableRows = ref([]); onMounted(() => { render(); }); function render() { originTableRows.value = createTable(); renderTableRows.value = originTableRows.value; getData(); } function _resetRowSpan(table) { table.forEach((row, rowIndex) => { row.columns.forEach((column, columnIndex) => { column.shouldSetRowSpan = shouldSetRowSpan(rowIndex, columnIndex); }); }); } function createTable() { let tableData = []; let details = props.modelValue; let theaderFilterParams = {}; props.specs.forEach((item) => { theaderFilterParams[item.title] = ''; }); headerFilterParams.value = theaderFilterParams; for (let i = 0; i < skuTotal.value; i++) { let columns = props.specs.map((t, j) => { let { title, id } = getShowValue(i, j); return { shouldSetRowSpan: shouldSetRowSpan(i, j), showValue: title, valueId: id, columnName: t.title, columnId: t.id, precision: t.precision, }; }); //获取当前组合 let pattern = columns .map((t) => t.valueId) .sort() .toString(); //从详情中找回同一个组合的sku数据 let rowDetails = details.find((t) => t.specIds === pattern); //当数据长度不为0,并新增了大的规格 if (details.length > 0 && details.length >= i && !rowDetails && details[0].specIds.split(',').length != columns.length) { rowDetails = details[i]; } tableData.push({ id: uuid(), ...createSkuPropertyFields(columns, i, rowDetails), columns, }); } return tableData; } function createSkuPropertyFields(columns, rowIndex, row) { return props.columns.reduce((res, item) => { if (row && row[item.id]) { res[item.id] = row[item.id] || ''; } else { if (item.defaultValue) { // 设置默认值,可以为string或function,fuction时会传入行的索引和列的信息 if (typeof item.defaultValue === 'string') { res[item.id] = item.defaultValue; } else if (typeof item.defaultValue === 'function') { res[item.id] = item.defaultValue({ columns, rowIndex }); } } else if (columnsValue.value[item.id]) { res[item.id] = columnsValue.value[item.id]; } else if (res.type == 'number') { res[item.id] = 0; } else { res[item.id] = ''; } } return res; }, {}); } function shouldSetRowSpan(rowIndex, colIndex) { return rowIndex % assignRule.value[colIndex] === 0; } function getShowValue(rowIndex, colIndex) { let datas = props.specs[colIndex].datas; let index; if (colIndex === props.specs.length - 1) { index = rowIndex % datas.length; } else { let step = assignRule.value[colIndex]; index = Math.floor(rowIndex / step); if (index >= datas.length) { index = index % datas.length; } } return datas[index]; } function onBatchEdit(id) { ElMessageBox.confirm(`确认批量修改吗?`, '提示', { customClass: 'diygw-messagebox', confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning', }) .then(() => { let value = columnsValue.value[id]; renderTableRows.value.forEach((item) => { item[id] = value; }); getData(); }) .catch(() => {}); } function checkValue(value, columnInfo, row) { // let { id, pattern } = columnInfo; // if (pattern) { // (row || columnsValue.value)[id] = value.replace(pattern, ''); // } getData(); } function getData() { let data = originTableRows.value.map((t) => { let columnObj = props.columns.reduce((res, item) => { res[item.id] = item.format ? item.format(t[item.id]) : t[item.id]; return res; }, {}); return { specIds: t.columns.map((t) => t.valueId).join(','), ...columnObj, }; }); modeldata.value = data; } const hasFilter = computed(() => { return Object.values(headerFilterParams.value).filter((t) => !!t).length > 0; }); const renderSpecs = computed(() => { return props.specs .map((t) => t.datas.length) .map((t, index) => { return hasFilter.value ? (headerFilterParams.value[props.specs[index].title] ? 1 : t) : t; }); }); const skuTotal = computed(() => { return renderSpecs.value.reduce((result, item) => result * item, renderSpecs.value.length ? 1 : 0); }); const assignRule = computed(() => { return renderSpecs.value.reduce((result, item, index) => { let preValue = result[index - 1]; if (preValue) { result.push(preValue / item); } else { result.push(skuTotal.value / item); } return result; }, []); }); const rowLastCanSpan = computed(() => { let indexArr = Array.from(new Array(skuTotal.value).keys()); //生成行的索引数组 //每列可以合并的最后一行的行索引数组,为了设置样式 return assignRule.value.map((t, index, array) => { return index === array.length - 1 ? null : indexArr.filter((row) => row % t === 0).pop(); }); }); watch( () => props.specs, () => { render(); }, { deep: true, immediate: true, } ); function resetTable() { if (hasFilter.value) { let trenderTableRows = originTableRows.value.filter((t) => { return t.columns.reduce((res, item) => { let filterValue = headerFilterParams.value[item.columnName]; return filterValue ? res && item.showValue === filterValue : res; }, true); }); _resetRowSpan(trenderTableRows); renderTableRows.value = trenderTableRows; } else { _resetRowSpan(originTableRows.value); renderTableRows.value = originTableRows.value; } } // watch( // headerFilterParams, // () => { // resetTable(); // }, // { // deep: true, // immediate: true, // } // ); </script>
组件调用
<template> <div class="container"> <div class="el-card is-always-shadow diygw-col-24"> <div class="el-card__body"> <div class="mb8"> <el-button type="primary" plain @click="onOpenAddModule"> <SvgIcon name="ele-Plus" />新增 </el-button> <el-button type="danger" plain :disabled="state.multiple" @click="onTabelRowDel"> <SvgIcon name="ele-Delete" />删除 </el-button> </div> <el-table @selection-change="handleSelectionChange" v-loading="state.loading" :data="state.tableData" stripe border current-row-key="id" empty-text="没有数据" style="width: 100%" > <el-table-column type="selection" width="55" align="center"></el-table-column> <el-table-column label="姓名" prop="name" align="center"> </el-table-column> <el-table-column label="性别" prop="sex" align="center"> </el-table-column> <el-table-column label="邮箱" prop="email" align="center"> </el-table-column> <el-table-column label="操作" align="center" fixed="right" width="180"> <template #default="scope"> <el-button type="primary" plain size="small" @click="onOpenEditModule(scope.row)"> <SvgIcon name="ele-Edit" />修改 </el-button> <el-button v-if="scope.row.id != 0" type="danger" plain size="small" @click="onTabelRowDel(scope.row)"> <SvgIcon name="ele-Delete" />删除 </el-button> </template> </el-table-column> </el-table> <!-- 分页设置--> <div v-show="state.total > 0"> <el-divider></el-divider> <el-pagination background :total="state.total" :current-page="state.queryParams.pageNum" :page-size="state.queryParams.pageSize" layout="total, sizes, prev, pager, next, jumper" @size-change="handleSizeChange" @current-change="handleCurrentChange" /> </div> </div> <!-- 添加或修改参数配置对话框 --> <el-dialog :fullscreen="isFullscreen" width="800px" v-model="isShowDialog" destroy-on-close title="CRUD表格" draggable center> <template #header="{ close, titleId, titleClass }"> <h4 :id="titleId" :class="titleClass">CRUD表格</h4> <el-tooltip v-if="!isFullscreen" content="最大化" placement="bottom"> <el-button class="diygw-max-icon el-dialog__headerbtn"> <el-icon @click="isFullscreen = !isFullscreen"> <FullScreen /> </el-icon> </el-button> </el-tooltip> <el-tooltip v-if="isFullscreen" content="返回" placement="bottom"> <el-button class="diygw-max-icon el-dialog__headerbtn"> <el-icon @click="isFullscreen = !isFullscreen"> <Remove /> </el-icon> </el-button> </el-tooltip> </template> <el-form class="flex flex-direction-row flex-wrap" :model="state.editForm" :rules="state.editFormRules" ref="editFormRef" label-width="80px"> <div class="flex diygw-col-24"> <el-form-item prop="sku" label="多规格"> <diy-sku :columns="editFormData.skuColumns" v-model:skus="editForm.sku.skus" v-model:specs="editForm.sku.specs"></diy-sku> </el-form-item> </div> </el-form> <template #footer> <div class="dialog-footer flex justify-center"> <el-button @click="closeDialog"> 取 消 </el-button> <el-button type="primary" @click="onSubmit" :loading="state.saveloading"> 保 存 </el-button> </div> </template> </el-dialog> </div> <div class="clearfix"></div> </div> </template> <script setup name="index"> import { ElMessageBox, ElMessage } from 'element-plus'; import { ref, toRefs, reactive, onMounted, getCurrentInstance, onUnmounted, unref } from 'vue'; import { deepClone, changeRowToForm } from '@/utils/other'; import { addData, updateData, delData, listData } from '@/api'; import { useRouter, useRoute } from 'vue-router'; import { storeToRefs } from 'pinia'; import { useUserInfo } from '@/stores/userInfo'; import DiySku from '@/components/sku/index.vue'; const stores = useUserInfo(); const { userInfos } = storeToRefs(stores); const { proxy } = getCurrentInstance(); const router = useRouter(); const route = useRoute(); const globalOption = ref(route.query); const getParamData = (e, row) => { row = row || {}; let dataset = e.currentTarget ? e.currentTarget.dataset : e; if (row) { dataset = Object.assign(dataset, row); } return dataset; }; const navigateTo = (e, row) => { let dataset = getParamData(e, row); let { type } = dataset; if ((type == 'page' || type == 'inner' || type == 'href') && dataset.url) { router.push({ path: (dataset.url.startsWith('/') ? '' : '/') + dataset.url, query: dataset, }); } else { ElMessage.error('待实现点击事件'); } }; const state = reactive({ userInfo: userInfos.value, // 遮罩层 loading: true, // 选中数组 ids: [], // 非单个禁用 single: true, // 非多个禁用 multiple: true, // 弹出层标题 title: '', // 总条数 total: 0, tableData: [], // 是否显示弹出层 isFullscreen: false, isShowDialog: false, saveloading: false, editFormData: { skuColumns: [ { title: '图片地址', id: 'thumb', type: 'img' }, { title: '价格', id: 'price', type: 'number' }, { title: '划线价格', id: 'linePrice', type: 'number' }, { title: '数量', id: 'amount', type: 'number' }, { title: '备注', id: 'sku', type: 'text' }, ], }, queryParams: { pageNum: 1, pageSize: 10, }, queryParamsRules: {}, editForm: { id: undefined, sku: { skus: [], specs: [], }, }, editFormRules: {}, }); const { userInfo, editFormData, queryParams, multiple, tableData, loading, title, single, total, isShowDialog, editForm, ids, saveloading, isFullscreen, } = toRefs(state); const editFormRef = ref(null); const editFormInit = deepClone(state.editForm); // 打开弹窗 const openDialog = (row) => { if (row.id && row.id != undefined && row.id != 0) { state.editForm = changeRowToForm(row, state.editForm); } else { initForm(); } state.isShowDialog = true; state.saveloading = false; }; // 关闭弹窗 const closeDialog = (row) => { proxy.mittBus.emit('onEditAdmintableModule', row); state.isShowDialog = false; }; // 保存 const onSubmit = () => { const formWrap = unref(editFormRef); if (!formWrap) return; formWrap.validate((valid) => { if (valid) { state.saveloading = true; if (state.editForm.id != undefined && state.editForm.id != 0) { updateData('/sys/user', state.editForm) .then(() => { ElMessage.success('修改成功'); state.saveloading = false; closeDialog(state.editForm); // 关闭弹窗 }) .finally(() => { state.saveloading = false; }); } else { addData('/sys/user', state.editForm) .then(() => { ElMessage.success('新增成功'); state.saveloading = false; closeDialog(state.editForm); // 关闭弹窗 }) .finally(() => { state.saveloading = false; }); } } else { ElMessage.error('验证未通过!'); } }); }; // 表单初始化,方法:`resetFields()` 无法使用 const initForm = () => { state.editForm = deepClone(editFormInit); }; const queryParamsInit = deepClone(state.queryParams); /** 查询CRUD表格列表 */ const handleQuery = () => { state.loading = true; listData('/sys/user', state.queryParams).then((response) => { state.tableData = response.rows; state.total = response.total; state.loading = false; }); }; /** 重置按钮操作 */ const resetQuery = () => { state.queryParams = deepClone(queryParamsInit); handleQuery(); }; // 打开新增CRUD表格弹窗 const onOpenAddModule = (row) => { row = []; state.title = '添加CRUD表格'; initForm(); openDialog(row); }; // 打开编辑CRUD表格弹窗 const onOpenEditModule = (row) => { state.title = '修改CRUD表格'; openDialog(row); }; /** 删除按钮操作 */ const onTabelRowDel = (row) => { const id = row.id || state.ids; ElMessageBox({ message: '是否确认删除选中的CRUD表格?', title: '警告', showCancelButton: true, confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning', }).then(function () { return delData('/sys/user', id).then(() => { handleQuery(); ElMessage.success('删除成功'); }); }); }; // 多选框选中数据 const handleSelectionChange = (selection) => { state.ids = selection.map((item) => item.id); state.single = selection.length != 1; state.multiple = !selection.length; }; //分页页面大小发生变化 const handleSizeChange = (val) => { state.queryParams.pageSize = val; handleQuery(); }; //当前页码发生变化 const handleCurrentChange = (val) => { state.queryParams.pageNum = val; handleQuery(); }; const init = async () => {}; // 页面加载时 onMounted(async () => { await init(); handleQuery(); proxy.mittBus.on('onEditAdmintableModule', () => { handleQuery(); }); }); // 页面卸载时 onUnmounted(() => { proxy.mittBus.off('onEditAdmintableModule'); }); </script> <style lang="scss" scoped> .container { font-size: 12px; } </style>
已集成进开源项目组件:diygw-ui-admin: 基于vite、vue3.x 、router、pinia、Typescript、Element plus等,适配手机、平板、pc 的后台开源免费模板库