import { defineComponent, onMounted, ref, watch, PropType, onUnmounted } from 'vue'
/**
* 渐变色停止点接口定义
* @interface ColorStop
* @property {number} offset - 渐变停止点位置(0-1)
* @property {string} color - 渐变颜色值
*/
interface ColorStop {
offset: number
color: string
}
/**
* 文字位置类型
*/
type TextPosition = 'front' | 'back' | 'follow'
/**
* 自定义插槽参数接口
*/
interface SlotProps {
percent: number
color: string
}
/**
* 环形进度条组件
* 支持自定义大小、颜色、进度和渐变色
* @component CircleProgress
* @example
* ```vue
*
*
*
*
*
*
*
*
*
*
*
* ```
*/
export const CircleProgress = defineComponent({
name: 'CircleProgress',
props: {
/**
* 进度条类型
* @default 'circle'
* @type {'circle' | 'horizontal'}
*/
type: {
type: String as PropType<'circle' | 'horizontal'>,
default: 'circle',
},
/**
* 进度值,范围 0-100
* @default 0
* @type {number}
*/
percent: {
type: Number,
default: 0,
validator: (value: number) => value >= 0 && value <= 100,
},
/**
* 组件大小,单位像素
* @default 300
* @type {number}
*/
size: {
type: Number,
default: 300,
},
/**
* 进度文字大小
* @default 30
* @type {number}
*/
textSize: {
type: Number,
default: 30,
},
/**
* 进度条宽度
* @default 20
* @type {number}
*/
strokeWidth: {
type: Number,
default: 10,
},
/**
* 轨道颜色
* @default '#e5f1fa'
* @type {string}
*/
trackColor: {
type: String,
default: '#e5f1fa',
},
/**
* 进度条颜色,支持纯色或渐变色数组
* @default '#2ba0fb'
* @type {string | ColorStop[]}
*/
progressColor: {
type: [String, Array] as PropType,
default: '#2ba0fb',
},
/**
* 进度文字颜色
* @default '#333'
* @type {string}
*/
textColor: {
type: String,
default: '#333',
},
/**
* 文字颜色是否跟随进度条颜色变化
* @default false
* @type {boolean}
*/
textColorFollowProgress: {
type: Boolean,
default: false,
},
/**
* 起始角度(弧度)
* @default -Math.PI / 2
* @type {number}
*/
startAngle: {
type: Number,
default: -Math.PI / 2, // 默认从12点钟方向开始
},
/**
* 是否顺时针旋转
* @default true
* @type {boolean}
*/
clockwise: {
type: Boolean,
default: true,
},
/**
* 动画过渡速度(0-1之间,值越大动画越快)
* @default 0.1
* @type {number}
*/
animationSpeed: {
type: Number,
default: 0.1,
validator: (value: number) => value > 0 && value <= 1,
},
/**
* 组件宽度,单位像素(仅横向进度条生效)
* @default 300
* @type {number}
*/
width: {
type: Number,
default: 300,
},
/**
* 组件高度,单位像素(仅横向进度条生效)
* @default 20
* @type {number}
*/
height: {
type: Number,
default: 20,
},
/**
* 是否启用圆角
* @default true
* @type {boolean}
*/
rounded: {
type: Boolean,
default: true,
},
/**
* 进度条颜色是否跟随进度变化
* @default false
* @type {boolean}
*/
colorFollowProgress: {
type: Boolean,
default: false,
},
/**
* 横向进度条文字位置
* @default 'follow'
* @type {'front' | 'back' | 'follow'}
*/
textPosition: {
type: String as PropType,
default: 'follow',
},
/**
* 自定义进度文字插槽
* @type {(props: SlotProps) => any}
*/
progressText: {
type: Function as PropType<(props: SlotProps) => JSX.Element>,
default: undefined,
},
},
setup(props) {
const canvasRef = ref(null) // 画布引用
const currentNum = ref(0) // 当前进度
const targetNum = ref(0) // 目标进度
const animationFrame = ref(null) // 动画帧
/**
* 计算圆角导致的进度偏差值
* @returns {number} 角度偏差值(弧度)
* @description
* 1. 计算整个圆的长度,以进度线段中心作为圆的长度
* 2. 获取进度线段线帽的半径(线段宽度的一半)
* 3. 计算线帽旋转需要的角度偏差
* 4. 如果未启用圆角或未使用渐变色,则返回0
* 5. 当圆弧长度大于圆的长度时,根据进度值计算额外偏移
*/
const roundDeviation = (): number => {
if (props.type === 'horizontal') return 0
// 如果未启用圆角或未使用渐变色,返回0
if (!props.rounded || !Array.isArray(props.progressColor) || props.percent === 100) {
return 0
}
// 计算圆的半径(以进度线段中心为基准)
const radius = (props.size - props.strokeWidth) / 2
// 获取线帽半径(线段宽度的一半)
const capRadius = props.strokeWidth / 2
// 计算线帽旋转需要的角度偏差
// 使用弧长公式:弧长 = 半径 * 角度
// 因此:角度 = 弧长 / 半径
// 这里使用线帽半径作为弧长,因为线帽旋转时走过的距离等于线帽半径
const deviation = capRadius / radius
// 计算当前圆的长度
// const circleLength = 2 * Math.PI * radius
// const progressLength = circleLength * (props.percent / 100) + props.strokeWidth
// 如果当前圆弧的长度大于圆的长度,且进度小于100%,则增加偏差
// if (progressLength > circleLength && props.percent <= 100) {
// deviation = deviation + (progressLength - circleLength) / radius
// }
return deviation
}
/**
* 创建渐变对象
* @param ctx - Canvas上下文
* @param centerX - 圆心X坐标
* @param centerY - 圆心Y坐标
* @param colorStops - 渐变色停止点数组
* @returns {CanvasGradient} 锥形渐变对象
* @description
* 创建一个锥形渐变,并添加颜色停止点。
* 确保渐变的起点和终点颜色正确,使渐变效果更加平滑。
*/
const createGradient = (
ctx: CanvasRenderingContext2D,
centerX: number,
centerY: number,
colorStops: ColorStop[],
): CanvasGradient => {
const deviation = roundDeviation()
console.log(deviation)
// 创建锥形渐变,起始角度为-90度(12点钟方向),增加一个偏差值,解决进度显示不完整的问题,同时排除显卡
const gradient = ctx.createConicGradient(props.startAngle - deviation, centerX, centerY)
// 添加颜色停止点
colorStops.forEach((stop) => {
gradient.addColorStop(stop.offset, stop.color)
})
// 确保渐变闭合
const firstStop = colorStops[0]
// 获取最后一个颜色停止点
const lastStop = colorStops[colorStops.length - 1]
console.log(firstStop, lastStop)
// 如果第一个颜色停止点不是0,则添加一个0偏移的颜色停止点
if (firstStop && firstStop.offset !== 0) {
gradient.addColorStop(0, firstStop.color)
}
// 如果最后一个颜色停止点不是1,则添加一个1偏移的颜色停止点
if (lastStop && lastStop.offset !== 1) {
gradient.addColorStop(1, lastStop.color)
}
return gradient
}
/**
* 获取当前进度的颜色或渐变
* @param ctx - Canvas上下文
* @param centerX - 圆心X坐标
* @param centerY - 圆心Y坐标
* @returns {string | CanvasGradient} 颜色值或渐变对象
* @description
* 根据progressColor属性的类型返回对应的颜色或渐变对象。
* 如果是字符串则返回纯色,如果是数组则创建渐变。
*/
const getProgressColor = (
ctx: CanvasRenderingContext2D,
centerX: number,
centerY: number,
): string | CanvasGradient => {
if (!Array.isArray(props.progressColor)) {
return props.progressColor
}
// 如果是横向进度条,使用线性渐变
if (props.type === 'horizontal') {
const gradient = ctx.createLinearGradient(0, centerY, props.width, centerY)
props.progressColor.forEach((stop) => {
gradient.addColorStop(stop.offset, stop.color)
})
return gradient
}
// 圆形进度条使用锥形渐变
return createGradient(ctx, centerX, centerY, props.progressColor)
}
/**
* 获取当前进度的颜色
* @param progress - 当前进度值(0-100)
* @returns {string} 当前进度的颜色
*/
const getCurrentProgressColor = (progress: number): string => {
// 如果不是渐变色数组,直接返回颜色
if (!Array.isArray(props.progressColor)) {
return typeof props.progressColor === 'string' ? props.progressColor : props.textColor
}
// 如果颜色停止点为空,返回默认颜色
const colorStops = props.progressColor as ColorStop[]
if (colorStops.length === 0) {
return props.textColor
}
// 如果进度达到100%,返回最后一个颜色
if (progress >= 100) {
const lastStop = colorStops[colorStops.length - 1]
return lastStop?.color || props.textColor
}
// 将进度转换为0-1之间的值
const normalizedProgress = progress / 100
// 找到当前进度所在的两个颜色停止点
for (let i = 0; i < colorStops.length - 1; i++) {
const currentStop = colorStops[i]
const nextStop = colorStops[i + 1]
if (
currentStop &&
nextStop &&
normalizedProgress >= currentStop.offset &&
normalizedProgress <= nextStop.offset
) {
// 计算两个颜色之间的插值
const range = nextStop.offset - currentStop.offset
const ratio = (normalizedProgress - currentStop.offset) / range
return currentStop.color
}
}
// 如果进度超出范围,返回最后一个颜色
const lastStop = colorStops[colorStops.length - 1]
return lastStop?.color || props.textColor
}
/**
* 格式化进度显示值
* @param value - 进度值
* @returns {string} 格式化后的进度值
*/
const formatProgressValue = (value: number): string => {
// 如果进度达到100,直接返回100%
if (value >= 100) return '100%'
// 将进度值转换为两位小数
// const decimalValue = Math.round(value * 100) / 100
// 如果是整数,直接返回
if (Number.isInteger(value)) {
return `${value}%`
}
// 否则返回两位小数
return `${value.toFixed(2)}%`
}
/**
* 获取文字位置样式
* @returns {object} 文字位置样式对象
*/
const getTextPositionStyle = (): object => {
if (props.type !== 'horizontal') {
return {
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
}
}
const progress = currentNum.value / 100
const width = props.width
const textWidth = 60 // 预估文字宽度
// 根据文字位置类型返回对应的样式
switch (props.textPosition) {
// 文字在进度条前面
case 'front':
return {
left: `${textWidth / 2}px`,
top: '50%',
transform: 'translateY(-50%)',
}
// 文字在进度条后面
case 'back':
return {
right: `${textWidth / 2}px`,
top: '50%',
transform: 'translateY(-50%)',
}
// 文字跟随进度条
case 'follow':
default:
return {
left: `${Math.max(textWidth / 2, Math.min(width - textWidth / 2, width * progress))}px`, // 文字位置
top: '50%',
transform: 'translateX(-50%) translateY(-50%)',
}
}
}
/**
* 绘制圆弧
* @param ctx - Canvas上下文
* @param color - 填充颜色或渐变
* @param x - 圆心X坐标
* @param y - 圆心Y坐标
* @param radius - 半径
* @param start - 起始角度(弧度)
* @param end - 结束角度(弧度)
* @description
* 使用Canvas绘制圆弧,支持纯色和渐变填充。
* 使用butt线帽和miter连接样式,确保线条无圆角。
*/
const drawCircle = (
ctx: CanvasRenderingContext2D,
color: string | CanvasGradient,
x: number,
y: number,
radius: number,
start: number,
end: number,
) => {
ctx.save() // 保存当前状态
// 设置线条样式
ctx.lineCap = props.rounded ? 'round' : 'butt' // 根据rounded属性设置线帽
ctx.lineJoin = props.rounded ? 'round' : 'miter' // 根据rounded属性设置连接样式
ctx.lineWidth = props.strokeWidth // 设置线宽
ctx.strokeStyle = color // 设置线条颜色
// 创建路径
ctx.beginPath() // 开始绘制路径
ctx.arc(x, y, radius, start, end, !props.clockwise) // 绘制圆弧
// 绘制线条
ctx.stroke() // 绘制线条
ctx.closePath() // 关闭路径
ctx.restore() // 恢复状态
}
/**
* 绘制横向进度条
* @param ctx - Canvas上下文
* @param color - 填充颜色或渐变
* @param x - 起始X坐标
* @param y - 起始Y坐标
* @param width - 宽度
* @param height - 高度
* @param progress - 进度值(0-1)
* @description
* 使用Canvas绘制横向进度条,支持纯色和渐变填充。
*/
const drawHorizontal = (
ctx: CanvasRenderingContext2D,
color: string | CanvasGradient,
x: number,
y: number,
width: number,
height: number,
progress: number,
) => {
ctx.save()
// 设置线条样式
ctx.lineCap = props.rounded ? 'round' : 'butt'
ctx.lineJoin = props.rounded ? 'round' : 'miter'
ctx.lineWidth = height
ctx.strokeStyle = color
// 计算圆角半径
const radius = props.rounded ? height / 2 : 0
// 计算实际进度宽度,考虑圆角
const actualWidth = Math.max(radius * 2, width * progress)
// 绘制进度条
ctx.beginPath()
// 从圆角中心点开始绘制
ctx.moveTo(x + radius, y + height / 2)
// 到圆角中心点结束
ctx.lineTo(x + actualWidth - radius, y + height / 2)
ctx.stroke()
ctx.closePath()
// 只在启用圆角时绘制起点和终点圆角
if (props.rounded) {
// 绘制起点圆角
ctx.beginPath()
ctx.arc(x + radius, y + height / 2, radius, -Math.PI / 2, Math.PI / 2)
ctx.fillStyle = color
ctx.fill()
ctx.closePath()
// 只在进度大于0时绘制终点圆角
if (progress > 0) {
ctx.beginPath()
ctx.arc(x + actualWidth - radius, y + height / 2, radius, Math.PI / 2, -Math.PI / 2)
ctx.fillStyle = color
ctx.fill()
ctx.closePath()
}
}
ctx.restore()
}
/**
* 执行动画绘制
* @description
* 使用requestAnimationFrame实现平滑的进度动画。
* 支持高DPI设备,确保显示清晰。
* 包含背景轨道、进度条和进度文字的绘制。
*/
const animate = () => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
// 设置画布的实际尺寸为显示尺寸的2倍,以支持高DPI设备
const dpr = window.devicePixelRatio || 1
const displayWidth = props.type === 'horizontal' ? props.width : props.size
const displayHeight = props.type === 'horizontal' ? props.height : props.size
canvas.width = displayWidth * dpr
canvas.height = displayHeight * dpr
ctx.scale(dpr, dpr)
const draw = () => {
// 平滑过渡到目标值
const diff = targetNum.value - currentNum.value
if (Math.abs(diff) > 0.1) {
currentNum.value += diff * props.animationSpeed
animationFrame.value = requestAnimationFrame(draw)
} else {
currentNum.value = targetNum.value
}
ctx.clearRect(0, 0, displayWidth, displayHeight)
if (props.type === 'horizontal') {
// 绘制背景轨道
drawHorizontal(ctx, props.trackColor, 0, 0, displayWidth, displayHeight, 1)
// 获取当前进度的颜色或渐变
const progressColor = getProgressColor(ctx, displayWidth / 2, displayHeight / 2)
// 绘制进度条
drawHorizontal(ctx, progressColor, 0, 0, displayWidth, displayHeight, currentNum.value / 100)
} else {
// 原有的圆形进度条绘制逻辑
const centerX = props.size / 2
const centerY = props.size / 2
const radius = (props.size - props.strokeWidth) / 2
// 绘制背景轨道
drawCircle(ctx, props.trackColor, centerX, centerY, radius, 0, 2 * Math.PI)
// 获取当前进度的颜色或渐变
const progressColor = getProgressColor(ctx, centerX, centerY)
// 绘制进度条
const progressAngle = ((2 * currentNum.value) / 100) * Math.PI
const adjustedStartAngle = props.startAngle
const adjustedEndAngle = props.startAngle + progressAngle
// 绘制进度条
drawCircle(ctx, progressColor, centerX, centerY, radius, adjustedStartAngle, adjustedEndAngle)
}
}
draw()
}
// 组件挂载时初始化
onMounted(() => {
targetNum.value = props.percent
currentNum.value = props.percent
animate()
})
// 监听进度值变化
watch(
() => props.percent,
(newValue) => {
// 限制进度值不超过100
const limitedValue = Math.min(newValue, 100)
// 如果已经达到100%,不再更新
if (currentNum.value >= 100 && limitedValue >= 100) {
return
}
targetNum.value = limitedValue
// 取消之前的动画帧
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value)
animationFrame.value = null
}
animate()
},
)
// 组件卸载时清理动画帧
onUnmounted(() => {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value)
animationFrame.value = null
}
})
return () => {
const currentColor = getCurrentProgressColor(currentNum.value)
const textContent = props.progressText
? props.progressText({ percent: Math.round(currentNum.value), color: currentColor })
: formatProgressValue(currentNum.value)
return (
)
}
},
})
export default CircleProgress