feat(number-animation): Add NumberAnimation component (#3301)

* feat(number-animation): [number-animation] Add NumberAnimation component

* feat(number-animation): [number-animation] Add NumberAnimation component

* feat(number-animation): [number-animation] Add NumberAnimation component

* feat(number-animation): [number-animation] Add NumberAnimation component

* feat(number-animation): [number-animation] Add NumberAnimation component

* feat(number-animation): [number-animation] Add NumberAnimation component
This commit is contained in:
lcy0620 2025-05-07 19:12:36 +08:00 committed by GitHub
parent f98b9367d7
commit b76c76d183
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 640 additions and 3 deletions

View File

@ -0,0 +1,104 @@
export default {
mode: ['pc'],
apis: [
{
name: 'number-animation',
type: 'component',
props: [
{
name: 'active',
type: 'boolean',
defaultValue: 'true',
desc: {
'zh-CN': '是否开始动画',
'en-US': 'Whether or not start animation'
},
mode: ['pc'],
pcDemo: 'basic-usage'
},
{
name: 'duration',
type: 'number',
defaultValue: '3000',
desc: {
'zh-CN': '动画持续时间',
'en-US': 'Animation duration'
},
mode: ['pc'],
pcDemo: 'basic-usage'
},
{
name: 'from',
type: 'number',
defaultValue: '0',
desc: {
'zh-CN': '数值动画起始值',
'en-US': 'Starting value of numerical animation'
},
mode: ['pc'],
pcDemo: 'basic-usage'
},
{
name: 'to',
type: 'number',
defaultValue: '',
desc: {
'zh-CN': '目标值',
'en-US': 'Target value'
},
mode: ['pc'],
pcDemo: 'basic-usage'
},
{
name: 'precision',
type: 'number',
defaultValue: '0',
desc: {
'zh-CN': '精度,保留小数点后几位',
'en-US': 'Precision, rounded to a few decimal places.'
},
mode: ['pc'],
pcDemo: 'precision'
},
{
name: 'separator',
type: 'string',
defaultValue: ',',
desc: {
'zh-CN': '千分位分隔符',
'en-US': 'Thousandth separator'
},
mode: ['pc'],
pcDemo: 'separator'
}
],
events: [
{
name: 'finish',
type: '() => void',
defaultValue: '',
desc: {
'zh-CN': '动画结束后的回调',
'en-US': 'The callback after the animation ends.'
},
mode: ['pc'],
pcDemo: 'finish-events'
}
],
methods: [
{
name: 'play',
type: '() => void',
defaultValue: '',
desc: {
'zh-CN': '播放动画',
'en-US': 'Play Animation'
},
mode: ['pc'],
pcDemo: 'finish-events'
}
],
slots: []
}
]
}

View File

@ -0,0 +1,16 @@
<template>
<tiny-number-animation ref="numberAnimationRef" :from="fromVal" :to="toVal" />
<tiny-button @click="handleClick">播放</tiny-button>
</template>
<script setup>
import { ref } from 'vue'
import { TinyButton, TinyNumberAnimation } from '@opentiny/vue'
const numberAnimationRef = ref(null)
const fromVal = ref(0)
const toVal = ref(12309)
function handleClick() {
numberAnimationRef.value?.play()
}
</script>

View File

@ -0,0 +1,8 @@
import { test, expect } from '@playwright/test'
test('基本用法', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull()) // 断言页面上不出现错误
await page.goto('number-animation#basic-usage') // 要测试的示例的相对地址
await page.waitForTimeout(1000)
await page.locator('#basic-usage').getByText('12,039')
})

View File

@ -0,0 +1,26 @@
<template>
<tiny-number-animation ref="numberAnimationRef" :from="fromVal" :to="toVal" />
<tiny-button @click="handleClick">播放</tiny-button>
</template>
<script lang="jsx">
import { TinyButton, TinyNumberAnimation } from '@opentiny/vue'
export default {
components: {
TinyButton,
TinyNumberAnimation
},
data() {
return {
fromVal: 0,
toVal: 12039
}
},
methods: {
handleClick() {
this.$refs.numberAnimationRef.play()
}
}
}
</script>

View File

@ -0,0 +1,20 @@
<template>
<tiny-number-animation ref="numberAnimationRef" :from="fromVal" :to="toVal" :active="false" @finish="handleFinish" />
<tiny-button @click="handleClick">播放</tiny-button>
</template>
<script setup lang="jsx">
import { ref } from 'vue'
import { TinyButton, TinyNumberAnimation, TinyModal } from '@opentiny/vue'
const numberAnimationRef = ref(null)
const fromVal = ref(0)
const toVal = ref(900)
function handleClick() {
numberAnimationRef.value?.play()
}
function handleFinish() {
TinyModal.message({ message: '动画结束了', status: 'info' })
}
</script>

View File

@ -0,0 +1,11 @@
import { test, expect } from '@playwright/test'
test('动画播放完成', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('number-animation#finish-events')
await page.getByRole('button', { name: '播放' }).click()
await page.waitForTimeout(1000)
await page.locator('#finish-events').getByText('900')
const messageLocator = page.locator('.tiny-modal__box').filter({ hasText: '动画结束了' })
await expect(messageLocator).toBeVisible()
})

View File

@ -0,0 +1,29 @@
<template>
<tiny-number-animation ref="numberAnimationRef" :from="fromVal" :to="toVal" :active="false" @finish="handleFinish" />
<tiny-button @click="handleClick">播放</tiny-button>
</template>
<script lang="jsx">
import { TinyButton, TinyNumberAnimation, TinyModal } from '@opentiny/vue'
export default {
components: {
TinyButton,
TinyNumberAnimation
},
data() {
return {
fromVal: 0,
toVal: 900
}
},
methods: {
handleClick() {
this.$refs.numberAnimationRef.play()
},
handleFinish() {
TinyModal.message({ message: '动画结束了', status: 'info' })
}
}
}
</script>

View File

@ -0,0 +1,16 @@
<template>
<tiny-number-animation ref="numberAnimationRef" :from="fromVal" :to="toVal" :active="false" :precision="4" />
<tiny-button @click="handleClick">播放</tiny-button>
</template>
<script setup lang="jsx">
import { ref } from 'vue'
import { TinyButton, TinyNumberAnimation } from '@opentiny/vue'
const numberAnimationRef = ref(null)
const fromVal = ref(0.0)
const toVal = ref(24)
function handleClick() {
numberAnimationRef.value?.play()
}
</script>

View File

@ -0,0 +1,9 @@
import { test, expect } from '@playwright/test'
test('精度', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull()) // 断言页面上不出现错误
await page.goto('number-animation#precision') // 要测试的示例的相对地址
await page.getByRole('button', { name: /播放/ }).click()
await page.waitForTimeout(1000)
await page.locator('#precision').getByText('24.0000')
})

View File

@ -0,0 +1,26 @@
<template>
<tiny-number-animation ref="numberAnimationRef" :from="fromVal" :to="toVal" :active="false" :precision="4" />
<tiny-button @click="handleClick">播放</tiny-button>
</template>
<script lang="jsx">
import { TinyButton, TinyNumberAnimation } from '@opentiny/vue'
export default {
components: {
TinyButton,
TinyNumberAnimation
},
data() {
return {
fromVal: 0.0,
toVal: 24
}
},
methods: {
handleClick() {
this.$refs.numberAnimationRef.play()
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<tiny-number-animation ref="numberAnimationRef" :from="fromVal" :to="toVal" :active="false" />
<tiny-button @click="handleClick">播放</tiny-button>
</template>
<script setup lang="jsx">
import { ref } from 'vue'
import { TinyButton, TinyNumberAnimation } from '@opentiny/vue'
const numberAnimationRef = ref(null)
const fromVal = ref(0)
const toVal = ref(100000000)
function handleClick() {
numberAnimationRef.value?.play()
}
</script>

View File

@ -0,0 +1,9 @@
import { test, expect } from '@playwright/test'
test('分隔符', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull()) // 断言页面上不出现错误
await page.goto('number-animation#separator') // 要测试的示例的相对地址
await page.getByRole('button', { name: '播放', exact: true }).click()
await page.waitForTimeout(1000)
await page.locator('#separator').getByText('100,000,000')
})

View File

@ -0,0 +1,26 @@
<template>
<tiny-number-animation ref="numberAnimationRef" :from="fromVal" :to="toVal" :active="false" />
<tiny-button @click="handleClick">播放</tiny-button>
</template>
<script lang="jsx">
import { TinyButton, TinyNumberAnimation } from '@opentiny/vue'
export default {
components: {
TinyButton,
TinyNumberAnimation
},
data() {
return {
fromVal: 0,
toVal: 100000000
}
},
methods: {
handleClick() {
this.$refs.numberAnimationRef.play()
}
}
}
</script>

View File

@ -0,0 +1,7 @@
---
title:
---
# Number Animation 数值动画
<div>数值播放动画</div>

View File

@ -0,0 +1,7 @@
---
title:
---
# Number Animation 数值动画
<div>Numerical playback animation</div>

View File

@ -0,0 +1,55 @@
export default {
column: '1',
owner: '',
demos: [
{
demoId: 'basic-usage',
name: {
'zh-CN': '基本用法',
'en-US': 'Basic Usage'
},
desc: {
'zh-CN': '通过 <code>from</code> 设置数值动画起始值;<code>to</code>设置目标值。',
'en-US': 'Set the starting value of numerical animation through<code>from</code>, <code>to</code>设置目标值.'
},
codeFiles: ['basic-usage.vue']
},
{
demoId: 'precision',
name: {
'zh-CN': '精度',
'en-US': 'Precision Mode'
},
desc: {
'zh-CN': '通过 <code>precision</code> 设置 设定精度。',
'en-US': 'Set precision through<code>precision</code>.'
},
codeFiles: ['precision.vue']
},
{
demoId: 'separator',
name: {
'zh-CN': '分隔符',
'en-US': 'Separator Mode'
},
desc: {
'zh-CN': '通过 <code>separator</code> 设置分隔符。',
'en-US': 'Set delimiter through<code>separator</code>.'
},
codeFiles: ['separator.vue']
},
{
demoId: 'finish-events',
name: {
'zh-CN': '动画结束事件',
'en-US': 'Finish Event'
},
desc: {
'zh-CN': '通过 <code>finish</code> 自定义动画结束后的事件',
'en-US': 'Customize the events after the animation ends through<code>finish</code>.'
},
codeFiles: ['finish-events.vue']
}
]
}

View File

@ -249,7 +249,8 @@ export const cmpMenus = [
} }
}, },
{ 'nameCn': '用户头像', 'name': 'UserHead', 'key': 'user-head' }, { 'nameCn': '用户头像', 'name': 'UserHead', 'key': 'user-head' },
{ 'nameCn': '流程图', 'name': 'Wizard', 'key': 'wizard' } { 'nameCn': '流程图', 'name': 'Wizard', 'key': 'wizard' },
{ 'nameCn': '数值动画', 'name': 'NumberAnimation', key: 'number-animation' }
] ]
}, },
{ {

View File

@ -1708,6 +1708,19 @@
"type": "template", "type": "template",
"exclude": false "exclude": false
}, },
"NumberAnimation": {
"path": "vue/src/number-animation/index.ts",
"type": "component",
"exclude": false,
"mode": [
"pc"
]
},
"NumberAnimationPc": {
"path": "vue/src/number-animation/src/pc.vue",
"type": "template",
"exclude": false
},
"Option": { "Option": {
"path": "vue/src/option/index.ts", "path": "vue/src/option/index.ts",
"type": "component", "type": "component",

View File

@ -0,0 +1,55 @@
export const onFinish =
({ emit, props, state }) =>
() => {
state.value = props.to
state.animating = false
emit('finish')
}
const easeOut = (t: number): number => 1 - (1 - t) ** 5
export const play =
({ props, state, api }) =>
() => {
animate(state, props, api)
}
export const animate = (state, props, api) => {
state.animating = true
state.value = props.from
if (props.from !== props.to) {
const startTime = performance.now()
const tick = () => {
const current = performance.now()
const elapsedTime = Math.min(current - startTime, props.duration)
const currentValue = props.from + (props.to - props.from) * easeOut(elapsedTime / props.duration)
if (elapsedTime === props.duration) {
api.onFinish()
return
}
state.value = currentValue
requestAnimationFrame(tick)
}
tick()
}
}
export const formattedValue =
({ state, props }) =>
() => {
// 类型检查
if (typeof state.value !== 'number' && typeof state.value !== 'string') return
if (typeof props.precision !== 'number') return
const numValue = Number(state.value)
if (isNaN(numValue) || !isFinite(numValue)) return
if (numValue === 0) {
return numValue.toFixed(props.precision)
}
let formatValue = numValue.toFixed(props.precision)
if (typeof props.separator === 'string' && props.separator !== '') {
const [integerPart, decimalPart] = formatValue.split('.')
formatValue =
integerPart.replace(/(\d)(?=(\d{3})+$)/g, '$1' + props.separator) + (decimalPart ? '.' + decimalPart : '')
}
return formatValue
}

View File

@ -0,0 +1,26 @@
import { play, formattedValue, onFinish } from './index'
export const api = ['state', 'play', 'formattedValue', 'onFinish']
export const renderless = (props, { onMounted, computed, reactive }, { emit }) => {
const api = {}
const state = reactive({
animating: true,
value: props.from,
showValue: computed(() => api.formattedValue(state, props))
})
onMounted(() => {
if (props.active) {
api.play(props, state)
}
})
Object.assign(api, {
state,
play: play({ props, state, api }),
formattedValue: formattedValue({ state, props }),
onFinish: onFinish({ emit, props, state })
})
return api
}

View File

@ -0,0 +1,11 @@
@import '../custom.less';
@import './vars.less';
@number-animation-item-prefix-cls: ~'@{css-prefix}number-animation';
.@{number-animation-item-prefix-cls} {
.inject-NumberAnimation-vars();
font-size: var(--tv-NumberAnimation-font-size);
font-weight: var(--tv-NumberAnimation-font-weight);
margin-bottom: var(--tv-NumberAnimation-margin-bottom);
}

View File

@ -0,0 +1,8 @@
.inject-NumberAnimation-vars() {
// 数字内容下间距
--tv-NumberAnimation-margin-bottom: 20px;
// 数字内容字体粗细
--tv-NumberAnimation-font-weight: var(--tv-font-weight-regular);
// 数字内容字体
--tv-NumberAnimation-font-size: var(--tv-font-size-heading-lg);
}

View File

@ -118,7 +118,7 @@
font-size: var(--tv-Table-icon-font-size); font-size: var(--tv-Table-icon-font-size);
border-radius: var(--tv-Table-check-icon-border-radius); border-radius: var(--tv-Table-check-icon-border-radius);
& path:last-child{ & path:last-child{
fill: var(--tv-Table-border-color); fill: var(--tv-Table-icon-border-color);
} }
& path:first-child { & path:first-child {

View File

@ -28,7 +28,9 @@
// 表格单元格字体大小 // 表格单元格字体大小
--tv-Table-td-font-size: var(--tv-font-size-default, 14px); --tv-Table-td-font-size: var(--tv-font-size-default, 14px);
// 表格边框颜色 // 表格边框颜色
--tv-Table-border-color: var(--tv-color-border-divider, #f0f0f0); --tv-Table-border-color: var(--tv-color-border-divider);
// 表格复选框边框颜色
--tv-Table-icon-border-color: var(--tv-color-border);
// 表头背景颜色 // 表头背景颜色
--tv-Table-thead-bg-color: var(--tv-color-bg-header, #f5f5f5); --tv-Table-thead-bg-color: var(--tv-color-bg-header, #f5f5f5);
// 表格图标字体大小 // 表格图标字体大小

View File

@ -137,6 +137,7 @@
"@opentiny/vue-month-table": "workspace:~", "@opentiny/vue-month-table": "workspace:~",
"@opentiny/vue-nav-menu": "workspace:~", "@opentiny/vue-nav-menu": "workspace:~",
"@opentiny/vue-notify": "workspace:~", "@opentiny/vue-notify": "workspace:~",
"@opentiny/vue-number-animation": "workspace:~",
"@opentiny/vue-numeric": "workspace:~", "@opentiny/vue-numeric": "workspace:~",
"@opentiny/vue-option": "workspace:~", "@opentiny/vue-option": "workspace:~",
"@opentiny/vue-option-group": "workspace:~", "@opentiny/vue-option-group": "workspace:~",

View File

@ -0,0 +1,3 @@
import { describe } from 'vitest'
describe('PC Mode', () => {})

View File

@ -0,0 +1,30 @@
/**
* Copyright (c) 2022 - present TinyVue Authors.
* Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd.
*
* Use of this source code is governed by an MIT-style license.
*
* THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL,
* BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR
* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS.
*
*/
import NumberAnimation from './src/index'
import '@opentiny/vue-theme/number-animation/index.less'
import { version } from './package.json'
/* istanbul ignore next */
NumberAnimation.install = function (Vue) {
Vue.component(NumberAnimation.name, NumberAnimation)
}
NumberAnimation.version = version
/* istanbul ignore next */
if (process.env.BUILD_TARGET === 'runtime') {
if (typeof window !== 'undefined' && window.Vue) {
NumberAnimation.install(window.Vue)
}
}
export default NumberAnimation

View File

@ -0,0 +1,26 @@
{
"name": "@opentiny/vue-number-animation",
"type": "module",
"version": "3.20.0",
"description": "",
"license": "MIT",
"sideEffects": false,
"main": "lib/index.js",
"module": "index.ts",
"scripts": {
"build": "pnpm -w build:ui $npm_package_name",
"//postversion": "pnpm build"
},
"dependencies": {
"@opentiny/vue-button": "workspace:~",
"@opentiny/vue-common": "workspace:~",
"@opentiny/vue-modal": "workspace:~",
"@opentiny/vue-renderless": "workspace:~",
"@opentiny/vue-statistic": "workspace:~",
"@opentiny/vue-theme": "workspace:~"
},
"devDependencies": {
"@opentiny-internal/vue-test-utils": "workspace:*",
"vitest": "catalog:"
}
}

View File

@ -0,0 +1,46 @@
import { $props, $prefix, $setup, defineComponent } from '@opentiny/vue-common'
import template from 'virtual-template?pc'
export const $constants = {
PREFIX: 'tiny-number-animation'
}
export const numberAnimationProps = {
...$props,
_constants: {
type: Object,
default: () => $constants
},
to: {
type: Number,
default: 0
},
precision: {
type: Number,
default: 0
},
separator: {
type: String,
default: ','
},
from: {
type: Number,
default: 0
},
active: {
type: Boolean,
default: true
},
duration: {
type: Number,
default: 2000
}
}
export default defineComponent({
name: $prefix + 'NumberAnimation',
props: numberAnimationProps,
setup(props, context) {
return $setup({ props, context, template })
}
})

View File

@ -0,0 +1,29 @@
<!--
* Copyright (c) 2022 - present TinyVue Authors.
* Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd.
*
* Use of this source code is governed by an MIT-style license.
*
* THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL,
* BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR
* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS.
*
-->
<template>
<div class="tiny-number-animation" ref="numberAnimationInstRef">
{{ state.showValue }}
</div>
</template>
<script lang="ts">
import { renderless, api } from '@opentiny/vue-renderless/number-animation/vue'
import { props, setup, defineComponent } from '@opentiny/vue-common'
export default defineComponent({
emits: ['finish'],
props: [...props, 'to', 'precision', 'separator', 'from', 'duration', 'active'],
setup(props, context) {
return setup({ props, context, renderless, api })
}
})
</script>