diff --git a/components/spin/Progress.tsx b/components/spin/Progress.tsx new file mode 100644 index 0000000000..ea77331396 --- /dev/null +++ b/components/spin/Progress.tsx @@ -0,0 +1,101 @@ +import { defineComponent, ref, computed, watchEffect } from 'vue'; + +export interface ProgressProps { + prefixCls: string; + percent: number; +} + +const viewSize = 100; +const borderWidth = viewSize / 5; +const radius = viewSize / 2 - borderWidth / 2; +const circumference = radius * 2 * Math.PI; +const position = 50; + +const CustomCircle = defineComponent({ + compatConfig: { MODE: 3 }, + inheritAttrs: false, + props: { + dotClassName: String, + style: Object, + hasCircleCls: Boolean, + }, + setup(props) { + const cStyle = computed(() => props.style || {}); + + return () => ( + + ); + }, +}); + +export default defineComponent({ + compatConfig: { MODE: 3 }, + name: 'Progress', + inheritAttrs: false, + props: { + percent: Number, + prefixCls: String, + }, + setup(props) { + const dotClassName = `${props.prefixCls}-dot`; + const holderClassName = `${dotClassName}-holder`; + const hideClassName = `${holderClassName}-hidden`; + + const render = ref(false); + + // ==================== Visible ===================== + watchEffect(() => { + if (props.percent !== 0) { + render.value = true; + } + }); + + // ==================== Progress ==================== + const safePtg = computed(() => Math.max(Math.min(props.percent, 100), 0)); + + const circleStyle = computed(() => ({ + strokeDashoffset: `${circumference / 4}`, + strokeDasharray: `${(circumference * safePtg.value) / 100} ${ + (circumference * (100 - safePtg.value)) / 100 + }`, + })); + + // ===================== Render ===================== + return () => { + if (!render.value) { + return null; + } + + return ( + + + + + + + ); + }; + }, +}); diff --git a/components/spin/Spin.tsx b/components/spin/Spin.tsx index 94155fd398..0e97d01df1 100644 --- a/components/spin/Spin.tsx +++ b/components/spin/Spin.tsx @@ -1,11 +1,22 @@ import type { VNode, ExtractPropTypes, PropType } from 'vue'; -import { onBeforeUnmount, cloneVNode, isVNode, defineComponent, shallowRef, watch } from 'vue'; +import { + onBeforeUnmount, + cloneVNode, + isVNode, + defineComponent, + shallowRef, + watch, + computed, +} from 'vue'; import { debounce } from 'throttle-debounce'; import PropTypes from '../_util/vue-types'; import { filterEmpty, getPropsSlot } from '../_util/props-util'; import initDefaultProps from '../_util/props-util/initDefaultProps'; import useStyle from './style'; import useConfigInject from '../config-provider/hooks/useConfigInject'; +import useCSSVarCls from '../config-provider/hooks/useCssVarCls'; +import Progress from './Progress'; +import usePercent from './usePercent'; export type SpinSize = 'small' | 'default' | 'large'; export const spinProps = () => ({ @@ -16,6 +27,8 @@ export const spinProps = () => ({ tip: PropTypes.any, delay: Number, indicator: PropTypes.any, + fullscreen: Boolean, + percent: [Number, String] as PropType, }); export type SpinProps = Partial>>; @@ -40,11 +53,16 @@ export default defineComponent({ size: 'default', spinning: true, wrapperClassName: '', + fullscreen: false, }), setup(props, { attrs, slots }) { const { prefixCls, size, direction } = useConfigInject('spin', props); - const [wrapSSR, hashId] = useStyle(prefixCls); + const rootCls = useCSSVarCls(prefixCls); + const [wrapCSSVar, hashId, cssVarCls] = useStyle(prefixCls, rootCls); const sSpinning = shallowRef(props.spinning && !shouldDelay(props.spinning, props.delay)); + + const mergedPercent = computed(() => usePercent(sSpinning.value, props.percent)); + let updateSpinning: any; watch( [() => props.spinning, () => props.delay], @@ -63,6 +81,7 @@ export default defineComponent({ onBeforeUnmount(() => { updateSpinning?.cancel(); }); + return () => { const { class: cls, ...divProps } = attrs; const { tip = slots.tip?.() } = props; @@ -78,8 +97,11 @@ export default defineComponent({ [cls as string]: !!cls, }; - function renderIndicator(prefixCls: string) { + function renderIndicator(prefixCls: string, percent: number) { const dotClassName = `${prefixCls}-dot`; + const holderClassName = `${dotClassName}-holder`; + const hideClassName = `${holderClassName}-hidden`; + let indicator = getPropsSlot(slots, props, 'indicator'); // should not be render default indicator when indicator value is null if (indicator === null) { @@ -89,43 +111,87 @@ export default defineComponent({ indicator = indicator.length === 1 ? indicator[0] : indicator; } if (isVNode(indicator)) { - return cloneVNode(indicator, { class: dotClassName }); + return cloneVNode(indicator, { class: dotClassName, percent }); } if (defaultIndicator && isVNode(defaultIndicator())) { - return cloneVNode(defaultIndicator(), { class: dotClassName }); + return cloneVNode(defaultIndicator(), { class: dotClassName, percent }); } return ( - - - - - - + <> + 0 && hideClassName]}> + + {[1, 2, 3, 4].map(i => ( + + ))} + + + {props.percent && } + ); } const spinElement = ( -
- {renderIndicator(prefixCls.value)} - {tip ?
{tip}
: null} +
+ {renderIndicator(prefixCls.value, mergedPercent.value.value)} + {tip ? ( +
+ {tip} +
+ ) : null}
); - if (children && filterEmpty(children).length) { + if (children && filterEmpty(children).length && !props.fullscreen) { const containerClassName = { [`${prefixCls.value}-container`]: true, [`${prefixCls.value}-blur`]: sSpinning.value, + [rootCls.value]: true, + [cssVarCls.value]: true, + [hashId.value]: true, }; - return wrapSSR( -
- {sSpinning.value &&
{spinElement}
} + return wrapCSSVar( +
+ {sSpinning.value && spinElement}
{children}
, ); } - return wrapSSR(spinElement); + + if (props.fullscreen) { + return wrapCSSVar( +
+ {spinElement} +
, + ); + } + + return wrapCSSVar(spinElement); }; }, }); diff --git a/components/spin/demo/custom-indicator.vue b/components/spin/demo/custom-indicator.vue index 566b6cefa4..cb1f2697f7 100644 --- a/components/spin/demo/custom-indicator.vue +++ b/components/spin/demo/custom-indicator.vue @@ -17,15 +17,38 @@ Use custom loading indicator. diff --git a/components/spin/demo/fullscreen.vue b/components/spin/demo/fullscreen.vue new file mode 100644 index 0000000000..2674935348 --- /dev/null +++ b/components/spin/demo/fullscreen.vue @@ -0,0 +1,51 @@ + +--- +order: 8 +title: + zh-CN: 全屏 + en-US: fullscreen +--- + +## zh-CN + +`fullscreen` 属性非常适合创建流畅的页面加载器。它添加了半透明覆盖层,并在其中心放置了一个旋转加载符号。 + +## en-US + +The `fullscreen` mode is perfect for creating page loaders. It adds a dimmed overlay with a centered spinner. + + + + + + diff --git a/components/spin/demo/index.vue b/components/spin/demo/index.vue index 2a1a556717..af8724d55a 100644 --- a/components/spin/demo/index.vue +++ b/components/spin/demo/index.vue @@ -7,6 +7,8 @@ + + + diff --git a/components/spin/demo/tip.vue b/components/spin/demo/tip.vue index c284e0952b..12a7acda69 100644 --- a/components/spin/demo/tip.vue +++ b/components/spin/demo/tip.vue @@ -1,6 +1,6 @@ --- -order: 4 +order: 4 title: zh-CN: 自定义描述文案 en-US: Customized description @@ -17,10 +17,23 @@ Customized description content. diff --git a/components/spin/index.en-US.md b/components/spin/index.en-US.md index 525e9bfd65..264a3106e6 100644 --- a/components/spin/index.en-US.md +++ b/components/spin/index.en-US.md @@ -17,7 +17,9 @@ When part of the page is waiting for asynchronous data or during a rendering pro | Property | Description | Type | Default Value | Version | | --- | --- | --- | --- | --- | | delay | specifies a delay in milliseconds for loading state (prevent flush) | number (milliseconds) | - | | +| fullscreen | Display a backdrop with the `Spin` component | boolean | false | | | indicator | vue node of the spinning indicator | vNode \|slot | - | | +| percent | The progress percentage, when set to `auto`, it will be an indeterminate progress | number \| 'auto' | - | | | size | size of Spin, options: `small`, `default` and `large` | string | `default` | | | spinning | whether Spin is visible | boolean | true | | | tip | customize description content when Spin has children | string \| slot | - | slot 3.0 | diff --git a/components/spin/index.zh-CN.md b/components/spin/index.zh-CN.md index f3537f6c82..d7df71d113 100644 --- a/components/spin/index.zh-CN.md +++ b/components/spin/index.zh-CN.md @@ -18,7 +18,9 @@ coverDark: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*i43_ToFrL8YAAA | 参数 | 说明 | 类型 | 默认值 | 版本 | | --- | --- | --- | --- | --- | | delay | 延迟显示加载效果的时间(防止闪烁) | number (毫秒) | - | | +| fullscreen | 显示带有`Spin`组件的背景 | boolean | false | | | indicator | 加载指示符 | vNode \| slot | - | | +| percent | 展示进度,当设置`percent="auto"`时会预估一个永远不会停止的进度 | number \| 'auto' | - | | | size | 组件大小,可选值为 `small` `default` `large` | string | `default` | | | spinning | 是否为加载中状态 | boolean | true | | | tip | 当作为包裹元素时,可以自定义描述文案 | string \| slot | - | slot 3.0 | diff --git a/components/spin/style/index.ts b/components/spin/style/index.ts index 2eccec181a..1aba8430f8 100644 --- a/components/spin/style/index.ts +++ b/components/spin/style/index.ts @@ -1,18 +1,34 @@ import type { CSSObject } from '../../_util/cssinjs'; import { Keyframes } from '../../_util/cssinjs'; -import type { FullToken, GenerateStyle } from '../../theme/internal'; -import { genComponentStyleHook, mergeToken } from '../../theme/internal'; +import type { FullToken, GenerateStyle, GetDefaultToken } from '../../theme/internal'; +import { genStyleHooks, mergeToken } from '../../theme/internal'; import { resetComponent } from '../../style'; export interface ComponentToken { - contentHeight: number; + /** + * @desc 内容区域高度 + * @descEN Height of content area + */ + contentHeight: number | string; + /** + * @desc 加载图标尺寸 + * @descEN Loading icon size + */ + dotSize: number; + /** + * @desc 小号加载图标尺寸 + * @descEN Small loading icon size + */ + dotSizeSM: number; + /** + * @desc 大号加载图标尺寸 + * @descEN Large loading icon size + */ + dotSizeLG: number; } interface SpinToken extends FullToken<'Spin'> { spinDotDefault: string; - spinDotSize: number; - spinDotSizeSM: number; - spinDotSizeLG: number; } const antSpinMove = new Keyframes('antSpinMove', { @@ -23,219 +39,309 @@ const antRotate = new Keyframes('antRotate', { to: { transform: 'rotate(405deg)' }, }); -const genSpinStyle: GenerateStyle = (token: SpinToken): CSSObject => ({ - [`${token.componentCls}`]: { - ...resetComponent(token), - position: 'absolute', - display: 'none', - color: token.colorPrimary, - textAlign: 'center', - verticalAlign: 'middle', - opacity: 0, - transition: `transform ${token.motionDurationSlow} ${token.motionEaseInOutCirc}`, - - '&-spinning': { - position: 'static', - display: 'inline-block', - opacity: 1, - }, +const genSpinStyle: GenerateStyle = (token: SpinToken): CSSObject => { + const { componentCls, calc } = token; + return { + [componentCls]: { + ...resetComponent(token), + position: 'absolute', + display: 'none', + color: token.colorPrimary, + fontSize: 0, + textAlign: 'center', + verticalAlign: 'middle', + opacity: 0, + transition: `transform ${token.motionDurationSlow} ${token.motionEaseInOutCirc}`, - '&-nested-loading': { - position: 'relative', - [`> div > ${token.componentCls}`]: { - position: 'absolute', - top: 0, - insetInlineStart: 0, - zIndex: 4, - display: 'block', - width: '100%', - height: '100%', - maxHeight: token.contentHeight, + '&-spinning': { + position: 'relative', + display: 'inline-block', + opacity: 1, + }, - [`${token.componentCls}-dot`]: { - position: 'absolute', - top: '50%', - insetInlineStart: '50%', - margin: -token.spinDotSize / 2, + [`${componentCls}-text`]: { + fontSize: token.fontSize, + paddingTop: calc(calc(token.dotSize).sub(token.fontSize)).div(2).add(2).equal(), + }, + + '&-fullscreen': { + position: 'fixed', + width: '100vw', + height: '100vh', + backgroundColor: token.colorBgMask, + zIndex: token.zIndexPopupBase, + inset: 0, + display: 'flex', + alignItems: 'center', + flexDirection: 'column', + justifyContent: 'center', + opacity: 0, + visibility: 'hidden', + transition: `all ${token.motionDurationMid}`, + '&-show': { + opacity: 1, + visibility: 'visible', + }, + + [componentCls]: { + [`${componentCls}-dot-holder`]: { + color: token.colorWhite, + }, + [`${componentCls}-text`]: { + color: token.colorTextLightSolid, + }, }, + }, - [`${token.componentCls}-text`]: { + '&-nested-loading': { + position: 'relative', + [`> ${componentCls}`]: { position: 'absolute', - top: '50%', + top: 0, + insetInlineStart: 0, + zIndex: 4, + display: 'block', width: '100%', - paddingTop: (token.spinDotSize - token.fontSize) / 2 + 2, - textShadow: `0 1px 2px ${token.colorBgContainer}`, // FIXME: shadow - }, + height: '100%', + maxHeight: token.contentHeight, + [`${componentCls}-dot`]: { + position: 'absolute', + top: '50%', + insetInlineStart: '50%', + margin: calc(token.dotSize).mul(-1).div(2).equal(), + }, - [`&${token.componentCls}-show-text ${token.componentCls}-dot`]: { - marginTop: -(token.spinDotSize / 2) - 10, - }, + [`${componentCls}-text`]: { + position: 'absolute', + top: '50%', + width: '100%', + // paddingTop: (token.spinDotSize - token.fontSize) / 2 + 2, + textShadow: `0 1px 2px ${token.colorBgContainer}`, // FIXME: shadow + }, - '&-sm': { - [`${token.componentCls}-dot`]: { - margin: -token.spinDotSizeSM / 2, + [`&${componentCls}-show-text ${componentCls}-dot`]: { + marginTop: calc(token.dotSize).div(2).mul(-1).sub(10).equal(), }, - [`${token.componentCls}-text`]: { - paddingTop: (token.spinDotSizeSM - token.fontSize) / 2 + 2, + + '&-sm': { + [`${componentCls}-dot`]: { + margin: calc(token.dotSizeSM).mul(-1).div(2).equal(), + }, + [`${componentCls}-text`]: { + paddingTop: calc(calc(token.dotSizeSM).sub(token.fontSize)).div(2).add(2).equal(), + }, + [`&${componentCls}-show-text ${componentCls}-dot`]: { + marginTop: calc(token.dotSizeSM).div(2).mul(-1).sub(10).equal(), + }, }, - [`&${token.componentCls}-show-text ${token.componentCls}-dot`]: { - marginTop: -(token.spinDotSizeSM / 2) - 10, + + '&-lg': { + [`${componentCls}-dot`]: { + margin: calc(token.dotSizeLG).mul(-1).div(2).equal(), + }, + [`${componentCls}-text`]: { + paddingTop: calc(calc(token.dotSizeLG).sub(token.fontSize)).div(2).add(2).equal(), + }, + [`&${componentCls}-show-text ${componentCls}-dot`]: { + marginTop: calc(token.dotSizeLG).div(2).mul(-1).sub(10).equal(), + }, }, }, - '&-lg': { - [`${token.componentCls}-dot`]: { - margin: -(token.spinDotSizeLG / 2), - }, - [`${token.componentCls}-text`]: { - paddingTop: (token.spinDotSizeLG - token.fontSize) / 2 + 2, + [`${componentCls}-container`]: { + position: 'relative', + transition: `opacity ${token.motionDurationSlow}`, + + '&::after': { + position: 'absolute', + top: 0, + insetInlineEnd: 0, + bottom: 0, + insetInlineStart: 0, + zIndex: 10, + width: '100%', + height: '100%', + background: token.colorBgContainer, + opacity: 0, + transition: `all ${token.motionDurationSlow}`, + content: '""', + pointerEvents: 'none', }, - [`&${token.componentCls}-show-text ${token.componentCls}-dot`]: { - marginTop: -(token.spinDotSizeLG / 2) - 10, + }, + + [`${componentCls}-blur`]: { + clear: 'both', + opacity: 0.5, + userSelect: 'none', + pointerEvents: 'none', + + [`&::after`]: { + opacity: 0.4, + pointerEvents: 'auto', }, }, }, - [`${token.componentCls}-container`]: { - position: 'relative', - transition: `opacity ${token.motionDurationSlow}`, + // tip + // ------------------------------ + [`&-tip`]: { + color: token.spinDotDefault, + }, - '&::after': { - position: 'absolute', - top: 0, - insetInlineEnd: 0, - bottom: 0, - insetInlineStart: 0, - zIndex: 10, - width: '100%', - height: '100%', - background: token.colorBgContainer, + // holder + // ------------------------------ + [`${componentCls}-dot-holder`]: { + width: '1em', + height: '1em', + fontSize: token.dotSize, + display: 'inline-block', + transition: `transform ${token.motionDurationSlow} ease, opacity ${token.motionDurationSlow} ease`, + transformOrigin: '50% 50%', + lineHeight: 1, + color: token.colorPrimary, + + '&-hidden': { + transform: 'scale(0.3)', opacity: 0, - transition: `all ${token.motionDurationSlow}`, - content: '""', - pointerEvents: 'none', }, }, - [`${token.componentCls}-blur`]: { - clear: 'both', - opacity: 0.5, - userSelect: 'none', - pointerEvents: 'none', - - [`&::after`]: { - opacity: 0.4, - pointerEvents: 'auto', - }, + // progress + // ------------------------------ + [`${componentCls}-dot-progress`]: { + position: 'absolute', + inset: 0, }, - }, - // tip - // ------------------------------ - [`&-tip`]: { - color: token.spinDotDefault, - }, + // dots + // ------------------------------ + [`${componentCls}-dot`]: { + position: 'relative', + display: 'inline-block', + fontSize: token.dotSize, + width: '1em', + height: '1em', - // dots - // ------------------------------ - [`${token.componentCls}-dot`]: { - position: 'relative', - display: 'inline-block', - fontSize: token.spinDotSize, - width: '1em', - height: '1em', + '&-item': { + position: 'absolute', + display: 'block', + width: calc(token.dotSize).sub(calc(token.marginXXS).div(2)).div(2).equal(), + height: calc(token.dotSize).sub(calc(token.marginXXS).div(2)).div(2).equal(), + backgroundColor: token.colorPrimary, + borderRadius: '100%', + transform: 'scale(0.75)', + transformOrigin: '50% 50%', + opacity: 0.3, + animationName: antSpinMove, + animationDuration: '1s', + animationIterationCount: 'infinite', + animationTimingFunction: 'linear', + animationDirection: 'alternate', - '&-item': { - position: 'absolute', - display: 'block', - width: (token.spinDotSize - token.marginXXS / 2) / 2, - height: (token.spinDotSize - token.marginXXS / 2) / 2, - backgroundColor: token.colorPrimary, - borderRadius: '100%', - transform: 'scale(0.75)', - transformOrigin: '50% 50%', - opacity: 0.3, - animationName: antSpinMove, - animationDuration: '1s', - animationIterationCount: 'infinite', - animationTimingFunction: 'linear', - animationDirection: 'alternate', - - '&:nth-child(1)': { - top: 0, - insetInlineStart: 0, - }, + '&:nth-child(1)': { + top: 0, + insetInlineStart: 0, + }, - '&:nth-child(2)': { - top: 0, - insetInlineEnd: 0, - animationDelay: '0.4s', + '&:nth-child(2)': { + top: 0, + insetInlineEnd: 0, + animationDelay: '0.4s', + }, + + '&:nth-child(3)': { + insetInlineEnd: 0, + bottom: 0, + animationDelay: '0.8s', + }, + + '&:nth-child(4)': { + bottom: 0, + insetInlineStart: 0, + animationDelay: '1.2s', + }, }, - '&:nth-child(3)': { - insetInlineEnd: 0, - bottom: 0, - animationDelay: '0.8s', + '&-spin': { + transform: 'rotate(45deg)', + animationName: antRotate, + animationDuration: '1.2s', + animationIterationCount: 'infinite', + animationTimingFunction: 'linear', }, - '&:nth-child(4)': { - bottom: 0, - insetInlineStart: 0, - animationDelay: '1.2s', + '&-circle': { + strokeLinecap: 'round', + transition: ['stroke-dashoffset', 'stroke-dasharray', 'stroke', 'stroke-width', 'opacity'] + .map(item => `${item} ${token.motionDurationSlow} ease`) + .join(','), + fillOpacity: 0, + stroke: 'currentcolor', }, - }, - '&-spin': { - transform: 'rotate(45deg)', - animationName: antRotate, - animationDuration: '1.2s', - animationIterationCount: 'infinite', - animationTimingFunction: 'linear', + '&-circle-bg': { + stroke: token.colorFillSecondary, + }, }, - }, - // Sizes - // ------------------------------ + // Sizes + // ------------------------------ - // small - [`&-sm ${token.componentCls}-dot`]: { - fontSize: token.spinDotSizeSM, - - i: { - width: (token.spinDotSizeSM - token.marginXXS / 2) / 2, - height: (token.spinDotSizeSM - token.marginXXS / 2) / 2, + [`&-sm ${componentCls}-dot`]: { + '&, &-holder': { + fontSize: token.dotSizeSM, + }, + }, + // small + [`&-sm ${componentCls}-dot-holder`]: { + i: { + width: calc(calc(token.dotSizeSM).sub(calc(token.marginXXS).div(2))) + .div(2) + .equal(), + height: calc(calc(token.dotSizeSM).sub(calc(token.marginXXS).div(2))) + .div(2) + .equal(), + }, }, - }, - // large - [`&-lg ${token.componentCls}-dot`]: { - fontSize: token.spinDotSizeLG, + // large + [`&-lg ${componentCls}-dot`]: { + '&, &-holder': { + fontSize: token.dotSizeLG, + }, + }, + [`&-lg ${componentCls}-dot-holder`]: { + i: { + width: calc(calc(token.dotSizeLG).sub(token.marginXXS)).div(2).equal(), + height: calc(calc(token.dotSizeLG).sub(token.marginXXS)).div(2).equal(), + }, + }, - i: { - width: (token.spinDotSizeLG - token.marginXXS) / 2, - height: (token.spinDotSizeLG - token.marginXXS) / 2, + [`&${componentCls}-show-text ${componentCls}-text`]: { + display: 'block', }, }, + }; +}; - [`&${token.componentCls}-show-text ${token.componentCls}-text`]: { - display: 'block', - }, - }, -}); +export const prepareComponentToken: GetDefaultToken<'Spin'> = token => { + const { controlHeightLG, controlHeight } = token; + return { + contentHeight: 400, + dotSize: controlHeightLG / 2, + dotSizeSM: controlHeightLG * 0.35, + dotSizeLG: controlHeight, + }; +}; // ============================== Export ============================== -export default genComponentStyleHook( +export default genStyleHooks( 'Spin', token => { const spinToken = mergeToken(token, { spinDotDefault: token.colorTextDescription, - spinDotSize: token.controlHeightLG / 2, - spinDotSizeSM: token.controlHeightLG * 0.35, - spinDotSizeLG: token.controlHeight, }); return [genSpinStyle(spinToken)]; }, - { - contentHeight: 400, - }, + prepareComponentToken, ); diff --git a/components/spin/usePercent.ts b/components/spin/usePercent.ts new file mode 100644 index 0000000000..39432e8d93 --- /dev/null +++ b/components/spin/usePercent.ts @@ -0,0 +1,46 @@ +import { ref, computed, watchEffect } from 'vue'; + +const AUTO_INTERVAL = 200; +const STEP_BUCKETS: [limit: number, stepPtg: number][] = [ + [30, 0.05], + [70, 0.03], + [96, 0.01], +]; + +export default function usePercent(spinning: boolean, percent?: number | 'auto') { + const mockPercent = ref(0); + const mockIntervalRef = ref | null>(null); + + const isAuto = ref(percent === 'auto'); + + watchEffect(() => { + // 清除现有定时器 + if (mockIntervalRef.value || !isAuto.value || !spinning) { + clearInterval(mockIntervalRef.value); + mockIntervalRef.value = null; + } + + if (isAuto.value && spinning) { + mockPercent.value = 0; + + mockIntervalRef.value = setInterval(() => { + mockPercent.value = calculateNextPercent(mockPercent.value); + }, AUTO_INTERVAL); + } + }); + + return computed(() => (isAuto.value ? mockPercent.value : +percent)); +} + +function calculateNextPercent(prev: number): number { + const restPTG = 100 - prev; + + for (let i = 0; i < STEP_BUCKETS.length; i += 1) { + const [limit, stepPtg] = STEP_BUCKETS[i]; + if (prev <= limit) { + return prev + restPTG * stepPtg; + } + } + + return prev; +}