diff --git a/docs/site/doc.config.js b/docs/site/doc.config.js
index b2d4abe78..1135945d0 100644
--- a/docs/site/doc.config.js
+++ b/docs/site/doc.config.js
@@ -227,6 +227,10 @@ export default {
path: '/collapse',
title: 'Collapse 折叠面板'
},
+ {
+ path: '/count-down',
+ title: 'CountDown 倒计时'
+ },
{
path: '/divider',
title: 'Divider 分割线'
@@ -561,6 +565,10 @@ export default {
path: '/collapse',
title: 'Collapse'
},
+ {
+ path: '/count-down',
+ title: 'CountDown'
+ },
{
path: '/divider',
title: 'Divider'
diff --git a/src/checkbox/README.zh-CN.md b/src/checkbox/README.zh-CN.md
index ed47033fb..90f7d4d83 100644
--- a/src/checkbox/README.zh-CN.md
+++ b/src/checkbox/README.zh-CN.md
@@ -179,7 +179,7 @@ export default {
### Checkbox Slots
-| 名称 | 说明 | slot-scope |
+| 名称 | 说明 | slot-scope 参数 |
|------|------|------|
| default | 自定义文本 | - |
| icon | 自定义图标 | checked: 是否为选中状态 |
diff --git a/src/count-down/README.md b/src/count-down/README.md
new file mode 100644
index 000000000..9d7440746
--- /dev/null
+++ b/src/count-down/README.md
@@ -0,0 +1,114 @@
+# CountDown
+
+### Install
+
+``` javascript
+import { CountDown } from 'vant';
+
+Vue.use(CountDown);
+```
+
+## Usage
+
+### Basic Usage
+
+```html
+
+```
+
+```js
+export default {
+ data() {
+ return {
+ time: 30 * 60 * 60 * 1000
+ };
+ }
+}
+```
+
+### Custom Format
+
+```html
+
+```
+
+### Millisecond
+
+```html
+
+```
+
+### Custom Style
+
+```html
+
+
+ {{ timeData.hours }}
+ {{ timeData.minutes }}
+ {{ timeData.seconds }}
+
+
+
+
+```
+
+### Manual Control
+
+```html
+
+
+
+
+
+
+```
+
+```js
+export default {
+ methods: {
+ start() {
+ this.$refs.countDown.start();
+ },
+ pause() {
+ this.$refs.countDown.pause();
+ },
+ reset() {
+ this.$refs.countDown.reset();
+ },
+ finish() {
+ this.$toast('Finished');
+ }
+ }
+}
+```
+
+## API
+
+### Props
+
+| Attribute | Description | Type | Default |
+|------|------|------|------|
diff --git a/src/count-down/README.zh-CN.md b/src/count-down/README.zh-CN.md
new file mode 100644
index 000000000..316f94d13
--- /dev/null
+++ b/src/count-down/README.zh-CN.md
@@ -0,0 +1,160 @@
+# CountDown 倒计时
+
+### 引入
+
+``` javascript
+import { CountDown } from 'vant';
+
+Vue.use(CountDown);
+```
+
+## 代码演示
+
+### 基本用法
+
+`time`属性表示倒计时总时长,单位为毫秒
+
+```html
+
+```
+
+```js
+export default {
+ data() {
+ return {
+ time: 30 * 60 * 60 * 1000
+ };
+ }
+}
+```
+
+### 自定义格式
+
+通过`format`属性设置倒计时文本的内容
+
+```html
+
+```
+
+### 毫秒级渲染
+
+倒计时默认每秒渲染一次,设置`millisecond`属性可以开启毫秒级渲染
+
+```html
+
+```
+
+### 自定义样式
+
+通过插槽自定义倒计时的样式,`timeData`对象格式见下方表格
+
+```html
+
+
+ {{ timeData.hours }}
+ {{ timeData.minutes }}
+ {{ timeData.seconds }}
+
+
+
+
+```
+
+### 手动控制
+
+通过 ref 获取到组件实例后,可以调用`start`、`pause`、`reset`方法
+
+```html
+
+
+
+
+
+
+```
+
+```js
+export default {
+ methods: {
+ start() {
+ this.$refs.countDown.start();
+ },
+ pause() {
+ this.$refs.countDown.pause();
+ },
+ reset() {
+ this.$refs.countDown.reset();
+ },
+ finish() {
+ this.$toast('倒计时结束');
+ }
+ }
+}
+```
+
+## API
+
+### Props
+
+| 参数 | 说明 | 类型 | 默认值 | 版本 |
+|------|------|------|------|------|
+| time | 倒计时时长,单位毫秒 | `Number` | - | - |
+| format | 时间格式,DD-日,HH-时,mm-分,ss-秒,SSS-毫秒 | `String` | `HH:mm:ss` | - |
+| auto-start | 是否自动开始倒计时 | `Boolean` | `true` | - |
+| millisecond | 是否开启毫秒级渲染 | `Boolean` | `false` | - |
+
+### Events
+
+| 事件名 | 说明 | 回调参数 |
+|------|------|------|
+| finish | 倒计时结束时触发 | - |
+
+### Slots
+
+| 名称 | 说明 | slot-scope 参数 |
+|------|------|------|
+| default | 自定义内容 | timeData |
+
+### timeData 格式
+
+| 名称 | 说明 | 类型 |
+|------|------|------|
+| days | 剩余天数 | `number` |
+| hours | 剩余小时 | `number` |
+| minutes | 剩余分钟 | `number` |
+| seconds | 剩余秒数 | `number` |
+| milliseconds | 剩余毫秒 | `number` |
+
+### 方法
+
+通过 ref 可以获取到 CountDown 实例并调用实例方法
+
+| 方法名 | 参数 | 返回值 | 介绍 |
+|------|------|------|------|
+| start | - | - | 开始倒计时 |
+| pause | - | - | 暂停倒计时 |
+| reset | - | - | 重设倒计时,若`auto-start`为`true`,重设后会自动开始倒计时 |
diff --git a/src/count-down/demo/index.vue b/src/count-down/demo/index.vue
new file mode 100644
index 000000000..ba3be3bcd
--- /dev/null
+++ b/src/count-down/demo/index.vue
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ currentTime.hours }}
+ {{ currentTime.minutes }}
+ {{ currentTime.seconds }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/count-down/index.js b/src/count-down/index.js
new file mode 100644
index 000000000..928f4298a
--- /dev/null
+++ b/src/count-down/index.js
@@ -0,0 +1,127 @@
+import { createNamespace } from '../utils';
+import { raf, cancelRaf } from '../utils/dom/raf';
+import { isSameSecond, parseTimeData, parseFormat } from './utils';
+
+const [createComponent, bem] = createNamespace('count-down');
+
+export default createComponent({
+ props: {
+ millisecond: Boolean,
+ time: {
+ type: Number,
+ default: 0
+ },
+ format: {
+ type: String,
+ default: 'HH:mm:ss'
+ },
+ autoStart: {
+ type: Boolean,
+ default: true
+ }
+ },
+
+ data() {
+ return {
+ remain: 0
+ };
+ },
+
+ computed: {
+ timeData() {
+ return parseTimeData(this.remain);
+ },
+
+ formattedTime() {
+ return parseFormat(this.format, this.timeData);
+ }
+ },
+
+ watch: {
+ time: {
+ immediate: true,
+ handler() {
+ this.reset();
+ }
+ }
+ },
+
+ methods: {
+ start() {
+ if (this.counting) {
+ return;
+ }
+
+ this.counting = true;
+ this.endTime = Date.now() + this.remain;
+ this.tick();
+ },
+
+ pause() {
+ this.counting = false;
+ cancelRaf(this.rafId);
+ },
+
+ reset() {
+ this.pause();
+ this.remain = this.time;
+
+ if (this.autoStart) {
+ this.start();
+ }
+ },
+
+ tick() {
+ if (this.millisecond) {
+ this.microTick();
+ } else {
+ this.macroTick();
+ }
+ },
+
+ microTick() {
+ this.rafId = raf(() => {
+ this.setRemain(this.getRemain());
+
+ if (this.remain !== 0) {
+ this.microTick();
+ }
+ });
+ },
+
+ macroTick() {
+ this.rafId = raf(() => {
+ const remain = this.getRemain();
+
+ if (!isSameSecond(remain, this.remain) || remain === 0) {
+ this.setRemain(remain);
+ }
+
+ if (this.remain !== 0) {
+ this.macroTick();
+ }
+ });
+ },
+
+ getRemain() {
+ return Math.max(this.endTime - Date.now(), 0);
+ },
+
+ setRemain(remain) {
+ this.remain = remain;
+
+ if (remain === 0) {
+ this.pause();
+ this.$emit('finish');
+ }
+ }
+ },
+
+ render(h) {
+ return (
+
+ {this.slots('default', this.timeData) || this.formattedTime}
+
+ );
+ }
+});
diff --git a/src/count-down/index.less b/src/count-down/index.less
new file mode 100644
index 000000000..5a6206a0b
--- /dev/null
+++ b/src/count-down/index.less
@@ -0,0 +1,7 @@
+@import '../style/var';
+
+.van-count-down {
+ color: @count-down-text-color;
+ font-size: @count-down-font-size;
+ line-height: @count-down-line-height;
+}
diff --git a/src/count-down/test/__snapshots__/demo.spec.js.snap b/src/count-down/test/__snapshots__/demo.spec.js.snap
new file mode 100644
index 000000000..237f2ee09
--- /dev/null
+++ b/src/count-down/test/__snapshots__/demo.spec.js.snap
@@ -0,0 +1,35 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders demo correctly 1`] = `
+
+`;
diff --git a/src/count-down/test/demo.spec.js b/src/count-down/test/demo.spec.js
new file mode 100644
index 000000000..d647cfabc
--- /dev/null
+++ b/src/count-down/test/demo.spec.js
@@ -0,0 +1,4 @@
+import Demo from '../demo';
+import demoTest from '../../../test/demo-test';
+
+demoTest(Demo);
diff --git a/src/count-down/utils.ts b/src/count-down/utils.ts
new file mode 100644
index 000000000..bed42a80e
--- /dev/null
+++ b/src/count-down/utils.ts
@@ -0,0 +1,65 @@
+import { padZero } from '../utils/format/string';
+
+export type TimeData = {
+ days: number;
+ hours: number;
+ minutes: number;
+ seconds: number;
+ milliseconds: number;
+};
+
+const SECOND = 1000;
+const MINUTE = 60 * SECOND;
+const HOUR = 60 * MINUTE;
+const DAY = 24 * HOUR;
+
+export function parseTimeData(time: number): TimeData {
+ const days = Math.floor(time / DAY);
+ const hours = Math.floor((time % DAY) / HOUR);
+ const minutes = Math.floor((time % HOUR) / MINUTE);
+ const seconds = Math.floor((time % MINUTE) / SECOND);
+ const milliseconds = Math.floor(time % SECOND);
+
+ return {
+ days,
+ hours,
+ minutes,
+ seconds,
+ milliseconds
+ };
+}
+
+export function parseFormat(format: string, timeData: TimeData): string {
+ const { days } = timeData;
+ let { hours, minutes, seconds, milliseconds } = timeData;
+
+ if (format.indexOf('DD') === -1) {
+ hours += days * 24;
+ } else {
+ format = format.replace('DD', padZero(days));
+ }
+
+ if (format.indexOf('HH') === -1) {
+ minutes += hours * 60;
+ } else {
+ format = format.replace('HH', padZero(hours));
+ }
+
+ if (format.indexOf('mm') === -1) {
+ seconds += minutes * 60;
+ } else {
+ format = format.replace('mm', padZero(minutes));
+ }
+
+ if (format.indexOf('ss') === -1) {
+ milliseconds += seconds * 1000;
+ } else {
+ format = format.replace('ss', padZero(seconds));
+ }
+
+ return format.replace('SSS', padZero(milliseconds, 3));
+}
+
+export function isSameSecond(time1: number, time2: number): boolean {
+ return Math.floor(time1 / 1000) === Math.floor(time2 / 1000);
+}
diff --git a/src/index.less b/src/index.less
index 4b58c1138..6a18e94d0 100644
--- a/src/index.less
+++ b/src/index.less
@@ -13,6 +13,7 @@
@import './image/index';
@import './circle/index';
@import './collapse-item/index';
+@import './count-down/index';
@import './divider/index';
@import './list/index';
@import './nav-bar/index';
diff --git a/src/index.ts b/src/index.ts
index 505a7e413..6aeed1a10 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -18,6 +18,7 @@ import CollapseItem from './collapse-item';
import ContactCard from './contact-card';
import ContactEdit from './contact-edit';
import ContactList from './contact-list';
+import CountDown from './count-down';
import Coupon from './coupon';
import CouponCell from './coupon-cell';
import CouponList from './coupon-list';
@@ -107,6 +108,7 @@ const components = [
ContactCard,
ContactEdit,
ContactList,
+ CountDown,
Coupon,
CouponCell,
CouponList,
@@ -201,6 +203,7 @@ export {
ContactCard,
ContactEdit,
ContactList,
+ CountDown,
Coupon,
CouponCell,
CouponList,
diff --git a/src/radio/README.zh-CN.md b/src/radio/README.zh-CN.md
index a38f468dc..add74ada4 100644
--- a/src/radio/README.zh-CN.md
+++ b/src/radio/README.zh-CN.md
@@ -143,7 +143,7 @@ export default {
### Radio Slots
-| 名称 | 说明 | slot-scope |
+| 名称 | 说明 | slot-scope 参数 |
|------|------|------|
| default | 自定义文本 | - |
| icon | 自定义图标 | checked: 是否为选中状态 |
diff --git a/src/style/var.less b/src/style/var.less
index 4d5c49efe..7690ca4cd 100644
--- a/src/style/var.less
+++ b/src/style/var.less
@@ -166,6 +166,11 @@
@contact-list-add-button-z-index: 9999;
@contact-list-item-padding: 15px;
+// CountDown
+@count-down-text-color: @text-color;
+@count-down-font-size: 14px;
+@count-down-line-height: 20px;
+
// Coupon
@coupon-margin: 0 15px 15px;
@coupon-content-height: 100px;
diff --git a/src/utils/format/string.ts b/src/utils/format/string.ts
index 9f39001f9..f801d910b 100644
--- a/src/utils/format/string.ts
+++ b/src/utils/format/string.ts
@@ -4,6 +4,12 @@ export function camelize(str: string): string {
return str.replace(camelizeRE, (_, c) => c.toUpperCase());
}
-export function padZero(num: number | string): string {
- return (num < 10 ? '0' : '') + num;
+export function padZero(num: number | string, targetLength = 2): string {
+ let str = num + '';
+
+ while (str.length < targetLength) {
+ str = '0' + str;
+ }
+
+ return str;
}
diff --git a/types/index.d.ts b/types/index.d.ts
index 83929b247..27095ce85 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -17,6 +17,7 @@ export class Button extends VanComponent {}
export class Card extends VanComponent {}
export class Cell extends VanComponent {}
export class CellGroup extends VanComponent {}
+export class CountDown extends VanComponent {}
export class Divider extends VanComponent {}
export class SwipeCell extends VanComponent {}
export class Checkbox extends VanComponent {}