通用属性系统设计与实现
这两年做过不少的小型电商系统,有的卖衣服,有的卖鞋子,有的卖电器,甚至还有些卖虚拟服务的。不同商品的属性千差万别,为了减少以后卖xxx的电商系统的工作量,特将属性系统做成通用版的。
设计思路如下:
1、可自定义的无限级商品类别。
2、各类别可自定义属性,属性的类型有:普通文本、数字、价格、单项选择、多项选择、日期、文本域、富文本、图片、布尔值等,添加商品时自动加载所需的组件。
3、支持公共属性。
4、支持属性继承,即子类别自动继承父类别的属性,并支持覆盖父类别同名属性。
5、支持属性值验证,添加商品时对必填项、正则表达式进行自动验证。
6、支持属性分组,添加商品时属性按照属性分组名进行分组。
模型设计:
Classify:商品类别表
Attribute:属性表
AttributeOption:属性选项表,只有类别为“单项选择”和“多项选择”时,属性需要设置属性选项。
Product:商品表
ProductAttribute:商品属性关系表
这里只是对商品属性进行了简单的建模,与属性无关的模型没有画出。
关键代码:
@{ ViewBag.Title = "新增产品"; Layout = "~/Areas/Admin/Views/Shared/_AdminLayout.cshtml"; } @section header{ <link href="~/Content/css/dataTables.bootstrap.css" rel="stylesheet" type="text/css" /> <link href="~/Content/css/bootstrap-datetimepicker.min.css" rel="stylesheet" type="text/css" /> <link href="~/Content/js/plugs/webuploader/webuploader.css" rel="stylesheet" type="text/css" /> } <div class="page-container"> <div class="page-body"> <div class="row"> <div class="col-lg-12 col-sm-12 col-xs-12"> <div id="simplewizard" class="wizard" data-target="#simplewizard-steps"> <ul class="steps"> <li data-target="#basicInfoStep" class="active"><span class="step">1</span><span class="title">基础信息</span> <span class="chevron"></span></li> <li data-target="#attributeStep"><span class="step">2</span><span class="title">产品属性</span> <span class="chevron"></span></li> <li data-target="#picInfoStep"><span class="step">3</span><span class="title">产品图片</span> <span class="chevron"></span></li> <li data-target="#confirmInfoStep"><span class="step">4</span><span class="title">确认信息</span> <span class="chevron"></span></li> </ul> </div> <div class="step-content" id="simplewizard-steps"> <!--基础信息--> <div class="step-pane active" id="basicInfoStep"> <form class="form-horizontal" role="form"> <div class="form-group"> <label for="name" class="col-sm-2 control-label no-padding-right"><span style="color:red;">* </span>产品名称:</label> <div class="col-sm-6"> <input type="text" class="form-control" id="name" v-model="product.name"> </div> </div> <div class="form-group"> <label for="originPrice" class="col-sm-2 control-label no-padding-right"><span style="color:red;">* </span>原价:</label> <div class="col-sm-6"> <input type="text" class="form-control" id="price" v-model="product.originPrice" data-type="2"> </div> </div> <div class="form-group"> <label for="price" class="col-sm-2 control-label no-padding-right"><span style="color:red;">* </span>销售价:</label> <div class="col-sm-6"> <input type="text" class="form-control" id="price" v-model="product.price" data-type="2"> </div> </div> <div class="form-group"> <label for="inventory" class="col-sm-2 control-label no-padding-right"><span style="color:red;">* </span>库存:</label> <div class="col-sm-6"> <input type="text" class="form-control" id="inventory" v-model="product.inventory" data-type="2"> </div> </div> <div class="form-group"> <label for="isOnShelf" class="col-sm-2 control-label no-padding-right"><span style="color:red;">* </span>是否上架:</label> <div class="col-sm-6"> <select id="isOnShelf" v-model="product.isOnShelf"> <option value="false">否</option> <option value="true">是</option> </select> </div> </div> <div class="form-group"> <label for="classifyId" class="col-sm-2 control-label no-padding-right"><span style="color:red;">* </span>所属分类:</label> <div class="col-sm-6"> <select id="classifyId" v-model="product.classifyId" v-on:change="classifyChange()" disabled="disabled"> <option v-for="option in classifies" v-bind:value="option.Value"> {{ option.Name }} </option> </select> </div> </div> </form> </div> <!--产品属性--> <div class="step-pane" id="attributeStep"> <div class="row"> <div class="col-sm-12"> <div class="tabbable"> <ul class="nav nav-tabs tabs-flat"> <template v-for="(index,group) in product.groupAttributes"> <li class="tab-sky"> <a data-toggle="tab" href="#group{{index}}" aria-expanded="true"> {{group.groupName}} </a> </li> </template> </ul> <div class="tab-content tabs-flat"> <template v-for="(index,group) in product.groupAttributes"> <div id="group{{index}}" class="tab-pane" style="width:99%"> <form class="form-horizontal" role="form"> <template v-for="attribute in group.attributes"> <div class="form-group"> <label class="col-sm-2 control-label no-padding-right"><span v-if="attribute.isRequired" style="color:red;">* </span>{{attribute.name}}:</label> <div class="col-sm-6"> <!--单选--> <select v-if="attribute.attributeType==4" class="form-control" id="atrribute_{{attribute.id}}" v-model="attribute.attributeOptionId"> <option v-for="item in attribute.options" v-bind:value="item.value">{{item.name}}</option> </select> <template v-else> <!--多选--> <div v-if="attribute.attributeType==5" class="row"> <div v-for="item in attribute.options" class="col-sm-3 col-lg-2"> <div class="checkbox"> <label> <input type="checkbox" v-bind:value="item.value" v-model="attribute.attributeOptionIds"> <span class="text">{{item.name}}</span> </label> </div> </div> </div> <template v-else> <!--文本域--> <textarea v-if="attribute.attributeType==7" class="form-control" data-type="{{attribute.attributeType}}" v-model="attribute.value"></textarea> <template v-else> <!--富文本--> <script v-if="attribute.attributeType==8" id="atrribute_{{attribute.id}}" data-type="{{attribute.attributeType}}" name="content" type="text/plain"> </script> <template v-else> <!--图片--> <template v-if="attribute.attributeType==9"> <img style="width:160px;height:90px;" id="img_{{attribute.id}}" v-bind:src="attribute.value" /> <div id="upload_{{attribute.id}}" data-type="{{attribute.attributeType}}">选择图片</div> </template> <input v-else type="text" class="form-control" id="atrribute_{{attribute.id}}" data-type="{{attribute.attributeType}}" v-model="attribute.value"> </template> </template> </template> </template> </div> <div class="col-sm-2" style="margin-top:7px;">{{attribute.tips}}</div> </div> </template> </form> </div> </template> </div> </div> </div> </div> </div> <!--产品图片--> <div class="step-pane" id="picInfoStep"> <form class="form-horizontal form-bordered" role="form"> <div class="form-group"> <div id="upload_album" class="col-sm-2 control-label no-padding-right">上传图片</div> <div class="col-sm-6"> <div class="row"> <div class="col-sm-3" v-for="path in product.albums"> <img style="width:160px;height:90px;" id="img_album" v-bind:src="path" /> </div> </div> </div> </div> </form> </div> <!--确认信息--> <div class="step-pane" id="confirmInfoStep"> <table class="table table-bordered table-hover"> <tbody> <tr> <td width="150px">商品名称</td> <td>{{product.name}}</td> </tr> <tr> <td width="150px">原价</td> <td>{{product.originPrice}}</td> </tr> <tr> <td width="150px">销售价</td> <td>{{product.price}}</td> </tr> <tr> <td width="150px">库存</td> <td>{{product.inventory}}</td> </tr> <tr> <td width="150px">是否上架</td> <td>{{product.isOnShelf}}</td> </tr> <tr> <td width="150px">所属城市</td> <td>{{product.regionId}}</td> </tr> <template v-for="group in product.groupAttributes"> <tr v-for="attribute in group.attributes"> <td width="150px">{{attribute.name}}</td> <td>{{{attribute.value}}}</td> </tr> </template> <tr> <td width="150px">产品图片</td> <td> <img v-for="path in product.albums" style="width:160px;height:90px;" v-bind:src="path" /> </td> </tr> </tbody> </table> </div> </div> <div class="actions actions-footer" id="simplewizard-actions"> <div class="btn-group"> <button type="button" class="btn btn-default btn-prev"> <i class="fa fa-angle-left"></i>上一步</button> <button type="button" class="btn btn-default btn-next">下一步<i class="fa fa-angle-right"></i></button> </div> </div> </div> </div> </div> </div> @section footer{ <script src="~/Content/js/bode/bode.wizard.js" type="text/javascript"></script> <script src="~/Content/js/plugs/datetime/bootstrap-datetimepicker.min.js" type="text/javascript"></script> <script src="~/Content/js/plugs/datetime/bootstrap-datetimepicker.zh-CN.js" type="text/javascript"></script> <script src="~/Content/js/plugs/webuploader/webuploader.js" type="text/javascript"></script> <script src="~/Content/js/plugs/ueditor/ueditor.config.js" type="text/javascript"></script> <script src="~/Content/js/plugs/ueditor/ueditor.all.min.js" type="text/javascript"></script> <script src="~/Content/js/plugs/textarea/jquery.autosize.js" type="text/javascript"></script> <script type="text/javascript"> $(document).ready(function(){ //$("#simplewizard-steps").height($(window).height() - 160); $.bode.tools.input.formatDiscount($("input[data-type='2']")); var attributeInitialized=false,uploaderInitialized=false; var vm = new Vue({ el: "#simplewizard-steps", data: { product: { name: "", originPrice:0.00, price: 0.00, inventory:0, cover:"", isOnShelf: "false", classifyId: parseInt("@ViewBag.ClassifyId"), groupAttributes: [], extendAttributes: [], albums:[] }, classifies: @Html.Raw(Json.Encode(ViewBag.Classifies)) }, methods: { classifyChange:function(){ var self=this; if(!self.product.classifyId)return; $.bode.ajax("/api/services/product/attributes/GetClassifyGroupAttributes",{id:parseInt(self.product.classifyId)},function(gruops){ self.product.groupAttributes=gruops; $("script[data-type='6']").each(function(){ var id=$(this).attr("id"); UE.getEditor(id).destroy(); }); attributeInitialized=false; }); }, deleteAlbum:function(path){ } }, created: function () { var self=this; $.bode.ajax("/api/services/product/attributes/GetClassifyGroupAttributes",{id:parseInt("@ViewBag.ClassifyId")},function(gruops){ self.product.groupAttributes=gruops; }); } }); var initUploader=function(pick,func){ var uploader = WebUploader.create({ auto: true,// 选完文件后,是否自动上传。 swf: '/Content/js/plugs/webuploader/Uploader.swf',// swf文件路径 server: "/api/File/UploadPic",// 文件接收服务端。 pick: pick, accept: { title: 'Images', extensions: 'jpg,jpeg,png', mimeTypes: 'image/jpg,image/jpeg,image/png' } }); uploader.on("uploadSuccess", function (file, resp) { func(this,resp); }); } //初始化wizard插件 var wizard = new $.bode.wizard("#simplewizard", { onNextClick: function() { var stepName = $("#simplewizard-steps").find(".active").attr("id"); if (stepName === "basicInfoStep") { //验证必填项 if(!vm.product.name){ layer.msg("商品名称不能为空"); return false; } if(vm.product.originPrice<=0){ layer.msg("原价必须大于0"); return false; } if(vm.product.price<=0){ layer.msg("售价必须大于0"); return false; } if(vm.product.regionId<=0){ layer.msg("请选择有效的城市"); return false; } setTimeout(function(){ $("#attributeStep li.tab-sky:eq(0)>a").click(); if(!attributeInitialized){ //初始化属性控件 $.bode.tools.input.formatDiscount($("input[data-type='2']")); $.bode.tools.input.formatDiscount($("input[data-type='3']")); $.bode.tools.input.formatTime($("input[data-type='6']")); $("textarea[data-type='7']").autosize({ append: "\n" }); $("script[data-type='8']").each(function(){ var id=$(this).attr("id"); UE.getEditor(id); }); $("div[data-type='9']").each(function(){ initUploader('#'+$(this).attr("id"),function(uploader,resp){ $(uploader.options.pick.replace("upload","img")).attr("src", resp); }); }); attributeInitialized=true; } },400); }else if (stepName === "attributeStep") { for(var i=0,iLen=vm.product.groupAttributes.length;i<iLen;i++){ var group=vm.product.groupAttributes[i]; for(var j=0,jLen=group.attributes.length;j<jLen;j++){ var attribute=group.attributes[j]; //对富文本属性进行赋值 if(attribute.attributeType===8){ var id="atrribute_"+attribute.id; attribute.value=UE.getEditor(id).getContent(); } //验证属性值 var valueField=attribute.attributeType===4?"attributeOptionId":attribute.attributeType===5?"attributeOptionIds":"value"; if(attribute.isRequired&&(attribute[valueField]===""||attribute[valueField]===null)){ layer.msg("【"+group.groupName+"】-【"+attribute.name+"】不能为空"); return false; } if(attribute.validateRegular){ var reg=eval("("+attribute.validateRegular+")"); if(!reg.test(attribute[valueField])){ layer.msg("【"+group.groupName+"】-【"+attribute.name+"】验证失败"); return false; } } } } if(!uploaderInitialized){ setTimeout(function(){ //初始化图片上传控件 initUploader("#upload_album",function(uploader,resp){ vm.product.albums.push(resp); }); uploaderInitialized=true; },10); } } return true; }, onPreClick:function(){ var stepName = $("#simplewizard-steps").find(".active").attr("id"); if(stepName === "picInfoStep"){ setTimeout(function(){ $("#attributeStep li.tab-sky:eq(0)>a").click(); uploaderInitialized=true; },400); } return true; }, onFinish: function() { $.bode.ajax("/api/services/product/products/CreateProduct",vm.product,function(){ layer.msg("保存成功"); }); return false; } }); }); </script> }
/// <inheritdoc/> public async Task CreateProduct(OperableProductDto input) { input.CheckNotNull("input"); input.ClassifyId.CheckGreaterThan("input.ClassifyId", 0); if (!_classifyRepository.CheckExists(p => p.Id == input.ClassifyId)) { throw new UserFriendlyException("指定的分类不存在"); } var product = input.MapTo<Domain.Product>(); if (input.IsOnShelf) { product.OnShelfTime = DateTime.Now; } foreach (var group in input.GroupAttributes) { foreach (var item in group.Attributes) { product.Attributes.Add(new ProductAttributeMap { AttributeId = item.Id, Value = item.Value, AttributeOptionIds = item.AttributeType == ProductAttributeType.Switch ? FormatOptionIds(item.attributeOptionId) : item.AttributeType == ProductAttributeType.Multiple ? FormatOptionIds(item.attributeOptionIds.ExpandAndToString()) : "" }); } } product.Assets = input.Albums.Select(p => new ProductAsset { Path = p, AssetType = AssetType.Picture }).ToList(); await _productRepository.InsertAsync(product); }
{ "groupAttributes": [ { "groupName": "string", "attributes": [ { "name": "string", "tips": "string", "value": "string", "attributeOptionId": "string", "attributeOptionIds": [ "string" ], "options": [ { "name": "string", "value": "string" } ], "validateRegular": "string", "groupName": "string", "isRequired": true, "attributeType": 1, "id": 0 } ] } ], "albums": [ "string" ], "name": "string", "originPrice": 0, "price": 0, "inventory": 0, "isOnShelf": true, "regionId": 0, "classifyId": 0, "id": 0 }
展示效果:
属性列表:
属性选项列表:
新增商品:
写在最后:
这种属性设计适用范围很广,几乎所有事物都可以使用属性来描述,比如新闻系统中的新闻,论坛中的帖子等等其实都可以用到。园子里有很多关于电商系统属性的设计,但几乎都只有模型。最近工作涉及到这一块,索性就将自己的设计思路与实现过程粗略的写出来,以供交流。