通用属性系统设计与实现

    这两年做过不少的小型电商系统,有的卖衣服,有的卖鞋子,有的卖电器,甚至还有些卖虚拟服务的。不同商品的属性千差万别,为了减少以后卖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
}
前端提交Json格式

 

示例源码:https://github.com/liuxx001/BodeAbp

 
展示效果:
 
属性列表:
属性选项列表:
 
新增商品:
 
写在最后:
这种属性设计适用范围很广,几乎所有事物都可以使用属性来描述,比如新闻系统中的新闻,论坛中的帖子等等其实都可以用到。园子里有很多关于电商系统属性的设计,但几乎都只有模型。最近工作涉及到这一块,索性就将自己的设计思路与实现过程粗略的写出来,以供交流。
 
 
 
 
 
posted @ 2016-10-18 20:41  _liuxx  阅读(1541)  评论(5编辑  收藏  举报