chore: temporaily remove sku

This commit is contained in:
chenjiahan 2020-08-20 22:31:50 +08:00
parent 63bd4700ab
commit 1e2f55db5a
24 changed files with 0 additions and 3906 deletions

View File

@ -1,397 +0,0 @@
# Sku
### Install
```js
import Vue from 'vue';
import { Sku } from 'vant';
Vue.use(Sku);
```
## Usage
### Basic Usage
```html
<van-sku
v-model="show"
: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"
:disable-stepper-input="disableStepperInput"
:message-config="messageConfig"
@buy-clicked="onBuyClicked"
@add-cart="onAddCartClicked"
/>
```
```js
export default {
data() {
return {
show: false,
sku: {},
goods: {},
messageConfig: {},
};
},
};
```
### Custom Stepper
```html
<van-sku
v-model="show"
: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"
/>
```
### Custom By Slot
```html
<van-sku
v-model="show"
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 #sku-header-price="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 #sku-actions="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_ | - |
| price-tag | Tag behind the price | _string_ | - |
| hide-stock | Whether to hide stock | _boolean_ | `false` |
| hide-quota-text | Whether to hide quota text | _boolean_ | `false` |
| hide-selected-text | Whether to hide selected text | _boolean_ | `false` |
| stock-threshold | stock threshold | _boolean_ | `50` |
| show-add-cart-btn | Whether to show cart button | _boolean_ | `true` |
| buy-text | Buy button text | _string_ | - | - |
| add-cart-text | Add cart button text | _string_ | - | - |
| 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_ | `true` |
| 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 \| () => Element_ | - |
| safe-area-inset-bottom `v2.2.1` | Whether to enable bottom safe area adaptation | _boolean_ | `true` |
| start-sale-num `v2.3.0` | Minimum quantity | _number_ | `1` |
| properties `v2.4.2` | Goods properties | _array_ | - |
| preview-on-click-image `v2.5.2` | Whether to preview image when click goods image | _boolean_ | `true` |
| show-header-image `v2.9.0` | Whether to display header image | _boolean_ | `true` |
| lazy-load | Whether to enable lazy loadshould register [Lazyload](#/en-US/lazyload) component | _boolean_ | `false` |
### 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 } |
| sku-prop-selected | Triggered when select property | { propValue, selectedProp, selectedSkuComb } |
| open-preview | Triggered when open image preview | data: object |
| close-preview | Triggered when close image preview | data: object |
| sku-reset `v2.8.1` | Triggered when reset sku and property | { selectedSku, selectedProp, selectedSkuComb } |
### Methods
Use [ref](https://vuejs.org/v2/api/#ref) to get Sku instance and call instance methods
| Name | Description | Attribute | Return value |
| --- | --- | --- | --- |
| getSkuData | Get current skuData | - | skuData |
| resetSelectedSku `v2.3.0` | Reset selected sku to initial sku | - | - |
### Slots
| Name | Description |
| ------------------------------- | --------------------------------- |
| sku-header | Custom header |
| sku-header-price | Custom header price area |
| sku-header-origin-price | Custom header origin price area |
| sku-header-extra | Extra header area |
| sku-header-image-extra `v2.5.2` | Custom header image extra 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-top `v2.4.7` | Custom content before sku-actions |
| sku-actions | Custom button actions |
### Sku Data Structure
```js
sku: {
tree: [
{
k: 'Color',
k_s: 's1',
v: [
{
id: '1',
name: 'Red',
imgUrl: 'https://img.yzcdn.cn/1.jpg',
previewImgUrl: 'https://img.yzcdn.cn/1p.jpg',
},
{
id: '1',
name: 'Blue',
imgUrl: 'https://img.yzcdn.cn/2.jpg',
previewImgUrl: 'https://img.yzcdn.cn/2p.jpg',
}
],
largeImageMode: true, // whether to enable large image mode
}
],
list: [
{
id: 2259,
s1: '1',
s2: '1',
price: 100,
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'
placeholder: ''
}
],
hide_stock: false,
properties: [
{
k_id: 123,
k: 'More',
is_multiple: true,
v: [
{
id: 1222,
name: 'Tea',
price: 1,
},
{
id: 1223,
name: 'Water',
price: 1,
}
],
}
]
}
```
### properties Data Structure
```js
[
{
k_id: 123,
k: 'More',
is_multiple: true,
v: [
{
id: 1222,
name: 'Tea',
price: 1,
},
{
id: 1223,
name: 'Water',
price: 1,
},
],
},
];
```
### initialSku Data Structure
```js
{
// KeyskuKeyStr
// ValueskuValueId
s1: '30349',
s2: '1193',
selectedNum: 3,
selectedProp: {
123: [1222]
}
}
```
### Goods Data Structure
```js
goods: {
picture: 'https://img.yzcdn.cn/1.jpg';
}
```
### customStepperConfig Data Structure
```js
customStepperConfig: {
// custom quota text
quotaText: 'only 5 can buy',
// custom callback when over limit
handleOverLimit: (data) => {
const { action, limitType, quota, quotaUsed, startSaleNum } = data;
if (action === 'minus') {
Toast(`at least select ${startSaleNum > 1 ? startSaleNum : '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');
}
}
},
// custom callback when stepper value change
handleStepperChange: currentValue => {},
// stock
stockNum: 1999,
// stock fomatter
stockFormatter: stockNum => {},
}
```
### messageConfig Data Structure
```js
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,
// placeholder config
placeholderMap: {
text: 'xxx',
tel: 'xxx',
...
},
// Keymessage name
// Valuemessage value
initialMessages: {
message: 'message value'
}
}
```
### Events Params Data Structure
```js
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,
properties: [
{
k_id: 123,
k: 'More',
is_multiple: true,
v: [
{
id: 1223,
name: 'Water',
price: 1
}
]
}
],
property_price: 1
}
}
```

View File

@ -1,401 +0,0 @@
# Sku 商品规格
### 引入
```js
import Vue from 'vue';
import { Sku } from 'vant';
Vue.use(Sku);
```
## 代码演示
### 基础用法
```html
<van-sku
v-model="show"
:sku="sku"
:goods="goods"
:goods-id="goodsId"
:quota="quota"
:quota-used="quotaUsed"
:hide-stock="sku.hide_stock"
:message-config="messageConfig"
@buy-clicked="onBuyClicked"
@add-cart="onAddCartClicked"
/>
```
```js
export default {
data() {
return {
show: false,
sku: {
// 数据结构见下方文档
},
goods: {
// 数据结构见下方文档
},
messageConfig: {
// 数据结构见下方文档
},
};
},
};
```
### 自定义步进器
```html
<van-sku
v-model="show"
:sku="sku"
:goods="goods"
:goods-id="goodsId"
:quota="quota"
:quota-used="quotaUsed"
:hide-stock="sku.hide_stock"
:custom-stepper-config="customStepperConfig"
@buy-clicked="onBuyClicked"
@add-cart="onAddCartClicked"
/>
```
### 通过插槽定制
```html
<van-sku
v-model="show"
stepper-title="我要买"
:sku="sku"
:goods="goods"
:goods-id="goodsId"
:quota="quota"
:quota-used="quotaUsed"
:hide-stock="sku.hide_stock"
show-add-cart-btn
reset-stepper-on-hide
:initial-sku="initialSku"
@buy-clicked="onBuyClicked"
@add-cart="onAddCartClicked"
>
<!-- 自定义 sku-header-price -->
<template #sku-header-price="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 #sku-actions="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 | 是否显示商品规格弹窗 | _boolean_ | `false` |
| sku | 商品 sku 数据 | _object_ | - |
| goods | 商品信息 | _object_ | - |
| goods-id | 商品 id | _number \| string_ | - |
| price-tag | 显示在价格后面的标签 | _string_ | - |
| hide-stock | 是否显示商品剩余库存 | _boolean_ | `false` |
| hide-quota-text | 是否显示限购提示 | _boolean_ | `false` |
| hide-selected-text | 是否隐藏已选提示 | _boolean_ | `false` |
| stock-threshold | 库存阈值。低于这个值会把库存数高亮显示 | _boolean_ | `50` |
| show-add-cart-btn | 是否显示加入购物车按钮 | _boolean_ | `true` |
| buy-text | 购买按钮文字 | _string_ | `立即购买` |
| add-cart-text | 加入购物车按钮文字 | _string_ | `加入购物车` |
| 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_ | `true` |
| stepper-title | 数量选择组件左侧文案 | _string_ | `购买数量` |
| custom-stepper-config | 步进器相关自定义配置 | _object_ | `{}` |
| message-config | 留言相关配置 | _object_ | `{}` |
| get-container | 指定挂载的节点,[用法示例](#/zh-CN/popup#zhi-ding-gua-zai-wei-zhi) | _string \| () => Element_ | - |
| initial-sku | 默认选中的 sku具体参考高级用法 | _object_ | `{}` |
| show-soldout-sku | 是否展示售罄的 sku默认展示并置灰 | _boolean_ | `true` |
| safe-area-inset-bottom `v2.2.1` | 是否开启[底部安全区适配](#/zh-CN/quickstart#di-bu-an-quan-qu-gua-pei) | _boolean_ | `true` |
| start-sale-num `v2.3.0` | 起售数量 | _number_ | `1` |
| properties `v2.4.2` | 商品属性 | _array_ | - |
| preview-on-click-image `v2.5.2` | 是否在点击商品图片时自动预览 | _boolean_ | `true` |
| show-header-image `v2.9.0` | 是否展示头部图片 | _boolean_ | `true` |
| lazy-load `v2.9.0` | 是否开启图片懒加载,须配合 [Lazyload](#/zh-CN/lazyload) 组件使用 | _boolean_ | `false` |
### Events
| 事件名 | 说明 | 回调参数 |
| --- | --- | --- |
| add-cart | 点击添加购物车回调 | skuData: object |
| buy-clicked | 点击购买回调 | skuData: object |
| stepper-change | 购买数量变化时触发 | value: number |
| sku-selected | 切换规格类目时触发 | { skuValue, selectedSku, selectedSkuComb } |
| sku-prop-selected | 切换商品属性时触发 | { propValue, selectedProp, selectedSkuComb } |
| open-preview | 打开商品图片预览时触发 | data: object |
| close-preview | 关闭商品图片预览时触发 | data: object |
| sku-reset `v2.8.1` | 规格和属性被重置时触发 | { selectedSku, selectedProp, selectedSkuComb } |
### 方法
通过 ref 可以获取到 Sku 实例并调用实例方法,详见[组件实例方法](#/zh-CN/quickstart#zu-jian-shi-li-fang-fa)
| 方法名 | 说明 | 参数 | 返回值 |
| ------------------------- | ---------------------- | ---- | ------- |
| getSkuData | 获取当前 skuData | - | skuData |
| resetSelectedSku `v2.3.0` | 重置选中规格到初始状态 | - | - |
### Slots
Sku 组件默认划分好了若干区块,这些区块都定义成了插槽,可以按需进行替换。区块顺序见下表:
| 名称 | 说明 |
| --- | --- |
| sku-header | 商品信息展示区,包含商品图片、名称、价格等信息 |
| sku-header-price | 自定义 sku 头部价格展示 |
| sku-header-origin-price | 自定义 sku 头部原价展示 |
| sku-header-extra | 额外 sku 头部区域 |
| sku-header-image-extra `v2.5.2` | 自定义 sku 头部图片额外的展示 |
| sku-body-top | sku 展示区上方的内容,无默认展示内容,按需使用 |
| sku-group | 商品 sku 展示区 |
| extra-sku-group | 额外商品 sku 展示区,一般用不到 |
| sku-stepper | 商品数量选择区 |
| sku-messages | 商品留言区 |
| sku-actions-top `v2.4.7` | 操作按钮区顶部内容,无默认展示内容,按需使用 |
| sku-actions | 操作按钮区 |
### sku 对象结构
```js
sku: {
// 所有sku规格类目与其值的从属关系比如商品有颜色和尺码两大类规格颜色下面又有红色和蓝色两个规格值。
// 可以理解为一个商品可以有多个规格类目,一个规格类目下可以有多个规格值。
tree: [
{
k: '颜色', // skuKeyName规格类目名称
k_s: 's1', // skuKeyStrsku 组合列表(下方 list中当前类目对应的 key 值value 值会是从属于当前类目的一个规格值 id
v: [
{
id: '1', // skuValueId规格值 id
name: '红色', // skuValueName规格值名称
imgUrl: 'https://img.yzcdn.cn/1.jpg', // 规格类目图片,只有第一个规格类目可以定义图片
previewImgUrl: 'https://img.yzcdn.cn/1p.jpg', // 用于预览显示的规格类目图片
},
{
id: '1',
name: '蓝色',
imgUrl: 'https://img.yzcdn.cn/2.jpg',
previewImgUrl: 'https://img.yzcdn.cn/2p.jpg',
}
],
largeImageMode: true, // 是否展示大图模式
}
],
// 所有 sku 的组合列表比如红色、M 码为一个 sku 组合红色、S 码为另一个组合
list: [
{
id: 2259, // skuId
s1: '1', // 规格类目 k_s 为 s1 的对应规格值 id
s2: '1', // 规格类目 k_s 为 s2 的对应规格值 id
price: 100, // 价格(单位分)
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' 表示必填
placeholder: '' // 可选值,占位文本
}
],
hide_stock: false // 是否隐藏剩余库存
}
```
### properties 对象结构
```js
[
// 商品属性
{
k_id: 123, // 属性id
k: '加料', // 属性名
is_multiple: true, // 是否可多选
v: [
{
id: 1222, // 属性值id
name: '珍珠', // 属性值名
price: 1, // 属性值加价
},
{
id: 1223,
name: '椰果',
price: 1,
},
],
},
];
```
### initialSku 对象结构
```js
{
// 键skuKeyStrsku 组合列表中当前类目对应的 key 值)
// 值skuValueId规格值 id
s1: '1',
s2: '1',
// 初始选中数量
selectedNum: 3,
// 初始选中的商品属性
// 键属性id
// 值属性值id列表
selectedProp: {
123: [1222]
}
}
```
### goods 对象结构
```js
goods: {
// 默认商品 sku 缩略图
picture: 'https://img.yzcdn.cn/1.jpg';
}
```
### customStepperConfig 对象结构
```js
customStepperConfig: {
// 自定义限购文案
quotaText: '每次限购xxx件',
// 自定义步进器超过限制时的回调
handleOverLimit: (data) => {
const { action, limitType, quota, quotaUsed, startSaleNum } = data;
if (action === 'minus') {
Toast(startSaleNum > 1 ? `${startSaleNum}件起售` : '至少选择一件商品');
} 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('库存不够了');
}
}
},
// 步进器变化的回调
handleStepperChange: currentValue => {},
// 库存
stockNum: 1999,
// 格式化库存
stockFormatter: stockNum => {},
}
```
### messageConfig 对象结构
```js
messageConfig: {
// 图片上传回调需要返回一个promisepromise正确执行的结果需要是一个图片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,
// placeholder 配置
placeholderMap: {
text: 'xxx',
tel: 'xxx',
...
},
// 初始留言信息
// 键:留言 name
// 值:留言内容
initialMessages: {
留言: '留言信息'
}
}
```
### 添加购物车和点击购买回调函数接收的 skuData 对象结构
```js
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,
properties: [
{
k_id: 123,
k: '加料',
is_multiple: true,
v: [
{
id: 1223,
name: '椰果',
price: 1
}
]
}
],
property_price: 1
},
}
```

View File

@ -1,807 +0,0 @@
import Vue from 'vue';
import Popup from '../popup';
import Toast from '../toast';
import ImagePreview from '../image-preview';
import SkuHeader from './components/SkuHeader';
import SkuHeaderItem from './components/SkuHeaderItem';
import SkuRow from './components/SkuRow';
import SkuRowItem from './components/SkuRowItem';
import SkuRowPropItem from './components/SkuRowPropItem';
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,
getSelectedPropValues,
getSelectedProperties,
} from './utils/sku-helper';
import { LIMIT_TYPE, UNSELECTED_SKU_VALUE_ID } from './constants';
const namespace = createNamespace('sku');
const [createComponent, bem, t] = namespace;
const { QUOTA_LIMIT } = LIMIT_TYPE;
export default createComponent({
props: {
sku: Object,
goods: Object,
value: Boolean,
buyText: String,
goodsId: [Number, String],
priceTag: String,
lazyLoad: Boolean,
hideStock: Boolean,
properties: Array,
addCartText: String,
stepperTitle: String,
getContainer: [String, Function],
hideQuotaText: Boolean,
hideSelectedText: Boolean,
resetStepperOnHide: Boolean,
customSkuValidator: Function,
disableStepperInput: Boolean,
resetSelectedSkuOnHide: Boolean,
quota: {
type: Number,
default: 0,
},
quotaUsed: {
type: Number,
default: 0,
},
startSaleNum: {
type: Number,
default: 1,
},
initialSku: {
type: Object,
default: () => ({}),
},
stockThreshold: {
type: Number,
default: 50,
},
showSoldoutSku: {
type: Boolean,
default: true,
},
showAddCartBtn: {
type: Boolean,
default: true,
},
customStepperConfig: {
type: Object,
default: () => ({}),
},
showHeaderImage: {
type: Boolean,
default: true,
},
previewOnClickImage: {
type: Boolean,
default: true,
},
safeAreaInsetBottom: {
type: Boolean,
default: true,
},
closeOnClickOverlay: {
type: Boolean,
default: true,
},
bodyOffsetTop: {
type: Number,
default: 200,
},
messageConfig: {
type: Object,
default: () => ({
initialMessages: {},
placeholderMap: {},
uploadImg: () => Promise.resolve(),
uploadMaxSize: 5,
}),
},
},
data() {
return {
selectedSku: {},
selectedProp: {},
selectedNum: 1,
show: this.value,
};
},
watch: {
show(val) {
this.$emit('input', val);
if (!val) {
this.$emit('sku-close', {
selectedSkuValues: this.selectedSkuValues,
selectedNum: this.selectedNum,
selectedSkuComb: this.selectedSkuComb,
});
if (this.resetStepperOnHide) {
this.resetStepper();
}
if (this.resetSelectedSkuOnHide) {
this.resetSelectedSku();
}
}
},
value(val) {
this.show = val;
},
skuTree: 'resetSelectedSku',
initialSku() {
this.resetStepper();
this.resetSelectedSku();
},
},
computed: {
skuGroupClass() {
return [
'van-sku-group-container',
{
'van-sku-group-container--hide-soldout': !this.showSoldoutSku,
},
];
},
bodyStyle() {
if (this.$isServer) {
return;
}
const maxHeight = window.innerHeight - this.bodyOffsetTop;
return {
maxHeight: maxHeight + 'px',
};
},
isSkuCombSelected() {
// SKU 未选完
if (this.hasSku && !isAllSelected(this.skuTree, this.selectedSku)) {
return false;
}
// 属性未全选
return !this.propList.some(
(it) => (this.selectedProp[it.k_id] || []).length < 1
);
},
isSkuEmpty() {
return Object.keys(this.sku).length === 0;
},
hasSku() {
return !this.sku.none_sku;
},
hasSkuOrAttr() {
return this.hasSku || this.propList.length > 0;
},
selectedSkuComb() {
let skuComb = null;
if (this.isSkuCombSelected) {
if (this.hasSku) {
skuComb = getSkuComb(this.skuList, this.selectedSku);
} else {
skuComb = {
id: this.sku.collection_id,
price: Math.round(this.sku.price * 100),
stock_num: this.sku.stock_num,
};
}
if (skuComb) {
skuComb.properties = getSelectedProperties(
this.propList,
this.selectedProp
);
skuComb.property_price = this.selectedPropValues.reduce(
(acc, cur) => acc + (cur.price || 0),
0
);
}
}
return skuComb;
},
selectedSkuValues() {
return getSelectedSkuValues(this.skuTree, this.selectedSku);
},
selectedPropValues() {
return getSelectedPropValues(this.propList, this.selectedProp);
},
price() {
if (this.selectedSkuComb) {
return (
(this.selectedSkuComb.price + this.selectedSkuComb.property_price) /
100
).toFixed(2);
}
// sku.price是一个格式化好的价格区间
return this.sku.price;
},
originPrice() {
if (this.selectedSkuComb && this.selectedSkuComb.origin_price) {
return (
(this.selectedSkuComb.origin_price +
this.selectedSkuComb.property_price) /
100
).toFixed(2);
}
return this.sku.origin_price;
},
skuTree() {
return this.sku.tree || [];
},
skuList() {
return this.sku.list || [];
},
propList() {
return this.properties || [];
},
imageList() {
const imageList = [this.goods.picture];
if (this.skuTree.length > 0) {
this.skuTree.forEach((treeItem) => {
if (!treeItem.v) {
return;
}
treeItem.v.forEach((vItem) => {
const imgUrl = vItem.previewImgUrl || vItem.imgUrl || vItem.img_url;
if (imgUrl && imageList.indexOf(imgUrl) === -1) {
imageList.push(imgUrl);
}
});
});
}
return imageList;
},
stock() {
const { stockNum } = this.customStepperConfig;
if (stockNum !== undefined) {
return stockNum;
}
if (this.selectedSkuComb) {
return this.selectedSkuComb.stock_num;
}
return this.sku.stock_num;
},
stockText() {
const { stockFormatter } = this.customStepperConfig;
if (stockFormatter) {
return stockFormatter(this.stock);
}
return [
`${t('stock')} `,
<span
class={bem('stock-num', {
highlight: this.stock < this.stockThreshold,
})}
>
{this.stock}
</span>,
` ${t('stockUnit')}`,
];
},
selectedText() {
if (this.selectedSkuComb) {
const values = this.selectedSkuValues.concat(this.selectedPropValues);
return `${t('selected')} ${values.map((item) => item.name).join(' ')}`;
}
const unselectedSku = this.skuTree
.filter(
(item) => this.selectedSku[item.k_s] === UNSELECTED_SKU_VALUE_ID
)
.map((item) => item.k);
const unselectedProp = this.propList
.filter((item) => (this.selectedProp[item.k_id] || []).length < 1)
.map((item) => item.k);
return `${t('select')} ${unselectedSku.concat(unselectedProp).join(' ')}`;
},
},
created() {
const skuEventBus = new Vue();
this.skuEventBus = skuEventBus;
skuEventBus.$on('sku:select', this.onSelect);
skuEventBus.$on('sku:propSelect', this.onPropSelect);
skuEventBus.$on('sku:numChange', this.onNumChange);
skuEventBus.$on('sku:previewImage', this.onPreviewImage);
skuEventBus.$on('sku:overLimit', this.onOverLimit);
skuEventBus.$on('sku:stepperState', this.onStepperState);
skuEventBus.$on('sku:addCart', this.onAddCart);
skuEventBus.$on('sku:buy', this.onBuy);
this.resetStepper();
this.resetSelectedSku();
// 组件初始化后的钩子抛出skuEventBus
this.$emit('after-sku-create', skuEventBus);
},
methods: {
resetStepper() {
const { skuStepper } = this.$refs;
const { selectedNum } = this.initialSku;
const num = isDef(selectedNum) ? selectedNum : this.startSaleNum;
// 用来缓存不合法的情况
this.stepperError = null;
if (skuStepper) {
skuStepper.setCurrentNum(num);
} else {
// 当首次加载skuStepper 为空)时,传入数量如果不合法,可能会存在问题
this.selectedNum = num;
}
},
// @exposed-api
resetSelectedSku() {
this.selectedSku = {};
// 重置 selectedSku
this.skuTree.forEach((item) => {
this.selectedSku[item.k_s] = UNSELECTED_SKU_VALUE_ID;
});
this.skuTree.forEach((item) => {
const key = item.k_s;
// 规格值只有1个时优先判断
const valueId =
item.v.length === 1 ? item.v[0].id : this.initialSku[key];
if (
valueId &&
isSkuChoosable(this.skuList, this.selectedSku, { key, valueId })
) {
this.selectedSku[key] = valueId;
}
});
const skuValues = this.selectedSkuValues;
if (skuValues.length > 0) {
this.$nextTick(() => {
this.$emit('sku-selected', {
skuValue: skuValues[skuValues.length - 1],
selectedSku: this.selectedSku,
selectedSkuComb: this.selectedSkuComb,
});
});
}
// 重置商品属性
this.selectedProp = {};
const { selectedProp = {} } = this.initialSku;
// 只有一个属性值时,默认选中,且选中外部传入信息
this.propList.forEach((item) => {
if (item.v && item.v.length === 1) {
this.selectedProp[item.k_id] = [item.v[0].id];
} else if (selectedProp[item.k_id]) {
this.selectedProp[item.k_id] = selectedProp[item.k_id];
}
});
const propValues = this.selectedPropValues;
if (propValues.length > 0) {
this.$emit('sku-prop-selected', {
propValue: propValues[propValues.length - 1],
selectedProp: this.selectedProp,
selectedSkuComb: this.selectedSkuComb,
});
}
// 抛出重置事件
this.$emit('sku-reset', {
selectedSku: this.selectedSku,
selectedProp: this.selectedProp,
selectedSkuComb: this.selectedSkuComb,
});
this.centerInitialSku();
},
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 t('unavailable');
}
if (this.isSkuCombSelected) {
return this.validateSkuMessages();
}
// 自定义sku校验
if (this.customSkuValidator) {
const err = this.customSkuValidator(this);
if (err) return err;
}
return t('selectSku');
},
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,
});
},
onPropSelect(propValue) {
const arr = this.selectedProp[propValue.skuKeyStr] || [];
const pos = arr.indexOf(propValue.id);
if (pos > -1) {
arr.splice(pos, 1);
} else if (propValue.multiple) {
arr.push(propValue.id);
} else {
arr.splice(0, 1, propValue.id);
}
this.selectedProp = {
...this.selectedProp,
[propValue.skuKeyStr]: arr,
};
this.$emit('sku-prop-selected', {
propValue,
selectedProp: this.selectedProp,
selectedSkuComb: this.selectedSkuComb,
});
},
onNumChange(num) {
this.selectedNum = num;
},
onPreviewImage(selectedValue) {
const { imageList } = this;
let index = 0;
let indexImage = imageList[0];
if (selectedValue && selectedValue.imgUrl) {
this.imageList.some((image, pos) => {
if (image === selectedValue.imgUrl) {
index = pos;
return true;
}
return false;
});
indexImage = selectedValue.imgUrl;
}
const params = {
...selectedValue,
index,
imageList: this.imageList,
indexImage,
};
this.$emit('open-preview', params);
if (!this.previewOnClickImage) {
return;
}
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') {
if (this.startSaleNum > 1) {
Toast(t('minusStartTip', this.startSaleNum));
} else {
Toast(t('minusTip'));
}
} else if (action === 'plus') {
if (limitType === QUOTA_LIMIT) {
if (quotaUsed > 0) {
Toast(t('quotaUsedTip', quota, quotaUsed));
} else {
Toast(t('quotaTip', quota));
}
} else {
Toast(t('soldout'));
}
}
},
onStepperState(data) {
this.stepperError = data.valid
? null
: {
...data,
action: 'plus',
};
},
onAddCart() {
this.onBuyOrAddCart('add-cart');
},
onBuy() {
this.onBuyOrAddCart('buy-clicked');
},
onBuyOrAddCart(type) {
// sku 不符合购买条件
if (this.stepperError) {
return this.onOverLimit(this.stepperError);
}
const error = this.validateSku();
if (error) {
Toast(error);
} else {
this.$emit(type, this.getSkuData());
}
},
// @exposed-api
getSkuData() {
return {
goodsId: this.goodsId,
messages: this.getSkuMessages(),
selectedNum: this.selectedNum,
cartMessages: this.getSkuCartMessages(),
selectedSkuComb: this.selectedSkuComb,
};
},
// 当 popup 完全打开后执行
onOpened() {
this.centerInitialSku();
},
centerInitialSku() {
(this.$refs.skuRows || []).forEach((it) => {
const { k_s } = it.skuRow || {};
it.centerItem(this.initialSku[k_s]);
});
},
},
render() {
if (this.isSkuEmpty) {
return;
}
const {
sku,
skuList,
goods,
price,
lazyLoad,
originPrice,
skuEventBus,
selectedSku,
selectedProp,
selectedNum,
stepperTitle,
selectedSkuComb,
showHeaderImage,
} = this;
const slotsProps = {
price,
originPrice,
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}
showHeaderImage={showHeaderImage}
>
<template slot="sku-header-image-extra">
{slots('sku-header-image-extra')}
</template>
{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>
{this.priceTag && (
<span class="van-sku__price-tag">{this.priceTag}</span>
)}
</div>
)}
{slots('sku-header-origin-price') ||
(originPrice && (
<SkuHeaderItem>
{t('originPrice')} {originPrice}
</SkuHeaderItem>
))}
{!this.hideStock && (
<SkuHeaderItem>
<span class="van-sku__stock">{this.stockText}</span>
</SkuHeaderItem>
)}
{this.hasSkuOrAttr && !this.hideSelectedText && (
<SkuHeaderItem>{this.selectedText}</SkuHeaderItem>
)}
{slots('sku-header-extra')}
</SkuHeader>
);
const Group =
slots('sku-group') ||
(this.hasSkuOrAttr && (
<div class={this.skuGroupClass}>
{this.skuTree.map((skuTreeItem) => (
<SkuRow skuRow={skuTreeItem} ref="skuRows" refInFor>
{skuTreeItem.v.map((skuValue) => (
<SkuRowItem
skuList={skuList}
lazyLoad={lazyLoad}
skuValue={skuValue}
skuKeyStr={skuTreeItem.k_s}
selectedSku={selectedSku}
skuEventBus={skuEventBus}
largeImageMode={skuTreeItem.largeImageMode}
/>
))}
</SkuRow>
))}
{this.propList.map((skuTreeItem) => (
<SkuRow skuRow={skuTreeItem}>
{skuTreeItem.v.map((skuValue) => (
<SkuRowPropItem
skuValue={skuValue}
skuKeyStr={skuTreeItem.k_id + ''}
selectedProp={selectedProp}
skuEventBus={skuEventBus}
multiple={skuTreeItem.is_multiple}
/>
))}
</SkuRow>
))}
</div>
));
const Stepper = slots('sku-stepper') || (
<SkuStepper
ref="skuStepper"
stock={this.stock}
quota={this.quota}
quotaUsed={this.quotaUsed}
startSaleNum={this.startSaleNum}
skuEventBus={skuEventBus}
selectedNum={selectedNum}
stepperTitle={stepperTitle}
skuStockNum={sku.stock_num}
disableStepperInput={this.disableStepperInput}
customStepperConfig={this.customStepperConfig}
hideQuotaText={this.hideQuotaText}
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}
addCartText={this.addCartText}
showAddCartBtn={this.showAddCartBtn}
/>
);
return (
<Popup
vModel={this.show}
round
closeable
position="bottom"
class="van-sku-container"
getContainer={this.getContainer}
closeOnClickOverlay={this.closeOnClickOverlay}
safeAreaInsetBottom={this.safeAreaInsetBottom}
onOpened={this.onOpened}
>
{Header}
<div class="van-sku-body" style={this.bodyStyle}>
{slots('sku-body-top')}
{Group}
{slots('extra-sku-group')}
{Stepper}
{Messages}
</div>
{slots('sku-actions-top')}
{Actions}
</Popup>
);
},
});

View File

@ -1,58 +0,0 @@
// Utils
import { createNamespace } from '../../utils';
import { inherit } from '../../utils/functional';
// Components
import Button from '../../button';
// Types
import { DefaultSlots } from '../../utils/types';
import Vue, { CreateElement, RenderContext } from 'vue/types';
export type SkuActionsProps = {
buyText?: string;
skuEventBus: Vue;
addCartText?: string;
showAddCartBtn?: boolean;
};
const [createComponent, bem, t] = 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
size="large"
type="warning"
text={props.addCartText || t('addCart')}
onClick={createEmitter('sku:addCart')}
/>
)}
<Button
size="large"
type="danger"
text={props.buyText || t('buy')}
onClick={createEmitter('sku:buy')}
/>
</div>
);
}
SkuActions.props = {
buyText: String,
addCartText: String,
skuEventBus: Object,
showAddCartBtn: Boolean,
};
export default createComponent<SkuActionsProps>(SkuActions);

View File

@ -1,107 +0,0 @@
// Utils
import { createNamespace } from '../../utils';
import { stringToDate, dateToString } from '../utils/time-helper';
// Components
import Popup from '../../popup';
import DateTimePicker from '../../datetime-picker';
import Field from '../../field';
const namespace = createNamespace('sku-datetime-field');
const createComponent = namespace[0];
const t = namespace[2];
export default createComponent({
props: {
value: String,
label: String,
required: Boolean,
placeholder: String,
type: {
type: String,
default: 'date',
},
},
data() {
return {
showDatePicker: false,
currentDate: this.type === 'time' ? '' : new Date(),
minDate: new Date(new Date().getFullYear() - 60, 0, 1),
};
},
watch: {
value(val) {
switch (this.type) {
case 'time':
this.currentDate = val;
break;
case 'date':
case 'datetime':
this.currentDate = stringToDate(val) || new Date();
break;
}
},
},
computed: {
title() {
return t(`title.${this.type}`);
},
},
methods: {
onClick() {
this.showDatePicker = true;
},
onConfirm(val) {
let data = val;
if (this.type !== 'time') {
data = dateToString(val, this.type);
}
this.$emit('input', data);
this.showDatePicker = false;
},
onCancel() {
this.showDatePicker = false;
},
formatter(type, val) {
const word = t(`format.${type}`);
return `${val}${word}`;
},
},
render() {
return (
<Field
readonly
is-link
center
value={this.value}
label={this.label}
required={this.required}
placeholder={this.placeholder}
onClick={this.onClick}
>
<Popup
vModel={this.showDatePicker}
round
slot="extra"
position="bottom"
getContainer="body"
>
<DateTimePicker
type={this.type}
title={this.title}
value={this.currentDate}
minDate={this.minDate}
formatter={this.formatter}
onCancel={this.onCancel}
onConfirm={this.onConfirm}
/>
</Popup>
</Field>
);
},
});

View File

@ -1,110 +0,0 @@
// Utils
import { createNamespace } from '../../utils';
import { inherit } from '../../utils/functional';
import { BORDER_BOTTOM } from '../../utils/constant';
// Components
import Image from '../../image';
// Types
import Vue, { CreateElement, RenderContext } from 'vue/types';
import { DefaultSlots, ScopedSlot } from '../../utils/types';
import { SkuData, SkuGoodsData, SelectedSkuData } from '../../../types/sku';
export type SkuHeaderProps = {
sku: SkuData;
goods: SkuGoodsData;
skuEventBus: Vue;
selectedSku: SelectedSkuData;
showHeaderImage: boolean;
};
export type SkuHeaderSlots = DefaultSlots & {
'sku-header-image-extra'?: ScopedSlot;
};
type SelectedValueType = {
ks: string;
imgUrl: string;
};
const [createComponent, bem] = createNamespace('sku-header');
function getSkuImgValue(
sku: SkuData,
selectedSku: SelectedSkuData
): SelectedValueType | undefined {
let imgValue;
sku.tree.some((item) => {
const id = selectedSku[item.k_s];
if (id && item.v) {
const matchedSku =
item.v.filter((skuValue) => skuValue.id === id)[0] || {};
const img =
matchedSku.previewImgUrl || matchedSku.imgUrl || matchedSku.img_url;
if (img) {
imgValue = {
...matchedSku,
ks: item.k_s,
imgUrl: img,
};
return true;
}
}
return false;
});
return imgValue;
}
function SkuHeader(
h: CreateElement,
props: SkuHeaderProps,
slots: SkuHeaderSlots,
ctx: RenderContext<SkuHeaderProps>
) {
const {
sku,
goods,
skuEventBus,
selectedSku,
showHeaderImage = true,
} = props;
const selectedValue = getSkuImgValue(sku, selectedSku);
const imgUrl = selectedValue ? selectedValue.imgUrl : goods.picture;
const previewImage = () => {
skuEventBus.$emit('sku:previewImage', selectedValue);
};
return (
<div class={[bem(), BORDER_BOTTOM]} {...inherit(ctx)}>
{showHeaderImage && (
<Image
fit="cover"
src={imgUrl}
class={bem('img-wrap')}
onClick={previewImage}
>
{slots['sku-header-image-extra']?.()}
</Image>
)}
<div class={bem('goods-info')}>{slots.default?.()}</div>
</div>
);
}
SkuHeader.props = {
sku: Object,
goods: Object,
skuEventBus: Object,
selectedSku: Object,
showHeaderImage: Boolean,
};
export default createComponent<SkuHeaderProps>(SkuHeader);

View File

@ -1,26 +0,0 @@
// Utils
import { createNamespace } from '../../utils';
import { inherit } from '../../utils/functional';
// Types
import { CreateElement, RenderContext } from 'vue/types';
import { DefaultSlots } from '../../utils/types';
export type SkuHeaderItemProps = {};
const [createComponent, bem] = createNamespace('sku-header-item');
function SkuHeader(
h: CreateElement,
props: SkuHeaderItemProps,
slots: DefaultSlots,
ctx: RenderContext<SkuHeaderItemProps>
) {
return (
<div class={bem()} {...inherit(ctx)}>
{slots.default && slots.default()}
</div>
);
}
export default createComponent<SkuHeaderItemProps>(SkuHeader);

View File

@ -1,73 +0,0 @@
// Utils
import { createNamespace } from '../../utils';
// Components
import Uploader from '../../uploader';
const namespace = createNamespace('sku-img-uploader');
const createComponent = namespace[0];
const t = namespace[2];
export default createComponent({
props: {
value: String,
uploadImg: Function,
maxSize: {
type: Number,
default: 6,
},
},
data() {
return {
fileList: [],
};
},
watch: {
value(val) {
if (val) {
this.fileList = [{ url: val, isImage: true }];
} else {
this.fileList = [];
}
},
},
methods: {
afterReadFile(file) {
file.status = 'uploading';
file.message = t('uploading');
this.uploadImg(file.file, file.content)
.then((img) => {
file.status = 'done';
this.$emit('input', img);
})
.catch(() => {
file.status = 'failed';
file.message = t('fail');
});
},
onOversize() {
this.$toast(t('oversize', this.maxSize));
},
onDelete() {
this.$emit('input', '');
},
},
render() {
return (
<Uploader
vModel={this.fileList}
maxCount={1}
afterRead={this.afterReadFile}
maxSize={this.maxSize * 1024 * 1024}
onOversize={this.onOversize}
onDelete={this.onDelete}
/>
);
},
});

View File

@ -1,184 +0,0 @@
// Utils
import { createNamespace } from '../../utils';
import { isEmail } from '../../utils/validate/email';
import { isNumeric } from '../../utils/validate/number';
// Components
import Cell from '../../cell';
import Field from '../../field';
import SkuImgUploader from './SkuImgUploader';
import SkuDateTimeField from './SkuDateTimeField';
const [createComponent, bem, t] = createNamespace('sku-messages');
export default createComponent({
props: {
messageConfig: Object,
goodsId: [Number, String],
messages: {
type: Array,
default: () => [],
},
},
data() {
return {
messageValues: this.resetMessageValues(this.messages),
};
},
watch: {
messages(val) {
this.messageValues = this.resetMessageValues(val);
},
},
methods: {
resetMessageValues(messages) {
const { messageConfig } = this;
const { initialMessages = {} } = messageConfig;
return (messages || []).map((message) => ({
value: initialMessages[message.name] || '',
}));
},
getType(message) {
if (+message.multiple === 1) {
return 'textarea';
}
if (message.type === 'id_no') {
return 'text';
}
return message.datetime > 0 ? 'datetime' : message.type;
},
getMessages() {
const messages = {};
this.messageValues.forEach((item, index) => {
messages[`message_${index}`] = item.value;
});
return messages;
},
getCartMessages() {
const messages = {};
this.messageValues.forEach((item, index) => {
const message = this.messages[index];
messages[message.name] = item.value;
});
return messages;
},
getPlaceholder(message) {
const type = +message.multiple === 1 ? 'textarea' : message.type;
const map = this.messageConfig.placeholderMap || {};
return message.placeholder || map[type] || t(`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') {
const textType = t(message.type === 'image' ? 'upload' : 'fill');
return textType + message.name;
}
} else {
if (message.type === 'tel' && !isNumeric(value)) {
return t('invalid.tel');
}
if (message.type === 'mobile' && !/^\d{6,20}$/.test(value)) {
return t('invalid.mobile');
}
if (message.type === 'email' && !isEmail(value)) {
return t('invalid.email');
}
if (
message.type === 'id_no' &&
(value.length < 15 || value.length > 18)
) {
return t('invalid.id_no');
}
}
}
},
/**
* The phone number copied from IOS mobile phone address book
* will add spaces and invisible Unicode characters
* which cannot pass the /^\d+$/ verification
* so keep numbers and dots
*/
getFormatter(message) {
return function formatter(value) {
if (message.type === 'mobile' || message.type === 'tel') {
return value.replace(/[^\d.]/g, '');
}
return value;
};
},
genMessage(message, index) {
if (message.type === 'image') {
return (
<Cell
key={`${this.goodsId}-${index}`}
title={message.name}
class={bem('image-cell')}
required={String(message.required) === '1'}
valueClass={bem('image-cell-value')}
>
<SkuImgUploader
vModel={this.messageValues[index].value}
maxSize={this.messageConfig.uploadMaxSize}
uploadImg={this.messageConfig.uploadImg}
/>
<div class={bem('image-cell-label')}>{t('imageLabel')}</div>
</Cell>
);
}
// 时间和日期使用的vant选择器
const isDateOrTime = ['date', 'time'].indexOf(message.type) > -1;
if (isDateOrTime) {
return (
<SkuDateTimeField
vModel={this.messageValues[index].value}
label={message.name}
key={`${this.goodsId}-${index}`}
required={String(message.required) === '1'}
placeholder={this.getPlaceholder(message)}
type={this.getType(message)}
/>
);
}
return (
<Field
vModel={this.messageValues[index].value}
maxlength="200"
center={!message.multiple}
label={message.name}
key={`${this.goodsId}-${index}`}
required={String(message.required) === '1'}
placeholder={this.getPlaceholder(message)}
type={this.getType(message)}
formatter={this.getFormatter(message)}
/>
);
},
},
render() {
return <div class={bem()}>{this.messages.map(this.genMessage)}</div>;
},
});

View File

@ -1,122 +0,0 @@
// Utils
import { createNamespace } from '../../utils';
import { BORDER_BOTTOM } from '../../utils/constant';
// Mixins
import { ParentMixin } from '../../mixins/relation';
import { BindEventMixin } from '../../mixins/bind-event';
const [createComponent, bem, t] = createNamespace('sku-row');
export { bem };
export default createComponent({
mixins: [
ParentMixin('vanSkuRows'),
BindEventMixin(function (bind) {
if (this.scrollable && this.$refs.scroller) {
bind(this.$refs.scroller, 'scroll', this.onScroll);
}
}),
],
props: {
skuRow: Object,
},
data() {
return {
progress: 0,
};
},
computed: {
scrollable() {
return this.skuRow.largeImageMode && this.skuRow.v.length > 6;
},
},
methods: {
onScroll() {
const { scroller, row } = this.$refs;
const distance = row.offsetWidth - scroller.offsetWidth;
this.progress = scroller.scrollLeft / distance;
},
genTitle() {
return (
<div class={bem('title')}>
{this.skuRow.k}
{this.skuRow.is_multiple && (
<span class={bem('title-multiple')}>{t('multiple')}</span>
)}
</div>
);
},
genIndicator() {
if (this.scrollable) {
const style = {
transform: `translate3d(${this.progress * 20}px, 0, 0)`,
};
return (
<div class={bem('indicator-wrapper')}>
<div class={bem('indicator')}>
<div class={bem('indicator-slider')} style={style} />
</div>
</div>
);
}
},
genContent() {
const nodes = this.slots();
if (this.skuRow.largeImageMode) {
const top = [];
const bottom = [];
nodes.forEach((node, index) => {
const group = Math.floor(index / 3) % 2 === 0 ? top : bottom;
group.push(node);
});
return (
<div class={bem('scroller')} ref="scroller">
<div class={bem('row')} ref="row">
{top}
</div>
{bottom.length ? <div class={bem('row')}>{bottom}</div> : null}
</div>
);
}
return nodes;
},
centerItem(selectSkuId) {
if (!this.skuRow.largeImageMode || !selectSkuId) {
return;
}
const { children = [] } = this;
const { scroller, row } = this.$refs;
const child = children.find((it) => +it.skuValue.id === +selectSkuId);
if (scroller && row && child && child.$el) {
const target = child.$el;
const to =
target.offsetLeft - (scroller.offsetWidth - target.offsetWidth) / 2;
scroller.scrollLeft = to;
}
},
},
render() {
return (
<div class={[bem(), BORDER_BOTTOM]}>
{this.genTitle()}
{this.genContent()}
{this.genIndicator()}
</div>
);
},
});

View File

@ -1,109 +0,0 @@
import { bem } from './SkuRow';
import { createNamespace } from '../../utils';
import { isSkuChoosable } from '../utils/sku-helper';
import { ChildrenMixin } from '../../mixins/relation';
import Image from '../../image';
const [createComponent] = createNamespace('sku-row-item');
export default createComponent({
mixins: [ChildrenMixin('vanSkuRows')],
props: {
lazyLoad: Boolean,
skuValue: Object,
skuKeyStr: String,
skuEventBus: Object,
selectedSku: Object,
largeImageMode: Boolean,
skuList: {
type: Array,
default: () => [],
},
},
computed: {
imgUrl() {
const url = this.skuValue.imgUrl || this.skuValue.img_url;
return this.largeImageMode
? url ||
'https://img.yzcdn.cn/upload_files/2020/06/24/FmKWDg0bN9rMcTp9ne8MXiQWGtLn.png'
: url;
},
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,
});
}
},
onPreviewImg(event) {
event.stopPropagation();
const { skuValue, skuKeyStr } = this;
this.skuEventBus.$emit('sku:previewImage', {
...skuValue,
ks: skuKeyStr,
imgUrl: skuValue.imgUrl || skuValue.img_url,
});
},
genImage(classPrefix) {
if (this.imgUrl) {
return (
<Image
fit="cover"
src={this.imgUrl}
class={`${classPrefix}-img`}
lazyLoad={this.lazyLoad}
/>
);
}
},
},
render() {
const choosed = this.skuValue.id === this.selectedSku[this.skuKeyStr];
const classPrefix = this.largeImageMode ? bem('image-item') : bem('item');
return (
<span
class={[
classPrefix,
choosed ? `${classPrefix}--active` : '',
!this.choosable ? `${classPrefix}--disabled` : '',
]}
onClick={this.onSelect}
>
{this.genImage(classPrefix)}
<div class={`${classPrefix}-name`}>
{this.largeImageMode ? (
<span class={{ 'van-multi-ellipsis--l2': this.largeImageMode }}>
{this.skuValue.name}
</span>
) : (
this.skuValue.name
)}
</div>
{this.largeImageMode && (
<img
class={`${classPrefix}-img-icon`}
src="https://img.yzcdn.cn/upload_files/2020/07/02/Fu4_ya0l0aAt4Mv4PL9jzPzfZnDX.png"
onClick={this.onPreviewImg}
/>
)}
</span>
);
},
});

View File

@ -1,49 +0,0 @@
import { createNamespace } from '../../utils';
const [createComponent] = createNamespace('sku-row-prop-item');
export default createComponent({
props: {
skuValue: Object,
skuKeyStr: String,
skuEventBus: Object,
selectedProp: Object,
multiple: Boolean,
},
computed: {
choosed() {
const { selectedProp, skuKeyStr, skuValue } = this;
if (selectedProp && selectedProp[skuKeyStr]) {
return selectedProp[skuKeyStr].indexOf(skuValue.id) > -1;
}
return false;
},
},
methods: {
onSelect() {
this.skuEventBus.$emit('sku:propSelect', {
...this.skuValue,
skuKeyStr: this.skuKeyStr,
multiple: this.multiple,
});
},
},
render() {
return (
<span
class={[
'van-sku-row__item',
{ 'van-sku-row__item--active': this.choosed },
]}
onClick={this.onSelect}
>
<span class="van-sku-row__item-name">{this.skuValue.name}</span>
</span>
);
},
});

View File

@ -1,178 +0,0 @@
import { createNamespace } from '../../utils';
import { LIMIT_TYPE } from '../constants';
import Stepper from '../../stepper';
const namespace = createNamespace('sku-stepper');
const createComponent = namespace[0];
const t = namespace[2];
const { QUOTA_LIMIT, STOCK_LIMIT } = LIMIT_TYPE;
export default createComponent({
props: {
stock: Number,
skuEventBus: Object,
skuStockNum: Number,
selectedNum: Number,
stepperTitle: String,
disableStepperInput: Boolean,
customStepperConfig: Object,
hideQuotaText: Boolean,
quota: {
type: Number,
default: 0,
},
quotaUsed: {
type: Number,
default: 0,
},
startSaleNum: {
type: Number,
default: 1,
},
},
data() {
return {
currentNum: this.selectedNum,
// 购买限制类型: 限购/库存
limitType: STOCK_LIMIT,
};
},
watch: {
currentNum(num) {
const intValue = parseInt(num, 10);
if (intValue >= this.stepperMinLimit && intValue <= this.stepperLimit) {
this.skuEventBus.$emit('sku:numChange', intValue);
}
},
stepperLimit(limit) {
if (limit < this.currentNum && this.stepperMinLimit <= limit) {
this.currentNum = limit;
}
this.checkState(this.stepperMinLimit, limit);
},
stepperMinLimit(start) {
if (start > this.currentNum || start > this.stepperLimit) {
this.currentNum = start;
}
this.checkState(start, this.stepperLimit);
},
},
computed: {
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;
},
stepperMinLimit() {
return this.startSaleNum < 1 ? 1 : this.startSaleNum;
},
quotaText() {
const { quotaText, hideQuotaText } = this.customStepperConfig;
if (hideQuotaText) return '';
let text = '';
if (quotaText) {
text = quotaText;
} else {
const textArr = [];
if (this.startSaleNum > 1) {
textArr.push(t('quotaStart', this.startSaleNum));
}
if (this.quota > 0) {
textArr.push(t('quotaLimit', this.quota));
}
text = textArr.join(t('comma'));
}
return text;
},
},
created() {
this.checkState(this.stepperMinLimit, this.stepperLimit);
},
methods: {
setCurrentNum(num) {
this.currentNum = num;
this.checkState(this.stepperMinLimit, this.stepperLimit);
},
onOverLimit(action) {
this.skuEventBus.$emit('sku:overLimit', {
action,
limitType: this.limitType,
quota: this.quota,
quotaUsed: this.quotaUsed,
startSaleNum: this.startSaleNum,
});
},
onChange(currentValue) {
const intValue = parseInt(currentValue, 10);
const { handleStepperChange } = this.customStepperConfig;
handleStepperChange && handleStepperChange(intValue);
this.$emit('change', intValue);
},
checkState(min, max) {
// 如果选择小于起售,则强制变为起售
if (this.currentNum < min || min > max) {
this.currentNum = min;
} else if (this.currentNum > max) {
// 当前选择数量大于最大可选时,需要重置已选数量
this.currentNum = max;
}
this.skuEventBus.$emit('sku:stepperState', {
valid: min <= max,
min,
max,
limitType: this.limitType,
quota: this.quota,
quotaUsed: this.quotaUsed,
startSaleNum: this.startSaleNum,
});
},
},
render() {
return (
<div class="van-sku-stepper-stock">
<div class="van-sku__stepper-title">
{this.stepperTitle || t('num')}
</div>
<Stepper
vModel={this.currentNum}
integer
class="van-sku__stepper"
min={this.stepperMinLimit}
max={this.stepperLimit}
disableInput={this.disableStepperInput}
onOverlimit={this.onOverLimit}
onChange={this.onChange}
/>
{!this.hideQuotaText && this.quotaText && (
<span class="van-sku__stepper-quota">({this.quotaText})</span>
)}
</div>
);
},
});

View File

@ -1,11 +0,0 @@
export const LIMIT_TYPE = {
QUOTA_LIMIT: 0,
STOCK_LIMIT: 1,
};
export const UNSELECTED_SKU_VALUE_ID = '';
export default {
LIMIT_TYPE,
UNSELECTED_SKU_VALUE_ID,
};

View File

@ -1,193 +0,0 @@
export function getSkuData(largeImageMode = false) {
return {
goods_id: '1',
quota: 5,
quota_used: 0,
start_sale_num: 2,
goods_info: {
price: 1,
title: '测试商品',
picture: 'https://b.yzcdn.cn/vant/sku/shoes-1.png',
},
sku: {
price: '1.00',
stock_num: 227,
none_sku: false,
hide_stock: false,
collection_id: 2261,
tree: [
{
k: '颜色',
k_s: 's1',
k_id: '1',
v: [
{
id: '1',
name: '粉色',
imgUrl: 'https://b.yzcdn.cn/vant/sku/shoes-1.png',
},
{
id: '2',
name: '黄色',
imgUrl: 'https://b.yzcdn.cn/vant/sku/shoes-2.png',
},
{
id: '3',
name: '蓝色',
imgUrl: 'https://b.yzcdn.cn/vant/sku/shoes-3.png',
},
],
largeImageMode,
},
{
k: '尺寸',
k_s: 's2',
k_id: '2',
v: [
{
id: '1',
name: '大',
},
{
id: '2',
name: '小',
},
],
},
],
list: [
{
id: 2259,
s1: '2',
s2: '1',
price: 100,
discount: 100,
stock_num: 110,
},
{
id: 2260,
s1: '3',
s2: '1',
price: 100,
discount: 100,
stock_num: 99,
},
{
id: 2257,
s1: '1',
s2: '1',
price: 100,
discount: 100,
stock_num: 111,
},
{
id: 2258,
s1: '1',
s2: '2',
price: 100,
discount: 100,
stock_num: 6,
},
],
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,
},
],
},
properties: [
{
k: '加料',
k_id: 124,
is_multiple: true,
v: [
{
id: 1224,
name: '布丁',
price: 3,
},
{
id: 1225,
name: '波霸',
price: 4,
},
{
id: 1226,
name: '珍珠',
price: 5,
},
],
},
],
};
}
export const initialSku = {
s1: '1',
s2: '1',
selectedNum: 3,
selectedProp: {
124: [1225, 1226],
},
};

View File

@ -1,277 +0,0 @@
<template>
<demo-section>
<demo-block :title="t('basicUsage')">
<div class="sku-container">
<van-sku
v-model="showBase"
:sku="skuData.sku"
:quota="skuData.quota"
:goods="skuData.goods_info"
:goods-id="skuData.goods_id"
:quota-used="skuData.quota_used"
:properties="skuData.properties"
:hide-stock="skuData.sku.hide_stock"
:message-config="messageConfig"
:start-sale-num="skuData.start_sale_num"
:custom-sku-validator="customSkuValidator"
disable-stepper-input
reset-stepper-on-hide
safe-area-inset-bottom
reset-selected-sku-on-hide
@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('customStepper')">
<div class="sku-container">
<van-sku
v-model="showStepper"
:sku="skuData.sku"
:quota="skuData.quota"
:goods="skuData.goods_info"
:goods-id="skuData.goods_id"
:quota-used="skuData.quota_used"
:properties="skuData.properties"
:hide-stock="skuData.sku.hide_stock"
:start-sale-num="skuData.start_sale_num"
:message-config="messageConfig"
:custom-stepper-config="customStepperConfig"
hide-quota-text
safe-area-inset-bottom
@buy-clicked="onBuyClicked"
@add-cart="onAddCartClicked"
/>
<van-button block type="primary" @click="showStepper = true">
{{ t('customStepper') }}
</van-button>
</div>
</demo-block>
<demo-block :title="t('hideSoldoutSku')">
<div class="sku-container">
<van-sku
v-model="showSoldout"
:sku="skuData.sku"
:quota="skuData.quota"
:goods="skuData.goods_info"
:goods-id="skuData.goods_id"
:quota-used="skuData.quota_used"
:properties="skuData.properties"
:hide-stock="skuData.sku.hide_stock"
:message-config="messageConfig"
:start-sale-num="skuData.start_sale_num"
:show-soldout-sku="false"
:custom-stepper-config="customStepperConfig"
hide-quota-text
safe-area-inset-bottom
@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('largeImageMode')">
<div class="sku-container">
<van-sku
v-model="showLargePicturePreview"
:sku="skuData2.sku"
:quota="skuData2.quota"
:goods="skuData2.goods_info"
:goods-id="skuData2.goods_id"
:hide-stock="skuData2.sku.hide_stock"
:properties="skuData2.properties"
:quota-used="skuData2.quota_used"
:message-config="messageConfig"
:start-sale-num="skuData2.start_sale_num"
:show-header-image="false"
:custom-sku-validator="customSkuValidator"
disable-stepper-input
reset-stepper-on-hide
safe-area-inset-bottom
reset-selected-sku-on-hide
@buy-clicked="onBuyClicked"
@add-cart="onAddCartClicked"
>
</van-sku>
<van-button
block
type="primary"
@click="showLargePicturePreview = true"
>
{{ t('largeImageMode') }}
</van-button>
</div>
</demo-block>
<demo-block :title="t('customBySlot')">
<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"
:start-sale-num="skuData.start_sale_num"
:properties="skuData.properties"
show-add-cart-btn
reset-stepper-on-hide
safe-area-inset-bottom
: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-top>
<div class="van-sku-header-item text-center">
{{ t('actionsTop') }}
</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="skuEventBus.$emit('sku:buy')"
>
{{ t('button2') }}
</van-button>
</div>
</template>
</van-sku>
<van-button block type="primary" @click="showCustom = true">
{{ t('customBySlot') }}
</van-button>
</div>
</demo-block>
</demo-section>
</template>
<script>
import { initialSku, getSkuData } from './data';
import { LIMIT_TYPE } from '../constants';
export default {
i18n: {
'zh-CN': {
button1: '积分兑换',
button2: '买买买',
actionsTop: '商品不多,赶快购买吧',
stepperTitle: '我要买',
customBySlot: '通过插槽定制',
customStepper: '自定义步进器',
hideSoldoutSku: '隐藏售罄规格',
largeImageMode: '大图预览模式',
},
'en-US': {
button1: 'Button',
button2: 'Button',
actionsTop: 'Action top info',
customBySlot: 'Custom By Slot',
stepperTitle: 'Stepper title',
customStepper: 'Custom Stepper',
hideSoldoutSku: 'Hide Soldout Sku',
largeImageMode: 'Large Image Mode',
},
},
data() {
this.skuData = getSkuData();
this.skuData2 = getSkuData(true);
this.initialSku = initialSku;
return {
showBase: false,
showCustom: false,
showStepper: false,
showSoldout: false,
showLargePicturePreview: false,
customSkuValidator: () => '请选择xxx',
customStepperConfig: {
quotaText: '单次限购100件',
stockFormatter: (stock) => `剩余${stock}`,
handleOverLimit: (data) => {
const { action, limitType, quota, startSaleNum = 1 } = data;
if (action === 'minus') {
this.$toast(
startSaleNum > 1 ? `${startSaleNum}件起售` : '至少选择一件商品'
);
} else if (action === 'plus') {
if (limitType === LIMIT_TYPE.QUOTA_LIMIT) {
this.$toast(`限购${quota}`);
} else {
this.$toast('库存不够了');
}
}
},
},
messageConfig: {
initialMessages: {
留言1: '商品留言',
},
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 @padding-md;
}
.text-center {
text-align: center;
}
}
</style>

View File

@ -1,31 +0,0 @@
// Utils
import lang from './lang';
import constants from './constants';
import skuHelper from './utils/sku-helper';
// Components
import Sku from './Sku';
import Locale from '../locale';
import SkuActions from './components/SkuActions';
import SkuHeader from './components/SkuHeader';
import SkuHeaderItem from './components/SkuHeaderItem';
import SkuMessages from './components/SkuMessages';
import SkuStepper from './components/SkuStepper';
import SkuRow from './components/SkuRow';
import SkuRowItem from './components/SkuRowItem';
import SkuRowPropItem from './components/SkuRowPropItem';
Locale.add(lang);
Sku.SkuActions = SkuActions;
Sku.SkuHeader = SkuHeader;
Sku.SkuHeaderItem = SkuHeaderItem;
Sku.SkuMessages = SkuMessages;
Sku.SkuStepper = SkuStepper;
Sku.SkuRow = SkuRow;
Sku.SkuRowItem = SkuRowItem;
Sku.SkuRowPropItem = SkuRowPropItem;
Sku.skuHelper = skuHelper;
Sku.skuConstants = constants;
export default Sku;

View File

@ -1,373 +0,0 @@
@import '../style/var';
@import '../style/mixins/clearfix';
.van-sku {
&-container {
display: flex;
flex-direction: column;
align-items: stretch;
min-height: 50%;
max-height: 80%;
overflow-y: visible;
font-size: @font-size-md;
background: @white;
}
&-body {
flex: 1 1 auto;
min-height: 44px;
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
display: none;
}
}
&-header {
display: flex;
flex-shrink: 0;
margin: 0 @padding-md;
&__img-wrap {
flex-shrink: 0;
width: 96px;
height: 96px;
margin: @padding-sm @padding-sm @padding-sm 0;
overflow: hidden;
border-radius: @border-radius-md;
}
&__goods-info {
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: @padding-sm 20px @padding-sm 0;
}
}
&-header-item {
margin-top: @padding-xs;
color: @gray-6;
font-size: @font-size-sm;
line-height: 16px;
}
&__price-symbol {
font-size: @font-size-lg;
vertical-align: bottom;
}
&__price-num {
font-weight: @font-weight-bold;
font-size: 22px;
vertical-align: bottom;
word-wrap: break-word;
}
&__goods-price {
// for price align
margin-left: -2px;
color: @red;
}
&__price-tag {
position: relative;
display: inline-block;
margin-left: @padding-xs;
padding: 0 5px;
overflow: hidden;
color: @red;
font-size: @font-size-sm;
line-height: 16px;
border-radius: 8px;
&::before {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: currentColor;
opacity: 0.1;
content: '';
}
}
&-group-container {
padding-top: @padding-sm;
&--hide-soldout {
.van-sku-row__item--disabled {
display: none;
}
}
}
/* sku row */
&-row {
margin: 0 @padding-md @padding-sm;
&:last-child {
margin-bottom: 0;
}
&__item,
&__image-item {
position: relative;
overflow: hidden;
color: @text-color;
border-radius: @border-radius-md;
cursor: pointer;
&::before {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: @sku-item-background-color;
content: '';
}
&--active {
color: @red;
&::before {
background: currentColor;
opacity: 0.1;
}
}
}
&__item {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
margin: 0 @padding-sm @padding-sm 0;
font-size: 13px;
line-height: 16px;
vertical-align: middle;
&-img {
z-index: 1;
width: 24px;
height: 24px;
margin: 4px 0 4px 4px;
object-fit: cover;
border-radius: @border-radius-sm;
}
&-name {
z-index: 1;
padding: @padding-xs;
}
&--disabled {
color: @gray-5;
background: @active-color;
cursor: not-allowed;
.van-sku-row__item-img {
opacity: 0.3;
}
}
}
&__image {
margin-right: 0;
&-item {
display: flex;
flex-direction: column;
width: 110px;
margin: 0 4px 4px 0;
border: 1px solid transparent;
&:last-child {
margin-right: 0;
}
&-img {
width: 100%;
height: 110px;
&-icon {
position: absolute;
top: 0;
right: 0;
z-index: 3;
width: 18px;
height: 18px;
}
}
&-name {
position: relative;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 40px;
padding: @padding-base;
font-size: 12px;
line-height: 16px;
span {
word-wrap: break-word;
}
}
&--active {
border-color: currentColor;
}
&--disabled {
color: @gray-5;
cursor: not-allowed;
&::before {
z-index: 2;
background: @active-color;
opacity: 0.4;
}
}
}
}
&__title {
padding-bottom: @padding-sm;
}
&__title-multiple {
color: @gray-6;
}
&__scroller {
margin: 0 -@padding-md;
overflow-x: scroll;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
display: none;
}
}
&__row {
display: inline-flex;
margin-bottom: 4px;
padding: 0 @padding-md;
}
&__indicator {
width: 40px;
height: 4px;
background: @gray-3;
border-radius: 2px;
&-wrapper {
display: flex;
justify-content: center;
padding-bottom: 16px;
}
&-slider {
width: 50%;
height: 100%;
background-color: @red;
border-radius: 2px;
}
}
}
&-stepper-stock {
padding: @padding-sm @padding-md;
overflow: hidden;
line-height: 30px;
}
&__stepper {
float: right;
padding-left: @padding-base;
&-title {
float: left;
}
&-quota {
float: right;
color: @red;
font-size: @font-size-sm;
}
}
&__stock {
display: inline-block;
margin-right: @padding-xs;
color: @gray-6;
font-size: @font-size-sm;
&-num--highlight {
color: @red;
}
}
&-messages {
padding-bottom: @padding-xl;
&__image-cell {
.van-cell__title {
max-width: @field-label-width;
margin-right: @field-label-margin-right;
color: @field-label-color;
text-align: left;
word-wrap: break-word;
}
.van-cell__value {
overflow: visible;
text-align: left;
}
&-label {
color: @cell-label-color;
font-size: @cell-label-font-size;
line-height: @cell-label-line-height;
}
}
}
&-actions {
display: flex;
flex-shrink: 0;
padding: @padding-xs @padding-md;
.van-button {
height: 40px;
font-weight: @font-weight-bold;
font-size: @font-size-md;
border: none;
border-radius: 0;
&:first-of-type {
border-top-left-radius: 20px;
border-bottom-left-radius: 20px;
}
&:last-of-type {
border-top-right-radius: 20px;
border-bottom-right-radius: 20px;
}
&--warning {
background: @gradient-orange;
}
&--danger {
background: @gradient-red;
}
}
}
}

View File

@ -1,77 +0,0 @@
/**
* Sku only provide zh-CN lang by default
*/
export default {
'zh-CN': {
vanSku: {
select: '请选择',
selected: '已选',
selectSku: '请先选择商品规格',
soldout: '库存不足',
originPrice: '原价',
minusTip: '至少选择一件',
minusStartTip: (start: number) => `${start}件起售`,
unavailable: '商品已经无法购买啦',
stock: '剩余',
stockUnit: '件',
quotaTip: (quota: number) => `每人限购${quota}`,
quotaUsedTip: (quota: number, count: number) =>
`每人限购${quota}件,你已购买${count}`,
},
vanSkuActions: {
buy: '立即购买',
addCart: '加入购物车',
},
vanSkuImgUploader: {
oversize: (maxSize: number) =>
`最大可上传图片为${maxSize}MB请尝试压缩图片尺寸`,
fail: '上传失败',
uploading: '上传中...',
},
vanSkuStepper: {
quotaLimit: (quota: number) => `限购${quota}`,
quotaStart: (start: number) => `${start}件起售`,
comma: '',
num: '购买数量',
},
vanSkuMessages: {
fill: '请填写',
upload: '请上传',
imageLabel: '仅限一张',
invalid: {
tel: '请填写正确的数字格式留言',
mobile: '手机号长度为6-20位数字',
email: '请填写正确的邮箱',
id_no: '请填写正确的身份证号码',
},
placeholder: {
id_no: '请填写身份证号',
text: '请填写留言',
tel: '请填写数字',
email: '请填写邮箱',
date: '请选择日期',
time: '请选择时间',
textarea: '请填写留言',
mobile: '请填写手机号',
},
},
vanSkuRow: {
multiple: '可多选',
},
vanSkuDatetimeField: {
title: {
date: '选择年月日',
time: '选择时间',
datetime: '选择日期时间',
},
format: {
year: '年',
month: '月',
day: '日',
hour: '时',
minute: '分',
},
},
},
};

View File

@ -1,46 +0,0 @@
// 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">
<div class="van-button__content"><span class="van-button__text">
基础用法
</span></div>
</button></div>
</div>
<div>
<div class="sku-container">
<!----> <button class="van-button van-button--primary van-button--normal van-button--block">
<div class="van-button__content"><span class="van-button__text">
自定义步进器
</span></div>
</button></div>
</div>
<div>
<div class="sku-container">
<!----> <button class="van-button van-button--primary van-button--normal van-button--block">
<div class="van-button__content"><span class="van-button__text">
隐藏售罄规格
</span></div>
</button></div>
</div>
<div>
<div class="sku-container">
<!----> <button class="van-button van-button--primary van-button--normal van-button--block">
<div class="van-button__content"><span class="van-button__text">
大图预览模式
</span></div>
</button></div>
</div>
<div>
<div class="sku-container">
<!----> <button class="van-button van-button--primary van-button--normal van-button--block">
<div class="van-button__content"><span class="van-button__text">
通过插槽定制
</span></div>
</button></div>
</div>
</div>
`;

View File

@ -1,4 +0,0 @@
import Demo from '../demo';
import { snapshotDemo } from '../../../test/demo';
snapshotDemo(Demo);

View File

@ -1,87 +0,0 @@
import { mount } from '../../../test';
import Sku from '..';
import { getSkuData, initialSku } from '../demo/data';
import { stringToDate, dateToString } from '../utils/time-helper';
const skuData = getSkuData();
test('resetSelectedSku method', () => {
skuData.sku.messages = [];
const wrapper = mount(Sku, {
propsData: {
value: true,
initialSku,
sku: skuData.sku,
quota: skuData.quota,
goods: skuData.goods_info,
goodsId: skuData.goods_id,
quotaUsed: skuData.quota_used,
hideStock: skuData.sku.hide_stock,
startSaleNum: skuData.start_sale_num,
},
});
wrapper.find('.van-button--danger').trigger('click');
expect(wrapper.emitted('buy-clicked').length).toEqual(1);
wrapper.setProps({ initialSku: {} });
wrapper.vm.resetSelectedSku();
wrapper.find('.van-button--danger').trigger('click');
expect(wrapper.emitted('buy-clicked').length).toEqual(1);
});
test('message formatter', () => {
const skuData = getSkuData();
skuData.sku.messages = skuData.sku.messages.filter(
message => message.type === 'tel'
);
const wrapper = mount(Sku, {
propsData: {
value: true,
initialSku,
sku: skuData.sku,
quota: skuData.quota,
goods: skuData.goods_info,
goodsId: skuData.goods_id,
quotaUsed: skuData.quota_used,
hideStock: skuData.sku.hide_stock,
startSaleNum: skuData.start_sale_num,
},
});
const input = wrapper.find('input');
const correctValue = '15000000000';
// \u202c
input.element.value = '15000000000';
input.trigger('input');
expect(input.element.value).toEqual(correctValue);
// \u0020
input.element.value = '150 0000 0000';
input.trigger('input');
expect(input.element.value).toEqual(correctValue);
// /[a-zA-z]/
input.element.value = 'a-zA-z';
input.trigger('input');
expect(input.element.value).toEqual('');
});
test('stringToDate', () => {
expect(dateToString(stringToDate(''))).toEqual('');
expect(dateToString(stringToDate('2020-07-01'))).toEqual('2020-07-01');
expect(dateToString(stringToDate('2020-07-01 22:44'), 'datetime')).toEqual(
'2020-07-01 22:44'
);
expect(dateToString(stringToDate('2020-12-31 23:59'), 'datetime')).toEqual(
'2020-12-31 23:59'
);
});

View File

@ -1,158 +0,0 @@
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;
};
export const normalizePropList = (propList) => {
const normalizedProp = {};
propList.forEach((item) => {
const itemObj = {};
item.v.forEach((it) => {
itemObj[it.id] = it;
});
normalizedProp[item.k_id] = itemObj;
});
return normalizedProp;
};
// 判断是否所有的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 = {
...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 const getSelectedPropValues = (propList, selectedProp) => {
const normalizeProp = normalizePropList(propList);
return Object.keys(selectedProp).reduce((acc, cur) => {
selectedProp[cur].forEach((it) => {
acc.push({
...normalizeProp[cur][it],
});
});
return acc;
}, []);
};
export const getSelectedProperties = (propList, selectedProp) => {
const list = [];
(propList || []).forEach((prop) => {
if (selectedProp[prop.k_id] && selectedProp[prop.k_id].length > 0) {
const v = [];
prop.v.forEach((it) => {
if (selectedProp[prop.k_id].indexOf(it.id) > -1) {
v.push({ ...it });
}
});
list.push({
...prop,
v,
});
}
});
return list;
};
export default {
normalizeSkuTree,
getSkuComb,
getSelectedSkuValues,
isAllSelected,
isSkuChoosable,
getSelectedPropValues,
getSelectedProperties,
};

View File

@ -1,28 +0,0 @@
import { padZero } from '../../utils/format/string';
// 字符串转 Date
// 只处理 YYYY-MM-DD 或者 YYYY-MM-DD HH:MM 格式
export function stringToDate(timeString) {
if (!timeString) {
return null;
}
return new Date(timeString.replace(/-/g, '/'));
}
// Date 转字符串
// type: date or datetime
export function dateToString(date, type = 'date') {
if (!date) {
return '';
}
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
let timeString = `${year}-${padZero(month)}-${padZero(day)}`;
if (type === 'datetime') {
const hours = date.getHours();
const minute = date.getMinutes();
timeString += ` ${padZero(hours)}:${padZero(minute)}`;
}
return timeString;
}