[improvement] rename packages dir to src (#3659)

This commit is contained in:
neverland
2019-06-27 11:25:57 +08:00
committed by GitHub
parent 8489918dca
commit 75c79b7044
619 changed files with 21 additions and 21 deletions
+480
View File
@@ -0,0 +1,480 @@
/* eslint-disable camelcase */
import Vue from 'vue';
import Popup from '../popup';
import Toast from '../toast';
import ImagePreview from '../image-preview';
import SkuHeader from './components/SkuHeader';
import SkuRow from './components/SkuRow';
import SkuRowItem from './components/SkuRowItem';
import SkuStepper from './components/SkuStepper';
import SkuMessages from './components/SkuMessages';
import SkuActions from './components/SkuActions';
import { createNamespace, isDef } from '../utils';
import { isAllSelected, isSkuChoosable, getSkuComb, getSelectedSkuValues } from './utils/skuHelper';
import { LIMIT_TYPE, UNSELECTED_SKU_VALUE_ID } from './constants';
const [createComponent] = createNamespace('sku');
const { QUOTA_LIMIT } = LIMIT_TYPE;
export default createComponent({
props: {
sku: Object,
goods: Object,
quota: Number,
value: Boolean,
buyText: String,
quotaUsed: Number,
goodsId: [Number, String],
hideStock: Boolean,
hideQuotaText: Boolean,
stepperTitle: String,
getContainer: Function,
customSkuValidator: Function,
closeOnClickOverlay: Boolean,
disableStepperInput: Boolean,
resetStepperOnHide: Boolean,
resetSelectedSkuOnHide: Boolean,
initialSku: {
type: Object,
default: () => ({})
},
showSoldoutSku: {
type: Boolean,
default: true
},
showAddCartBtn: {
type: Boolean,
default: true
},
bodyOffsetTop: {
type: Number,
default: 200
},
messageConfig: {
type: Object,
default: () => ({
placeholderMap: {},
uploadImg: () => Promise.resolve(),
uploadMaxSize: 5
})
},
customStepperConfig: {
type: Object,
default: () => ({})
},
},
data() {
return {
selectedSku: {},
selectedNum: 1,
show: this.value
};
},
watch: {
show(val) {
this.$emit('input', val);
if (!val) {
const selectedSkuValues = getSelectedSkuValues(this.sku.tree, this.selectedSku);
this.$emit('sku-close', {
selectedSkuValues,
selectedNum: this.selectedNum,
selectedSkuComb: this.selectedSkuComb
});
if (this.resetStepperOnHide) {
this.resetStepper();
}
if (this.resetSelectedSkuOnHide) {
this.resetSelectedSku(this.skuTree);
}
}
},
value(val) {
this.show = val;
},
skuTree(val) {
this.resetSelectedSku(val);
}
},
computed: {
skuGroupClass() {
return [
'van-sku-group-container',
'van-hairline--bottom',
{
'van-sku-group-container--hide-soldout': !this.showSoldoutSku
}
];
},
bodyStyle() {
if (this.$isServer) {
return;
}
// header高度82px, sku actions高度50px,如果改动了样式自己传下bodyOffsetTop调整下
const maxHeight = window.innerHeight - this.bodyOffsetTop;
return {
maxHeight: maxHeight + 'px'
};
},
isSkuCombSelected() {
return isAllSelected(this.sku.tree, this.selectedSku);
},
isSkuEmpty() {
return Object.keys(this.sku).length === 0;
},
hasSku() {
return !this.sku.none_sku;
},
selectedSkuComb() {
if (!this.hasSku) {
return {
id: this.sku.collection_id,
price: Math.round(this.sku.price * 100),
stock_num: this.sku.stock_num
};
}
if (this.isSkuCombSelected) {
return getSkuComb(this.sku.list, this.selectedSku);
}
return null;
},
price() {
if (this.selectedSkuComb) {
return (this.selectedSkuComb.price / 100).toFixed(2);
}
// sku.price是一个格式化好的价格区间
return this.sku.price;
},
skuTree() {
return this.sku.tree || [];
},
imageList() {
const imageList = [this.goods.picture];
if (this.skuTree.length > 0) {
const treeItem = this.skuTree.filter(item => item.k_s === 's1')[0] || {};
if (!treeItem.v) {
return imageList;
}
treeItem.v.forEach(vItem => {
const img = vItem.imgUrl || vItem.img_url;
if (img) {
imageList.push(img);
}
});
}
return imageList;
}
},
created() {
const skuEventBus = new Vue();
this.skuEventBus = skuEventBus;
skuEventBus.$on('sku:close', this.onClose);
skuEventBus.$on('sku:select', this.onSelect);
skuEventBus.$on('sku:numChange', this.onNumChange);
skuEventBus.$on('sku:previewImage', this.onPreviewImage);
skuEventBus.$on('sku:overLimit', this.onOverLimit);
skuEventBus.$on('sku:addCart', this.onAddCart);
skuEventBus.$on('sku:buy', this.onBuy);
this.resetStepper();
this.resetSelectedSku(this.skuTree);
// 组件初始化后的钩子,抛出skuEventBus
this.$emit('after-sku-create', skuEventBus);
},
methods: {
resetStepper() {
const { skuStepper } = this.$refs;
const { selectedNum } = this.initialSku;
const num = isDef(selectedNum) ? selectedNum : 1;
if (skuStepper) {
skuStepper.setCurrentNum(num);
} else {
this.selectedNum = num;
}
},
resetSelectedSku(skuTree) {
this.selectedSku = {};
// 重置 selectedSku
skuTree.forEach(item => {
this.selectedSku[item.k_s] = this.initialSku[item.k_s] || UNSELECTED_SKU_VALUE_ID;
});
// 只有一个 sku 规格值时默认选中
skuTree.forEach(item => {
const key = item.k_s;
const valueId = item.v[0].id;
if (
item.v.length === 1 &&
isSkuChoosable(this.sku.list, this.selectedSku, { key, valueId })
) {
this.selectedSku[key] = valueId;
}
});
},
getSkuMessages() {
return this.$refs.skuMessages ? this.$refs.skuMessages.getMessages() : {};
},
getSkuCartMessages() {
return this.$refs.skuMessages ? this.$refs.skuMessages.getCartMessages() : {};
},
validateSkuMessages() {
return this.$refs.skuMessages ? this.$refs.skuMessages.validateMessages() : '';
},
validateSku() {
if (this.selectedNum === 0) {
return '商品已经无法购买啦';
}
if (this.isSkuCombSelected) {
return this.validateSkuMessages();
}
// 自定义sku校验
if (this.customSkuValidator) {
const err = this.customSkuValidator(this);
if (err) return err;
}
return '请先选择商品规格';
},
onClose() {
this.show = false;
},
onSelect(skuValue) {
// 点击已选中的sku时则取消选中
this.selectedSku =
this.selectedSku[skuValue.skuKeyStr] === skuValue.id
? { ...this.selectedSku, [skuValue.skuKeyStr]: UNSELECTED_SKU_VALUE_ID }
: { ...this.selectedSku, [skuValue.skuKeyStr]: skuValue.id };
this.$emit('sku-selected', {
skuValue,
selectedSku: this.selectedSku,
selectedSkuComb: this.selectedSkuComb
});
},
onNumChange(num) {
this.selectedNum = num;
},
onPreviewImage(indexImage) {
const index = this.imageList.findIndex(image => image === indexImage);
const params = {
index,
imageList: this.imageList,
indexImage
};
this.$emit('open-preview', params);
ImagePreview({
images: this.imageList,
startPosition: index,
onClose: () => {
this.$emit('close-preview', params);
}
});
},
onOverLimit(data) {
const { action, limitType, quota, quotaUsed } = data;
const { handleOverLimit } = this.customStepperConfig;
if (handleOverLimit) {
handleOverLimit(data);
return;
}
if (action === 'minus') {
Toast('至少选择一件');
} else if (action === 'plus') {
if (limitType === QUOTA_LIMIT) {
let msg = `限购${quota}`;
if (quotaUsed > 0) msg += `${`你已购买${quotaUsed}`}`;
Toast(msg);
} else {
Toast('库存不足');
}
}
},
onAddCart() {
this.onBuyOrAddCart('add-cart');
},
onBuy() {
this.onBuyOrAddCart('buy-clicked');
},
onBuyOrAddCart(type) {
const error = this.validateSku();
if (error) {
Toast(error);
} else {
this.$emit(type, this.getSkuData());
}
},
getSkuData() {
return {
goodsId: this.goodsId,
selectedNum: this.selectedNum,
selectedSkuComb: this.selectedSkuComb,
messages: this.getSkuMessages(),
cartMessages: this.getSkuCartMessages()
};
}
},
render(h) {
if (this.isSkuEmpty) {
return;
}
const {
sku,
goods,
price,
skuEventBus,
selectedSku,
selectedNum,
stepperTitle,
hideQuotaText,
selectedSkuComb
} = this;
const slotsProps = {
price,
selectedNum,
skuEventBus,
selectedSku,
selectedSkuComb
};
const slots = name => this.slots(name, slotsProps);
const Header = slots('sku-header') || (
<SkuHeader sku={sku} goods={goods} skuEventBus={skuEventBus} selectedSku={selectedSku}>
{slots('sku-header-price') || (
<div class="van-sku__goods-price">
<span class="van-sku__price-symbol"></span>
<span class="van-sku__price-num">{price}</span>
</div>
)}
</SkuHeader>
);
const Group =
slots('sku-group') ||
(this.hasSku && (
<div class={this.skuGroupClass}>
{this.skuTree.map(skuTreeItem => (
<SkuRow skuRow={skuTreeItem}>
{skuTreeItem.v.map(skuValue => (
<SkuRowItem
skuList={sku.list}
skuValue={skuValue}
selectedSku={selectedSku}
skuEventBus={skuEventBus}
skuKeyStr={skuTreeItem.k_s}
/>
))}
</SkuRow>
))}
</div>
));
const Stepper = slots('sku-stepper') || (
<SkuStepper
ref="skuStepper"
quota={this.quota}
hideStock={this.hideStock}
quotaUsed={this.quotaUsed}
skuEventBus={skuEventBus}
selectedNum={selectedNum}
selectedSku={selectedSku}
stepperTitle={stepperTitle}
skuStockNum={sku.stock_num}
hideQuotaText={hideQuotaText}
selectedSkuComb={selectedSkuComb}
disableStepperInput={this.disableStepperInput}
customStepperConfig={this.customStepperConfig}
onChange={event => {
this.$emit('stepper-change', event);
}}
/>
);
const Messages = slots('sku-messages') || (
<SkuMessages
ref="skuMessages"
goodsId={this.goodsId}
messageConfig={this.messageConfig}
messages={sku.messages}
/>
);
const Actions = slots('sku-actions') || (
<SkuActions
buyText={this.buyText}
skuEventBus={skuEventBus}
showAddCartBtn={this.showAddCartBtn}
/>
);
return (
<Popup
vModel={this.show}
position="bottom"
class="van-sku-container"
getContainer={this.getContainer}
closeOnClickOverlay={this.closeOnClickOverlay}
>
{Header}
<div class="van-sku-body" style={this.bodyStyle}>
{slots('sku-body-top')}
{Group}
{slots('extra-sku-group')}
{Stepper}
{Messages}
</div>
{Actions}
</Popup>
);
}
});
+55
View File
@@ -0,0 +1,55 @@
import { createNamespace } from '../../utils';
import { inherit } from '../../utils/functional';
import Button from '../../button';
// Types
import Vue, { CreateElement, RenderContext } from 'vue/types';
import { DefaultSlots } from '../../utils/types';
export type SkuActionsProps = {
buyText?: string;
skuEventBus: Vue;
showAddCartBtn?: boolean;
};
const [createComponent, bem] = createNamespace('sku-actions');
function SkuActions(
h: CreateElement,
props: SkuActionsProps,
slots: DefaultSlots,
ctx: RenderContext<SkuActionsProps>
) {
const createEmitter = (name: string) => () => {
props.skuEventBus.$emit(name);
};
return (
<div class={bem()} {...inherit(ctx)}>
{props.showAddCartBtn && (
<Button
square
size="large"
type="warning"
text="加入购物车"
onClick={createEmitter('sku:addCart')}
/>
)}
<Button
square
size="large"
type="danger"
text={props.buyText || '立即购买'}
onClick={createEmitter('sku:buy')}
/>
</div>
);
}
SkuActions.props = {
buyText: String,
skuEventBus: Object,
showAddCartBtn: Boolean
};
export default createComponent<SkuActionsProps>(SkuActions);
+74
View File
@@ -0,0 +1,74 @@
import { createNamespace } from '../../utils';
import { inherit } from '../../utils/functional';
import Icon from '../../icon';
// Types
import Vue, { CreateElement, RenderContext } from 'vue/types';
import { DefaultSlots } from '../../utils/types';
import { SkuData, SkuGoodsData, SelectedSkuData } from '../type';
export type SkuHeaderProps = {
sku: SkuData;
goods: SkuGoodsData;
skuEventBus: Vue;
selectedSku: SelectedSkuData;
};
const [createComponent, bem] = createNamespace('sku-header');
function getSkuImg(sku: SkuData, selectedSku: SelectedSkuData) {
const id = selectedSku.s1;
if (id) {
// skuImg 挂载在 skuTree 中 s1 上
const treeItem = sku.tree.filter(item => item.k_s === 's1')[0] || {};
if (treeItem.v) {
const matchedSku = treeItem.v.filter(skuValue => skuValue.id === id)[0];
if (matchedSku) {
return matchedSku.imgUrl || matchedSku.img_url;
}
}
}
}
function SkuHeader(
h: CreateElement,
props: SkuHeaderProps,
slots: DefaultSlots,
ctx: RenderContext<SkuHeaderProps>
) {
const { sku, goods, skuEventBus, selectedSku } = props;
const goodsImg = getSkuImg(sku, selectedSku) || goods.picture;
const previewImage = () => {
skuEventBus.$emit('sku:previewImage', goodsImg);
};
return (
<div class={[bem(), 'van-hairline--bottom']} {...inherit(ctx)}>
<div class={bem('img-wrap')} onClick={previewImage}>
<img src={goodsImg} />
</div>
<div class={bem('goods-info')}>
<div class="van-sku__goods-name van-ellipsis">{goods.title}</div>
{slots.default && slots.default()}
<Icon
name="close"
class="van-sku__close-icon"
onClick={() => {
skuEventBus.$emit('sku:close');
}}
/>
</div>
</div>
);
}
SkuHeader.props = {
sku: Object,
goods: Object,
skuEventBus: Object,
selectedSku: Object
};
export default createComponent<SkuHeaderProps>(SkuHeader);
+103
View File
@@ -0,0 +1,103 @@
import { createNamespace } from '../../utils';
import Icon from '../../icon';
import Loading from '../../loading';
import Uploader from '../../uploader';
const [createComponent, bem] = createNamespace('sku-img-uploader');
export default createComponent({
props: {
value: String,
uploadImg: Function,
maxSize: {
type: Number,
default: 6
}
},
data() {
return {
// 正在上传的图片 base64
paddingImg: ''
};
},
computed: {
imgList() {
return this.value && !this.paddingImg ? [this.value] : [];
}
},
methods: {
afterReadFile(file) {
// 上传文件
this.paddingImg = file.content;
this.uploadImg(file.file, file.content)
.then(img => {
this.$emit('input', img);
this.$nextTick(() => {
this.paddingImg = '';
});
})
.catch(() => {
this.paddingImg = '';
});
},
onOversize() {
this.$toast(`最大可上传图片为${this.maxSize}MB,请尝试压缩图片尺寸`);
}
},
render(h) {
const { imgList, paddingImg } = this;
const ImageList = (paddingImg || imgList.length > 0) && (
<div class="van-clearfix">
{imgList.map(img => (
<div class={bem('img')}>
<img src={img} />
<Icon
name="clear"
class={bem('delete')}
onClick={() => {
this.$emit('input', '');
}}
/>
</div>
))}
{paddingImg && (
<div class={bem('img')}>
<img src={paddingImg} />
<Loading type="spinner" class={bem('uploading')} />
</div>
)}
</div>
);
return (
<div class={bem()}>
<Uploader
disabled={!!paddingImg}
afterRead={this.afterReadFile}
maxSize={this.maxSize * 1024 * 1024}
onOversize={this.onOversize}
>
<div class={bem('header')}>
{paddingImg ? (
<div>正在上传...</div>
) : (
[
<Icon name="photograph" />,
<span class="label">{this.value ? '重拍' : '拍照'} </span>,
<Icon name="photo" />,
<span class="label">{this.value ? '重新选择照片' : '选择照片'}</span>
]
)}
</div>
</Uploader>
{ImageList}
</div>
);
}
});
+155
View File
@@ -0,0 +1,155 @@
import { createNamespace } from '../../utils';
import Cell from '../../cell';
import CellGroup from '../../cell-group';
import Field from '../../field';
import { isEmail } from '../../utils/validate/email';
import { isNumber } from '../../utils/validate/number';
import SkuImgUploader from './SkuImgUploader';
const [createComponent, bem] = createNamespace('sku-messages');
const PLACEHOLDER = {
id_no: '输入身份证号码',
text: '输入文本',
tel: '输入数字',
email: '输入邮箱',
date: '点击选择日期',
time: '点击选择时间',
textarea: '点击填写段落文本',
mobile: '输入手机号码'
};
export default createComponent({
props: {
messages: Array,
messageConfig: Object,
goodsId: [Number, String]
},
data() {
return {
messageValues: this.resetMessageValues(this.messages)
};
},
watch: {
messages(val) {
this.messageValues = this.resetMessageValues(val);
}
},
methods: {
resetMessageValues(messages) {
return (messages || []).map(() => ({ value: '' }));
},
getType(message) {
if (+message.multiple === 1) {
return 'textarea';
}
if (message.type === 'id_no') {
return 'text';
}
return message.datetime > 0 ? 'datetime-local' : message.type;
},
getMessages() {
const messages = {};
this.messageValues.forEach((item, index) => {
let { value } = item;
if (this.messages[index].datetime > 0) {
value = value.replace(/T/g, ' ');
}
messages[`message_${index}`] = value;
});
return messages;
},
getCartMessages() {
const messages = {};
this.messageValues.forEach((item, index) => {
let { value } = item;
const message = this.messages[index];
if (message.datetime > 0) {
value = value.replace(/T/g, ' ');
}
messages[message.name] = value;
});
return messages;
},
getPlaceholder(message) {
const type = +message.multiple === 1 ? 'textarea' : message.type;
const map = this.messageConfig.placeholderMap || {};
return map[type] || PLACEHOLDER[type];
},
validateMessages() {
const values = this.messageValues;
for (let i = 0; i < values.length; i++) {
const { value } = values[i];
const message = this.messages[i];
if (value === '') {
// 必填字段的校验
if (String(message.required) === '1') { // eslint-disable-line
const textType = message.type === 'image'
? '请上传'
: '请填写';
return textType + message.name;
}
} else {
if (message.type === 'tel' && !isNumber(value)) {
return '请填写正确的数字格式留言';
}
if (message.type === 'mobile' && !/^\d{6,20}$/.test(value)) {
return '手机号长度为6-20位数字';
}
if (message.type === 'email' && !isEmail(value)) {
return '请填写正确的邮箱';
}
if (message.type === 'id_no' && (value.length < 15 || value.length > 18)) {
return '请填写正确的身份证号码';
}
}
}
}
},
render(h) {
return (
<CellGroup class={bem()}>
{this.messages.map((message, index) => (message.type === 'image' ? (
<Cell
class={bem('image-cell')}
label="仅限一张"
title={message.name}
key={`${this.goodsId}-${index}`}
required={String(message.required) === '1'}
>
<SkuImgUploader
vModel={this.messageValues[index].value}
uploadImg={this.messageConfig.uploadImg}
maxSize={this.messageConfig.uploadMaxSize}
/>
</Cell>
) : (
<Field
vModel={this.messageValues[index].value}
maxlength="200"
label={message.name}
key={`${this.goodsId}-${index}`}
required={String(message.required) === '1'}
placeholder={this.getPlaceholder(message)}
type={this.getType(message)}
/>
)))}
</CellGroup>
);
}
});
+33
View File
@@ -0,0 +1,33 @@
import { createNamespace } from '../../utils';
import { inherit } from '../../utils/functional';
// Types
import { CreateElement, RenderContext } from 'vue/types';
import { DefaultSlots } from '../../utils/types';
import { SkuTreeItemData } from '../type';
export type SkuRowProps = {
skuRow: SkuTreeItemData;
};
const [createComponent, bem] = createNamespace('sku-row');
function SkuRow(
h: CreateElement,
props: SkuRowProps,
slots: DefaultSlots,
ctx: RenderContext<SkuRowProps>
) {
return (
<div class={bem()} {...inherit(ctx)}>
<div class={bem('title')}>{props.skuRow.k}</div>
{slots.default && slots.default()}
</div>
);
}
SkuRow.props = {
skuRow: Object
};
export default createComponent<SkuRowProps>(SkuRow);
+53
View File
@@ -0,0 +1,53 @@
import { createNamespace } from '../../utils';
import { isSkuChoosable } from '../utils/skuHelper';
const [createComponent] = createNamespace('sku-row-item');
export default createComponent({
props: {
skuList: Array,
skuValue: Object,
skuKeyStr: String,
skuEventBus: Object,
selectedSku: Object
},
computed: {
choosable() {
return isSkuChoosable(this.skuList, this.selectedSku, {
key: this.skuKeyStr,
valueId: this.skuValue.id
});
}
},
methods: {
onSelect() {
if (this.choosable) {
this.skuEventBus.$emit('sku:select', {
...this.skuValue,
skuKeyStr: this.skuKeyStr
});
}
}
},
render(h) {
const choosed = this.skuValue.id === this.selectedSku[this.skuKeyStr];
return (
<span
class={[
'van-sku-row__item',
{
'van-sku-row__item--active': choosed,
'van-sku-row__item--disabled': !this.choosable
}
]}
onClick={this.onSelect}
>
{this.skuValue.name}
</span>
);
}
});
+139
View File
@@ -0,0 +1,139 @@
import { createNamespace } from '../../utils';
import Stepper from '../../stepper';
import { LIMIT_TYPE } from '../constants';
const [createComponent] = createNamespace('sku-stepper');
const { QUOTA_LIMIT, STOCK_LIMIT } = LIMIT_TYPE;
export default createComponent({
props: {
quota: Number,
quotaUsed: Number,
hideStock: Boolean,
skuEventBus: Object,
skuStockNum: Number,
selectedSku: Object,
selectedNum: Number,
stepperTitle: String,
hideQuotaText: Boolean,
selectedSkuComb: Object,
disableStepperInput: Boolean,
customStepperConfig: Object
},
data() {
return {
currentNum: this.selectedNum,
// 购买限制类型: 限购/库存
limitType: STOCK_LIMIT
};
},
watch: {
currentNum(num) {
this.skuEventBus.$emit('sku:numChange', num);
},
stepperLimit(limit) {
if (limit < this.currentNum) {
this.currentNum = limit;
}
}
},
computed: {
stock() {
const { stockNum } = this.customStepperConfig;
if (stockNum !== undefined) {
return stockNum;
}
if (this.selectedSkuComb) {
return this.selectedSkuComb.stock_num;
}
return this.skuStockNum;
},
stockText() {
const { stockFormatter } = this.customStepperConfig;
if (stockFormatter) return stockFormatter(this.stock);
return `剩余${this.stock}`;
},
quotaText() {
const { quotaText, hideQuotaText } = this.customStepperConfig;
if (hideQuotaText) return '';
let text = '';
if (quotaText) {
text = quotaText;
} else if (this.quota > 0) {
text = `每人限购${this.quota}`;
}
return text;
},
stepperLimit() {
const quotaLimit = this.quota - this.quotaUsed;
let limit;
// 无限购时直接取库存,有限购时取限购数和库存数中小的那个
if (this.quota > 0 && quotaLimit <= this.stock) {
// 修正负的limit
limit = quotaLimit < 0 ? 0 : quotaLimit;
this.limitType = QUOTA_LIMIT;
} else {
limit = this.stock;
this.limitType = STOCK_LIMIT;
}
return limit;
}
},
methods: {
setCurrentNum(num) {
this.currentNum = num;
},
onOverLimit(action) {
this.skuEventBus.$emit('sku:overLimit', {
action,
limitType: this.limitType,
quota: this.quota,
quotaUsed: this.quotaUsed
});
},
onChange(currentValue) {
const { handleStepperChange } = this.customStepperConfig;
handleStepperChange && handleStepperChange(currentValue);
this.$emit('change', currentValue);
}
},
render(h) {
return (
<div class="van-sku-stepper-stock">
<div class="van-sku-stepper-container">
<div class="van-sku__stepper-title">{this.stepperTitle || '购买数量'}</div>
<Stepper
vModel={this.currentNum}
class="van-sku__stepper"
max={this.stepperLimit}
disableInput={this.disableStepperInput}
onOverlimit={this.onOverLimit}
onChange={this.onChange}
/>
</div>
{!this.hideStock && <div class="van-sku__stock">{this.stockText}</div>}
{!this.hideQuotaText && this.quotaText && (
<div class="van-sku__quota">{this.quotaText}</div>
)}
</div>
);
}
});
+11
View File
@@ -0,0 +1,11 @@
export const LIMIT_TYPE = {
QUOTA_LIMIT: 0,
STOCK_LIMIT: 1
};
export const UNSELECTED_SKU_VALUE_ID = '';
export default {
LIMIT_TYPE,
UNSELECTED_SKU_VALUE_ID
};
+183
View File
@@ -0,0 +1,183 @@
/* eslint-disable */
export default {
goods_id: '946755',
quota: 15,
quota_used: 0,
goods_info: {
title: '测试商品',
picture:
'https://img.yzcdn.cn/upload_files/2017/03/16/Fs_OMbSFPa183sBwvG_94llUYiLa.jpeg?imageView2/2/w/100/h/100/q/75/format/jpg',
price: 1
},
sku: {
price: '1.00',
stock_num: 227,
none_sku: false,
hide_stock: false,
collection_id: 2261,
tree: [
{
k: '颜色',
k_id: '1',
v: [
{
id: '30349',
name: '天蓝色',
imgUrl:
'https://img.yzcdn.cn/upload_files/2017/02/21/FjKTOxjVgnUuPmHJRdunvYky9OHP.jpg!100x100.jpg'
},
{
id: '1215',
name: '白色'
}
],
k_s: 's1',
count: 2
},
{
k: '尺寸',
k_id: '2',
v: [
{
id: '1193',
name: '1'
},
{
id: '1194',
name: '2'
}
],
k_s: 's2',
count: 2
}
],
list: [
{
id: 2259,
price: 100,
discount: 100,
code: '',
s1: '1215',
s2: '1193',
s3: '0',
s4: '0',
s5: '0',
stock_num: 110,
goods_id: 946755
},
{
id: 2260,
price: 100,
discount: 100,
code: '',
s1: '1215',
s2: '1194',
s3: '0',
s4: '0',
s5: '0',
stock_num: 0,
goods_id: 946755
},
{
id: 2257,
price: 100,
discount: 100,
code: '',
s1: '30349',
s2: '1193',
s3: '0',
s4: '0',
s5: '0',
stock_num: 111,
goods_id: 946755
},
{
id: 2258,
price: 100,
discount: 100,
code: '',
s1: '30349',
s2: '1194',
s3: '0',
s4: '0',
s5: '0',
stock_num: 6,
goods_id: 946755
}
],
messages: [
{
datetime: '0',
disable: false,
multiple: '0',
name: '留言1',
type: 'text',
required: '1'
},
{
datetime: '0',
disable: false,
multiple: 0,
name: '留言2',
type: 'id_no',
required: 0
},
{
datetime: '0',
disable: false,
multiple: 0,
name: '留言3',
type: 'image',
required: 0
},
{
datetime: '0',
disable: false,
multiple: 1,
name: '留言4',
type: 'text',
required: 0
},
{
datetime: '0',
disable: false,
name: '数字',
multiple: 0,
type: 'tel',
required: 0
},
{
datetime: '0',
disable: false,
name: '邮件',
multiple: 0,
type: 'email',
required: 0
},
{
datetime: '0',
disable: false,
name: '日期',
multiple: 0,
type: 'date',
required: 0
},
{
datetime: '0',
disable: false,
name: '时间含日期',
multiple: 0,
type: 'time',
required: 0
},
{
datetime: '0',
disable: false,
name: '时间',
multiple: 0,
type: 'time',
required: 0
}
]
}
};
+232
View File
@@ -0,0 +1,232 @@
<template>
<demo-section>
<!-- 基础用法 -->
<demo-block :title="$t('basicUsage')">
<div class="sku-container">
<van-sku
v-model="showBase"
:sku="skuData.sku"
:goods="skuData.goods_info"
:goods-id="skuData.goods_id"
:hide-stock="skuData.sku.hide_stock"
:quota="skuData.quota"
:quota-used="skuData.quota_used"
reset-stepper-on-hide
reset-selected-sku-on-hide
disable-stepper-input
:close-on-click-overlay="closeOnClickOverlay"
:message-config="messageConfig"
:custom-sku-validator="customSkuValidator"
@buy-clicked="onBuyClicked"
@add-cart="onAddCartClicked"
/>
<van-button
block
type="primary"
@click="showBase = true"
>
{{ $t('basicUsage') }}
</van-button>
</div>
</demo-block>
<!-- 自定义步进器 -->
<demo-block :title="$t('title2')">
<div class="sku-container">
<van-sku
hide-quota-text
v-model="showStepper"
:sku="skuData.sku"
:goods="skuData.goods_info"
:goods-id="skuData.goods_id"
:hide-stock="skuData.sku.hide_stock"
:quota="skuData.quota"
:quota-used="skuData.quota_used"
:custom-stepper-config="customStepperConfig"
:message-config="messageConfig"
@buy-clicked="onBuyClicked"
@add-cart="onAddCartClicked"
/>
<van-button
block
type="primary"
@click="showStepper = true"
>
{{ $t('title2') }}
</van-button>
</div>
</demo-block>
<!-- 隐藏售罄sku -->
<demo-block :title="$t('hideSoldoutSku')">
<div class="sku-container">
<van-sku
hide-quota-text
v-model="showSoldout"
:sku="skuData.sku"
:goods="skuData.goods_info"
:goods-id="skuData.goods_id"
:hide-stock="skuData.sku.hide_stock"
:quota="skuData.quota"
:quota-used="skuData.quota_used"
:custom-stepper-config="customStepperConfig"
:message-config="messageConfig"
:show-soldout-sku="false"
@buy-clicked="onBuyClicked"
@add-cart="onAddCartClicked"
/>
<van-button
block
type="primary"
@click="showSoldout = true"
>
{{ $t('hideSoldoutSku') }}
</van-button>
</div>
</demo-block>
<demo-block :title="$t('advancedUsage')">
<div class="sku-container">
<van-sku
v-model="showCustom"
:stepper-title="$t('stepperTitle')"
:sku="skuData.sku"
:goods="skuData.goods_info"
:goods-id="skuData.goods_id"
:hide-stock="skuData.sku.hide_stock"
:quota="skuData.quota"
:quota-used="skuData.quota_used"
show-add-cart-btn
reset-stepper-on-hide
:initial-sku="initialSku"
:message-config="messageConfig"
@buy-clicked="onBuyClicked"
@add-cart="onAddCartClicked"
>
<template #sku-header-price="{ price }">
<div class="van-sku__goods-price">
<span class="van-sku__price-symbol"></span><span class="van-sku__price-num">{{ price }}</span>
</div>
</template>
<template #sku-actions="{ skuEventBus }">
<div class="van-sku-actions">
<van-button
square
size="large"
type="warning"
@click="onPointClicked"
>
{{ $t('button1') }}
</van-button>
<van-button
square
size="large"
type="danger"
@click="props.skuEventBus.$emit('sku:buy')"
>
{{ $t('button2') }}
</van-button>
</div>
</template>
</van-sku>
<van-button
block
type="primary"
@click="showCustom = true"
>
{{ $t('advancedUsage') }}
</van-button>
</div>
</demo-block>
</demo-section>
</template>
<script>
import skuData from './data';
import { LIMIT_TYPE } from '../constants';
export default {
i18n: {
'zh-CN': {
title2: '自定义步进器',
hideSoldoutSku: '隐藏售罄规格',
stepperTitle: '我要买',
button1: '积分兑换',
button2: '买买买'
},
'en-US': {
title2: 'Custom Stepper Related Config',
hideSoldoutSku: 'Hide Soldout Sku',
stepperTitle: 'Stepper title',
button1: 'Button',
button2: 'Button'
}
},
data() {
this.skuData = skuData;
return {
showBase: false,
showCustom: false,
showStepper: false,
showSoldout: false,
closeOnClickOverlay: true,
initialSku: {
s1: '30349',
s2: '1193',
selectedNum: 3
},
customSkuValidator: () => '请选择xxx',
customStepperConfig: {
quotaText: '单次限购100件',
stockFormatter: (stock) => `剩余${stock}`,
handleOverLimit: (data) => {
const { action, limitType, quota } = data;
if (action === 'minus') {
this.$toast('至少选择一件商品');
} else if (action === 'plus') {
if (limitType === LIMIT_TYPE.QUOTA_LIMIT) {
this.$toast(`限购${quota}`);
} else {
this.$toast('库存不够了');
}
}
}
},
messageConfig: {
uploadImg: (file, img) => new Promise(resolve => {
setTimeout(() => resolve(img), 1000);
}),
uploadMaxSize: 3
}
};
},
methods: {
onBuyClicked(data) {
this.$toast('buy:' + JSON.stringify(data));
},
onAddCartClicked(data) {
this.$toast('add cart:' + JSON.stringify(data));
},
onPointClicked() {
this.$toast('积分兑换');
}
}
};
</script>
<style lang="less">
@import "../../style/var";
.demo-sku {
background-color: @white;
.sku-container {
padding: 0 15px;
}
}
</style>
+297
View File
@@ -0,0 +1,297 @@
# Sku
### Install
```javascript
import { Sku } from 'vant';
Vue.use(Sku);
```
## Usage
### Basic Usage
```html
<van-sku
v-model="showBase"
:sku="sku"
:goods="goods"
:goods-id="goodsId"
:hide-stock="sku.hide_stock"
:quota="quota"
:quota-used="quotaUsed"
:reset-stepper-on-hide="resetStepperOnHide"
:reset-selected-sku-on-hide="resetSelectedSkuOnHide"
:close-on-click-overlay="closeOnClickOverlay"
:disable-stepper-input="disableStepperInput"
:message-config="messageConfig"
@buy-clicked="onBuyClicked"
@add-cart="onAddCartClicked"
/>
```
### Custom Stepper Config
```html
<van-sku
v-model="showBase"
:sku="sku"
:goods="goods"
:goods-id="goodsId"
:hide-stock="sku.hide_stock"
:quota="quota"
:quota-used="quotaUsed"
:custom-stepper-config="customStepperConfig"
@buy-clicked="onBuyClicked"
@add-cart="onAddCartClicked"
/>
```
### Advanced Usage
```html
<van-sku
v-model="showCustomAction"
stepper-title="Stepper title"
:sku="sku"
:goods="goods"
:goods-id="goodsId"
:hide-stock="sku.hide_stock"
:quota="quota"
:quota-used="quotaUsed"
show-add-cart-btn
reset-stepper-on-hide
:initial-sku="initialSku"
@buy-clicked="onBuyClicked"
@add-cart="onAddCartClicked"
>
<!-- custom sku-header-price -->
<template slot="sku-header-price" slot-scope="props">
<div class="van-sku__goods-price">
<span class="van-sku__price-symbol"></span><span class="van-sku__price-num">{{ props.price }}</span>
</div>
</template>
<!-- custom sku actions -->
<template slot="sku-actions" slot-scope="props">
<div class="van-sku-actions">
<van-button
square
size="large"
type="warning"
@click="onPointClicked"
>
Button
</van-button>
<!-- trigger sku inner event -->
<van-button
square
size="large"
type="danger"
@click="props.skuEventBus.$emit('sku:buy')"
>
Button
</van-button>
</div>
</template>
</van-sku>
```
## API
### Props
| Attribute | Description | Type | Default |
|------|------|------|------|
| v-model | Whether to show sku | `Boolean` | `false` |
| sku | Sku data | `Object` | - |
| goods | Goods info | `Object` | - |
| goods-id | Goods id | `String | Number` | - |
| hide-stock | Whether to hide stock | `Boolean` | `false` |
| hide-quota-text | Whether to hide quota text | `Boolean` | `false` |
| show-add-cart-btn | Whether to show cart button | `Boolean` | `true` |
| quota | Quota (0 as no limit) | `Number` | `0` |
| quota-used | Used quota | `Number` | `0` |
| reset-stepper-on-hide | Whether to reset stepper when hide | `Boolean` | `false` |
| reset-selected-sku-on-hide | Whether to reset selected sku when hide | `Boolean` | `false` |
| disable-stepper-input | Whether to disable stepper input | `Boolean` | `false` |
| close-on-click-overlay | Whether to close sku popup when click overlay | `Boolean` | `false` |
| stepper-title | Quantity title | `String` | `Quantity` |
| custom-stepper-config | Custom stepper related config | `Object` | `{}` |
| message-config | Message related config | `Object` | `{}` |
| get-container | Return the mount node for sku | `String | () => HTMLElement` | - |
### Events
| Event | Description | Arguments |
|------|------|------|
| add-cart | Triggered when click cart button | data: Object |
| buy-clicked | Triggered when click buy button | data: Object |
| stepper-change | Triggered when stepper value changed | value: number |
| sku-selected | Triggered when select sku | { skuValue, selectedSku, selectedSkuComb } |
| open-preview | Triggered when open image preview | data: Object |
| close-preview | Triggered when close image preview | data: Object |
### Methods
Use ref to get sku instance and call instance methods
| Name | Attribute | Return value | Description |
|------|------|------|------|
| getSkuData | - | skuData | Get current skuData |
### Slots
| Name | Description |
|------|------|
| sku-header | Custom header |
| sku-header-price | Custom header price area |
| sku-body-top | Custom content before sku-group |
| sku-group | Custom sku |
| extra-sku-group | Extra custom content |
| sku-stepper | Custom stepper |
| sku-messages | Custom messages |
| sku-actions | Custom button actions |
### Sku Data Structure
```javascript
sku: {
tree: [
{
k: 'Color',
v: [
{
id: '30349',
name: 'Red',
imgUrl: 'https://img.yzcdn.cn/1.jpg'
},
{
id: '1215',
name: 'Blue',
imgUrl: 'https://img.yzcdn.cn/2.jpg'
}
],
k_s: 's1'
}
],
list: [
{
id: 2259,
price: 100,
s1: '1215',
s2: '1193',
s3: '0',
stock_num: 110
}
],
price: '1.00',
stock_num: 227,
collection_id: 2261,
none_sku: false,
messages: [
{
datetime: '0',
multiple: '0',
name: 'Message',
type: 'text',
required: '1'
}
],
hide_stock: false
}
```
### initialSku Data Structure
```javascript
{
// KeyskuKeyStr
// ValueskuValueId
s1: '30349',
s2: '1193',
selectedNum: 3
}
```
### Goods Data Structure
```javascript
goods: {
title: 'Title',
picture: 'https://img.yzcdn.cn/1.jpg'
}
```
### customStepperConfig Data Structure
```javascript
customStepperConfig: {
// custom quota text
quotaText: 'only 5 can buy',
// custom callback when over limit
handleOverLimit: (data) => {
const { action, limitType, quota, quotaUsed } = data;
if (action === 'minus') {
Toast('at least select one');
} else if (action === 'plus') {
// const { LIMIT_TYPE } = Sku.skuConstants;
if (limitType === LIMIT_TYPE.QUOTA_LIMIT) {
let msg = `Buy up to ${quota}`;
if (quotaUsed > 0) msg += `you already buy ${quotaUsed}`;
Toast(msg);
} else {
Toast('not enough stock');
}
}
}
}
```
### messageConfig Data Structure
```javascript
messageConfig: {
// the upload image callback
uploadImg: () => {
return new Promise((resolve) => {
setTimeout(() => resolve('https://img.yzcdn.cn/upload_files/2017/02/21/FjKTOxjVgnUuPmHJRdunvYky9OHP.jpg!100x100.jpg'), 1000);
});
},
// max file size (MB)
uploadMaxSize: 3,
// placehold config
placeholderMap: {
text: 'xxx',
tel: 'xxx',
...
}
}
```
### Events Params Data Structure
```javascript
skuData: {
goodsId: '946755',
messages: {
message_0: '12',
message_1: ''
},
cartMessages: {
'Message 1': 'xxxx'
},
selectedNum: 1,
selectedSkuComb: {
id: 2257,
price: 100,
s1: '30349',
s2: '1193',
s3: '0',
stock_num: 111
}
}
```
+20
View File
@@ -0,0 +1,20 @@
import Sku from './Sku';
import SkuActions from './components/SkuActions';
import SkuHeader from './components/SkuHeader';
import SkuMessages from './components/SkuMessages';
import SkuStepper from './components/SkuStepper';
import SkuRow from './components/SkuRow';
import SkuRowItem from './components/SkuRowItem';
import skuHelper from './utils/skuHelper';
import constants from './constants';
Sku.SkuActions = SkuActions;
Sku.SkuHeader = SkuHeader;
Sku.SkuMessages = SkuMessages;
Sku.SkuStepper = SkuStepper;
Sku.SkuRow = SkuRow;
Sku.SkuRowItem = SkuRowItem;
Sku.skuHelper = skuHelper;
Sku.skuConstants = constants;
export default Sku;
+249
View File
@@ -0,0 +1,249 @@
@import '../style/var';
@import '../style/mixins/clearfix';
.van-sku {
&-container {
max-height: max-content; /* avoid androiod keyboard cover fields */
overflow-y: visible;
font-size: 14px;
background: @white;
}
&-body {
max-height: 350px;
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
display: none;
}
}
/* sku header */
&-header {
margin-left: 15px;
&__img-wrap {
position: relative;
float: left;
width: 80px;
height: 80px;
margin-top: -10px;
background: @background-color;
border-radius: 2px;
img {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
max-width: 100%;
max-height: 100%;
margin: auto;
}
}
&__goods-info {
box-sizing: border-box;
min-height: 82px;
padding: 10px 60px 10px 10px;
overflow: hidden;
}
}
&__goods-name {
font-size: 12px;
}
&__price-symbol {
vertical-align: middle;
}
&__price-num {
font-size: 16px;
vertical-align: middle;
}
&__goods-price {
margin-top: 10px;
color: @red;
vertical-align: middle;
}
&__close-icon {
position: absolute;
top: 10px;
right: 15px;
color: @gray-dark;
font-size: 20px;
text-align: center;
}
&-group-container {
margin-left: 15px;
padding: 12px 0 2px;
&--hide-soldout {
.van-sku-row__item--disabled {
display: none;
}
}
}
/* sku row */
&-row {
margin: 0 15px 10px 0;
&:last-child {
margin-bottom: 0;
}
&__title {
padding-bottom: 10px;
}
&__item {
display: inline-block;
box-sizing: border-box;
min-width: 52px;
height: 28px;
margin: 0 10px 10px 0;
padding: 5px 9px;
color: @text-color;
font-size: 12px;
line-height: 16px;
text-align: center;
border: 1px solid @gray-dark;
border-radius: 3px;
&--active {
color: @white;
background: @red;
border-color: @red;
}
&--disabled {
color: @gray;
background: @active-color;
border-color: @border-color;
}
}
}
/* sku stepper */
&-stepper {
&-stock {
margin-left: 15px;
padding: 12px 0;
}
&-container {
height: 30px;
margin-right: 20px;
}
}
&__stepper {
float: right;
&-title {
float: left;
line-height: 30px;
}
}
&__stock {
display: inline-block;
margin-right: 10px;
color: @gray-dark;
font-size: 12px;
}
&__quota {
display: inline-block;
color: @red;
font-size: 12px;
}
&-messages {
padding-bottom: 10px;
background: @background-color;
&__image-cell {
.van-cell__title {
max-width: 90px;
}
.van-cell__value {
text-align: left;
}
}
}
&-img-uploader {
display: inline-block;
&__header {
padding: 0 10px;
color: @text-color;
font-size: 12px;
line-height: 24px;
border: 1px solid @border-color;
border-radius: 3px;
.van-icon {
top: 3px;
margin-right: 5px;
font-size: 14px;
}
}
&__img {
position: relative;
float: left;
width: 60px;
height: 60px;
margin: 10px 10px 0 0;
border: 1px solid @border-color;
img {
position: relative;
top: 50%;
max-width: 100%;
max-height: 100%;
transform: translateY(-50%);
}
}
&__delete {
position: absolute;
top: -12px;
right: -14px;
z-index: 1;
padding: 6px;
color: @red;
&::before {
background-color: @white;
border-radius: 14px;
}
}
&__uploading {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 20px;
height: 20px;
margin: auto;
}
}
/* sku actions */
&-actions {
display: flex;
}
}
@@ -0,0 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders demo correctly 1`] = `
<div>
<div>
<div class="sku-container">
<!----> <button class="van-button van-button--primary van-button--normal van-button--block"><span class="van-button__text">
基础用法
</span></button></div>
</div>
<div>
<div class="sku-container">
<!----> <button class="van-button van-button--primary van-button--normal van-button--block"><span class="van-button__text">
自定义步进器
</span></button></div>
</div>
<div>
<div class="sku-container">
<!----> <button class="van-button van-button--primary van-button--normal van-button--block"><span class="van-button__text">
隐藏售罄规格
</span></button></div>
</div>
<div>
<div class="sku-container">
<!----> <button class="van-button van-button--primary van-button--normal van-button--block"><span class="van-button__text">
高级用法
</span></button></div>
</div>
</div>
`;
+4
View File
@@ -0,0 +1,4 @@
import Demo from '../demo';
import demoTest from '../../../test/demo-test';
demoTest(Demo);
+52
View File
@@ -0,0 +1,52 @@
/* eslint-disable camelcase */
export type SkuData = {
price: string;
none_sku: boolean;
stock_num: number;
hide_stock: boolean;
collection_id: number;
tree: SkuTreeItemData[];
list: SkuListItemData[];
messages: SkuMessageData[];
};
export type SkuTreeItemData = {
k: string;
v: SkuTreeItemValueData[];
k_s: string;
};
export type SkuTreeItemValueData = {
id: string;
name: string;
imgUrl?: string;
img_url?: string;
};
export type SkuListItemData = {
id: number;
s1: string;
s2: string;
s3: string;
price: number;
stock_num: number;
};
export type SkuMessageData = {
name: string;
type: string;
required?: string;
datetime?: string;
multiple?: string;
};
export type SkuGoodsData = {
title: string;
picture: string;
};
export type SelectedSkuData = {
s1: string;
s2: string;
};
+112
View File
@@ -0,0 +1,112 @@
import { UNSELECTED_SKU_VALUE_ID } from '../constants';
/*
normalize sku tree
[
{
count: 2,
k: "品种", // 规格名称 skuKeyName
k_id: "1200", // skuKeyId
k_s: "s1" // skuKeyStr
v: [ // skuValues
{ // skuValue
id: "1201", // skuValueId
name: "萌" // 具体的规格值 skuValueName
}, {
id: "973",
name: "帅"
}
]
},
...
]
|
v
{
s1: [{
id: "1201",
name: "萌"
}, {
id: "973",
name: "帅"
}],
...
}
*/
export const normalizeSkuTree = skuTree => {
const normalizedTree = {};
skuTree.forEach(treeItem => {
normalizedTree[treeItem.k_s] = treeItem.v;
});
return normalizedTree;
};
// 判断是否所有的sku都已经选中
export const isAllSelected = (skuTree, selectedSku) => {
// 筛选selectedSku对象中key值不为空的值
const selected = Object.keys(selectedSku).filter(
skuKeyStr => selectedSku[skuKeyStr] !== UNSELECTED_SKU_VALUE_ID
);
return skuTree.length === selected.length;
};
// 根据已选择的 sku 获取 skuComb
export const getSkuComb = (skuList, selectedSku) => {
const skuComb = skuList.filter(item => (
Object.keys(selectedSku).every(
skuKeyStr => String(item[skuKeyStr]) === String(selectedSku[skuKeyStr])
)
));
return skuComb[0];
};
// 获取已选择的sku名称
export const getSelectedSkuValues = (skuTree, selectedSku) => {
const normalizedTree = normalizeSkuTree(skuTree);
return Object.keys(selectedSku).reduce((selectedValues, skuKeyStr) => {
const skuValues = normalizedTree[skuKeyStr];
const skuValueId = selectedSku[skuKeyStr];
if (skuValueId !== UNSELECTED_SKU_VALUE_ID) {
const skuValue = skuValues.filter(value => value.id === skuValueId)[0];
skuValue && selectedValues.push(skuValue);
}
return selectedValues;
}, []);
};
// 判断sku是否可选
export const isSkuChoosable = (skuList, selectedSku, skuToChoose) => {
const { key, valueId } = skuToChoose;
// 先假设sku已选中,拼入已选中sku对象中
const matchedSku = Object.assign({}, selectedSku, {
[key]: valueId
});
// 再判断剩余sku是否全部不可选,若不可选则当前sku不可选中
const skusToCheck = Object.keys(matchedSku).filter(
skuKey => matchedSku[skuKey] !== UNSELECTED_SKU_VALUE_ID
);
const filteredSku = skuList.filter(sku => (
skusToCheck.every(
skuKey => String(matchedSku[skuKey]) === String(sku[skuKey])
)
));
const stock = filteredSku.reduce((total, sku) => {
total += sku.stock_num;
return total;
}, 0);
return stock > 0;
};
export default {
normalizeSkuTree,
getSkuComb,
getSelectedSkuValues,
isAllSelected,
isSkuChoosable
};
+312
View File
@@ -0,0 +1,312 @@
# Sku 商品规格
### 引入
```javascript
import { Sku } from 'vant';
Vue.use(Sku);
```
## 代码演示
### 基础用法
```html
<van-sku
v-model="showBase"
:sku="sku"
:goods="goods"
:goods-id="goodsId"
:hide-stock="sku.hide_stock"
:quota="quota"
:quota-used="quotaUsed"
:reset-stepper-on-hide="resetStepperOnHide"
:reset-selected-sku-on-hide="resetSelectedSkuOnHide"
:close-on-click-overlay="closeOnClickOverlay"
:disable-stepper-input="disableStepperInput"
:message-config="messageConfig"
@buy-clicked="onBuyClicked"
@add-cart="onAddCartClicked"
/>
```
### 自定义步进器
```html
<van-sku
v-model="showBase"
:sku="sku"
:goods="goods"
:goods-id="goodsId"
:hide-stock="sku.hide_stock"
:quota="quota"
:quota-used="quotaUsed"
:custom-stepper-config="customStepperConfig"
@buy-clicked="onBuyClicked"
@add-cart="onAddCartClicked"
/>
```
### 高级用法
```html
<van-sku
v-model="showCustomAction"
stepper-title="我要买"
:sku="sku"
:goods="goods"
:goods-id="goodsId"
:hide-stock="sku.hide_stock"
:quota="quota"
:quota-used="quotaUsed"
show-add-cart-btn
reset-stepper-on-hide
:initial-sku="initialSku"
@buy-clicked="onBuyClicked"
@add-cart="onAddCartClicked"
>
<!-- 自定义 sku-header-price -->
<template slot="sku-header-price" slot-scope="props">
<div class="van-sku__goods-price">
<span class="van-sku__price-symbol"></span><span class="van-sku__price-num">{{ props.price }}</span>
</div>
</template>
<!-- 自定义 sku actions -->
<template slot="sku-actions" slot-scope="props">
<div class="van-sku-actions">
<van-button
square
size="large"
type="warning"
@click="onPointClicked"
>
积分兑换
</van-button>
<!-- 直接触发 sku 内部事件,通过内部事件执行 onBuyClicked 回调 -->
<van-button
square
size="large"
type="danger"
@click="props.skuEventBus.$emit('sku:buy')"
>
买买买
</van-button>
</div>
</template>
</van-sku>
```
## API
### Props
| 参数 | 说明 | 类型 | 默认值 | 版本 |
|------|------|------|------|------|
| v-model | 是否显示sku | `Boolean` | `false` | - |
| sku | 商品sku数据 | `Object` | - | - |
| goods | 商品信息 | `Object` | - | - |
| goods-id | 商品 id | `String | Number` | - | - |
| hide-stock | 是否显示商品剩余库存 | `Boolean` | `false` | - |
| hide-quota-text | 是否显示限购提示 | `Boolean` | `false` | 1.4.8 |
| show-add-cart-btn | 是否显示加入购物车按钮 | `Boolean` | `true` | - |
| quota | 限购数,0 表示不限购 | `Number` | `0` | - |
| quota-used | 已经购买过的数量 | `Number` | `0` | - |
| reset-stepper-on-hide | 隐藏时重置选择的商品数量 | `Boolean` | `false` | - |
| reset-selected-sku-on-hide | 隐藏时重置已选择的 sku | `Boolean` | `false` | - |
| disable-stepper-input | 是否禁用步进器输入 | `Boolean` | `false` | - |
| close-on-click-overlay | 是否在点击遮罩层后关闭 | `Boolean` | `false` | - |
| stepper-title | 数量选择组件左侧文案 | `String` | `购买数量` | - |
| custom-stepper-config | 步进器相关自定义配置 | `Object` | `{}` | - |
| message-config | 留言相关配置 | `Object` | `{}` | - |
| get-container | 指定挂载的节点,可以传入选择器,<br>或一个返回节点的函数 | `String | () => HTMLElement` | - | - |
| initial-sku | 默认选中的sku,具体参考高级用法 | `Object` | `{}` | - |
| show-soldout-sku | 是否展示售罄的sku,如果展示,则为置灰不可选状态,不展示则直接隐藏 | `Boolean` | `true` | - |
### Events
| 事件名 | 说明 | 回调参数 |
|------|------|------|
| add-cart | 点击添加购物车回调 | skuData: Object |
| buy-clicked | 点击购买回调 | skuData: Object |
| stepper-change | 购买数量变化时触发 | value: number |
| sku-selected | 切换规格类目时触发 | { skuValue, selectedSku, selectedSkuComb } |
| open-preview | 打开商品图片预览时触发 | data: Object |
| close-preview | 关闭商品图片预览时触发 | data: Object |
### 方法
通过 ref 可以获取到 sku 实例并调用实例方法
| 方法名 | 参数 | 返回值 | 介绍 |
|------|------|------|------|
| getSkuData | - | skuData | 获取当前 skuData |
### Slots
Sku 组件默认划分好了若干区块,这些区块都定义成了插槽,可以按需进行替换。区块顺序见下表:
| 名称 | 说明 |
|------|------|
| sku-header | 商品信息展示区,包含商品图片、名称、价格等信息 |
| sku-header-price | 自定义 sku 头部价格展示 |
| sku-body-top | sku 展示区上方的内容,无默认展示内容,按需使用 |
| sku-group | 商品 sku 展示区 |
| extra-sku-group | 额外商品 sku 展示区,一般用不到 |
| sku-stepper | 商品数量选择区 |
| sku-messages | 商品留言区 |
| sku-actions | 操作按钮区 |
### sku 对象结构
```javascript
sku: {
// 所有sku规格类目与其值的从属关系,比如商品有颜色和尺码两大类规格,颜色下面又有红色和蓝色两个规格值。
// 可以理解为一个商品可以有多个规格类目,一个规格类目下可以有多个规格值。
tree: [
{
k: '颜色', // skuKeyName:规格类目名称
v: [
{
id: '30349', // skuValueId:规格值 id
name: '红色', // skuValueName:规格值名称
imgUrl: 'https://img.yzcdn.cn/1.jpg' // 规格类目图片,只有第一个规格类目可以定义图片
},
{
id: '1215',
name: '蓝色',
imgUrl: 'https://img.yzcdn.cn/2.jpg'
}
],
k_s: 's1' // skuKeyStrsku 组合列表(下方 list)中当前类目对应的 key 值,value 值会是从属于当前类目的一个规格值 id
}
],
// 所有 sku 的组合列表,比如红色、M 码为一个 sku 组合,红色、S 码为另一个组合
list: [
{
id: 2259, // skuId,下单时后端需要
price: 100, // 价格(单位分)
s1: '1215', // 规格类目 k_s 为 s1 的对应规格值 id
s2: '1193', // 规格类目 k_s 为 s2 的对应规格值 id
s3: '0', // 最多包含3个规格值,为0表示不存在该规格
stock_num: 110 // 当前 sku 组合对应的库存
}
],
price: '1.00', // 默认价格(单位元)
stock_num: 227, // 商品总库存
collection_id: 2261, // 无规格商品 skuId 取 collection_id,否则取所选 sku 组合对应的 id
none_sku: false, // 是否无规格商品
messages: [
{
// 商品留言
datetime: '0', // 留言类型为 time 时,是否含日期。'1' 表示包含
multiple: '0', // 留言类型为 text 时,是否多行文本。'1' 表示多行
name: '留言', // 留言名称
type: 'text', // 留言类型,可选: id_no(身份证), text, tel, date, time, email
required: '1' // 是否必填 '1' 表示必填
}
],
hide_stock: false // 是否隐藏剩余库存
}
```
### initialSku 对象结构
```javascript
{
// 键:skuKeyStrsku 组合列表中当前类目对应的 key 值)
// 值:skuValueId(规格值 id
s1: '30349',
s2: '1193',
// 初始选中数量
selectedNum: 3
}
```
### goods 对象结构
```javascript
goods: {
// 商品标题
title: '测试商品',
// 默认商品 sku 缩略图
picture: 'https://img.yzcdn.cn/1.jpg'
}
```
### customStepperConfig 对象结构
```javascript
customStepperConfig: {
// 自定义限购文案
quotaText: '每次限购xxx件',
// 自定义步进器超过限制时的回调
handleOverLimit: (data) => {
const { action, limitType, quota, quotaUsed } = data;
if (action === 'minus') {
Toast('至少选择一件商品');
} else if (action === 'plus') {
// const { LIMIT_TYPE } = Sku.skuConstants;
if (limitType === LIMIT_TYPE.QUOTA_LIMIT) {
let msg = `单次限购${quota}`;
if (quotaUsed > 0) msg += `,你已购买${quotaUsed}`;
Toast(msg);
} else {
Toast('库存不够了');
}
}
}
}
```
### messageConfig Data Structure
```javascript
messageConfig: {
// 图片上传回调,需要返回一个promise,promise正确执行的结果需要是一个图片url
uploadImg: () => {
return new Promise((resolve) => {
setTimeout(() => resolve('https://img.yzcdn.cn/upload_files/2017/02/21/FjKTOxjVgnUuPmHJRdunvYky9OHP.jpg!100x100.jpg'), 1000);
});
},
// 最大上传体积 (MB)
uploadMaxSize: 3,
// placehold配置
placeholderMap: {
text: 'xxx',
tel: 'xxx',
...
}
}
```
### 添加购物车和点击购买回调函数接收的 skuData 对象结构
```javascript
skuData: {
// 商品 id
goodsId: '946755',
// 留言信息
messages: {
message_0: '12',
message_1: ''
},
// 另一种格式的留言,key 不同
cartMessages: {
'留言1': 'xxxx'
},
// 选择的商品数量
selectedNum: 1,
// 选择的 sku 组合
selectedSkuComb: {
id: 2257,
price: 100,
s1: '30349',
s2: '1193',
s3: '0',
stock_num: 111
}
}
```