pull/8215/merge
JoeXu727 2025-06-02 08:54:30 +00:00 committed by GitHub
commit 9c697c3b5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 687 additions and 209 deletions

View File

@ -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 () => (
<circle
class={[
`${props.dotClassName}-circle`,
{
[`${props.dotClassName}-circle-bg`]: props.hasCircleCls,
},
]}
r={radius}
cx={position}
cy={position}
stroke-width={borderWidth}
style={cStyle.value}
/>
);
},
});
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 (
<span
class={[holderClassName, `${dotClassName}-progress`, safePtg.value <= 0 && hideClassName]}
>
<svg
viewBox={`0 0 ${viewSize} ${viewSize}`}
{...({
role: 'progressbar',
'aria-valuemin': 0,
'aria-valuemax': 100,
'aria-valuenow': safePtg.value,
} as any)}
>
<CustomCircle dotClassName={dotClassName} hasCircleCls={true} />
<CustomCircle dotClassName={dotClassName} style={circleStyle.value} />
</svg>
</span>
);
};
},
});

View File

@ -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<number | 'auto'>,
});
export type SpinProps = Partial<ExtractPropTypes<ReturnType<typeof spinProps>>>;
@ -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 (
<span class={`${dotClassName} ${prefixCls}-dot-spin`}>
<i class={`${prefixCls}-dot-item`} />
<i class={`${prefixCls}-dot-item`} />
<i class={`${prefixCls}-dot-item`} />
<i class={`${prefixCls}-dot-item`} />
</span>
<>
<span class={[holderClassName, percent > 0 && hideClassName]}>
<span class={[dotClassName, `${prefixCls}-dot-spin`]}>
{[1, 2, 3, 4].map(i => (
<i class={`${prefixCls}-dot-item`} key={i} />
))}
</span>
</span>
{props.percent && <Progress prefixCls={prefixCls} percent={percent} />}
</>
);
}
const spinElement = (
<div {...divProps} class={spinClassName} aria-live="polite" aria-busy={sSpinning.value}>
{renderIndicator(prefixCls.value)}
{tip ? <div class={`${prefixCls.value}-text`}>{tip}</div> : null}
<div
{...divProps}
key="loading"
class={[spinClassName, rootCls.value, cssVarCls.value]}
aria-live="polite"
aria-busy={sSpinning.value}
>
{renderIndicator(prefixCls.value, mergedPercent.value.value)}
{tip ? (
<div class={[`${prefixCls.value}-text`, hashId.value, rootCls.value, cssVarCls.value]}>
{tip}
</div>
) : null}
</div>
);
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(
<div class={[`${prefixCls.value}-nested-loading`, props.wrapperClassName, hashId.value]}>
{sSpinning.value && <div key="loading">{spinElement}</div>}
return wrapCSSVar(
<div
class={[
`${prefixCls.value}-nested-loading`,
props.wrapperClassName,
hashId.value,
rootCls.value,
cssVarCls.value,
]}
>
{sSpinning.value && spinElement}
<div class={containerClassName} key="container">
{children}
</div>
</div>,
);
}
return wrapSSR(spinElement);
if (props.fullscreen) {
return wrapCSSVar(
<div
class={[
`${prefixCls.value}-fullscreen`,
{
[`${prefixCls.value}-fullscreen-show`]: sSpinning.value,
},
hashId.value,
rootCls.value,
cssVarCls.value,
]}
>
{spinElement}
</div>,
);
}
return wrapCSSVar(spinElement);
};
},
});

View File

@ -17,15 +17,38 @@ Use custom loading indicator.
</docs>
<template>
<a-spin :indicator="indicator" />
<a-flex align="center" gap="middle">
<a-spin :indicator="smallIndicator" />
<a-spin :indicator="indicator" />
<a-spin :indicator="largeIndicator" />
<a-spin :indicator="customIndicator" />
</a-flex>
</template>
<script lang="ts" setup>
import { LoadingOutlined } from '@ant-design/icons-vue';
import { h } from 'vue';
const smallIndicator = h(LoadingOutlined, {
style: {
fontSize: '16px',
},
spin: true,
});
const indicator = h(LoadingOutlined, {
style: {
fontSize: '24px',
},
spin: true,
});
const largeIndicator = h(LoadingOutlined, {
style: {
fontSize: '36px',
},
spin: true,
});
const customIndicator = h(LoadingOutlined, {
style: {
fontSize: '48px',
},
spin: true,
});
</script>

View File

@ -0,0 +1,51 @@
<docs>
---
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.
</docs>
<template>
<a-button @click="showLoader">Show fullscreen</a-button>
<a-spin :spinning="spinning" :percent="percent" fullscreen />
</template>
<script setup>
import { ref, onUnmounted } from 'vue';
const spinning = ref(false);
const percent = ref(0);
let interval = null;
const showLoader = () => {
spinning.value = true;
let ptg = -10;
interval = setInterval(() => {
ptg += 5;
percent.value = ptg;
if (ptg > 120) {
if (interval) clearInterval(interval);
spinning.value = false;
percent.value = 0;
}
}, 100);
};
//
onUnmounted(() => {
if (interval) clearInterval(interval);
});
</script>

View File

@ -7,6 +7,8 @@
<tip />
<delay />
<custom-indicator />
<fullscreen />
<percent />
</demo-sort>
</template>
<script lang="ts">
@ -16,6 +18,8 @@ import Inside from './inside.vue';
import Nested from './nested.vue';
import Tip from './tip.vue';
import Delay from './delay.vue';
import Fullscreen from './fullscreen.vue';
import Percent from './percent.vue';
import CustomIndicator from './custom-indicator.vue';
import CN from '../index.zh-CN.md';
import US from '../index.en-US.md';
@ -31,6 +35,8 @@ export default defineComponent({
Tip,
Delay,
CustomIndicator,
Fullscreen,
Percent,
},
setup() {
return {};

View File

@ -0,0 +1,62 @@
<docs>
---
order: 7
title:
zh-CN: 进度
en-US: progress
---
## zh-CN
展示进度当设置 `percent="auto"` 时会预估一个永远不会停止的进度条
## en-US
Show the progress. When `percent="auto"` is set, an indeterminate progress will be displayed.
</docs>
<template>
<div style="display: flex; align-items: center; gap: 16px">
<!-- 开关组件 -->
<a-switch
v-model:checked="auto"
checked-children="Auto"
un-checked-children="Auto"
@change="toggleAuto"
/>
<!-- 加载动画组件 -->
<a-spin :percent="mergedPercent" size="small" />
<a-spin :percent="mergedPercent" />
<a-spin :percent="mergedPercent" size="large" />
</div>
</template>
<script setup>
import { ref, computed, watch, onUnmounted, onMounted, getCurrentInstance } from 'vue';
const auto = ref(false); //
const percent = ref(-50); //
let interval = null;
const mergedPercent = computed(() => (auto.value ? 'auto' : percent.value));
const toggleAuto = checked => {
auto.value = checked;
};
onMounted(() => {
interval = setInterval(() => {
percent.value = percent.value + 5;
if (percent.value > 150) {
percent.value = -50;
}
}, 100);
});
onUnmounted(() => {
if (interval) clearInterval(interval);
});
</script>
<style scoped></style>

View File

@ -1,6 +1,6 @@
<docs>
---
order: 4
order: 4
title:
zh-CN: 自定义描述文案
en-US: Customized description
@ -17,10 +17,23 @@ Customized description content.
</docs>
<template>
<a-spin tip="Loading...">
<a-alert
message="Alert message title"
description="Further details about the context of this alert."
></a-alert>
</a-spin>
<a-flex gap="middle" vertical>
<a-flex gap="middle">
<a-spin tip="Loading" size="small">
<div style="padding: 50px; background: rgba(0, 0, 0, 0.05); border-radius: 4px"></div>
</a-spin>
<a-spin tip="Loading">
<div style="padding: 50px; background: rgba(0, 0, 0, 0.05); border-radius: 4px"></div>
</a-spin>
<a-spin tip="Loading" size="large">
<div style="padding: 50px; background: rgba(0, 0, 0, 0.05); border-radius: 4px"></div>
</a-spin>
</a-flex>
<a-spin tip="Loading...">
<a-alert
message="Alert message title"
description="Further details about the context of this alert."
></a-alert>
</a-spin>
</a-flex>
</template>

View File

@ -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 |

View File

@ -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 |

View File

@ -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<SpinToken> = (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}`,
const genSpinStyle: GenerateStyle<SpinToken> = (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}`,
'&-spinning': {
position: 'static',
display: 'inline-block',
opacity: 1,
},
'&-spinning': {
position: 'relative',
display: 'inline-block',
opacity: 1,
},
'&-nested-loading': {
position: 'relative',
[`> div > ${token.componentCls}`]: {
position: 'absolute',
top: 0,
insetInlineStart: 0,
zIndex: 4,
display: 'block',
width: '100%',
height: '100%',
maxHeight: token.contentHeight,
[`${componentCls}-text`]: {
fontSize: token.fontSize,
paddingTop: calc(calc(token.dotSize).sub(token.fontSize)).div(2).add(2).equal(),
},
[`${token.componentCls}-dot`]: {
position: 'absolute',
top: '50%',
insetInlineStart: '50%',
margin: -token.spinDotSize / 2,
'&-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',
},
[`${token.componentCls}-text`]: {
position: 'absolute',
top: '50%',
width: '100%',
paddingTop: (token.spinDotSize - token.fontSize) / 2 + 2,
textShadow: `0 1px 2px ${token.colorBgContainer}`, // FIXME: shadow
},
[`&${token.componentCls}-show-text ${token.componentCls}-dot`]: {
marginTop: -(token.spinDotSize / 2) - 10,
},
'&-sm': {
[`${token.componentCls}-dot`]: {
margin: -token.spinDotSizeSM / 2,
[componentCls]: {
[`${componentCls}-dot-holder`]: {
color: token.colorWhite,
},
[`${token.componentCls}-text`]: {
paddingTop: (token.spinDotSizeSM - token.fontSize) / 2 + 2,
},
[`&${token.componentCls}-show-text ${token.componentCls}-dot`]: {
marginTop: -(token.spinDotSizeSM / 2) - 10,
},
},
'&-lg': {
[`${token.componentCls}-dot`]: {
margin: -(token.spinDotSizeLG / 2),
},
[`${token.componentCls}-text`]: {
paddingTop: (token.spinDotSizeLG - token.fontSize) / 2 + 2,
},
[`&${token.componentCls}-show-text ${token.componentCls}-dot`]: {
marginTop: -(token.spinDotSizeLG / 2) - 10,
[`${componentCls}-text`]: {
color: token.colorTextLightSolid,
},
},
},
[`${token.componentCls}-container`]: {
'&-nested-loading': {
position: 'relative',
transition: `opacity ${token.motionDurationSlow}`,
'&::after': {
[`> ${componentCls}`]: {
position: 'absolute',
top: 0,
insetInlineEnd: 0,
bottom: 0,
insetInlineStart: 0,
zIndex: 10,
zIndex: 4,
display: 'block',
width: '100%',
height: '100%',
background: token.colorBgContainer,
opacity: 0,
transition: `all ${token.motionDurationSlow}`,
content: '""',
maxHeight: token.contentHeight,
[`${componentCls}-dot`]: {
position: 'absolute',
top: '50%',
insetInlineStart: '50%',
margin: calc(token.dotSize).mul(-1).div(2).equal(),
},
[`${componentCls}-text`]: {
position: 'absolute',
top: '50%',
width: '100%',
// paddingTop: (token.spinDotSize - token.fontSize) / 2 + 2,
textShadow: `0 1px 2px ${token.colorBgContainer}`, // FIXME: shadow
},
[`&${componentCls}-show-text ${componentCls}-dot`]: {
marginTop: calc(token.dotSize).div(2).mul(-1).sub(10).equal(),
},
'&-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(),
},
},
'&-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(),
},
},
},
[`${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',
},
},
[`${componentCls}-blur`]: {
clear: 'both',
opacity: 0.5,
userSelect: 'none',
pointerEvents: 'none',
[`&::after`]: {
opacity: 0.4,
pointerEvents: 'auto',
},
},
},
[`${token.componentCls}-blur`]: {
clear: 'both',
opacity: 0.5,
userSelect: 'none',
pointerEvents: 'none',
[`&::after`]: {
opacity: 0.4,
pointerEvents: 'auto',
},
// tip
// ------------------------------
[`&-tip`]: {
color: token.spinDotDefault,
},
},
// tip
// ------------------------------
[`&-tip`]: {
color: token.spinDotDefault,
},
// dots
// ------------------------------
[`${token.componentCls}-dot`]: {
position: 'relative',
display: 'inline-block',
fontSize: token.spinDotSize,
width: '1em',
height: '1em',
'&-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)',
// 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%',
opacity: 0.3,
animationName: antSpinMove,
animationDuration: '1s',
animationIterationCount: 'infinite',
animationTimingFunction: 'linear',
animationDirection: 'alternate',
lineHeight: 1,
color: token.colorPrimary,
'&:nth-child(1)': {
top: 0,
insetInlineStart: 0,
},
'&: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',
'&-hidden': {
transform: 'scale(0.3)',
opacity: 0,
},
},
'&-spin': {
transform: 'rotate(45deg)',
animationName: antRotate,
animationDuration: '1.2s',
animationIterationCount: 'infinite',
animationTimingFunction: 'linear',
// progress
// ------------------------------
[`${componentCls}-dot-progress`]: {
position: 'absolute',
inset: 0,
},
// dots
// ------------------------------
[`${componentCls}-dot`]: {
position: 'relative',
display: 'inline-block',
fontSize: token.dotSize,
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',
'&:nth-child(1)': {
top: 0,
insetInlineStart: 0,
},
'&: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',
},
},
'&-spin': {
transform: 'rotate(45deg)',
animationName: antRotate,
animationDuration: '1.2s',
animationIterationCount: 'infinite',
animationTimingFunction: 'linear',
},
'&-circle': {
strokeLinecap: 'round',
transition: ['stroke-dashoffset', 'stroke-dasharray', 'stroke', 'stroke-width', 'opacity']
.map(item => `${item} ${token.motionDurationSlow} ease`)
.join(','),
fillOpacity: 0,
stroke: 'currentcolor',
},
'&-circle-bg': {
stroke: token.colorFillSecondary,
},
},
// Sizes
// ------------------------------
[`&-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 ${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(),
},
},
[`&${componentCls}-show-text ${componentCls}-text`]: {
display: 'block',
},
},
};
};
// Sizes
// ------------------------------
// small
[`&-sm ${token.componentCls}-dot`]: {
fontSize: token.spinDotSizeSM,
i: {
width: (token.spinDotSizeSM - token.marginXXS / 2) / 2,
height: (token.spinDotSizeSM - token.marginXXS / 2) / 2,
},
},
// large
[`&-lg ${token.componentCls}-dot`]: {
fontSize: token.spinDotSizeLG,
i: {
width: (token.spinDotSizeLG - token.marginXXS) / 2,
height: (token.spinDotSizeLG - token.marginXXS) / 2,
},
},
[`&${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<SpinToken>(token, {
spinDotDefault: token.colorTextDescription,
spinDotSize: token.controlHeightLG / 2,
spinDotSizeSM: token.controlHeightLG * 0.35,
spinDotSizeLG: token.controlHeight,
});
return [genSpinStyle(spinToken)];
},
{
contentHeight: 400,
},
prepareComponentToken,
);

View File

@ -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<ReturnType<typeof setInterval> | 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;
}