設計一個SKU多維規格生成組件(AngularX)

問題描述

咱們在選購一件商品的時候一般都是須要選擇相應的產品規格來計算價錢,不一樣規格的選擇出來的價格以及庫存數量都是不同的,好比衣服就有顏色,尺碼等屬性前端

下面引用sku的概念算法

最小庫存管理單元(Stock Keeping Unit, SKU)是一個會計學名詞,定義爲庫存管理中的最小可用單元,例如紡織品中一個SKU一般表示規格、顏色、款式,而在連鎖零售門店中有時稱單品爲一個SKU。最小庫存管理單元能夠區分不一樣商品銷售的最小單元,是科學管理商品的採購、銷售、物流和財務管理以及POS和MIS系統的數據統計的需求,一般對應一個管理信息系統的編碼。 —— form wikipediatypescript

那麼咱們在後臺管理系統當中該如何去對商品的規格進行添加編輯刪除,那麼咱們就須要設計一個sku規格生成組件來管理咱們的產品規格設置json

目標

在設計一個組件的時候咱們須要知道最終要完成的效果是如何,需求是否是可以知足咱們
clipboard.png後端

如圖中例子所示,咱們須要設計相似這麼一個能夠無限級添加規格以及規格值,而後在表格裏面設置產品價格和成本價庫存等信息,最終便完成了咱們的需求數組

分析

從大的方面說,規格和表格列表須要放在不一樣的組件裏面,由於處理的邏輯不一樣
而後每個規格都只能選擇其下的規格值,且已經選過的規格不能再被選擇,規格和規格值容許被刪除,每一次規格的增刪改都會影響着表格中的內容,但規格不受表格的影響,同時規格能夠無限級添加....ide

儘量的往多個方面考慮組件設計的合理性以及可能出現的狀況this

而後咱們還須要知道後端那邊須要咱們前端傳什麼樣的數據類型和方式(很重要)
假設後端須要的添加規格數據爲編碼

{
    spec_format: Array<{
        spec_name: string;
        spec_id: number;
        value: Array<{
            spec_value_name: string,
            spec_value_id: number
        }>
    }> 
    
}

而後設置每個規格價格和庫存的數據爲spa

{
    skuArray: Array<{
        attr_value_items: Array<{
            spec_id: number,
            spec_value_id: number
        }>;    
        price: number;
        renew_price: number;
        cost_renew_price?: number;
        cost_price?: number;
        stock: number;
    }>
}

這裏我把目錄分紅如圖

clipboard.png

g-specification用來管理規格和表格的組件,想當於他們的父級
spec-price是設置價格庫存等信息的表格組件
spec-item是規格
spec-value是其某個規格下的規格值列表選擇組件

規格組件

我本身的我的習慣是喜歡把和視圖有關的數據好比組件的顯示與否,包括ngIf判斷的字段等等單獨放在一個ViewModel數據模型裏,這樣好和其餘的接口提交數據區分開來,並且也便於後期其餘人的維護,在這裏我就不詳細講解視圖上的邏輯交互了

首先建立一個SpecModel

class SpecModel {
    'spec_name': string = ''
    'spec_id': number = 0

    'value': any[] = [] // 該規格對應的有的值

    constructor() {}

    /*
    賦值操做
     */
    public setData( data: any ): void {
        this['spec_name'] = data['spec_name']!=undefined?data['spec_name']:this['spec_name']
        this['spec_name'] = data['spec_id']!=undefined?data['spec_id']:this['spec_id']
    }

    /*
    規格值賦值
     */
    public setValue( data: any[] ): void {
        this['value'] = Array.isArray( data ) == true ? [...data] : []
    }

}

這裏我定義了一個和後端所須要的spec_format字段裏的數組子集同樣的數據模型,每個規格組件在建立的時候都會new一個這麼一個對象,方便在g-specification組件裏獲取到多個規格組件裏的SpecModel組裝成一個spec_format數組

規格價格和庫存設置組件

規格組件的設計因人而異,只是普通的數據傳入和傳出,組件之間的數據交互可能用Input or Output,也能夠經過服務建立一個EventEmitter來交互,假設到了這裏咱們已經把規格組件和規格值列表組件處理完畢了而且經過g-specification.service這個文件來進行數據傳輸

在這個組件裏我新創了一個SpecDataModel模型,做用是統一數據的來源,可以在spec-price組件裏面處理的數據類型和字段不缺失或多餘等

export class SpecDataModel {

    'spec': any = {}
    'specValue': any[] = []

    constructor( data: any = {} ){

        this['spec'] = data['spec'] ? data['spec']: this['spec']
        this['specValue'] = data['specValue'] ? data['specValue'] : this['specValue']

        this['specValue'].map(_e=>_e['spec']=this['spec'])

    }

}

在這個服務裏建立了一個EventEmitter來進行跨組件數據傳遞,主要傳遞的數據類型是SpecDataModel

@Injectable()
export class GSpecificationService {

    public launchSpecData: EventEmitter<SpecDataModel> = new EventEmitter<SpecDataModel>()

    constructor() { }

}

在規格組件裏面每一次的增長和刪除都會next一次規格數據,圖例列舉了取消規格的操做
,每一次next的數據都會在spec-price組件裏接收到

/*
    點擊取消選中的規格值
     */
    public closeSpecValue( data: any, index: number ): void {
        this.viewModel['_selectSpecValueList'].splice( index,1 )
        this.gSpecificationService.launchSpecData.next( this.launchSpecDataModel( this.viewModel['_selectSpecValueList'] ) )
        
    }

    /*
    操做完以後須要傳遞的值
     */
    public launchSpecDataModel( specValue: any[], spec: SpecModel = this.specModel ): SpecDataModel {
        return new SpecDataModel( {'spec':spec,'specValue':[...specValue] } )
    }

而後在spec-price組件裏就能接受其餘地方傳遞進來的SpecDataModel數據

this.launchSpecRX$ = this.gSpecificationService.launchSpecData.subscribe(res=>{
        // res === SpecDataModel
    })

數據處理

如今spec-price組件已經可以實時的獲取到規格組件傳遞進來的數據了,包括選擇的規格和規格值,那麼
該如何處理這些數據使得知足圖中的合併表格的樣式以及將價格、成本價、和庫存等信息數據綁定到全部規格里面,處理每一次的規格操做都能獲得最新的SpecDataModel,顯然是須要將這些SpecDataModel統一歸併到一個數組裏面,負責存放全部選擇過的規格

顯然仍是須要在組件裏面創建一個數據模型來處理接收過來的SpecDataModel,那麼假定有一個_specAllData數組來存放全部規格

同時咱們還觀察到,圖中的表格涉及到合併單元格,那麼就須要用到tr標籤的rowspan屬性(還記得麼?)

而後再次分析,發現不一樣數量的規格和規格值所出來的結果是一個全排列組合的狀況

例:

版本: v1, v2, v3
容量: 10人,20人

那麼出來的結果有3 X 2 = 6種狀況,那麼在表格當中呈現的結果就是六種,若是此時容量再多加一個規格值,那麼結果就是3 X 3 = 9種狀況
因此表格的呈現方式涉及到全排列算法和rowspan的計算方式

咱們新建一個SpecPriceModel數據模型

class SpecPriceModel {

    '_title': string[] = ['新購價格(元)','成本價(元)','續費價格(元)','續費成本價(元)','庫存'] // 表格標題頭部
    '_specAllData': any[] = [] // 全部規格傳遞過來的值

    private 'constTitle': string[] = [...this._title] // 初始的固定標題頭


}

由於表格的最後5列是固定的標題頭,並且每一次的規格添加都會增長一個標題頭,那麼就須要把標題頭存放到一個變量裏面
雖然_specAllData能接收到全部的規格但也有可能遇到重複數據的狀況,並且固然全部的規格都被刪除了以後,_specAllData也應該會一個空數組,因此在SpecPriceModel裏面就須要對_specAllData去重

public setAllSpecDataList( data: SpecDataModel ): void {
        if( data['specValue'].length > 0 ) {
            let _length = this._specAllData.length
            let bools: boolean = true
            for( let i: number = 0; i<_length; i++ ) {
                if( this._specAllData[i]['spec']['id'] == data['spec']['id'] ) {
                    this._specAllData[i]['specValue'] = [...data['specValue']]
                    bools = false
                    break
                }
            }
            if( bools == true ) {
                this._specAllData.push( data )
            }
        }else {
            this._specAllData = this._specAllData.filter( _e=>_e['spec']['name'] != data['spec']['name'] )
        }
        this.setTitle()
    }

假設這個時候咱們獲得的_specAllData數據爲

[
    {
        spec:{
            name: '版本,
            id: 1
        },
        specValue:[
            {
                spec_value_id: 11,
                spec_value_name: 'v1.0'
            },
            {
                spec_value_id: 111,
                spec_value_name: 'v2.0'
            },
            {
                spec_value_id: 1111,
                spec_value_name: 'v3.0'
            }
        ]
    },
    {
        spec:{
            name: '容量,
            id: 2
        },
        specValue:[
            {
                spec_value_id: 22,
                spec_value_name: '10人'
            },
            {
                spec_value_id: 222,
                spec_value_name: '20人'
            }
        ]
    }
]

那麼咱們就剩下最後的合併單元格以及處理全排列組合的問題了,其實這個算法也有一個專業名詞叫笛卡爾積

笛卡爾乘積是指在數學中,兩個集合X和Y的笛卡尓積(Cartesian product),又稱直積,表示爲X × Y,第一個對象是X的成員而第二個對象是Y的全部可能有序對的其中一個成員

這裏我用了遞歸的方法處理全部存在的按順序的排列組合可能

// 笛卡爾積
let _recursion_spec_obj = ( data: any )=>{

    let len: number = data.length
    if(len>=2){
        let len1 = data[0].length
        let len2 = data[1].length
        let newlen = len1 * len2
        let temp = new Array( newlen )
        let index = 0
        for(let i = 0; i<len1; i++){
            for(let j=0; j<len2; j++){
                if( Array.isArray( data[0][i] ) ) {
                    temp[index]=[...data[0][i],data[1][j]]
                }else {
                    temp[index]=[data[0][i],data[1][j]]
                }
                index++
            }
        }
        let newArray = new Array( len-1 )
        for(let i=2; i<len; i++){
            newArray[i-1]= data[i]
        }
        newArray[0]=temp
        return _recursion_spec_obj(newArray)
    }
    else{
        return data[0]
    }

}

那麼就能獲得全部出現的排列組合結果,爲一個二維數組,暫時就叫_mergeRowspan好了

[
    [
        {
            spec:{
                name: '版本',
                id: 1
            },
            spec_value_id: 11,
            spec_value_name: 'v1.0'
        },
        {
            spec:{
                name: '容量',
                id: 1
            },
            spec_value_id: 22,
            spec_value_name: '10人'
        }
    ]
    
    // ....等等
]

出現的結果有3 X 2 = 6種

而tr標籤的rowspan屬性是規定單元格可橫跨的行數。

如圖例

clipboard.png

v1.0 橫跨的行數爲2,那麼他的rowspan爲2
10人和20人都是最小單元安麼rowspan天然爲1
可能圖中的例子的行數比較少並不能直接的看出規律,那麼此次來個數據多點的

clipboard.png
此次 v1.0的rowspan爲4
10人和20人的rowspan爲2
。。。

那麼咱們就能得出,只要算出_mergeRowspan數組裏面的每個排列狀況的rowspan值,而後在渲染表格的時候雙向綁定到tr標籤的rowspan就能夠了

計算rowpsan

舉上圖爲例,總共有 3 X 2 X 2 = 12種狀況,其中第一個規格的每個規格值各佔4行,第二個規格的每個規格值各佔2行,最後一個規格的規格值每一個各佔一行

this._tr_length = 1 // 全排列組合的總數
this._specAllData.forEach((_e,_index)=>{
    this._tr_length *= _e['specValue'].length
})
// 計算rowspan的值
let _rowspan_divide = 1
for( let i: number = 0; i<this._specAllData.length; i++ ) {
    _rowspan_divide *= this._specAllData[i]['specValue'].length
    for( let e: number = 0; e<this._specAllData[i]['specValue'].length; e++ ) {
        this._specAllData[i]['specValue'][e]['rowspan'] = (this._tr_length)/_rowspan_divide
    }
}

最終獲得的數據如圖

clipboard.png

這裏咱們的每一條數據都能知道本身對應的rowspan的值是多少,這樣在渲染表格的時候咱們就能經過*ngIf來判斷哪些該顯示哪些不應顯示。可能有的人會說,這個rowspan的拼接用原生DOM操做就能夠了,那你知道操做這些rowspan須要多少行麼。。

由於rowspan爲4的佔總數12的三分之一,因此只會在第一行和第五行以及第九行出現
rowspan爲2的佔總數12的六分之一,因此只會在第1、3、5、7、9、十一行出現
rospan爲1的每一行都有

那麼咱們得出*ngIf的判斷條件爲 childen['rowspan']==1||(i==0?true:i%childen['rowspan']==0)

<tr *ngFor = "let list of tableModel['_mergeRowspan'];index as i">
    <ng-container *ngFor = "let childen of list['items'];index as e">
        <td class="customer-content"
            attr.rowspan="{{childen['rowspan']}}"
            *ngIf="childen['rowspan']==1||(i==0?true:i%childen['rowspan']==0)">
            {{childen['spec_value_name']}}
        </td>
    </ng-container>
</tr>

最後附完整的SpecPriceModel模型

class TableModel {

    '_title': string[] = ['新購價格(元)','成本價(元)','續費價格(元)','續費成本價(元)','庫存']
    '_specAllData': any[] = [] // 全部規格傳遞過來的值
    /*
    合併全部的數據同時計算出最多存在的tr標籤的狀況
    須要用到二維數組
    一層數組存放總tr條數
    二層數組存放對象,該對象是全部規格按照排列組合的順序排序同時保存該規格的rowpan值
    rowpan值的計算爲,前一個規格 = 後面每一個規格的規格值個數相乘
     */
    '_mergeRowspan': any[] = [] 
    '_tr_length': number = 1 // tr標籤的總數

    private 'constTitle': string[] = [...this._title] // 初始的固定標題頭

    /*
    傳遞回來的規格數據處理
     */
    public setAllSpecDataList( data: SpecDataModel ): void {
        if( data['specValue'].length > 0 ) {
            let _length = this._specAllData.length
            let bools: boolean = true
            for( let i: number = 0; i<_length; i++ ) {
                if( this._specAllData[i]['spec']['id'] == data['spec']['id'] ) {
                    this._specAllData[i]['specValue'] = [...data['specValue']]
                    bools = false
                    break
                }
            }
            if( bools == true ) {
                this._specAllData.push( data )
            }
        }else {
            this._specAllData = this._specAllData.filter( _e=>_e['spec']['name'] != data['spec']['name'] )
        }
        this.setTitle()
    }

    /*
    設置標題頭部
     */
    private setTitle(): void {
        let _title_arr = this._specAllData.map( _e=> _e['spec']['name'] )
        this._title = [..._title_arr,...this.constTitle]
        this.handleMergeRowspan()
        
    }


    /****計算規格 合併表格單元*****/ 
    private handleMergeRowspan():void {
        this._tr_length = 1 // 全排列組合的總數
        this._specAllData.forEach((_e,_index)=>{
            this._tr_length *= _e['specValue'].length
        })
        // 計算rowspan的值
        let _rowspan_divide = 1
        for( let i: number = 0; i<this._specAllData.length; i++ ) {
            _rowspan_divide *= this._specAllData[i]['specValue'].length
            for( let e: number = 0; e<this._specAllData[i]['specValue'].length; e++ ) {
                this._specAllData[i]['specValue'][e]['rowspan'] = (this._tr_length)/_rowspan_divide
            }
        }
        // 笛卡爾積
        let _recursion_spec_obj = ( data: any )=>{

            let len: number = data.length
            if(len>=2){
                let len1 = data[0].length
                let len2 = data[1].length
                let newlen = len1 * len2
                let temp = new Array( newlen )
                let index = 0
                for(let i = 0; i<len1; i++){
                    for(let j=0; j<len2; j++){
                        if( Array.isArray( data[0][i] ) ) {
                            temp[index]=[...data[0][i],data[1][j]]
                        }else {
                            temp[index]=[data[0][i],data[1][j]]
                        }
                        index++
                    }
                }
                let newArray = new Array( len-1 )
                for(let i=2; i<len; i++){
                    newArray[i-1]= data[i]
                }
                newArray[0]=temp
                return _recursion_spec_obj(newArray)
            }
            else{
                return data[0]
            }

        }
        let _result_arr = this._specAllData.map( _e=>_e['specValue'] )
        this._mergeRowspan = _result_arr.length == 1? (()=>{
            let result: any[] = []
            _result_arr[0].forEach(_e=>{
                result.push([_e])
            })
            return result || []
        })() : _recursion_spec_obj( _result_arr )

        // 重組處理完以後的數據,用於數據綁定
        if( Array.isArray( this._mergeRowspan ) == true ) {
            this._mergeRowspan = this._mergeRowspan.map(_e=>{
                return {
                    items: _e,
                    costData: {
                        price: 0.01,
                        renew_price: 0.01,
                        cost_renew_price: 0.01,
                        cost_price: 0.01,
                        stock: 1
                    }
                }
            })
        }else{
            this._mergeRowspan = []
        }

    }


}

相比於傳統DOM操做rospan來動態合併表格的方式,這種經過計算規律和數據雙向綁定的方式來處理不只顯得簡短也易於維護

本文只是提煉了設計sku組件當中比較困難的部分,固然也只是其中的一個處理方式,這種方法不只在添加規格的時候顯得輕鬆,在編輯已有的規格也能輕鬆應對

相關文章
相關標籤/搜索