import React, { useMemo, useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
/** 容器:用于定位百分比文本和设置整体尺寸 */
const ProgressWrapper = styled.div`
position: relative;
width: ${props => props.$size}px;
height: ${props => props.$size}px;
`;
/** 环形路径样式:通用于主环和外环 */
const Circle = styled.circle`
transition: stroke-dashoffset 0.3s ease;
stroke: ${props => props.$color};
stroke-width: ${props => props.$strokeWidth}px;
fill: none;
${props => props.$continuous && 'stroke-linecap: round;'} // 连续外圈需要圆头线帽
`;
/** SVG包裹层:设置3D倾斜效果 */
const SvgWrapper = styled.div`
perspective: 500px;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
svg {
transform: rotateX(60deg) rotateZ(0deg); // 倾斜角度创建3D视觉
transform-style: preserve-3d;
}
`;
/** 中心文本样式,可通过 prop 定制 */
const PercentText = styled.div`
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 24px;
font-weight: bold;
color: #fff;
${props => props.$customStyle || ''}
`;
const StepProgressCircle = ({
percent = 0, // 外部传入的目标百分比
steps = { count: 12, gap: 2 }, // 主环分段数量和间隔
trailColor = 'rgba(0, 0, 0, 0.06)', // 未激活颜色
strokeWidth = 10, // 主环线宽
activeColor = '#1677ff', // 激活颜色
size = 120, // 整体尺寸
outerStrokeWidth = 6, // 外圈线宽
gapBetween = 8, // 主环与外圈的间距
showPercent = true, // 是否显示数值
percentTextStyle = '', // 百分比文本样式
}) => {
const [animatedPercent, setAnimatedPercent] = useState(0); // 动画值
const requestRef = useRef(); // 用于记录 requestAnimationFrame 的引用
/** 触发百分比动画(初始或更新) */
useEffect(() => {
let start = null;
const duration = 1000; // 动画持续时间
const animate = (timestamp) => {
if (!start) start = timestamp;
const progress = timestamp - start;
const eased = Math.min(progress / duration, 1); // 线性补间
setAnimatedPercent(percent * eased); // 更新动画百分比
if (progress < duration) {
requestRef.current = requestAnimationFrame(animate);
} else {
setAnimatedPercent(percent); // 确保最后为完整值
cancelAnimationFrame(requestRef.current);
}
};
// 重启动画
cancelAnimationFrame(requestRef.current);
setAnimatedPercent(0);
requestRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestRef.current);
}, [percent]);
/** 圆的半径与总长计算(基于视图Box 100x100) */
const mainRadius = 50 - strokeWidth / 2 - gapBetween - outerStrokeWidth;
const outerRadius = 50 - outerStrokeWidth / 2;
const mainCircumference = 2 * Math.PI * mainRadius;
const outerCircumference = 2 * Math.PI * outerRadius;
// 外圈偏移(用于环形动画)
const outerDashoffset = outerCircumference * (1 - animatedPercent / 100);
const outerDasharray = outerCircumference;
/** 主环分段信息:计算每段 dasharray 和 dashoffset */
const stepInfo = useMemo(() => {
const { count, gap } = steps;
const gapLength = (mainCircumference * gap) / 100; // 间隔长度
const visibleLength = mainCircumference / count - gapLength;
return Array.from({ length: count }).map((_, index) => ({
dasharray: `${visibleLength} ${mainCircumference - visibleLength}`,
dashoffset: -index * (visibleLength + gapLength),
}));
}, [steps, mainCircumference]);
// 当前应高亮的段数
const activeIndex = useMemo(() => {
return Math.floor((animatedPercent / 100) * steps.count);
}, [animatedPercent, steps.count]);
/** 百分比格式化:整数保留整型,小数保留一位 */
const formatPercent = (value) =>
Number.isInteger(value) ? value : value.toFixed(1);
return (
<ProgressWrapper $size={size}>
<SvgWrapper>
<svg viewBox="0 0 100 100" width={size} height={size}>
{/* 外圈背景 */}
<Circle
cx="50"
cy="50"
r={outerRadius}
$strokeWidth={outerStrokeWidth}
$color={trailColor}
strokeDasharray="100%"
/>
{/* 外圈渐变进度环 */}
<defs>
<linearGradient id="outerGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={activeColor} />
<stop offset="100%" stopColor="#66ccff" />
</linearGradient>
</defs>
<Circle
cx="50"
cy="50"
r={outerRadius}
$strokeWidth={outerStrokeWidth}
$color="url(#outerGradient)"
$continuous
strokeDasharray={outerDasharray}
strokeDashoffset={outerDashoffset}
/>
{/* 主环背景段 */}
{stepInfo.map((info, index) => (
<Circle
key={`trail-${index}`}
cx="50"
cy="50"
r={mainRadius}
$strokeWidth={strokeWidth}
$color={trailColor}
strokeDasharray={info.dasharray}
strokeDashoffset={info.dashoffset}
/>
))}
{/* 主环激活段 */}
{stepInfo.map(
(info, index) =>
index < activeIndex && (
<Circle
key={`active-${index}`}
cx="50"
cy="50"
r={mainRadius}
$strokeWidth={strokeWidth}
$color={activeColor}
strokeDasharray={info.dasharray}
strokeDashoffset={info.dashoffset}
/>
)
)}
</svg>
</SvgWrapper>
{/* 中心文本(可隐藏和自定义样式) */}
{showPercent && (
<PercentText $customStyle={percentTextStyle}>
{formatPercent(animatedPercent)}
</PercentText>
)}
</ProgressWrapper>
);
};
export default StepProgressCircle;
// 使用
<StepProgressCircle
percent={62.4}
size={140}
steps={{ count: 16, gap: 2 }}
strokeWidth={8}
gapBetween={8}
outerStrokeWidth={1}
trailColor={"#1a254d"}
/>
效果: