水波纹进度条,带有“水波纹”或“扭曲”效果,filter,svg

4,294 阅读10分钟

PixPin_2025-09-05_15-31-32.png

  • 绘制基础图形 (HTML/SVG)

    • 我们先用 <svg> 标签画出两个叠在一起的圆环(<circle>):一个作为灰色的背景,另一个作为亮黄色的进度条。
    • 通过 CSS 的 stroke-dasharraystroke-dashoffset 属性,我们可以精确地控制黄色圆环显示多少,从而实现进度条功能。
  • 创建“水波纹”滤镜 (SVG Filter)

    • 这是最关键的一步。我们在 SVG 中定义了一个 <filter>
    • 滤镜内部,首先使用 feTurbulence 标签生成一张看不见的、类似云雾或大理石纹理的随机噪声图。这个噪声图本身就是动态变化的。
    • 然后,使用 feDisplacementMap 标签,将这张噪声图作为一张“置换地图”,应用到我们第一步画的圆环上。它会根据噪声图的明暗信息,去扭曲和移动圆环上的每一个点,于是就产生了我们看到的波纹效果。
  • 添加交互控制 (JavaScript)

    • 最后,我们用 JavaScript 监听几个 HTML 滑块(<input type="range">)的变化。
    • 当用户拖动滑块时,JS 会实时地去修改 SVG 滤镜中的各种参数,比如 feTurbulencebaseFrequency(波纹的频率)和 feDisplacementMapscale(波纹的幅度),让用户可以自由定制喜欢的效果。
<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>动态水波纹边框</title>
    <style>
        :root {
            --progress: 50; /* 进度: 0-100 */
            --base-frequency-x: 0.05;
            --base-frequency-y: 0.05;
            --num-octaves: 2;
            --scale: 15;
            --active-color: #ceff00;
            --inactive-color: #333;
            --bg-color: #1a1a1a;
            --text-color: #ceff00;
        }

        body {
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            background-color: var(--bg-color);
            font-family: Arial, sans-serif;
            margin: 0;
            flex-direction: column;
            gap: 40px;
        }

        .progress-container {
            width: 250px;
            height: 250px;
            position: relative;
        }

        .progress-ring {
            width: 100%;
            height: 100%;
            transform: rotate(-90deg); /* 让起点在顶部 */
            filter: url(#wobble-filter); /* 应用SVG滤镜 */
        }

        .progress-ring__circle {
            fill: none;
            stroke-width: 20;
            transition: stroke-dashoffset 0.35s;
        }

        .progress-ring__background {
            stroke: var(--inactive-color);
        }

        .progress-ring__progress {
            stroke: var(--active-color);
            stroke-linecap: round; /* 圆角端点 */
        }

        .progress-text {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            color: var(--text-color);
            font-size: 50px;
            font-weight: bold;
        }

        .controls {
            display: flex;
            flex-direction: column;
            gap: 15px;
            background: #2c2c2c;
            padding: 20px;
            border-radius: 8px;
            color: white;
            width: 300px;
        }

        .control-group {
            display: flex;
            flex-direction: column;
            gap: 5px;
        }
        
        .control-group label {
          display: flex;
          justify-content: space-between;
        }

        input[type="range"] {
            width: 100%;
        }
    </style>
</head>
<body>

    <div class="progress-container">
        <svg class="progress-ring" viewBox="0 0 120 120">
            <!-- 背景圆环 -->
            <circle class="progress-ring__circle progress-ring__background" r="50" cx="60" cy="60"></circle>
            <!-- 进度圆环 -->
            <circle class="progress-ring__circle progress-ring__progress" r="50" cx="60" cy="60"></circle>
        </svg>
        <div class="progress-text">50%</div>
    </div>

    <!-- SVG 滤镜定义 -->
    <svg width="0" height="0">
        <filter id="wobble-filter">
            <!-- 
                feTurbulence: 创建湍流噪声
                - baseFrequency: 噪声的基础频率,值越小,波纹越大越平缓
                - numOctaves: 噪声的倍频数,值越大,细节越多越锐利
                - type: 'fractalNoise' 或 'turbulence'
            -->
            <feTurbulence id="turbulence" type="fractalNoise" baseFrequency="0.05 0.05" numOctaves="2" result="turbulenceResult">
                <!-- 动画:让 turbulence 的基础频率动起来,模拟流动效果 -->
                <animate attributeName="baseFrequency" dur="10s" values="0.05 0.05;0.08 0.02;0.05 0.05;" repeatCount="indefinite"></animate>
            </feTurbulence>
            
            <!-- 
                feDisplacementMap: 用一个图像(这里是上面的噪声)来置换另一个图像
                - in: 输入源,这里是 SourceGraphic,即我们的圆环
                - in2: 置换图源,这里是上面生成的噪声
                - scale: 置换的缩放因子,即波纹的幅度/强度
                - xChannelSelector / yChannelSelector: 指定使用噪声的哪个颜色通道进行置换
            -->
            <feDisplacementMap in="SourceGraphic" in2="turbulenceResult" scale="15" xChannelSelector="R" yChannelSelector="G"></feDisplacementMap>
        </filter>
    </svg>

    <div class="controls">
        <div class="control-group">
            <label for="progress">进度: <span id="progress-value">50%</span></label>
            <input type="range" id="progress" min="0" max="100" value="50">
        </div>
        <div class="control-group">
            <label for="scale">波纹幅度 (scale): <span id="scale-value">15</span></label>
            <input type="range" id="scale" min="0" max="50" value="15" step="1">
        </div>
        <div class="control-group">
            <label for="frequency">波纹频率 (baseFrequency): <span id="frequency-value">0.05</span></label>
            <input type="range" id="frequency" min="0.01" max="0.2" value="0.05" step="0.01">
        </div>
         <div class="control-group">
            <label for="octaves">波纹细节 (numOctaves): <span id="octaves-value">2</span></label>
            <input type="range" id="octaves" min="1" max="10" value="2" step="1">
        </div>
    </div>

    <script>
        const root = document.documentElement;
        const progressCircle = document.querySelector('.progress-ring__progress');
        const progressText = document.querySelector('.progress-text');
        const radius = progressCircle.r.baseVal.value;
        const circumference = 2 * Math.PI * radius;

        progressCircle.style.strokeDasharray = `${circumference} ${circumference}`;

        function setProgress(percent) {
            const offset = circumference - (percent / 100) * circumference;
            progressCircle.style.strokeDashoffset = offset;
            progressText.textContent = `${Math.round(percent)}%`;
            root.style.setProperty('--progress', percent);
        }

        // --- 控制器逻辑 ---
        const progressSlider = document.getElementById('progress');
        const scaleSlider = document.getElementById('scale');
        const frequencySlider = document.getElementById('frequency');
        const octavesSlider = document.getElementById('octaves');
        
        const progressValue = document.getElementById('progress-value');
        const scaleValue = document.getElementById('scale-value');
        const frequencyValue = document.getElementById('frequency-value');
        const octavesValue = document.getElementById('octaves-value');

        const turbulence = document.getElementById('turbulence');
        const displacementMap = document.querySelector('feDisplacementMap');

        progressSlider.addEventListener('input', (e) => {
            const value = e.target.value;
            setProgress(value);
            progressValue.textContent = `${value}%`;
        });

        scaleSlider.addEventListener('input', (e) => {
            const value = e.target.value;
            displacementMap.setAttribute('scale', value);
            scaleValue.textContent = value;
        });

        frequencySlider.addEventListener('input', (e) => {
            const value = e.target.value;
            turbulence.setAttribute('baseFrequency', `${value} ${value}`);
            frequencyValue.textContent = value;
        });
        
        octavesSlider.addEventListener('input', (e) => {
            const value = e.target.value;
            turbulence.setAttribute('numOctaves', value);
            octavesValue.textContent = value;
        });

        // 初始化
        setProgress(50);
    </script>

</body>
</html>


第二版本-带进度条边框宽度版本

image.png

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>动态水波纹边框</title>
    <style>
        :root {
            --progress: 50; /* 进度: 0-100 */
            --stroke-width: 20; /* 边框宽度 */
            --base-frequency-x: 0.05;
            --base-frequency-y: 0.05;
            --num-octaves: 2;
            --scale: 15;
            --active-color: #ceff00;
            --inactive-color: #333;
            --bg-color: #1a1a1a;
            --text-color: #ceff00;
        }

        body {
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            background-color: var(--bg-color);
            font-family: Arial, sans-serif;
            margin: 0;
            flex-direction: column;
            gap: 40px;
        }

        .progress-container {
            width: 250px;
            height: 250px;
            position: relative;
        }

        .progress-ring {
            width: 100%;
            height: 100%;
            transform: rotate(-90deg); /* 让起点在顶部 */
            filter: url(#wobble-filter); /* 应用SVG滤镜 */
        }

        .progress-ring__circle {
            fill: none;
            stroke-width: var(--stroke-width);
            transition: stroke-dashoffset 0.35s;
        }

        .progress-ring__background {
            stroke: var(--inactive-color);
        }

        .progress-ring__progress {
            stroke: var(--active-color);
            stroke-linecap: round; /* 圆角端点 */
        }

        .progress-text {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            color: var(--text-color);
            font-size: 50px;
            font-weight: bold;
        }

        .controls {
            display: flex;
            flex-direction: column;
            gap: 15px;
            background: #2c2c2c;
            padding: 20px;
            border-radius: 8px;
            color: white;
            width: 300px;
        }

        .control-group {
            display: flex;
            flex-direction: column;
            gap: 5px;
        }
        
        .control-group label {
          display: flex;
          justify-content: space-between;
        }

        input[type="range"] {
            width: 100%;
        }
    </style>
</head>
<body>

    <div class="progress-container">
        <svg class="progress-ring" viewBox="0 0 120 120">
            <!-- 背景圆环 -->
            <circle class="progress-ring__circle progress-ring__background" r="50" cx="60" cy="60"></circle>
            <!-- 进度圆环 -->
            <circle class="progress-ring__circle progress-ring__progress" r="50" cx="60" cy="60"></circle>
        </svg>
        <div class="progress-text">50%</div>
    </div>

    <!-- SVG 滤镜定义 -->
    <svg width="0" height="0">
        <filter id="wobble-filter">
            <!-- 
                feTurbulence: 创建湍流噪声
                - baseFrequency: 噪声的基础频率,值越小,波纹越大越平缓
                - numOctaves: 噪声的倍频数,值越大,细节越多越锐利
                - type: 'fractalNoise' 或 'turbulence'
            -->
            <feTurbulence id="turbulence" type="fractalNoise" baseFrequency="0.05 0.05" numOctaves="2" result="turbulenceResult">
                <!-- 动画:让 turbulence 的基础频率动起来,模拟流动效果 -->
                <animate attributeName="baseFrequency" dur="10s" values="0.05 0.05;0.08 0.02;0.05 0.05;" repeatCount="indefinite"></animate>
            </feTurbulence>
            
            <!-- 
                feDisplacementMap: 用一个图像(这里是上面的噪声)来置换另一个图像
                - in: 输入源,这里是 SourceGraphic,即我们的圆环
                - in2: 置换图源,这里是上面生成的噪声
                - scale: 置换的缩放因子,即波纹的幅度/强度
                - xChannelSelector / yChannelSelector: 指定使用噪声的哪个颜色通道进行置换
            -->
            <feDisplacementMap in="SourceGraphic" in2="turbulenceResult" scale="15" xChannelSelector="R" yChannelSelector="G"></feDisplacementMap>
        </filter>
    </svg>

    <div class="controls">
        <div class="control-group">
            <label for="progress">进度: <span id="progress-value">50%</span></label>
            <input type="range" id="progress" min="0" max="100" value="50">
        </div>
        <div class="control-group">
            <label for="stroke-width">边框宽度: <span id="stroke-width-value">20</span></label>
            <input type="range" id="stroke-width" min="1" max="50" value="20" step="1">
        </div>
        <div class="control-group">
            <label for="scale">波纹幅度 (scale): <span id="scale-value">15</span></label>
            <input type="range" id="scale" min="0" max="50" value="15" step="1">
        </div>
        <div class="control-group">
            <label for="frequency">波纹频率 (baseFrequency): <span id="frequency-value">0.05</span></label>
            <input type="range" id="frequency" min="0.01" max="0.2" value="0.05" step="0.01">
        </div>
         <div class="control-group">
            <label for="octaves">波纹细节 (numOctaves): <span id="octaves-value">2</span></label>
            <input type="range" id="octaves" min="1" max="10" value="2" step="1">
        </div>
    </div>

    <script>
        const root = document.documentElement;
        const progressCircle = document.querySelector('.progress-ring__progress');
        const progressText = document.querySelector('.progress-text');
        const radius = progressCircle.r.baseVal.value;
        const circumference = 2 * Math.PI * radius;

        progressCircle.style.strokeDasharray = `${circumference} ${circumference}`;

        function setProgress(percent) {
            const offset = circumference - (percent / 100) * circumference;
            progressCircle.style.strokeDashoffset = offset;
            progressText.textContent = `${Math.round(percent)}%`;
            root.style.setProperty('--progress', percent);
        }

        // --- 控制器逻辑 ---
        const progressSlider = document.getElementById('progress');
        const strokeWidthSlider = document.getElementById('stroke-width');
        const scaleSlider = document.getElementById('scale');
        const frequencySlider = document.getElementById('frequency');
        const octavesSlider = document.getElementById('octaves');
        
        const progressValue = document.getElementById('progress-value');
        const strokeWidthValue = document.getElementById('stroke-width-value');
        const scaleValue = document.getElementById('scale-value');
        const frequencyValue = document.getElementById('frequency-value');
        const octavesValue = document.getElementById('octaves-value');

        const turbulence = document.getElementById('turbulence');
        const displacementMap = document.querySelector('feDisplacementMap');

        progressSlider.addEventListener('input', (e) => {
            const value = e.target.value;
            setProgress(value);
            progressValue.textContent = `${value}%`;
        });
        
        strokeWidthSlider.addEventListener('input', (e) => {
            const value = e.target.value;
            root.style.setProperty('--stroke-width', value);
            strokeWidthValue.textContent = value;
        });

        scaleSlider.addEventListener('input', (e) => {
            const value = e.target.value;
            displacementMap.setAttribute('scale', value);
            scaleValue.textContent = value;
        });

        frequencySlider.addEventListener('input', (e) => {
            const value = e.target.value;
            turbulence.setAttribute('baseFrequency', `${value} ${value}`);
            frequencyValue.textContent = value;
        });
        
        octavesSlider.addEventListener('input', (e) => {
            const value = e.target.value;
            turbulence.setAttribute('numOctaves', value);
            octavesValue.textContent = value;
        });

        // 初始化
        setProgress(50);
    </script>

</body>
</html>


vue3版本

<template>
  <div class="progress-container" :style="containerStyle">
    <svg class="progress-ring" viewBox="0 0 120 120">
      <!-- 背景圆环 -->
      <circle
        class="progress-ring__circle progress-ring__background"
        :style="{ stroke: inactiveColor }"
        :r="radius"
        cx="60"
        cy="60"
      ></circle>
      <!-- 进度圆环 -->
      <circle
        class="progress-ring__circle progress-ring__progress"
        :style="{ 
          stroke: activeColor, 
          strokeDashoffset: strokeDashoffset 
        }"
        :r="radius"
        cx="60"
        cy="60"
      ></circle>
    </svg>
    <div class="progress-text" :style="{ color: textColor }">
      {{ Math.round(progress) }}%
    </div>

    <!-- SVG 滤镜定义 (在组件内部,不会污染全局) -->
    <svg width="0" height="0" style="position: absolute">
        <filter :id="filterId">
            <feTurbulence 
              ref="turbulenceFilter"
              type="fractalNoise" 
              :baseFrequency="`${frequency} ${frequency}`" 
              :numOctaves="octaves" 
              result="turbulenceResult">
                <animate 
                  attributeName="baseFrequency" 
                  dur="10s" 
                  :values="`${frequency} ${frequency};${frequency + 0.03} ${frequency - 0.03};${frequency} ${frequency};`" 
                  repeatCount="indefinite">
                </animate>
            </feTurbulence>
            <feDisplacementMap 
              ref="displacementMapFilter"
              in="SourceGraphic" 
              in2="turbulenceResult" 
              :scale="scale" 
              xChannelSelector="R" 
              yChannelSelector="G">
            </feDisplacementMap>
        </filter>
    </svg>
  </div>
</template>

<script setup>
import { computed, ref, watchEffect, onMounted } from 'vue';

// 定义组件接收的 Props
const props = defineProps({
  size: { type: Number, default: 250 },
  progress: { type: Number, default: 50, validator: (v) => v >= 0 && v <= 100 },
  strokeWidth: { type: Number, default: 20 },
  scale: { type: Number, default: 15 },
  frequency: { type: Number, default: 0.05 },
  octaves: { type: Number, default: 2 },
  activeColor: { type: String, default: '#ceff00' },
  inactiveColor: { type: String, default: '#333' },
  textColor: { type: String, default: '#ceff00' },
});

// 生成一个唯一的 ID,避免多个组件实例之间滤镜冲突
const filterId = `wobble-filter-${Math.random().toString(36).substring(7)}`;

// --- 响应式计算 ---
const radius = 50;
const circumference = 2 * Math.PI * radius;

// 计算进度条的偏移量
const strokeDashoffset = computed(() => {
  return circumference - (props.progress / 100) * circumference;
});

// 计算容器样式
const containerStyle = computed(() => ({
  width: `${props.size}px`,
  height: `${props.size}px`,
}));

// --- DOM 引用 (虽然Vue会自动更新属性,但保留引用以备将来更复杂的操作) ---
const turbulenceFilter = ref(null);
const displacementMapFilter = ref(null);

onMounted(() => {
  // 可以在这里访问 DOM 元素
  // console.log(turbulenceFilter.value);
});
</script>

<style scoped>
.progress-container {
  position: relative;
  display: inline-block; /* 改为 inline-block 以适应 size prop */
}

.progress-ring {
  width: 100%;
  height: 100%;
  transform: rotate(-90deg);
  /* 动态应用滤镜 */
  filter: v-bind('`url(#${filterId})`');
}

.progress-ring__circle {
  fill: none;
  stroke-width: v-bind('strokeWidth');
  transition: stroke-dashoffset 0.35s ease;
  stroke-dasharray: v-bind('`${circumference} ${circumference}`');
}

.progress-ring__progress {
  stroke-linecap: round;
}

.progress-text {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  font-size: v-bind('`${size * 0.2}px`'); /* 字体大小与容器大小关联 */
  font-weight: bold;
}
</style>

react版本公共组件

import React, { useState, useMemo, useId } from 'react';

// --- WavyProgress Component ---
// 将 WavyProgress 组件直接定义在 App.jsx 文件中,以解决导入问题
const WavyProgress = ({
  size = 250,
  progress = 50,
  strokeWidth = 20,
  scale = 15,
  frequency = 0.05,
  octaves = 2,
  activeColor = '#ceff00',
  inactiveColor = '#333',
  textColor = '#ceff00',
}) => {
  const filterId = useId();

  const radius = 50;
  const circumference = 2 * Math.PI * radius;

  const strokeDashoffset = useMemo(() => {
    return circumference - (progress / 100) * circumference;
  }, [progress, circumference]);

  const containerStyle = useMemo(() => ({
    position: 'relative',
    width: `${size}px`,
    height: `${size}px`,
  }), [size]);

  const textStyle = useMemo(() => ({
    color: textColor,
    position: 'absolute',
    top: '50%',
    left: '50%',
    transform: 'translate(-50%, -50%)',
    fontSize: `${size * 0.2}px`,
    fontWeight: 'bold',
  }), [textColor, size]);
  
  const circleStyle = {
      fill: 'none',
      strokeWidth: strokeWidth,
      transition: 'stroke-dashoffset 0.35s ease',
      strokeDasharray: `${circumference} ${circumference}`,
  };

  return (
    <div style={containerStyle}>
      <svg
        className="progress-ring"
        style={{
          width: '100%',
          height: '100%',
          transform: 'rotate(-90deg)',
          filter: `url(#${filterId})`,
        }}
        viewBox="0 0 120 120"
      >
        <circle
          className="progress-ring__background"
          style={{ ...circleStyle, stroke: inactiveColor }}
          r={radius}
          cx="60"
          cy="60"
        />
        <circle
          className="progress-ring__progress"
          style={{
            ...circleStyle,
            stroke: activeColor,
            strokeDashoffset: strokeDashoffset,
            strokeLinecap: 'round',
          }}
          r={radius}
          cx="60"
          cy="60"
        />
      </svg>
      <div style={textStyle}>
        {`${Math.round(progress)}%`}
      </div>

      <svg width="0" height="0" style={{ position: 'absolute' }}>
        <filter id={filterId}>
          <feTurbulence
            type="fractalNoise"
            baseFrequency={`${frequency} ${frequency}`}
            numOctaves={octaves}
            result="turbulenceResult"
          >
            <animate
              attributeName="baseFrequency"
              dur="10s"
              values={`${frequency} ${frequency};${frequency + 0.03} ${frequency - 0.03};${frequency} ${frequency};`}
              repeatCount="indefinite"
            />
          </feTurbulence>
          <feDisplacementMap
            in="SourceGraphic"
            in2="turbulenceResult"
            scale={scale}
            xChannelSelector="R"
            yChannelSelector="G"
          />
        </filter>
      </svg>
    </div>
  );
};


// --- App Component ---
// App 组件现在可以直接使用上面的 WavyProgress 组件
const App = () => {
  const [progress, setProgress] = useState(50);
  const [strokeWidth, setStrokeWidth] = useState(20);
  const [scale, setScale] = useState(15);
  const [frequency, setFrequency] = useState(0.05);
  const [octaves, setOctaves] = useState(2);

  // 将 CSS 样式直接嵌入到组件中
  const styles = `
    body {
      background-color: #1a1a1a;
      margin: 0;
      font-family: Arial, sans-serif;
    }

    #app-container {
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      flex-direction: column;
      gap: 40px;
    }

    .controls {
      display: flex;
      flex-direction: column;
      gap: 15px;
      background: #2c2c2c;
      padding: 20px;
      border-radius: 8px;
      color: white;
      width: 300px;
    }

    .control-group {
      display: flex;
      flex-direction: column;
      gap: 5px;
    }

    .control-group label {
      display: flex;
      justify-content: space-between;
    }

    input[type="range"] {
      width: 100%;
    }
  `;

  return (
    <>
      <style>{styles}</style>
      <div id="app-container">
        <WavyProgress
          progress={progress}
          strokeWidth={strokeWidth}
          scale={scale}
          frequency={frequency}
          octaves={octaves}
        />

        <div className="controls">
          <div className="control-group">
            <label>进度: <span>{progress}%</span></label>
            <input
              type="range"
              value={progress}
              onChange={(e) => setProgress(Number(e.target.value))}
              min="0"
              max="100"
            />
          </div>
          <div className="control-group">
            <label>边框宽度: <span>{strokeWidth}</span></label>
            <input
              type="range"
              value={strokeWidth}
              onChange={(e) => setStrokeWidth(Number(e.target.value))}
              min="1"
              max="50"
              step="1"
            />
          </div>
          <div className="control-group">
            <label>波纹幅度 (scale): <span>{scale}</span></label>
            <input
              type="range"
              value={scale}
              onChange={(e) => setScale(Number(e.target.value))}
              min="0"
              max="50"
              step="1"
            />
          </div>
          <div className="control-group">
            <label>波纹频率 (frequency): <span>{frequency.toFixed(2)}</span></label>
            <input
              type="range"
              value={frequency}
              onChange={(e) => setFrequency(Number(e.target.value))}
              min="0.01"
              max="0.2"
              step="0.01"
            />
          </div>
          <div className="control-group">
            <label>波纹细节 (octaves): <span>{octaves}</span></label>
            <input
              type="range"
              value={octaves}
              onChange={(e) => setOctaves(Number(e.target.value))}
              min="1"
              max="10"
              step="1"
            />
          </div>
        </div>
      </div>
    </>
  );
};

export default App;


``

# canvas-版本

    <!DOCTYPE html>
    <html lang="zh-CN">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>笔刷效果环形进度条 (流动方向修正版)</title>
        <script src="https://cdn.tailwindcss.com"></script>
        <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
        <style>
            body {
                font-family: 'Inter', sans-serif;
                -webkit-font-smoothing: antialiased;
                -moz-osx-font-smoothing: grayscale;
            }
            input[type="range"] {
                -webkit-appearance: none;
                appearance: none;
                width: 100%;
                height: 8px;
                background: #4a5568;
                border-radius: 5px;
                outline: none;
                opacity: 0.7;
                transition: opacity .2s;
            }
            input[type="range"]:hover {
                opacity: 1;
            }
            input[type="range"]::-webkit-slider-thumb {
                -webkit-appearance: none;
                appearance: none;
                width: 20px;
                height: 20px;
                background: #90eea8;
                cursor: pointer;
                border-radius: 50%;
            }
            input[type="range"]::-moz-range-thumb {
                width: 20px;
                height: 20px;
                background: #90eea8;
                cursor: pointer;
                border-radius: 50%;
            }
        </style>
    </head>
    <body class="bg-gray-900 text-white flex flex-col lg:flex-row items-center justify-center min-h-screen p-4">

        <div class="w-full lg:w-1/2 flex items-center justify-center p-8">
            <canvas id="progressCanvas"></canvas>
        </div>

        <div class="w-full lg:w-1/3 bg-gray-800 p-6 rounded-2xl shadow-2xl space-y-5 border border-gray-700">
            <h2 class="text-2xl font-bold text-center text-green-300 mb-6">配置属性</h2>

            <div class="space-y-2">
                <div class="flex justify-between items-center">
                    <label for="percentage" class="font-medium text-gray-300">百分比</label>
                    <span id="percentageValue" class="px-2 py-1 bg-gray-700 text-green-300 rounded-md text-sm font-mono">48%</span>
                </div>
                <input type="range" id="percentage" min="0" max="100" value="48" class="w-full">
            </div>

            <div class="space-y-2">
                <div class="flex justify-between items-center">
                    <label for="lineWidth" class="font-medium text-gray-300">进度条粗细</label>
                    <span id="lineWidthValue" class="px-2 py-1 bg-gray-700 text-green-300 rounded-md text-sm font-mono">16px</span>
                </div>
                <input type="range" id="lineWidth" min="5" max="60" value="16" class="w-full">
            </div>

            <div class="space-y-2">
                <div class="flex justify-between items-center">
                    <label for="roughness" class="font-medium text-gray-300">边缘粗糙度</label>
                    <span id="roughnessValue" class="px-2 py-1 bg-gray-700 text-green-300 rounded-md text-sm font-mono">3</span>
                </div>
                <input type="range" id="roughness" min="0" max="40" value="3" class="w-full">
            </div>
            
            <div class="space-y-2">
                <div class="flex justify-between items-center">
                    <label for="animationSpeed" class="font-medium text-gray-300">过渡速度</label>
                    <span id="animationSpeedValue" class="px-2 py-1 bg-gray-700 text-green-300 rounded-md text-sm font-mono">7</span>
                </div>
                <input type="range" id="animationSpeed" min="1" max="100" value="7" class="w-full">
            </div>
            
            <div class="space-y-2">
                <div class="flex justify-between items-center">
                    <label for="flowSpeed" class="font-medium text-gray-300">流动速度</label>
                    <span id="flowSpeedValue" class="px-2 py-1 bg-gray-700 text-green-300 rounded-md text-sm font-mono">3</span>
                </div>
                <input type="range" id="flowSpeed" min="1" max="100" value="3" class="w-full">
            </div>

            <div class="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4">
                <div class="flex flex-col items-center space-y-2">
                    <label for="progressColor" class="font-medium text-gray-300">进度颜色</label>
                    <input type="color" id="progressColor" value="#ADFF2F" class="w-full h-10 p-1 bg-gray-700 rounded-md cursor-pointer border-2 border-gray-600">
                </div>
                <div class="flex flex-col items-center space-y-2">
                    <label for="baseColor" class="font-medium text-gray-300">底环颜色</label>
                    <input type="color" id="baseColor" value="#333333" class="w-full h-10 p-1 bg-gray-700 rounded-md cursor-pointer border-2 border-gray-600">
                </div>
            </div>
        </div>

        <script>
            const canvas = document.getElementById('progressCanvas');
            const ctx = canvas.getContext('2d');

            const controls = {
                percentage: document.getElementById('percentage'),
                lineWidth: document.getElementById('lineWidth'),
                roughness: document.getElementById('roughness'),
                animationSpeed: document.getElementById('animationSpeed'),
                flowSpeed: document.getElementById('flowSpeed'),
                progressColor: document.getElementById('progressColor'),
                baseColor: document.getElementById('baseColor'),
            };

            const valueDisplays = {
                percentage: document.getElementById('percentageValue'),
                lineWidth: document.getElementById('lineWidthValue'),
                roughness: document.getElementById('roughnessValue'),
                animationSpeed: document.getElementById('animationSpeedValue'),
                flowSpeed: document.getElementById('flowSpeedValue'),
            };

            let config = {
                percentage: 48,
                lineWidth: 16,
                radius: 100,
                roughness: 3,
                steps: 100, 
                animationSpeed: 7,
                flowSpeed: 3, 
                progressColor: '#ADFF2F',
                baseColor: '#333333',
            };
            
            let animatedPercentage = 0;
            let currentDisplacements = [];
            let targetDisplacements = [];
            let texturePhase = 0;

            const lerp = (start, end, amt) => (1 - amt) * start + amt * end;

            function generateTargetDisplacements() {
                targetDisplacements = [];
                for (let i = 0; i <= config.steps; i++) {
                    const outer = (Math.random() - 0.5) * 2;
                    const inner = (Math.random() - 0.5) * 2;
                    targetDisplacements.push({ outer, inner });
                }
            }

            function setupCanvas() {
                const dpr = window.devicePixelRatio || 1;
                const size = (config.radius + config.lineWidth + config.roughness) * 2.2;
                canvas.width = size * dpr;
                canvas.height = size * dpr;
                canvas.style.width = `${size}px`;
                canvas.style.height = `${size}px`;
                ctx.scale(dpr, dpr);
            }
            
            function drawRoughArc(cx, cy, radius, lineWidth, startAngle, endAngle, color, roughness, steps, displacements) {
                const innerRadius = radius - lineWidth / 2;
                const outerRadius = radius + lineWidth / 2;
                
                if (steps <= 0 || displacements.length === 0) return;

                const angleStep = (endAngle - startAngle) / steps;
                
                const outerPoints = [];
                const innerPoints = [];

                for (let i = 0; i <= steps; i++) {
                    const angle = startAngle + i * angleStep;
                    const cosA = Math.cos(angle);
                    const sinA = Math.sin(angle);
                    
                    // 根据点的实际角度和流动相位来确定使用哪个纹理数据
                    let normalizedAngle = angle % (Math.PI * 2);
                    if (normalizedAngle < 0) normalizedAngle += Math.PI * 2;
                    const indexFromAngle = Math.round((normalizedAngle / (Math.PI * 2)) * config.steps);
                    const totalDisplacements = displacements.length;
                    const displacementIndex = (indexFromAngle + Math.floor(texturePhase)) % totalDisplacements;
                    
                    const disp = displacements[displacementIndex] || { outer: 0, inner: 0 };
                    const currentOuterRadius = outerRadius + disp.outer * roughness;
                    const currentInnerRadius = innerRadius + disp.inner * roughness;

                    outerPoints.push({ x: cx + cosA * currentOuterRadius, y: cy + sinA * currentOuterRadius });
                    innerPoints.push({ x: cx + cosA * currentInnerRadius, y: cy + sinA * currentInnerRadius });
                }

                ctx.fillStyle = color;
                ctx.beginPath();
                ctx.moveTo(outerPoints[0].x, outerPoints[0].y);
                for (let i = 1; i < outerPoints.length; i++) {
                    ctx.lineTo(outerPoints[i].x, outerPoints[i].y);
                }
                ctx.lineTo(innerPoints[innerPoints.length - 1].x, innerPoints[innerPoints.length - 1].y);
                for (let i = innerPoints.length - 2; i >= 0; i--) {
                    ctx.lineTo(innerPoints[i].x, innerPoints[i].y);
                }
                ctx.closePath();
                ctx.fill();
            }
            
            function draw(percentageToDraw) {
                const size = (config.radius + config.lineWidth + config.roughness) * 2.2;
                const center = size / 2;
                
                ctx.clearRect(0, 0, canvas.width, canvas.height);

                drawRoughArc(center, center, config.radius, config.lineWidth, 0, Math.PI * 2, config.baseColor, config.roughness, config.steps, currentDisplacements);

                if (percentageToDraw > 0) {
                    const endAngle = (Math.PI * 2 * percentageToDraw) / 100 - Math.PI / 2;
                    const startAngle = -Math.PI / 2;
                    const progressSteps = Math.max(1, Math.round(config.steps * (percentageToDraw / 100)));
                    drawRoughArc(center, center, config.radius, config.lineWidth, startAngle, endAngle, config.progressColor, config.roughness, progressSteps, currentDisplacements);
                }
                
                ctx.fillStyle = config.progressColor;
                ctx.font = `bold ${config.radius * 0.5}px Inter`;
                ctx.textAlign = 'center';
                ctx.textBaseline = 'middle';
                ctx.fillText(`${Math.round(percentageToDraw)}%`, center, center);
            }
            
            function animate() {
                requestAnimationFrame(animate);

                // 1. 百分比平滑过渡
                const targetPercentage = config.percentage;
                const easingFactor = config.animationSpeed / 1000; 
                const diff = targetPercentage - animatedPercentage;
                if (Math.abs(diff) > 0.01) {
                    animatedPercentage += diff * easingFactor;
                } else {
                    animatedPercentage = targetPercentage;
                }
                
                // 2. 边缘呼吸效果的平滑过渡
                const transitionSpeed = config.flowSpeed / 1000;
                for (let i = 0; i <= config.steps; i++) {
                    if (currentDisplacements[i] && targetDisplacements[i]) {
                        currentDisplacements[i].outer = lerp(currentDisplacements[i].outer, targetDisplacements[i].outer, transitionSpeed);
                        currentDisplacements[i].inner = lerp(currentDisplacements[i].inner, targetDisplacements[i].inner, transitionSpeed);
                    }
                }

                // --- 核心改动:纹理整体流动,改为减少相位来反向流动 ---
                texturePhase -= config.flowSpeed / 50; 
                // 保持texturePhase在一个合理的范围内,防止数字过大
                if (texturePhase < 0) texturePhase += config.steps;
                texturePhase %= config.steps;


                // 4. 当呼吸效果接近目标时,生成新目标
                if (currentDisplacements.length > 0 && Math.abs(currentDisplacements[0].outer - targetDisplacements[0].outer) < 0.01) {
                    generateTargetDisplacements();
                }

                // 5. 每帧执行绘制
                draw(animatedPercentage);
            }
            
            function updateConfigFromControls() {
                const sizeChanged = config.lineWidth !== parseFloat(controls.lineWidth.value) || 
                                    config.roughness !== parseFloat(controls.roughness.value);
                
                config.percentage = parseFloat(controls.percentage.value);
                config.lineWidth = parseFloat(controls.lineWidth.value);
                config.roughness = parseFloat(controls.roughness.value);
                config.animationSpeed = parseFloat(controls.animationSpeed.value);
                config.flowSpeed = parseFloat(controls.flowSpeed.value);
                config.progressColor = controls.progressColor.value;
                config.baseColor = controls.baseColor.value;

                valueDisplays.percentage.textContent = `${Math.round(config.percentage)}%`;
                valueDisplays.lineWidth.textContent = `${config.lineWidth}px`;
                valueDisplays.roughness.textContent = `${config.roughness}`;
                valueDisplays.animationSpeed.textContent = `${Math.round(config.animationSpeed)}`;
                valueDisplays.flowSpeed.textContent = `${Math.round(config.flowSpeed)}`;

                if (sizeChanged) {
                    setupCanvas();
                    // 重新设置位移数据,确保流畅
                    generateTargetDisplacements();
                    currentDisplacements = JSON.parse(JSON.stringify(targetDisplacements));
                }
            }

            for (const key in controls) {
                controls[key].addEventListener('input', updateConfigFromControls);
            }
            
            window.addEventListener('resize', setupCanvas);
            
            function initialize() {
                updateConfigFromControls();
                setupCanvas();
                
                generateTargetDisplacements();
                currentDisplacements = JSON.parse(JSON.stringify(targetDisplacements));

                requestAnimationFrame(animate);
            }

            initialize();
        </script>
    </body>
    </html>

# 微信小程序测试版本

    <template>
    	<view class="container">
    		
    		<view class="progress-display-area">
    			<rough-circular-progress
    				:canvas-size="250"
    				:percentage="config.percentage"
    				:line-width="config.lineWidth"
    				:roughness="config.roughness"
    				:font-size="config.fontSize"
    				progress-color="#ADFF2F"
    				base-color="#444444"
    			></rough-circular-progress>
    		</view>
    		
    		<view class="controls-area">
    			<view class="control-item">
    				<view class="control-label">
    					<text>进度 (Percentage)</text>
    					<text class="value-display">{{ config.percentage.toFixed(0) }}%</text>
    				</view>
    				<slider 
    					:value="config.percentage" 
    					@changing="onSliderChange('percentage', $event)"
    					min="0"
    					max="100"
    					active-color="#ADFF2F"
    					block-size="20"
    				/>
    			</view>
    			
    			<view class="control-item">
    				<view class="control-label">
    					<text>线宽 (LineWidth)</text>
    					<text class="value-display">{{ config.lineWidth.toFixed(1) }}</text>
    				</view>
    				<slider 
    					:value="config.lineWidth" 
    					@changing="onSliderChange('lineWidth', $event)"
    					min="5"
    					max="40"
    					step="0.5"
    					active-color="#ADFF2F"
    					block-size="20"
    				/>
    			</view>
    			
    			<view class="control-item">
    				<view class="control-label">
    					<text>粗糙度 (Roughness)</text>
    					<text class="value-display">{{ config.roughness.toFixed(1) }}</text>
    				</view>
    				<slider 
    					:value="config.roughness" 
    					@changing="onSliderChange('roughness', $event)"
    					min="0"
    					max="10"
    					step="0.1"
    					active-color="#ADFF2F"
    					block-size="20"
    				/>
    			</view>
    			
    			<view class="control-item">
    				<view class="control-label">
    					<text>字号 (FontSize)</text>
    					<text class="value-display">{{ config.fontSize.toFixed(0) }}</text>
    				</view>
    				<slider 
    					:value="config.fontSize" 
    					@changing="onSliderChange('fontSize', $event)"
    					min="20"
    					max="80"
    					active-color="#ADFF2F"
    					block-size="20"
    				/>
    			</view>
    		</view>
    		
    	</view>
    </template>

    <script>
    	// 引入组件
    	import RoughCircularProgress from '@/components/rough-circular-progress.vue';

    	export default {
    		// 注册组件
    		components: {
    			RoughCircularProgress
    		},
    		data() {
    			return {
    				// 将所有可配置参数集中管理
    				config: {
    					percentage: 48,
    					lineWidth: 20,
    					roughness: 4,
    					fontSize: 50,
    				}
    			};
    		},
    		methods: {
    			// 创建一个通用的滑块更新方法
    			onSliderChange(key, event) {
    				// 使用 key 来动态更新 config 对象中对应的属性
    				this.config[key] = event.detail.value;
    			}
    		}
    	};
    </script>

    <style scoped>
    	.container {
    		display: flex;
    		flex-direction: column;
    		align-items: center;
    		min-height: 100vh;
    		background-color: #1a1a1a;
    		padding: 20px;
    		box-sizing: border-box;
    	}

    	.progress-display-area {
    		flex-shrink: 0;
    		display: flex;
    		justify-content: center;
    		align-items: center;
    		width: 100%;
    		padding: 40px 0;
    	}

    	.controls-area {
    		width: 90%;
    		max-width: 400px;
    	}

    	.control-item {
    		margin-bottom: 25px;
    	}

    	.control-label {
    		display: flex;
    		justify-content: space-between;
    		align-items: center;
    		margin-bottom: 10px;
    		color: #cccccc;
    		font-size: 15px;
    	}
    	
    	.value-display {
    		font-weight: bold;
    		color: #ffffff;
    		background-color: #333333;
    		padding: 2px 8px;
    		border-radius: 4px;
    		font-family: monospace; /* 使用等宽字体让数字更好看 */
    	}
    	
    	/* 覆盖 uni-app slider 的默认样式,使其更贴合主题 */
    	/deep/ .uni-slider-handle-wrapper {
    		height: 40px; 
    	}
    </style>


将环形进度条改为直线形式,同时保留核心的“笔刷”和“流动”效果

<template>
	<view class="progress-container" :style="{ width: width + 'px', height: height + 'px' }">
		<canvas 
			type="2d" 
			id="linearProgressCanvas" 
			canvas-id="linearProgressCanvas"
			:style="{ width: width + 'px', height: height + 'px' }"
		></canvas>
	</view>
</template>

<script>
	export default {
		name: "rough-linear-progress",
		props: {
			// 画布宽度
			width: {
				type: Number,
				default: 300
			},
			// 画布高度(即进度条粗细)
			height: {
				type: Number,
				default: 40
			},
			// 进度百分比 (0-100)
			percentage: {
				type: Number,
				default: 60
			},
			// 边缘粗糙度/波浪幅度
			roughness: {
				type: Number,
				default: 5
			},
			// 进度条颜色
			progressColor: {
				type: String,
				default: '#ADFF2F'
			},
			// 背景颜色
			baseColor: {
				type: String,
				default: '#333333'
			},
			// 文字大小
			fontSize: {
				type: Number,
				default: 16
			},
			// 文字颜色
			fontColor: {
				type: String,
				default: '#111111'
			},
			// 是否显示文字
			showText: {
				type: Boolean,
				default: true
			},
			// 过渡动画速度 (值越小越快)
			transitionSpeed: {
				type: Number,
				default: 0.07 
			}
		},
		data() {
			return {
				ctx: null,
				canvas: null,
				animatedPercentage: 0,
				animationFrameId: null,
			};
		},
		watch: {
			'$props': {
				handler() {
					if (!this.animationFrameId) {
						this.startAnimation();
					}
				},
				deep: true,
				immediate: false 
			}
		},
		mounted() {
			this.$nextTick(() => {
				this.initCanvas();
			});
		},
		beforeDestroy() {
			this.stopAnimation();
		},
		methods: {
			initCanvas() {
				const query = uni.createSelectorQuery().in(this);
				query.select('#linearProgressCanvas')
					.fields({ node: true, size: true })
					.exec((res) => {
						if (!res[0] || !res[0].node) {
							console.error('无法找到Canvas节点');
							return;
						}
						
						this.canvas = res[0].node;
						this.ctx = this.canvas.getContext('2d');
						
						const dpr = uni.getSystemInfoSync().pixelRatio;
						this.canvas.width = this.width * dpr;
						this.canvas.height = this.height * dpr;
						this.ctx.scale(dpr, dpr);

						this.animatedPercentage = this.percentage;
						this.startAnimation();
					});
			},
			
			startAnimation() {
				if (this.animationFrameId) return;
				this.animate();
			},
			
			stopAnimation() {
				if (this.animationFrameId && this.canvas) {
					this.canvas.cancelAnimationFrame(this.animationFrameId);
					this.animationFrameId = null;
				}
			},

			animate() {
				this.animationFrameId = this.canvas.requestAnimationFrame(this.animate);
				
				const targetPercentage = this.percentage;
				const diff = targetPercentage - this.animatedPercentage;
				
				if (Math.abs(diff) > 0.01) {
					this.animatedPercentage += diff * this.transitionSpeed;
				} else {
					this.animatedPercentage = targetPercentage;
				}
				
				this.draw();
			},

			draw() {
				this.ctx.clearRect(0, 0, this.width, this.height);
				
				// 绘制背景
				this.drawRoughRect(0, 0, this.width, this.height, this.baseColor, this.roughness);

				// 绘制进度条
				const progressWidth = (this.width * this.animatedPercentage) / 100;
				if (progressWidth > 0) {
					this.drawRoughRect(0, 0, progressWidth, this.height, this.progressColor, this.roughness);
				}
				
				// 绘制文字
				if (this.showText) {
					this.ctx.fillStyle = this.fontColor;
					this.ctx.font = `bold ${this.fontSize}px sans-serif`;
					this.ctx.textAlign = 'center';
					this.ctx.textBaseline = 'middle';
					this.ctx.fillText(`${Math.round(this.animatedPercentage)}%`, this.width / 2, this.height / 2);
				}
			},

			/**
			 * --- 核心改造函数 ---
			 * 绘制带粗糙边缘的矩形
			 */
			drawRoughRect(x, y, width, height, color, roughness) {
				const points = [];
				const step = 10; // 每隔10px计算一个锚点

				// 1. 生成上边缘的点
				for (let i = 0; i <= width; i += step) {
					points.push({
						x: x + i,
						y: y + (Math.random() - 0.5) * roughness
					});
				}
				points.push({x: x + width, y: y + (Math.random() - 0.5) * roughness});

				// 2. 生成右边缘的点
				for (let i = 0; i <= height; i += step) {
					points.push({
						x: x + width + (Math.random() - 0.5) * roughness,
						y: y + i
					});
				}
				points.push({x: x + width + (Math.random() - 0.5) * roughness, y: y + height});
				
				// 3. 生成下边缘的点(反向)
				for (let i = width; i >= 0; i -= step) {
					points.push({
						x: x + i,
						y: y + height + (Math.random() - 0.5) * roughness
					});
				}
				points.push({x: x, y: y + height + (Math.random() - 0.5) * roughness});

				// 4. 生成左边缘的点(反向)
				for (let i = height; i >= 0; i -= step) {
					points.push({
						x: x + (Math.random() - 0.5) * roughness,
						y: y + i
					});
				}
				points.push({x: x + (Math.random() - 0.5) * roughness, y: y});
				
				this.ctx.fillStyle = color;
				this.ctx.beginPath();
				this.ctx.moveTo(points[0].x, points[0].y);
				for (let i = 1; i < points.length; i++) {
					this.ctx.lineTo(points[i].x, points[i].y);
				}
				this.ctx.closePath();
				this.ctx.fill();
			}
		}
	}
</script>

<style scoped>
	.progress-container {
		display: flex;
		justify-content: center;
		align-items: center;
	}
</style>