refactor(components): [statistic] (#10939)

* refactor(components): [statistic]

* fix: type error

* docs: fix style

* chore: use rAF

* chore: update docs
This commit is contained in:
zz 2022-12-12 10:42:48 +08:00 committed by GitHub
parent a4b73eccda
commit 04f7ea8105
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 544 additions and 696 deletions

View File

@ -11,13 +11,13 @@ Display statistics.
:::demo To highlight a number or a group of numbers, such as statistical value, amount, and ranking, you can add elements such as icon and unit before and after the number and title.
statistic/num
statistic/basic
:::
## Countdown
:::demo Set `timeIndices`Start the countdown. Countdown component, support English and Chinese countdown, support to add other components control countdown.
:::demo Countdown component, support to add other components control countdown.
statistic/countdown
:::
@ -40,10 +40,10 @@ statistic/card
### Statistic Attributes
| Attribute | Description | Type | Default |
|-------------------|--------------------------------|---------------------------------------------------------------------|---------|
| value | Numerical content | ^[string] / ^[number] | 0 |
| ----------------- | ------------------------------ | ------------------------------------------------------------------- | ------- |
| value | Numerical content | ^[number] | 0 |
| decimal-separator | Setting the decimal point | ^[string] | . |
| formatter | Custom numerical presentation | ^[Function]`(value: string \| number) => string \| number` | — |
| formatter | Custom numerical presentation | ^[Function]`(value: number) => string \| number` | — |
| group-separator | Sets the thousandth identifier | ^[string] | , |
| precision | numerical precision | ^[number] | 0 |
| prefix | Sets the prefix of a number | ^[string] | — |
@ -54,7 +54,7 @@ statistic/card
### Statistic Slots
| Name | Description |
|--------|-----------------------------|
| ------ | --------------------------- |
| prefix | Numeric prefix |
| suffix | Suffixes for numeric values |
| title | Numeric titles |
@ -62,15 +62,15 @@ statistic/card
### Statistic Exposes
| Name | Description | Type |
|--------------|-----------------------|----------------------------------|
| displayValue | Current display value | ^[object]`Ref<string \| number>` |
| ------------ | --------------------- | -------------------------------- |
| displayValue | current display value | ^[object]`Ref<string \| number>` |
## Countdown API
### Countdown Attributes
| Attribute | Description | Type | Default |
|-------------|----------------------------------|---------------------------------------------------------------------|----------|
| ----------- | -------------------------------- | ------------------------------------------------------------------- | -------- |
| value | target time | ^[number] / ^[Dayjs] | — |
| format | Formatting the countdown display | ^[string] | HH:mm:ss |
| prefix | Sets the prefix of a countdown | ^[string] | — |
@ -81,14 +81,14 @@ statistic/card
### Countdown Events
| Method | Description | Type |
|--------|------------------------------|--------------------------------------|
| ------ | ---------------------------- | ------------------------------------ |
| change | Time difference change event | ^[Function]`(value: number) => void` |
| finish | countdown end event | ^[Function]`() => void` |
### Countdown Slots
| Name | Description |
|--------|------------------------|
| ------ | ---------------------- |
| prefix | countdown value prefix |
| suffix | countdown value suffix |
| title | countdown title |
@ -96,9 +96,5 @@ statistic/card
### Countdown Exposes
| Name | Description | Type |
|--------------|-----------------------|------------------------|
| displayValue | Current display value | ^[object]`Ref<string>` |
<style lang="scss">
@use '../../examples/statistic/index.scss';
</style>
| ------------ | --------------------- | ---------------------- |
| displayValue | current display value | ^[object]`Ref<string>` |

View File

@ -0,0 +1,42 @@
<template>
<el-row>
<el-col :span="6">
<el-statistic title="Daily active users" :value="268500" />
</el-col>
<el-col :span="6">
<el-statistic :value="138">
<template #title>
<div style="display: inline-flex; align-items: center">
Ratio of men to women
<el-icon style="margin-left: 4px" :size="12">
<Male />
</el-icon>
</div>
</template>
<template #suffix>/100</template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="Total Transactions" :value="172000" />
</el-col>
<el-col :span="6">
<el-statistic title="Feedback number" :value="562">
<template #suffix>
<el-icon style="vertical-align: -0.125em">
<ChatLineRound />
</el-icon>
</template>
</el-statistic>
</el-col>
</el-row>
</template>
<script lang="ts" setup>
import { ChatLineRound, Male } from '@element-plus/icons-vue'
</script>
<style scoped>
.el-col {
text-align: center;
}
</style>

View File

@ -1,233 +1,166 @@
<template>
<div class="play-container3">
<div class="s-card s-bg">
<el-row :gutter="16">
<el-col :span="8">
<el-card class="itemCard">
<ElStatistic :value="98500" :value-style="{ fontSize: '28px' }">
<template #title>
<div class="titleLeft">
<span> Daily active users </span>
<el-tooltip
class="box-item"
effect="dark"
content="Number of users who logged into the product in one day"
placement="top"
>
<el-icon :size="12">
<Warning />
</el-icon>
</el-tooltip>
</div>
</template>
</ElStatistic>
<div class="s-Bottom">
<el-row>
<el-col :span="12" :xs="24" class="item">
<div>than yesterday</div>
<div class="green">
24%
<el-icon>
<CaretTop />
</el-icon>
</div>
</el-col>
<el-col
:span="12"
:xs="24"
class="item"
style="text-align: right"
>
<div>two days ago</div>
<div class="red">
17%
<el-icon>
<CaretBottom />
</el-icon>
</div>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<div class="statistic-card">
<el-statistic :value="98500">
<template #title>
<div style="display: inline-flex; align-items: center">
Daily active users
<el-tooltip
effect="dark"
content="Number of users who logged into the product in one day"
placement="top"
>
<el-icon style="margin-left: 4px" :size="12">
<Warning />
</el-icon>
</el-tooltip>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="itemCard">
<ElStatistic :value="693700" :value-style="{ fontSize: '28px' }">
<template #title>
<div class="titleLeft">
Monthly Active Users
<el-tooltip
class="box-item"
effect="dark"
content="Number of users who logged into the product in one day"
placement="top"
>
<el-icon :size="12">
<Warning />
</el-icon>
</el-tooltip>
</div>
</template>
</ElStatistic>
<div class="s-Bottom">
<el-row>
<el-col :span="12" :xs="24" class="item">
<div>year on year</div>
<div class="green">
24%
<el-tooltip
class="box-item"
effect="dark"
content="Top Center prompts info"
placement="top"
>
<el-icon :size="12">
<Warning />
</el-icon>
</el-tooltip>
</div>
</el-col>
<el-col
:span="12"
:xs="24"
class="item"
style="text-align: right"
>
<div>month on month</div>
<div class="red">
12%
<el-icon>
<CaretBottom />
</el-icon>
</div>
</el-col>
</el-row>
</template>
</el-statistic>
<div class="statistic-footer">
<div class="footer-item">
<span>than yesterday</span>
<span class="green">
24%
<el-icon>
<CaretTop />
</el-icon>
</span>
</div>
<div class="footer-item">
<span>two days ago</span>
<span class="red">
17%
<el-icon>
<CaretBottom />
</el-icon>
</span>
</div>
</div>
</div>
</el-col>
<el-col :span="8">
<div class="statistic-card">
<el-statistic :value="693700">
<template #title>
<div style="display: inline-flex; align-items: center">
Monthly Active Users
<el-tooltip
effect="dark"
content="Number of users who logged into the product in one month"
placement="top"
>
<el-icon style="margin-left: 4px" :size="12">
<Warning />
</el-icon>
</el-tooltip>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="itemCard">
<ElStatistic :value-style="{ fontSize: '28px' }" :value="72000">
<template #title>
<div class="titleLeft">
New transactions today
<!-- <el-icon>
<Warning />
</el-icon> -->
</div>
</template>
</ElStatistic>
<div class="s-Bottom">
<el-row>
<el-col :span="22">
<span class="item">
than yesterday
<span class="red">
16%
<el-icon>
<CaretBottom />
</el-icon>
</span>
</span>
</el-col>
<el-col :span="2">
<el-icon :size="14" @click="onClick">
<ArrowRight />
</el-icon>
</el-col>
</el-row>
</template>
</el-statistic>
<div class="statistic-footer">
<div class="footer-item">
<span>year on year</span>
<span class="green">
24%
<el-icon>
<CaretTop />
</el-icon>
</span>
</div>
<div class="footer-item">
<span>month on month</span>
<span class="red">
12%
<el-icon>
<CaretBottom />
</el-icon>
</span>
</div>
</div>
</div>
</el-col>
<el-col :span="8">
<div class="statistic-card">
<el-statistic :value="72000" title="New transactions today">
<template #title>
<div style="display: inline-flex; align-items: center">
New transactions today
</div>
</el-card>
</el-col>
</el-row>
</div>
</div>
</template>
</el-statistic>
<div class="statistic-footer">
<div class="footer-item">
<span>than yesterday</span>
<span class="green">
16%
<el-icon>
<CaretTop />
</el-icon>
</span>
</div>
<div class="footer-item">
<el-icon :size="14">
<ArrowRight />
</el-icon>
</div>
</div>
</div>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
<script lang="ts" setup>
import {
ArrowRight,
CaretBottom,
CaretTop,
Warning,
} from '@element-plus/icons-vue'
function onClick() {
ElMessage({
message: 'Are you going far away?',
type: 'success',
})
}
// code here
</script>
<style lang="scss" scoped>
<style scoped>
:global(h2#card-usage ~ .example .example-showcase) {
background-color: var(--el-fill-color) !important;
}
.el-statistic {
--el-statistic-head-justify-content: flex-start;
--el-statistic-content-justify-content: flex-start;
--el-statistic-content-font-size: 28px;
}
.play-container3 {
.statistic-card {
height: 100%;
width: 100%;
.f-center {
text-align: center;
margin-top: 10px;
}
.s-card {
width: 100%;
margin-top: 20px;
}
.s-bg {
padding: 1rem;
box-sizing: border-box;
width: 100%;
background: var(--el-fill-color);
}
padding: 20px;
border-radius: 4px;
background-color: var(--el-bg-color-overlay);
}
.itemCard {
width: 100%;
.statistic-footer {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
font-size: 12px;
color: var(--el-text-color-regular);
margin-top: 16px;
}
.titleLeft {
.statistic-footer .footer-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.statistic-footer .footer-item span:last-child {
display: inline-flex;
align-items: center;
text-align: left;
width: 100%;
flex-wrap: nowrap;
flex-direction: row;
justify-content: flex-start;
i {
margin-left: 4px;
}
margin-left: 4px;
}
.s-Bottom {
margin-top: 16px;
font-size: 12px;
color: var(--el-text-color-regular);
.item {
height: 20px;
padding-left: 4px;
padding-bottom: 4px;
display: inline-block;
margin-bottom: 0px;
}
.green {
color: var(--el-color-success);
}
.red {
color: var(--el-color-error);
}
.green {
color: var(--el-color-success);
}
.red {
color: var(--el-color-error);
}
</style>

View File

@ -1,75 +1,54 @@
<template>
<div class="play-container2">
<div class="s-card">
<el-row>
<el-col :span="8">
<ElCountdown title="Start to grab" countdown :value="value" />
</el-col>
<el-col :span="8">
<ElCountdown
title="Remaining VIP time"
format="HH:mm:ss"
:value="value1"
/>
<div class="f-center">
<el-button type="primary" @click="reset">Reset</el-button>
<el-row>
<el-col :span="8">
<el-countdown title="Start to grab" :value="value" />
</el-col>
<el-col :span="8">
<el-countdown
title="Remaining VIP time"
format="HH:mm:ss"
:value="value1"
/>
<el-button class="countdown-footer" type="primary" @click="reset"
>Reset
</el-button>
</el-col>
<el-col :span="8">
<el-countdown format="DD [days] HH [hours] mm:ss" :value="value2">
<template #title>
<div style="display: inline-flex; align-items: center">
<el-icon style="margin-right: 4px" :size="12">
<Calendar />
</el-icon>
New Year's Day is still to come
</div>
</el-col>
<el-col :span="8">
<ElCountdown :value="value2" format="DD day HH:mm:ss">
<template #title>
<el-icon style="margin-right: 4px" :size="12"
><Calendar
/></el-icon>
<span> New Year's Day is still to come </span>
</template>
</ElCountdown>
<div class="f-center">2023-01-01 00:00:00</div>
</el-col>
</el-row>
</div>
</div>
</template>
</el-countdown>
<div class="countdown-footer">2023-01-01 00:00:00</div>
</el-col>
</el-row>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { ref } from 'vue'
import dayjs from 'dayjs'
import { Calendar } from '@element-plus/icons-vue'
const value = ref(Date.now() + 1000 * 60 * 60 * 7)
const value1 = ref(Date.now() + 1000 * 60 * 60 * 24 * 2)
const value2 = ref(dayjs('2023-01-01 00:00:00').valueOf())
const value2 = ref(dayjs('2023-01-01 00:00:00'))
function reset() {
value1.value = Date.now() + 1000 * 60 * 60 * 24 * 2
}
</script>
<style lang="scss" scoped>
.play-container2 {
height: 100%;
width: 100%;
.f-center {
text-align: center;
margin-top: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.s-card {
width: 100%;
margin-top: 20px;
}
.s-bg {
box-sizing: border-box;
width: 100%;
padding: 20px;
background: #f0f2f5;
border: 1px solid #dcdfe6;
}
.item {
width: 100%;
height: 200px;
text-align: left;
}
<style scoped>
.el-col {
text-align: center;
}
.countdown-footer {
margin-top: 8px;
}
</style>

View File

@ -1,3 +0,0 @@
h2#card-usage ~ .example .example-showcase {
background-color: var(--el-fill-color) !important;
}

View File

@ -1,77 +0,0 @@
<template>
<div class="play-container1">
<div class="s-card">
<el-row>
<el-col :span="6">
<el-statistic title="Daily active users" :value="268500" />
</el-col>
<el-col :span="6">
<el-statistic :value="138">
<template #title>
Ratio of men to women
<el-icon style="" :size="12">
<Male />
</el-icon>
</template>
<template #suffix> /100 </template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="Total Transactions" :value="172000">
<template #suffix>
<span style="font-size: 12px" />
</template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="Feedback number" :value="562">
<template #suffix>
<el-icon>
<ChatLineRound />
</el-icon>
</template>
</el-statistic>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ChatLineRound, Male } from '@element-plus/icons-vue'
const value: any = ref(Date.now() + 1000 * 60 * 60 * 24 * 2)
function add() {
value.value = value.value + 1000 * 60 * 10
}
</script>
<style lang="scss" scoped>
.play-container1 {
height: 100%;
width: 100%;
.f-center {
text-align: center;
margin-top: 10px;
}
.s-card {
width: 100%;
margin-top: 20px;
}
.s-bg {
box-sizing: border-box;
width: 100%;
padding: 20px;
background: var(--el-fill-color);
}
.item {
width: 100%;
height: 200px;
text-align: left;
}
}
</style>

View File

@ -0,0 +1,73 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import dayjs from 'dayjs'
import Countdown from '../src/countdown.vue'
import type { VueWrapper } from '@vue/test-utils'
const TITLE_CLASS = '.el-statistic__head'
const CONTENT_CLASS = '.el-statistic__content'
describe('Countdown.vue', () => {
let wrapper: VueWrapper<any>
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
wrapper.unmount()
})
it('render test', () => {
wrapper = mount(() => (
<Countdown title="test" value={Date.now() + 1000 * 60} />
))
expect(wrapper.find(TITLE_CLASS).text()).toBe('test')
expect(wrapper.find(CONTENT_CLASS).text()).toBe('00:01:00')
})
describe('format', () => {
it.each([
['DD HH:mm:ss', '02 02:02:02'],
['DD [days] HH [hours] mm:ss', '02 days 02 hours 02:02'],
['HH:mm:ss', '50:02:02'],
['HH:mm:ss:SSS', '50:02:02:002'],
])('should work with %s', (format, expected) => {
const value = dayjs()
.add(2, 'd')
.add(2, 'h')
.add(2, 'm')
.add(2, 's')
.add(2, 'ms')
wrapper = mount(() => <Countdown value={value} format={format} />)
expect(wrapper.find(CONTENT_CLASS).text()).toBe(expected)
})
})
it('change event', () => {
const onChange = vi.fn()
wrapper = mount(() => (
<Countdown onChange={onChange} value={Date.now() + 1000 * 60} />
))
vi.advanceTimersByTime(16)
expect(onChange).toHaveBeenCalledTimes(1)
})
it('finish event', () => {
const onFinish = vi.fn()
wrapper = mount(() => (
<Countdown onFinish={onFinish} value={Date.now() + 1000 * 60} />
))
vi.advanceTimersByTime(1000 * 30)
expect(onFinish).not.toHaveBeenCalled()
vi.runAllTimers()
expect(onFinish).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,7 @@
import { withInstall } from '@element-plus/utils'
import Countdown from './src/countdown.vue'
export const ElCountdown = withInstall(Countdown)
export default ElCountdown
export * from './src/countdown'

View File

@ -0,0 +1,50 @@
import { buildProps, definePropType, isNumber } from '@element-plus/utils'
import { CHANGE_EVENT } from '@element-plus/constants'
import type { ExtractPropTypes, StyleValue } from 'vue'
import type { Dayjs } from 'dayjs'
import type Countdown from './countdown.vue'
export const countdownProps = buildProps({
/**
* @description Formatting the countdown display
*/
format: {
type: String,
default: 'HH:mm:ss',
},
/**
* @description Sets the prefix of a countdown
*/
prefix: String,
/**
* @description Sets the suffix of a countdown
*/
suffix: String,
/**
* @description countdown titles
*/
title: String,
/**
* @description target time
*/
value: {
type: definePropType<number | Dayjs>([Number, Object]),
default: 0,
},
/**
* @description Styles countdown values
*/
valueStyle: {
type: definePropType<StyleValue>([String, Object, Array]),
},
} as const)
export type CountdownProps = ExtractPropTypes<typeof countdownProps>
export const countdownEmits = {
finish: () => true,
[CHANGE_EVENT]: (value: number) => isNumber(value),
}
export type CountdownEmits = typeof countdownEmits
export type CountdownInstance = InstanceType<typeof Countdown>

View File

@ -0,0 +1,80 @@
<template>
<el-statistic
:value="rawValue"
:title="title"
:prefix="prefix"
:suffix="suffix"
:value-style="valueStyle"
:formatter="formatter"
>
<template v-for="(_, name) in $slots" #[name]>
<slot :name="name" />
</template>
</el-statistic>
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElStatistic } from '@element-plus/components/statistic'
import { cAF, rAF } from '@element-plus/utils'
import { countdownEmits, countdownProps } from './countdown'
import { formatTime, getTime } from './utils'
defineOptions({
name: 'ElCountdown',
})
const props = defineProps(countdownProps)
const emit = defineEmits(countdownEmits)
let timer: ReturnType<typeof rAF> | undefined
const rawValue = ref(getTime(props.value) - Date.now())
const displayValue = computed(() => formatTime(rawValue.value, props.format))
const formatter = (val: number) => formatTime(val, props.format)
const stopTimer = () => {
if (timer) {
cAF(timer)
timer = undefined
}
}
const startTimer = () => {
const timestamp = getTime(props.value)
const frameFunc = () => {
let diff = timestamp - Date.now()
emit('change', diff)
if (diff <= 0) {
diff = 0
stopTimer()
emit('finish')
} else {
timer = rAF(frameFunc)
}
rawValue.value = diff
}
timer = rAF(frameFunc)
}
watch(
() => [props.value, props.format],
() => {
stopTimer()
startTimer()
},
{
immediate: true,
}
)
onBeforeUnmount(() => {
stopTimer()
})
defineExpose({
/**
* @description current display value
*/
displayValue,
})
</script>

View File

@ -0,0 +1,36 @@
import { isNumber } from '@element-plus/utils'
import type { Dayjs } from 'dayjs'
const timeUnits = [
['Y', 1000 * 60 * 60 * 24 * 365], // years
['M', 1000 * 60 * 60 * 24 * 30], // months
['D', 1000 * 60 * 60 * 24], // days
['H', 1000 * 60 * 60], // hours
['m', 1000 * 60], // minutes
['s', 1000], // seconds
['S', 1], // million seconds
] as const
export const getTime = (value: number | Dayjs) => {
return isNumber(value) ? new Date(value).getTime() : value.valueOf()
}
export const formatTime = (timestamp: number, format: string) => {
let timeLeft = timestamp
const escapeRegex = /\[([^\]]*)]/g
const replacedText = timeUnits.reduce((current, [name, unit]) => {
const replaceRegex = new RegExp(`${name}+(?![^\\[\\]]*\\])`, 'g')
if (replaceRegex.test(current)) {
const value = Math.floor(timeLeft / unit)
timeLeft -= value * unit
return current.replace(replaceRegex, (match) =>
String(value).padStart(match.length, '0')
)
}
return current
}, format)
return replacedText.replace(escapeRegex, '$1')
}

View File

@ -0,0 +1,2 @@
import '@element-plus/components/base/style/css'
import '@element-plus/theme-chalk/el-statistic.css'

View File

@ -0,0 +1,2 @@
import '@element-plus/components/base/style'
import '@element-plus/theme-chalk/src/statistic.scss'

View File

@ -19,6 +19,7 @@ export * from './collapse-transition'
export * from './color-picker'
export * from './config-provider'
export * from './container'
export * from './countdown'
export * from './date-picker'
export * from './descriptions'
export * from './dialog'
@ -50,6 +51,7 @@ export * from './select-v2'
export * from './skeleton'
export * from './slider'
export * from './space'
export * from './statistic'
export * from './steps'
export * from './switch'
export * from './table'
@ -66,7 +68,6 @@ export * from './tree-select'
export * from './tree-v2'
export * from './upload'
export * from './virtual-list'
export * from './statistic'
// plugins
export * from './infinite-scroll'

View File

@ -1,63 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import dayjs from 'dayjs'
import Countdown from '../src/countdown.vue'
const TITLE_CLASS = '.el-statistic__head'
const CONTENT_CLASS = '.el-statistic__content'
describe('Countdown.vue', () => {
it('render test', async () => {
const wrapper = mount(() => (
<Countdown title="test" value={Date.now() + 1000 * 60} />
))
expect(wrapper.find(TITLE_CLASS).text()).toBe('test')
expect(wrapper.find(CONTENT_CLASS).text()).toBe('00:00:59')
})
describe('format', () => {
const value = dayjs()
.add(2, 'd')
.add(2, 'h')
.add(2, 'm')
.add(2, 's')
.add(2, 'ms')
it.each([
['DD HH:mm:ss', '02 02:02:01'],
['HH:mm:ss', '50:02:01'],
['H:m:s', '50:2:1'],
])('should work with %s', async (format, expected) => {
const wrapper = mount(() => <Countdown value={value} format={format} />)
expect(wrapper.find(CONTENT_CLASS).text()).toBe(expected)
})
})
it('change event', async () => {
vi.useFakeTimers()
const onChange = vi.fn()
mount(() => (
<Countdown onChange={onChange} value={Date.now() + 1000 * 60} />
))
vi.advanceTimersByTime((1000 / 30) * 2)
expect(onChange).toHaveBeenCalledTimes(2)
vi.useRealTimers()
})
it('finish event', async () => {
vi.useFakeTimers()
const onFinish = vi.fn()
mount(() => (
<Countdown onFinish={onFinish} value={Date.now() + 1000 * 60} />
))
vi.advanceTimersByTime(1000 * 30)
expect(onFinish).not.toHaveBeenCalled()
vi.runAllTimers()
expect(onFinish).toHaveBeenCalled()
vi.useRealTimers()
})
})

View File

@ -1,11 +1,7 @@
import { withInstall } from '@element-plus/utils'
import Statistic from './src/statistic.vue'
import Countdown from './src/countdown.vue'
export * from './src/statistic'
export * from './src/countdown'
export const ElStatistic = withInstall(Statistic)
export const ElCountdown = withInstall(Countdown)
export default ElStatistic
export * from './src/statistic'

View File

@ -1,86 +0,0 @@
import { padStart } from 'lodash-unified'
import { buildProps, definePropType, isNumber } from '@element-plus/utils'
import { CHANGE_EVENT } from '@element-plus/constants'
import type { StyleValue } from 'vue'
import type { Dayjs } from 'dayjs'
import type Countdown from './countdown.vue'
export const countdownProps = buildProps({
/**
* @description Formatting the countdown display
*/
format: {
type: String,
default: 'HH:mm:ss',
},
/**
* @description Sets the prefix of a countdown
*/
prefix: String,
/**
* @description Sets the suffix of a countdown
*/
suffix: String,
/**
* @description countdown titles
*/
title: String,
/**
* @description target time
*/
value: {
type: definePropType<number | Dayjs>([Number, Object]),
default: 0,
},
/**
* @description Styles countdown values
*/
valueStyle: {
type: definePropType<StyleValue>([String, Object, Array]),
},
} as const)
export const countdownEmits = {
finish: () => true,
[CHANGE_EVENT]: (value: number) => isNumber(value),
}
export const formatTimeStr = (format: string, time: number) => {
const timeUnits: [string, number][] = [
['Y', 1000 * 60 * 60 * 24 * 365], // years
['M', 1000 * 60 * 60 * 24 * 30], // months
['D', 1000 * 60 * 60 * 24], // days
['H', 1000 * 60 * 60], // hours
['m', 1000 * 60], // minutes
['s', 1000], // seconds
['S', 1], // million seconds
]
// The unformatted value of the previous tick
let pre = 0
// previous tick
let preTick = 0
// time left
let timeLeft = time
return timeUnits.reduce((con: string, item: [string, number]) => {
const name = item[0]
return con.replace(new RegExp(`${name}+`, 'g'), (match) => {
let sum = 0
if (!format.includes(name)) {
pre = Math.floor(timeLeft / item[1])
timeLeft = timeLeft - pre * item[1]
preTick = item[1]
} else {
pre = 0
preTick = 0
sum = Math.floor(timeLeft / item[1])
timeLeft = timeLeft - sum * item[1]
if (pre > 0) {
sum = sum + pre * (preTick / item[1])
pre = 0
}
}
return padStart(String(sum), match.length, '0') // autoCompletion
})
}, format)
}
export type CountdownInstance = InstanceType<typeof Countdown>

View File

@ -1,93 +0,0 @@
<template>
<div :class="ns.b()">
<div v-if="!!$slots.title || title" ref="title" :class="ns.e('head')">
<span :class="ns.e('title')">
<slot name="title">
{{ title }}
</slot>
</span>
</div>
<div :class="ns.e('content')">
<span v-if="!!$slots.title || prefix" :class="ns.e('prefix')">
<slot name="prefix">
{{ prefix }}
</slot>
</span>
<span :class="ns.e('number')" :style="valueStyle">
{{ displayValue }}
</span>
<span v-if="!!$slots.title || suffix" :class="ns.e('suffix')">
<slot name="suffix">
{{ suffix }}
</slot>
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { onBeforeUnmount, ref, watch } from 'vue'
import { useNamespace } from '@element-plus/hooks'
import { isNumber } from '@element-plus/utils'
import { countdownEmits, countdownProps, formatTimeStr } from './countdown'
const REFRESH_INTERVAL = 1000 / 30
defineOptions({
name: 'ElCountdown',
})
const props = defineProps(countdownProps)
const emit = defineEmits(countdownEmits)
const ns = useNamespace('statistic')
const displayValue = ref('')
let timer: ReturnType<typeof setInterval> | undefined
const getTime = (val: number) => {
return new Date(val).getTime()
}
const stopTimer = () => {
if (timer) {
clearInterval(timer)
timer = undefined
}
}
const startTimer = () => {
const { value, format } = props
const timestamp = getTime(isNumber(value) ? value : value.valueOf())
displayValue.value = formatTimeStr(format, timestamp - Date.now())
timer = setInterval(() => {
let diff = timestamp - Date.now()
emit('change', diff)
if (diff <= 0) {
diff = 0
stopTimer()
emit('finish')
}
displayValue.value = formatTimeStr(format, diff)
}, REFRESH_INTERVAL)
}
watch(
() => [props.value, props.format],
() => {
stopTimer()
startTimer()
},
{
immediate: true,
}
)
onBeforeUnmount(() => {
stopTimer()
})
defineExpose({
/**
* @description Current display value
*/
displayValue,
})
</script>

View File

@ -1,6 +1,9 @@
import { buildProps, definePropType } from '@element-plus/utils'
import type { StyleValue } from 'vue'
import type { ExtractPropTypes, StyleValue } from 'vue'
import type { Dayjs } from 'dayjs'
import type Statistic from './statistic.vue'
export const statisticProps = buildProps({
/**
* @description Setting the decimal point
@ -26,14 +29,12 @@ export const statisticProps = buildProps({
/**
* @description Custom numerical presentation
*/
formatter: {
type: definePropType<(val: string | number) => string | number>(Function),
},
formatter: Function,
/**
* @description Numerical content
*/
value: {
type: [String, Number],
type: definePropType<number | Dayjs>([Number, Object]),
default: 0,
},
/**
@ -55,17 +56,7 @@ export const statisticProps = buildProps({
valueStyle: {
type: definePropType<StyleValue>([String, Object, Array]),
},
// rate: {
// type: Number,
// default: 3,
// },
} as const)
export const groupFormat = function (
target: string,
step = 3,
groupSeparator = ','
): string {
const reg = new RegExp(`\\B(?=(\\d{${step}})+(?!\\d))`, 'g')
return target.replace(reg, groupSeparator)
}
export type StatisticProps = ExtractPropTypes<typeof statisticProps>
export type StatisticInstance = InstanceType<typeof Statistic>

View File

@ -1,6 +1,6 @@
<template>
<div :class="ns.b()">
<div v-if="$slots.title || title" ref="title" :class="ns.e('head')">
<div v-if="$slots.title || title" :class="ns.e('head')">
<slot name="title">
{{ title }}
</slot>
@ -24,45 +24,36 @@
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { isNil } from 'lodash-unified'
import { useNamespace } from '@element-plus/hooks'
import { isFunction } from '@element-plus/utils'
import { groupFormat, statisticProps } from './statistic'
import { isFunction, isNumber } from '@element-plus/utils'
import { statisticProps } from './statistic'
defineOptions({
name: 'ElStatistic',
})
const THOUSANDTH = 3
const props = defineProps(statisticProps)
const ns = useNamespace('statistic')
const displayValue = computed(() => {
if (isFunction(props.formatter)) {
return props.formatter(props.value)
} else if (
isNil(props.value) ||
!/^(-|\+)?\d+(\.\d+)?$/.test(`${props.value}`)
) {
return props.value
} else {
let [integer, decimal] = `${props.value}`.split('.')
if (!isNil(props.precision)) {
decimal = `${decimal || ''}${(1)
.toFixed(props.precision)
.replace('.', '')
.slice(1)}`
decimal = decimal.slice(0, props.precision)
}
integer = groupFormat(integer, THOUSANDTH, props.groupSeparator)
return [integer, decimal].join(decimal ? props.decimalSeparator || '.' : '')
}
const { value, formatter, precision, decimalSeparator, groupSeparator } =
props
if (isFunction(formatter)) return formatter(value)
if (!isNumber(value)) return value
let [integer, decimal = ''] = String(value).split('.')
decimal = decimal
.padEnd(precision, '0')
.slice(0, precision > 0 ? precision : 0)
integer = integer.replace(/\B(?=(\d{3})+(?!\d))/g, groupSeparator)
return [integer, decimal].join(decimal ? decimalSeparator : '')
})
defineExpose({
/**
* @description Current display value
* @description current display value
*/
displayValue,
})

View File

@ -82,6 +82,8 @@ import { ElSelectV2 } from '@element-plus/components/select-v2'
import { ElSkeleton, ElSkeletonItem } from '@element-plus/components/skeleton'
import { ElSlider } from '@element-plus/components/slider'
import { ElSpace } from '@element-plus/components/space'
import { ElStatistic } from '@element-plus/components/statistic'
import { ElCountdown } from '@element-plus/components/countdown'
import { ElStep, ElSteps } from '@element-plus/components/steps'
import { ElSwitch } from '@element-plus/components/switch'
import { ElTable, ElTableColumn } from '@element-plus/components/table'
@ -95,15 +97,13 @@ import { ElTooltip } from '@element-plus/components/tooltip'
import { ElTooltipV2 } from '@element-plus/components/tooltip-v2'
import { ElTransfer } from '@element-plus/components/transfer'
import { ElTree } from '@element-plus/components/tree'
import { ElCountdown, ElStatistic } from '@element-plus/components/statistic'
import { ElTreeSelect } from '@element-plus/components/tree-select'
import { ElTreeV2 } from '@element-plus/components/tree-v2'
import { ElUpload } from '@element-plus/components/upload'
import type { Plugin } from 'vue'
export default [
ElCountdown,
ElStatistic,
ElAffix,
ElAlert,
ElAutocomplete,
@ -178,6 +178,8 @@ export default [
ElSkeletonItem,
ElSlider,
ElSpace,
ElStatistic,
ElCountdown,
ElSteps,
ElStep,
ElSwitch,

View File

@ -627,12 +627,12 @@ $cascader: map.merge(
$statistic: () !default;
$statistic: map.merge(
(
'head-justify-content': center,
'content-justify-content': center,
'padding': 0 5px,
'content-margin-top': 4px,
'content-text-color': getCssVar('text-color', 'primary'),
'head-text-color': getCssVar('text-color', 'regular'),
'title-font-weight': 400,
'title-font-size': getCssVar('font-size', 'extra-small'),
'title-color': getCssVar('text-color', 'regular'),
'content-font-weight': 400,
'content-font-size': getCssVar('font-size', 'extra-large'),
'content-color': getCssVar('text-color', 'primary'),
),
$statistic
);

View File

@ -4,43 +4,32 @@
@include b(statistic) {
@include set-component-css-var('statistic', $statistic);
width: 100%;
box-sizing: border-box;
margin: 0;
padding: 0;
font-variant: tabular-nums;
// line-height: 1.5715;
list-style: none;
font-feature-settings: 'tnum';
@include e(head) {
padding: getCssVar('statistic-padding');
font-style: normal;
font-weight: 400;
font-size: getCssVar('font-size', 'extra-small');
width: 100%;
display: inline-flex;
align-items: center;
justify-content: getCssVar('statistic-head-justify-content');
color: getCssVar('statistic-head-text-color');
@include e(head) {
font-weight: getCssVar('statistic-title-font-weight');
font-size: getCssVar('statistic-title-font-size');
color: getCssVar('statistic-title-color');
line-height: 20px;
margin-bottom: 4px;
}
@include e(content) {
display: flex;
justify-content: getCssVar('statistic-content-justify-content');
align-items: center;
padding-top: getCssVar('statistic-content-margin-top');
@include e(number) {
font-style: normal;
padding: getCssVar('statistic-padding');
font-family: sans-serif;
}
span {
font-weight: 400;
color: getCssVar('statistic-content-text-color');
font-size: getCssVar('font-size', 'extra-large');
font-weight: getCssVar('statistic-content-font-weight');
font-size: getCssVar('statistic-content-font-size');
color: getCssVar('statistic-content-color');
@include e(value) {
display: inline-block;
}
@include e(prefix) {
margin-right: 4px;
display: inline-block;
}
@include e(suffix) {
margin-left: 4px;
display: inline-block;
margin: 0;
line-height: 28px;
}
}
}