zlwu 10 месяцев назад
Родитель
Сommit
393006e6cd
2 измененных файлов с 309 добавлено и 9 удалено
  1. 279 0
      frontend/src/components/ProgressRingEndCap.tsx
  2. 30 9
      frontend/src/pages/ChatBotPage.tsx

+ 279 - 0
frontend/src/components/ProgressRingEndCap.tsx

@@ -0,0 +1,279 @@
+import { useEffect, useRef, useImperativeHandle, forwardRef, useMemo, useCallback, useState } from 'react';
+
+interface ProgressRingEndCapProps {
+  progress: number; // 0-100,只绘制末端 3%
+  width?: number;
+  height?: number;
+}
+
+export interface ProgressRingEndCapHandle {
+  redraw: () => void;
+}
+
+// 颜色节点配置数组(按照百分比位置)
+const colorStops = [
+  { percent: 0, color: '#FFC900' },
+  { percent: 11, color: '#FFC900' },
+  { percent: 32, color: '#13A6FF' },
+  { percent: 50, color: '#806EFF' },
+  { percent: 66, color: '#FF8EEC' },
+  { percent: 89, color: '#FE4F51' },
+  { percent: 100, color: '#FE4F51' },
+];
+
+// 根据百分比获取颜色
+const getColorForPercent = (percent: number): string | null => {
+  // 确保百分比在0-100范围内
+  percent = Math.max(0, Math.min(100, percent));
+
+  // 如果进度为0,返回null表示无颜色
+  if (percent === 0) {
+    return null;
+  }
+
+  // 找到百分比所在的两个颜色节点
+  for (let i = 0; i < colorStops.length - 1; i++) {
+    const startStop = colorStops[i];
+    const endStop = colorStops[i + 1];
+
+    if (percent >= startStop.percent && percent <= endStop.percent) {
+      // 计算在两个节点之间的比例
+      const t = (percent - startStop.percent) / (endStop.percent - startStop.percent);
+      return interpolateColor(startStop.color, endStop.color, t);
+    }
+  }
+
+  return colorStops[colorStops.length - 1].color;
+};
+
+// 颜色插值函数
+const interpolateColor = (color1: string, color2: string, t: number): string => {
+  // 将十六进制颜色转换为RGB
+  const r1 = parseInt(color1.slice(1, 3), 16);
+  const g1 = parseInt(color1.slice(3, 5), 16);
+  const b1 = parseInt(color1.slice(5, 7), 16);
+
+  const r2 = parseInt(color2.slice(1, 3), 16);
+  const g2 = parseInt(color2.slice(3, 5), 16);
+  const b2 = parseInt(color2.slice(5, 7), 16);
+
+  // 线性插值
+  const r = Math.round(r1 + (r2 - r1) * t);
+  const g = Math.round(g1 + (g2 - g1) * t);
+  const b = Math.round(b1 + (b2 - b1) * t);
+
+  // 转回十六进制
+  return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
+};
+
+export const ProgressRingEndCap = forwardRef<ProgressRingEndCapHandle, ProgressRingEndCapProps>(
+  ({ progress }, ref) => {
+    const canvasRef = useRef<HTMLCanvasElement>(null);
+    const containerRef = useRef<HTMLDivElement>(null);
+    const [actualSize, setActualSize] = useState({ width: 114, height: 114 });
+
+    // 进度条相关参数(适配响应式容器)
+    const radius = useMemo(() => {
+      // 圆环中心半径,按实际容器尺寸比例缩放
+      // 设计稿 114px 对应 54px 半径
+      // 减小 0.5px 使 Canvas 圆环与 SVG 圆环重合
+      return (actualSize.width / 114) * 54 - 0.5;
+    }, [actualSize.width]);
+
+    const thickness = useMemo(() => {
+      // 圆环厚度,按实际容器尺寸比例缩放
+      // 设计稿 114px 对应 6.5px 厚度
+      return (actualSize.width / 114) * 6.5;
+    }, [actualSize.width]);
+
+    // 角度配置:Canvas 旋转 -180deg(逆时针 180 度)
+    // 底部圆环显示:215° 起始(7点钟方向),顺时针到 503°(215° + 288°)
+    // Canvas 内部角度:从 305° 开始(旋转后对应显示 215°),到 233° 结束(旋转后对应显示 503°)
+    // 每 1% 进度 = 2.88 度
+    const startAngleDeg = 305; // Canvas 内部起始角度(旋转后对应底部圆环的 215°)
+    const totalAngleDeg = 288; // 总角度:288度
+    const endCapPercent = 0.1; // 末端圆角部分占 0.1%
+
+    // 转换为弧度
+    const startAngleRad = useMemo(() => (startAngleDeg * Math.PI) / 180, []);
+    const totalAngleRad = useMemo(() => (totalAngleDeg * Math.PI) / 180, []);
+
+    // 计算画布中心点(使用实际尺寸)
+    const centerX = actualSize.width / 2;
+    const centerY = actualSize.height / 2;
+
+    const drawEndCap = useCallback(() => {
+      const canvas = canvasRef.current;
+      if (!canvas) return;
+
+      const ctx = canvas.getContext('2d');
+      if (!ctx) return;
+
+      // 设置高分辨率渲染(提高清晰度)
+      const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
+      const displayWidth = actualSize.width;
+      const displayHeight = actualSize.height;
+      
+      if (canvas.width !== displayWidth * dpr || canvas.height !== displayHeight * dpr) {
+        canvas.width = displayWidth * dpr;
+        canvas.height = displayHeight * dpr;
+        canvas.style.width = `${displayWidth}px`;
+        canvas.style.height = `${displayHeight}px`;
+        ctx.scale(dpr, dpr);
+      }
+
+      // 优化渲染质量
+      ctx.imageSmoothingEnabled = true;
+      ctx.imageSmoothingQuality = 'high';
+
+      // 清空画布
+      ctx.clearRect(0, 0, displayWidth, displayHeight);
+
+      // 只有当进度大于 0 时才绘制末端圆角
+      if (progress > 0) {
+        // 计算末端圆角的起始和结束百分比
+        const endCapStartPercent = Math.max(0, progress - endCapPercent);
+        const endCapEndPercent = progress;
+
+        // 如果起始百分比为 0,说明进度小于 0.1%,直接绘制从 0 到 progress
+        if (endCapStartPercent === 0) {
+          // 只绘制末端部分,从 0 到 progress
+          const endAngle = startAngleRad + (totalAngleRad * progress / 100);
+          
+          // 获取末端颜色(使用 progress 的颜色)
+          const endColor = getColorForPercent(progress);
+          
+          if (endColor) {
+            ctx.beginPath();
+            ctx.arc(centerX, centerY, radius, startAngleRad, endAngle);
+            ctx.lineWidth = thickness;
+            ctx.lineCap = 'round'; // 圆角末端
+            ctx.strokeStyle = endColor;
+            ctx.globalAlpha = 1;
+            ctx.stroke();
+          }
+        } else {
+          // 绘制末端 3% 部分
+          // 计算 Canvas 内部角度
+          const endCapStartAngle = startAngleRad + (totalAngleRad * endCapStartPercent / 100);
+          const endCapEndAngle = startAngleRad + (totalAngleRad * endCapEndPercent / 100);
+
+          // 处理跨 0° 的情况
+          let normalizedStartAngle = endCapStartAngle;
+          let normalizedEndAngle = endCapEndAngle;
+
+          // 如果超过 360°,需要归一化
+          if (normalizedStartAngle > Math.PI * 2) {
+            normalizedStartAngle = normalizedStartAngle - Math.PI * 2;
+          }
+          if (normalizedEndAngle > Math.PI * 2) {
+            normalizedEndAngle = normalizedEndAngle - Math.PI * 2;
+          }
+
+          // 计算末端部分的颜色
+          // 使用 progress 的颜色,确保与 SVG 在相同进度位置的颜色一致
+          const endColor = getColorForPercent(progress);
+          
+          if (endColor) {
+            ctx.beginPath();
+            
+            // 处理跨 0° 的情况
+            if (normalizedStartAngle > normalizedEndAngle) {
+              // 第一段:从 normalizedStartAngle 到 2π
+              ctx.arc(centerX, centerY, radius, normalizedStartAngle, Math.PI * 2);
+              ctx.lineWidth = thickness;
+              ctx.lineCap = 'round';
+              ctx.strokeStyle = endColor;
+              ctx.globalAlpha = 1;
+              ctx.stroke();
+              
+              // 第二段:从 0 到 normalizedEndAngle
+              ctx.beginPath();
+              ctx.arc(centerX, centerY, radius, 0, normalizedEndAngle);
+              ctx.lineWidth = thickness;
+              ctx.lineCap = 'round';
+              ctx.strokeStyle = endColor;
+              ctx.globalAlpha = 1;
+              ctx.stroke();
+            } else {
+              // 正常情况:从起始角度到结束角度
+              ctx.arc(centerX, centerY, radius, normalizedStartAngle, normalizedEndAngle);
+              ctx.lineWidth = thickness;
+              ctx.lineCap = 'round'; // 圆角末端
+              ctx.strokeStyle = endColor;
+              ctx.globalAlpha = 1;
+              ctx.stroke();
+            }
+          }
+        }
+      }
+    }, [progress, actualSize, radius, thickness, startAngleRad, totalAngleRad, centerX, centerY]);
+
+    useImperativeHandle(ref, () => ({
+      redraw: drawEndCap,
+    }));
+
+    // 监听容器尺寸变化,动态更新 Canvas 尺寸
+    useEffect(() => {
+      const updateSize = () => {
+        if (containerRef.current) {
+          const rect = containerRef.current.getBoundingClientRect();
+          setActualSize({
+            width: rect.width,
+            height: rect.height,
+          });
+        }
+      };
+
+      // 初始设置
+      updateSize();
+
+      // 监听窗口大小变化
+      window.addEventListener('resize', updateSize);
+      
+      // 使用 ResizeObserver 监听容器尺寸变化(更精确)
+      const resizeObserver = new ResizeObserver(updateSize);
+      if (containerRef.current) {
+        resizeObserver.observe(containerRef.current);
+      }
+
+      return () => {
+        window.removeEventListener('resize', updateSize);
+        resizeObserver.disconnect();
+      };
+    }, []);
+
+    useEffect(() => {
+      drawEndCap();
+    }, [drawEndCap]);
+
+    return (
+      <div
+        ref={containerRef}
+        style={{
+          position: 'absolute',
+          // top: '0.5px',
+          // left: '-0.5PX', // 使用大写 PX 避免转换
+          width: '100%',
+          height: '100%',
+          pointerEvents: 'none',
+          zIndex: 1,
+          // Canvas 旋转 -180deg(逆时针 180 度),使内部角度 305° 对应显示 215°
+          transform: 'rotate(-180deg)',
+        }}
+      >
+        <canvas
+          ref={canvasRef}
+          style={{
+            display: 'block',
+            width: '100%',
+            height: '100%',
+          }}
+        />
+      </div>
+    );
+  }
+);
+
+ProgressRingEndCap.displayName = 'ProgressRingEndCap';
+

+ 30 - 9
frontend/src/pages/ChatBotPage.tsx

@@ -31,7 +31,6 @@ import type { Message, EnergyStoneSpawnMessage } from '@/types';
 import '@/styles/chatbot-fix.css';
 import { getOssUrl } from '@/utils/oss';
 import { AudioWave, type AudioWaveHandle } from '@/components/AudioWave';
-import { ProgressRingCanvas, type ProgressRingCanvasHandle } from '@/components/ProgressRingCanvas';
 
 const avatarC = getOssUrl('Avatar-C.svg');
 const avatarGift = getOssUrl('Gift.gif');
@@ -39,6 +38,7 @@ const audioButton = getOssUrl('audio-button.png');
 const avatarHint = getOssUrl('Avatar-hint.png');
 const microphoneIcon = getOssUrl('microphone-icon.svg');
 const progressRing = getOssUrl('progress-ring.svg');
+const progressFull = getOssUrl('progress-full.svg');
 const tapToChat = getOssUrl('TapToChat.svg');
 const cancelIcon = getOssUrl('cancel-icon.svg');
 const sendIcon = getOssUrl('send-icon.svg');
@@ -140,7 +140,6 @@ export const ChatBotPage = () => {
   const mobileAudioPreparedRef = useRef(false);
   const sendTimeRef = useRef<number>(0);
   const audioWaveRef = useRef<AudioWaveHandle | null>(null);
-  const progressRingCanvasRef = useRef<ProgressRingCanvasHandle | null>(null);
   const isMountedRef = useRef(true);
   const [userLevelValue, setUserLevelValue] = useState(() => getUserLevel());
   const levelRedirectRef = useRef(false);
@@ -2769,13 +2768,35 @@ export const ChatBotPage = () => {
               }}
             />
             
-            {/* 进度填充层 - 使用 Canvas 绘制(类似 arrow-process.html) */}
-            <ProgressRingCanvas
-              ref={progressRingCanvasRef}
-              progress={displayProgress}
-              width={118}
-              height={118}
-            />
+            {/* 进度填充层 - 使用 SVG + CSS Mask */}
+            {/* SVG Mask 显示完整进度(到 progress%) */}
+            {displayProgress > 0 && (
+              <div
+                className="pointer-events-none"
+                style={{
+                  position: 'absolute',
+                  top: px2vmin(0),  // 设计稿 top 0px,基于 vw/vh 最小值
+                  left: '-0.5PX',  // 设计稿 left -0.5px,使用大写 PX 避免转换
+                  width: '100%',
+                  height: '100%',
+                  // CSS conic-gradient mask:从 215° 开始,每 1% = 2.88 度
+                  // 显示完整进度(到 progress%)
+                  maskImage: `conic-gradient(from 215deg at 50% 50%, black 0deg, black ${displayProgress * 2.88}deg, transparent ${displayProgress * 2.88}deg, transparent 360deg)`,
+                  WebkitMaskImage: `conic-gradient(from 215deg at 50% 50%, black 0deg, black ${displayProgress * 2.88}deg, transparent ${displayProgress * 2.88}deg, transparent 360deg)`,
+                  zIndex: 2,
+                }}
+              >
+                <img
+                  src={progressFull}
+                  alt="Progress Full"
+                  style={{
+                    width: '100%',
+                    height: '100%',
+                    display: 'block',
+                  }}
+                />
+              </div>
+            )}
 
             {/* 进度条提示 - 相对于进度环定位 - 首次有进度时显示 */}
             {showProgressBarTooltip && isConnected && (