Files
chunyu_prject_react/docs/plans/2026-06-13-slider-captcha-api.md
T
2026-08-05 23:59:22 +08:00

36 KiB

滑块验证码 API 实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 为小雨开发工具箱创建一个纯前端的滑块验证码组件和模拟 API,包含验证码生成、滑块校验、刷新重置等功能,可直接集成到登录/注册流程中。

Architecture: 采用纯前端实现方案:使用 HTML5 Canvas 生成拼图验证码图片(底图 + 缺口 + 滑块),通过 React 组件管理交互状态。模拟后端 API 使用本地计算验证滑块位置偏移量。组件提供完整的生命周期:生成验证码 -> 用户拖拽 -> 校验结果 -> 成功回调/失败重置。

Tech Stack: React 18, TypeScript, Vite, Ant Design, HTML5 Canvas API


文件结构

文件 操作 说明
src/components/SliderCaptcha/SliderCaptcha.tsx 创建 滑块验证码主组件
src/components/SliderCaptcha/SliderCaptcha.css 创建 滑块验证码样式
src/components/SliderCaptcha/index.ts 创建 组件导出入口
src/components/SliderCaptcha/captchaUtils.ts 创建 Canvas 生成与验证工具函数
src/pages/SliderCaptchaDemo/SliderCaptchaDemo.tsx 创建 演示页面(展示组件用法)
src/pages/SliderCaptchaDemo/SliderCaptchaDemo.css 创建 演示页面样式
src/App.tsx 修改 添加演示页面路由
src/pages/Utility/Utility.tsx 修改 在工具列表中注册滑块验证码

设计规范

  • 组件尺寸: 默认宽度 320px,高度自适应(图片区 + 滑块轨道区)
  • 图片尺寸: 底图 320x160px,缺口大小 40x40px
  • 滑块轨道: 高度 40px,圆角 20px,背景色 #e8ecf1
  • 滑块按钮: 40x40px 正方形,圆角 4px,主色渐变背景
  • 颜色主题: 成功绿色 #22c55e,失败红色 #ef4444,主色 #3b82f6
  • 动画效果: 滑块拖拽跟随、验证成功/失败抖动动画、刷新旋转动画
  • 响应式: 支持通过 props 自定义宽度

组件接口设计

interface SliderCaptchaProps {
  width?: number;           // 验证码宽度,默认 320
  height?: number;          // 图片高度,默认 160
  gapSize?: number;         // 缺口大小,默认 40
  tolerance?: number;       // 容错像素,默认 5
  onSuccess?: () => void;   // 验证成功回调
  onFail?: () => void;      // 验证失败回调
  onRefresh?: () => void;   // 刷新回调
  className?: string;       // 自定义类名
  style?: React.CSSProperties; // 自定义样式
}

Task 1: 创建 Canvas 验证码工具函数

Files:

  • Create: src/components/SliderCaptcha/captchaUtils.ts

功能需求

  1. 生成随机缺口位置:在图片区域内随机生成缺口坐标 (x, y)
  2. 绘制底图:在 Canvas 上绘制背景图,并在缺口位置绘制半透明遮罩
  3. 绘制滑块:提取缺口区域的图像作为滑块,绘制到单独的 Canvas
  4. 验证位置:比对用户滑动的位置与缺口位置,计算偏移量是否在容错范围内
  5. 生成干扰线:在底图上绘制随机干扰线增加识别难度
  • Step 1: 创建 captchaUtils.ts
/**
 * 滑块验证码工具函数
 * 纯前端实现,使用 Canvas 生成拼图验证码
 */

export interface GapPosition {
  x: number;
  y: number;
}

export interface CaptchaResult {
  bgCanvas: HTMLCanvasElement;    // 背景图(带缺口遮罩)
  sliderCanvas: HTMLCanvasElement; // 滑块图
  gapX: number;                   // 缺口 X 坐标(正确答案)
  gapY: number;                   // 缺口 Y 坐标
}

/**
 * 生成随机整数 [min, max]
 */
export const randomInt = (min: number, max: number): number => {
  return Math.floor(Math.random() * (max - min + 1)) + min;
};

/**
 * 生成随机颜色
 */
export const randomColor = (min = 0, max = 255): string => {
  const r = randomInt(min, max);
  const g = randomInt(min, max);
  const b = randomInt(min, max);
  return `rgb(${r}, ${g}, ${b})`;
};

/**
 * 生成随机缺口位置
 */
export const generateGapPosition = (
  width: number,
  height: number,
  gapSize: number
): GapPosition => {
  const padding = gapSize + 10;
  return {
    x: randomInt(padding, width - padding),
    y: randomInt(padding, height - padding),
  };
};

/**
 * 绘制圆角矩形路径
 */
export const drawRoundRectPath = (
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  radius: number
) => {
  ctx.beginPath();
  ctx.moveTo(x + radius, y);
  ctx.lineTo(x + width - radius, y);
  ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
  ctx.lineTo(x + width, y + height - radius);
  ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
  ctx.lineTo(x + radius, y + height);
  ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
  ctx.lineTo(x, y + radius);
  ctx.quadraticCurveTo(x, y, x + radius, y);
  ctx.closePath();
};

/**
 * 绘制缺口形状(带凸起的拼图块)
 */
export const drawGapShape = (
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  size: number
) => {
  const r = size / 2;
  const bulgeR = size / 3;

  ctx.beginPath();
  ctx.moveTo(x, y + r);

  // 上边 - 带凸起
  ctx.lineTo(x + r - bulgeR, y);
  ctx.arc(x + r, y, bulgeR, Math.PI, 0, false);
  ctx.lineTo(x + size, y + r);

  // 右边 - 带凸起
  ctx.lineTo(x + size, y + r - bulgeR);
  ctx.arc(x + size, y + r, bulgeR, -Math.PI / 2, Math.PI / 2, false);
  ctx.lineTo(x + size, y + size);

  // 下边
  ctx.lineTo(x, y + size);

  // 左边
  ctx.lineTo(x, y + r);

  ctx.closePath();
};

/**
 * 生成验证码图片
 */
export const generateCaptcha = async (
  imageSrc: string,
  width: number,
  height: number,
  gapSize: number
): Promise<CaptchaResult> => {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = "anonymous";

    img.onload = () => {
      const gapPos = generateGapPosition(width, height, gapSize);

      // 创建背景 Canvas
      const bgCanvas = document.createElement("canvas");
      bgCanvas.width = width;
      bgCanvas.height = height;
      const bgCtx = bgCanvas.getContext("2d")!;

      // 绘制背景图
      bgCtx.drawImage(img, 0, 0, width, height);

      // 绘制缺口遮罩
      bgCtx.save();
      bgCtx.globalCompositeOperation = "destination-out";
      drawGapShape(bgCtx, gapPos.x, gapPos.y, gapSize);
      bgCtx.fill();
      bgCtx.restore();

      // 绘制缺口边框和高亮
      bgCtx.save();
      bgCtx.strokeStyle = "rgba(255, 255, 255, 0.6)";
      bgCtx.lineWidth = 2;
      bgCtx.shadowColor = "rgba(0, 0, 0, 0.3)";
      bgCtx.shadowBlur = 5;
      drawGapShape(bgCtx, gapPos.x, gapPos.y, gapSize);
      bgCtx.stroke();
      bgCtx.restore();

      // 绘制干扰线
      for (let i = 0; i < 5; i++) {
        bgCtx.beginPath();
        bgCtx.moveTo(randomInt(0, width), randomInt(0, height));
        bgCtx.lineTo(randomInt(0, width), randomInt(0, height));
        bgCtx.strokeStyle = randomColor(100, 200);
        bgCtx.lineWidth = randomInt(1, 3);
        bgCtx.stroke();
      }

      // 创建滑块 Canvas
      const sliderCanvas = document.createElement("canvas");
      sliderCanvas.width = gapSize;
      sliderCanvas.height = height;
      const sliderCtx = sliderCanvas.getContext("2d")!;

      // 绘制滑块形状(从原图裁剪)
      sliderCtx.save();
      drawGapShape(sliderCtx, 0, gapPos.y, gapSize);
      sliderCtx.clip();
      sliderCtx.drawImage(img, 0, 0, width, height);
      sliderCtx.restore();

      // 绘制滑块边框
      sliderCtx.save();
      sliderCtx.strokeStyle = "rgba(255, 255, 255, 0.8)";
      sliderCtx.lineWidth = 2;
      sliderCtx.shadowColor = "rgba(0, 0, 0, 0.4)";
      sliderCtx.shadowBlur = 8;
      drawGapShape(sliderCtx, 0, gapPos.y, gapSize);
      sliderCtx.stroke();
      sliderCtx.restore();

      resolve({
        bgCanvas,
        sliderCanvas,
        gapX: gapPos.x,
        gapY: gapPos.y,
      });
    };

    img.onerror = () => reject(new Error("图片加载失败"));
    img.src = imageSrc;
  });
};

/**
 * 验证滑块位置
 */
export const verifyCaptcha = (
  userX: number,
  gapX: number,
  tolerance: number = 5
): boolean => {
  return Math.abs(userX - gapX) <= tolerance;
};

/**
 * 获取随机图片 URL(使用 picsum 或本地图片)
 */
export const getRandomImage = (width: number, height: number): string => {
  const seed = randomInt(1, 1000);
  return `https://picsum.photos/seed/${seed}/${width}/${height}`;
};

Task 2: 创建滑块验证码主组件

Files:

  • Create: src/components/SliderCaptcha/SliderCaptcha.tsx
  • Create: src/components/SliderCaptcha/SliderCaptcha.css

功能需求

  1. 状态管理:加载中、就绪、拖拽中、验证中、成功、失败 六种状态
  2. 拖拽交互:鼠标/触摸按下 -> 拖拽移动 -> 释放,滑块跟随手指移动
  3. 边界限制:滑块只能在轨道范围内移动
  4. 验证反馈:释放后计算位置,显示成功(绿色)或失败(红色)动画
  5. 自动刷新:验证失败后自动重置,成功后显示成功状态
  6. 手动刷新:点击刷新按钮重新生成验证码
  • Step 2: 创建 SliderCaptcha.tsx
import "./SliderCaptcha.css";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { LoadingOutlined, ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons";
import {
  generateCaptcha,
  verifyCaptcha,
  getRandomImage,
  CaptchaResult,
} from "./captchaUtils";

export interface SliderCaptchaProps {
  width?: number;
  height?: number;
  gapSize?: number;
  tolerance?: number;
  onSuccess?: () => void;
  onFail?: () => void;
  onRefresh?: () => void;
  className?: string;
  style?: React.CSSProperties;
}

type CaptchaStatus = "loading" | "ready" | "dragging" | "verifying" | "success" | "fail";

const SliderCaptcha: React.FC<SliderCaptchaProps> = ({
  width = 320,
  height = 160,
  gapSize = 40,
  tolerance = 5,
  onSuccess,
  onFail,
  onRefresh,
  className = "",
  style = {},
}) => {
  const [status, setStatus] = useState<CaptchaStatus>("loading");
  const [captchaData, setCaptchaData] = useState<CaptchaResult | null>(null);
  const [sliderX, setSliderX] = useState(0);
  const [message, setMessage] = useState("向右滑动完成验证");

  const containerRef = useRef<HTMLDivElement>(null);
  const trackRef = useRef<HTMLDivElement>(null);
  const isDraggingRef = useRef(false);
  const startXRef = useRef(0);
  const startSliderXRef = useRef(0);
  const maxSliderX = width - gapSize;

  const loadCaptcha = useCallback(async () => {
    setStatus("loading");
    setSliderX(0);
    setMessage("向右滑动完成验证");
    try {
      const imageSrc = getRandomImage(width, height);
      const data = await generateCaptcha(imageSrc, width, height, gapSize);
      setCaptchaData(data);
      setStatus("ready");
    } catch {
      setMessage("图片加载失败,请刷新重试");
      setStatus("fail");
    }
  }, [width, height, gapSize]);

  useEffect(() => {
    loadCaptcha();
  }, [loadCaptcha]);

  const handleRefresh = () => {
    loadCaptcha();
    onRefresh?.();
  };

  const getSliderXFromEvent = (clientX: number): number => {
    const track = trackRef.current;
    if (!track) return 0;
    const rect = track.getBoundingClientRect();
    const x = clientX - rect.left - gapSize / 2;
    return Math.max(0, Math.min(x, maxSliderX));
  };

  const handleMouseDown = (e: React.MouseEvent) => {
    if (status !== "ready" && status !== "fail") return;
    isDraggingRef.current = true;
    startXRef.current = e.clientX;
    startSliderXRef.current = sliderX;
    setStatus("dragging");
    setMessage("");
  };

  const handleTouchStart = (e: React.TouchEvent) => {
    if (status !== "ready" && status !== "fail") return;
    isDraggingRef.current = true;
    startXRef.current = e.touches[0].clientX;
    startSliderXRef.current = sliderX;
    setStatus("dragging");
    setMessage("");
  };

  const handleMouseMove = useCallback(
    (e: MouseEvent) => {
      if (!isDraggingRef.current) return;
      const deltaX = e.clientX - startXRef.current;
      const newX = Math.max(0, Math.min(startSliderXRef.current + deltaX, maxSliderX));
      setSliderX(newX);
    },
    [maxSliderX]
  );

  const handleTouchMove = useCallback(
    (e: TouchEvent) => {
      if (!isDraggingRef.current) return;
      const deltaX = e.touches[0].clientX - startXRef.current;
      const newX = Math.max(0, Math.min(startSliderXRef.current + deltaX, maxSliderX));
      setSliderX(newX);
    },
    [maxSliderX]
  );

  const doVerify = useCallback(() => {
    if (!isDraggingRef.current || !captchaData) return;
    isDraggingRef.current = false;
    setStatus("verifying");

    const isValid = verifyCaptcha(sliderX, captchaData.gapX, tolerance);

    if (isValid) {
      setStatus("success");
      setMessage("验证成功");
      onSuccess?.();
    } else {
      setStatus("fail");
      setMessage("验证失败,请重试");
      setSliderX(0);
      onFail?.();
      setTimeout(() => {
        handleRefresh();
      }, 1000);
    }
  }, [captchaData, sliderX, tolerance, onSuccess, onFail]);

  const handleMouseUp = useCallback(() => {
    doVerify();
  }, [doVerify]);

  const handleTouchEnd = useCallback(() => {
    doVerify();
  }, [doVerify]);

  useEffect(() => {
    window.addEventListener("mousemove", handleMouseMove);
    window.addEventListener("mouseup", handleMouseUp);
    window.addEventListener("touchmove", handleTouchMove);
    window.addEventListener("touchend", handleTouchEnd);
    return () => {
      window.removeEventListener("mousemove", handleMouseMove);
      window.removeEventListener("mouseup", handleMouseUp);
      window.removeEventListener("touchmove", handleTouchMove);
      window.removeEventListener("touchend", handleTouchEnd);
    };
  }, [handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);

  const getStatusColor = () => {
    switch (status) {
      case "success": return "#22c55e";
      case "fail": return "#ef4444";
      default: return "#3b82f6";
    }
  };

  const getStatusIcon = () => {
    switch (status) {
      case "success": return <CheckCircleOutlined />;
      case "fail": return <CloseCircleOutlined />;
      default: return null;
    }
  };

  return (
    <div
      ref={containerRef}
      className={`slider-captcha ${className}`}
      style={{ width, ...style }}
    >
      {/* 图片区域 */}
      <div className="sc-image-area" style={{ width, height }}>
        {status === "loading" && (
          <div className="sc-loading">
            <LoadingOutlined spin style={{ fontSize: 32, color: "#3b82f6" }} />
            <span>加载中...</span>
          </div>
        )}

        {captchaData && (
          <>
            {/* 背景图 */}
            <img
              src={captchaData.bgCanvas.toDataURL()}
              alt="captcha background"
              className="sc-bg-image"
              style={{ width, height }}
              draggable={false}
            />
            {/* 滑块图 */}
            <div
              className="sc-slider-piece"
              style={{
                width: gapSize,
                height,
                transform: `translateX(${sliderX}px)`,
              }}
            >
              <img
                src={captchaData.sliderCanvas.toDataURL()}
                alt="slider"
                style={{ width: gapSize, height }}
                draggable={false}
              />
            </div>
            {/* 刷新按钮 */}
            <button
              className="sc-refresh-btn"
              onClick={handleRefresh}
              title="刷新"
            >
              <ReloadOutlined spin={status === "loading"} />
            </button>
          </>
        )}

        {/* 状态遮罩 */}
        {(status === "success" || status === "fail") && (
          <div
            className={`sc-status-overlay ${status}`}
            style={{ backgroundColor: status === "success" ? "rgba(34,197,94,0.15)" : "rgba(239,68,68,0.15)" }}
          >
            <div className="sc-status-content">
              {getStatusIcon()}
              <span>{message}</span>
            </div>
          </div>
        )}
      </div>

      {/* 滑块轨道 */}
      <div
        ref={trackRef}
        className={`sc-track ${status}`}
        style={{ width, height: gapSize }}
      >
        {/* 轨道背景文字 */}
        <div className="sc-track-text" style={{ color: getStatusColor() }}>
          {message}
        </div>

        {/* 进度条 */}
        <div
          className="sc-track-progress"
          style={{
            width: sliderX + gapSize / 2,
            backgroundColor: getStatusColor(),
            opacity: status === "dragging" || status === "verifying" ? 0.3 : 0,
          }}
        />

        {/* 滑块按钮 */}
        <div
          className={`sc-slider-btn ${status}`}
          style={{
            width: gapSize,
            height: gapSize,
            transform: `translateX(${sliderX}px)`,
            backgroundColor: getStatusColor(),
            cursor: status === "ready" || status === "fail" ? "grab" : "default",
          }}
          onMouseDown={handleMouseDown}
          onTouchStart={handleTouchStart}
        >
          {status === "loading" ? (
            <LoadingOutlined spin />
          ) : status === "success" ? (
            <CheckCircleOutlined />
          ) : status === "fail" ? (
            <CloseCircleOutlined />
          ) : (
            <span className="sc-arrow">→</span>
          )}
        </div>
      </div>
    </div>
  );
};

export default SliderCaptcha;
  • Step 3: 创建 SliderCaptcha.css
.slider-captcha {
  user-select: none;
  -webkit-user-select: none;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}

/* 图片区域 */
.sc-image-area {
  position: relative;
  border-radius: 8px 8px 0 0;
  overflow: hidden;
  background: #f1f5f9;
}

.sc-bg-image {
  display: block;
  border-radius: 8px 8px 0 0;
}

.sc-slider-piece {
  position: absolute;
  top: 0;
  left: 0;
  transition: transform 0.1s ease-out;
  pointer-events: none;
}

.sc-slider-piece img {
  display: block;
}

.sc-refresh-btn {
  position: absolute;
  top: 8px;
  right: 8px;
  width: 32px;
  height: 32px;
  border: none;
  border-radius: 6px;
  background: rgba(255, 255, 255, 0.9);
  backdrop-filter: blur(4px);
  color: #64748b;
  font-size: 14px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 0.2s ease;
  z-index: 10;
}

.sc-refresh-btn:hover {
  background: #fff;
  color: #3b82f6;
  transform: rotate(180deg);
}

.sc-loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  width: 100%;
  height: 100%;
  color: #94a3b8;
  font-size: 14px;
}

/* 状态遮罩 */
.sc-status-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  animation: scFadeIn 0.3s ease;
  z-index: 5;
}

.sc-status-overlay.success {
  animation: scSuccessPulse 0.5s ease;
}

.sc-status-overlay.fail {
  animation: scShake 0.5s ease;
}

.sc-status-content {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 20px;
  background: rgba(255, 255, 255, 0.95);
  border-radius: 20px;
  font-size: 14px;
  font-weight: 600;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.sc-status-content .anticon {
  font-size: 18px;
}

/* 滑块轨道 */
.sc-track {
  position: relative;
  background: #e8ecf1;
  border-radius: 0 0 8px 8px;
  overflow: hidden;
}

.sc-track-text {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 13px;
  font-weight: 500;
  transition: color 0.3s ease;
  z-index: 1;
}

.sc-track-progress {
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  border-radius: 0 0 0 8px;
  transition: width 0.1s ease-out, opacity 0.3s ease;
  z-index: 0;
}

.sc-slider-btn {
  position: absolute;
  top: 0;
  left: 0;
  border-radius: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #fff;
  font-size: 16px;
  transition: transform 0.1s ease-out, background-color 0.3s ease;
  z-index: 2;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}

.sc-slider-btn:active {
  cursor: grabbing !important;
}

.sc-slider-btn.success,
.sc-slider-btn.fail {
  cursor: default !important;
}

.sc-arrow {
  font-size: 18px;
  font-weight: bold;
}

/* 动画 */
@keyframes scFadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes scSuccessPulse {
  0% { transform: scale(1); }
  50% { transform: scale(1.02); }
  100% { transform: scale(1); }
}

@keyframes scShake {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-8px); }
  40% { transform: translateX(8px); }
  60% { transform: translateX(-4px); }
  80% { transform: translateX(4px); }
}

Task 3: 创建组件导出入口

Files:

  • Create: src/components/SliderCaptcha/index.ts

  • Step 4: 创建 index.ts

export { default as SliderCaptcha } from "./SliderCaptcha";
export type { SliderCaptchaProps } from "./SliderCaptcha";
export * from "./captchaUtils";

Task 4: 创建演示页面

Files:

  • Create: src/pages/SliderCaptchaDemo/SliderCaptchaDemo.tsx
  • Create: src/pages/SliderCaptchaDemo/SliderCaptchaDemo.css

功能需求

  1. 展示组件:居中展示滑块验证码组件
  2. 状态反馈:显示验证成功/失败的状态提示
  3. 参数调节:提供宽度、高度、缺口大小、容错值的调节滑块
  4. 代码示例:展示组件的使用代码
  5. 集成说明:说明如何集成到登录/注册流程
  • Step 5: 创建 SliderCaptchaDemo.tsx
import "./SliderCaptchaDemo.css";
import React, { useState } from "react";
import { Button, Slider, message, Card } from "antd";
import {
  ArrowLeftOutlined,
  SafetyOutlined,
  CheckCircleOutlined,
  InfoCircleOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { useRecordHistory } from "@/hooks/useRecordHistory";
import { SliderCaptcha } from "@/components/SliderCaptcha";

const SliderCaptchaDemo: React.FC = () => {
  const navigate = useNavigate();

  useRecordHistory({
    type: "tool",
    title: "滑块验证码",
    description: "拼图滑块验证码组件,支持拖拽验证",
    category: "实用",
    link: "/utility/slider-captcha",
  });

  const [width, setWidth] = useState(320);
  const [height, setHeight] = useState(160);
  const [gapSize, setGapSize] = useState(40);
  const [tolerance, setTolerance] = useState(5);
  const [verifyResult, setVerifyResult] = useState<string>("");
  const [key, setKey] = useState(0);

  const handleSuccess = () => {
    setVerifyResult("success");
    message.success("验证成功!");
  };

  const handleFail = () => {
    setVerifyResult("fail");
    message.error("验证失败,请重试");
  };

  const handleRefresh = () => {
    setVerifyResult("");
  };

  const resetComponent = () => {
    setKey((k) => k + 1);
    setVerifyResult("");
  };

  const codeExample = `<SliderCaptcha
  width={${width}}
  height={${height}}
  gapSize={${gapSize}}
  tolerance={${tolerance}}
  onSuccess={() => console.log("验证成功")}
  onFail={() => console.log("验证失败")}
/>`;

  return (
    <div className="slider-captcha-demo-page">
      <div className="slider-captcha-demo-hero">
        <div className="slider-captcha-demo-hero-particles">
          {Array.from({ length: 6 }).map((_, i) => (
            <div key={i} className={`scc-particle scc-particle-${i + 1}`} />
          ))}
        </div>
        <div className="slider-captcha-demo-hero-content">
          <div className="slider-captcha-demo-hero-icon">
            <SafetyOutlined />
          </div>
          <h1 className="slider-captcha-demo-hero-title">滑块验证码</h1>
        </div>
      </div>

      <div className="slider-captcha-demo-container">
        <div className="slider-captcha-demo-toolbar">
          <Button
            icon={<ArrowLeftOutlined />}
            className="scc-back-btn"
            onClick={() => navigate("/utility")}
          >
            返回工具列表
          </Button>
        </div>

        <div className="scc-demo-main">
          {/* 左侧:验证码组件 */}
          <div className="scc-demo-left">
            <div className="scc-component-wrapper">
              <SliderCaptcha
                key={key}
                width={width}
                height={height}
                gapSize={gapSize}
                tolerance={tolerance}
                onSuccess={handleSuccess}
                onFail={handleFail}
                onRefresh={handleRefresh}
              />
            </div>

            {verifyResult && (
              <div className={`scc-result ${verifyResult}`}>
                {verifyResult === "success" ? (
                  <>
                    <CheckCircleOutlined />
                    <span>验证通过!您可以继续操作。</span>
                  </>
                ) : (
                  <>
                    <InfoCircleOutlined />
                    <span>验证未通过,组件已自动刷新。</span>
                  </>
                )}
              </div>
            )}

            <Button onClick={resetComponent} style={{ marginTop: 16 }}>
              重置组件
            </Button>
          </div>

          {/* 右侧:参数调节 */}
          <div className="scc-demo-right">
            <Card title="参数配置" size="small">
              <div className="scc-param-group">
                <label>宽度 {width}px</label>
                <Slider min={200} max={500} value={width} onChange={setWidth} />
              </div>
              <div className="scc-param-group">
                <label>高度 {height}px</label>
                <Slider min={100} max={300} value={height} onChange={setHeight} />
              </div>
              <div className="scc-param-group">
                <label>缺口大小 {gapSize}px</label>
                <Slider min={30} max={60} value={gapSize} onChange={setGapSize} />
              </div>
              <div className="scc-param-group">
                <label>容错像素 {tolerance}px</label>
                <Slider min={0} max={15} value={tolerance} onChange={setTolerance} />
              </div>
            </Card>

            <Card title="使用示例" size="small" style={{ marginTop: 16 }}>
              <pre className="scc-code-block">{codeExample}</pre>
            </Card>

            <Card title="集成说明" size="small" style={{ marginTop: 16 }}>
              <div className="scc-guide">
                <p><strong>1. 导入组件</strong></p>
                <pre>{`import { SliderCaptcha } from "@/components/SliderCaptcha";`}</pre>
                <p><strong>2. 在表单中使用</strong></p>
                <pre>{`<SliderCaptcha
  onSuccess={() => setCaptchaVerified(true)}
  onFail={() => setCaptchaVerified(false)}
/>`}</pre>
                <p><strong>3. 表单提交校验</strong></p>
                <pre>{`if (!captchaVerified) {
  message.error("请先完成滑块验证");
  return;
}`}</pre>
              </div>
            </Card>
          </div>
        </div>

        <div className="slider-captcha-demo-tips">
          <div className="scc-tip-card">
            <div className="scc-tip-icon">🔒</div>
            <div className="scc-tip-content">
              <h4>安全验证</h4>
              <p>滑块验证码有效防止机器人攻击,比传统图形验证码用户体验更好。</p>
            </div>
          </div>
          <div className="scc-tip-card">
            <div className="scc-tip-icon">⚡</div>
            <div className="scc-tip-content">
              <h4>纯前端实现</h4>
              <p>无需后端支持,所有验证逻辑在浏览器本地完成,响应速度快。</p>
            </div>
          </div>
          <div className="scc-tip-card">
            <div className="scc-tip-icon">📱</div>
            <div className="scc-tip-content">
              <h4>多端适配</h4>
              <p>支持鼠标拖拽和触摸滑动,完美适配桌面端和移动端。</p>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

export default SliderCaptchaDemo;
  • Step 6: 创建 SliderCaptchaDemo.css
.slider-captcha-demo-page {
  min-height: 100vh;
  background: #f0f2f5;
}

.slider-captcha-demo-hero {
  min-height: 200px;
  background: linear-gradient(135deg, #10b981 0%, #3b82f6 50%, #8b5cf6 100%);
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  overflow: hidden;
  padding: 40px 20px;
}

.slider-captcha-demo-hero::before {
  content: "";
  position: absolute;
  top: -50%;
  left: -50%;
  width: 200%;
  height: 200%;
  background: radial-gradient(circle at 30% 50%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
    radial-gradient(circle at 70% 80%, rgba(255, 255, 255, 0.08) 0%, transparent 40%);
  animation: sccHeroLight 8s ease-in-out infinite alternate;
}

@keyframes sccHeroLight {
  0% { transform: translate(0, 0); }
  100% { transform: translate(-20px, -10px); }
}

.slider-captcha-demo-hero-particles {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
}

.scc-particle {
  position: absolute;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.12);
  animation: sccFloat 6s ease-in-out infinite;
}

.scc-particle-1 { width: 50px; height: 50px; top: 15%; left: 8%; animation-delay: 0s; }
.scc-particle-2 { width: 35px; height: 35px; top: 65%; left: 18%; animation-delay: 1s; }
.scc-particle-3 { width: 65px; height: 65px; top: 25%; right: 12%; animation-delay: 2s; }
.scc-particle-4 { width: 25px; height: 25px; top: 70%; right: 25%; animation-delay: 0.5s; }
.scc-particle-5 { width: 40px; height: 40px; top: 45%; left: 45%; animation-delay: 1.5s; }
.scc-particle-6 { width: 30px; height: 30px; bottom: 20%; left: 30%; animation-delay: 3s; }

@keyframes sccFloat {
  0%, 100% { transform: translateY(0) scale(1); opacity: 0.3; }
  50% { transform: translateY(-18px) scale(1.1); opacity: 0.6; }
}

.slider-captcha-demo-hero-content {
  text-align: center;
  position: relative;
  z-index: 1;
}

.slider-captcha-demo-hero-icon {
  width: 64px;
  height: 64px;
  border-radius: 20px;
  background: rgba(255, 255, 255, 0.2);
  backdrop-filter: blur(10px);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 28px;
  color: #fff;
  margin-bottom: 16px;
  border: 1px solid rgba(255, 255, 255, 0.3);
}

.slider-captcha-demo-hero-title {
  font-size: 32px;
  font-weight: 800;
  color: #fff;
  margin: 0;
  letter-spacing: 2px;
}

.slider-captcha-demo-container {
  max-width: 900px;
  margin: -30px auto 0;
  padding: 0 20px 60px;
  position: relative;
  z-index: 2;
}

.slider-captcha-demo-toolbar {
  display: flex;
  align-items: center;
  margin-bottom: 20px;
}

.scc-back-btn {
  border-radius: 8px;
  font-weight: 500;
  color: #64748b;
  border-color: #e2e8f0;
}

.scc-back-btn:hover {
  color: #10b981;
  border-color: #10b981;
}

.scc-demo-main {
  display: grid;
  grid-template-columns: 1fr 360px;
  gap: 24px;
  margin-bottom: 24px;
}

.scc-demo-left {
  background: #fff;
  border-radius: 14px;
  padding: 32px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
  border: 1px solid #e8ecf1;
  display: flex;
  flex-direction: column;
  align-items: center;
  min-height: 400px;
}

.scc-component-wrapper {
  display: flex;
  justify-content: center;
}

.scc-result {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 16px;
  padding: 10px 20px;
  border-radius: 8px;
  font-size: 14px;
  font-weight: 500;
  animation: sccFadeIn 0.3s ease;
}

.scc-result.success {
  background: #dcfce7;
  color: #16a34a;
}

.scc-result.fail {
  background: #fee2e2;
  color: #dc2626;
}

.scc-demo-right {
  display: flex;
  flex-direction: column;
}

.scc-param-group {
  margin-bottom: 16px;
}

.scc-param-group label {
  display: block;
  font-size: 12px;
  font-weight: 600;
  color: #64748b;
  margin-bottom: 6px;
}

.scc-code-block {
  background: #1e1e2e;
  color: #a6accd;
  padding: 12px;
  border-radius: 8px;
  font-size: 12px;
  line-height: 1.6;
  overflow-x: auto;
  margin: 0;
}

.scc-guide p {
  font-size: 13px;
  font-weight: 600;
  color: #334155;
  margin: 12px 0 6px;
}

.scc-guide pre {
  background: #f8fafc;
  padding: 10px;
  border-radius: 6px;
  font-size: 12px;
  color: #64748b;
  overflow-x: auto;
  margin: 0 0 10px;
}

.slider-captcha-demo-tips {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
}

.scc-tip-card {
  background: white;
  border-radius: 14px;
  padding: 20px;
  border: 1px solid #e8ecf1;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
  display: flex;
  gap: 14px;
  transition: all 0.3s ease;
}

.scc-tip-card:hover {
  transform: translateY(-3px);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}

.scc-tip-icon {
  font-size: 28px;
  flex-shrink: 0;
  line-height: 1;
}

.scc-tip-content h4 {
  font-size: 14px;
  font-weight: 700;
  color: #1e293b;
  margin: 0 0 6px;
}

.scc-tip-content p {
  font-size: 13px;
  color: #64748b;
  margin: 0;
  line-height: 1.6;
}

@keyframes sccFadeIn {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: translateY(0); }
}

@media (max-width: 768px) {
  .slider-captcha-demo-hero {
    min-height: 160px;
    padding: 30px 16px;
  }

  .slider-captcha-demo-hero-title {
    font-size: 24px;
  }

  .slider-captcha-demo-container {
    padding: 0 12px 40px;
    margin-top: -20px;
  }

  .scc-demo-main {
    grid-template-columns: 1fr;
  }

  .slider-captcha-demo-tips {
    grid-template-columns: 1fr;
    gap: 10px;
  }
}

Task 5: 在 Utility.tsx 中注册滑块验证码工具

Files:

  • Modify: src/pages/Utility/Utility.tsx

  • Step 7: 导入 SafetyOutlined 图标

在 Utility.tsx 的 import 区域添加:

import {
  ToolOutlined,
  SearchOutlined,
  CalculatorOutlined,
  CalendarOutlined,
  QrcodeOutlined,
  LinkOutlined,
  ClockCircleOutlined,
  TranslationOutlined,
  CompressOutlined,
  FileTextOutlined,
  CodeOutlined,
  PictureOutlined,
  ThunderboltOutlined,
  EditOutlined,
  SafetyOutlined, // 新增
} from "@ant-design/icons";
  • Step 8: 在 utilityTools 数组中添加滑块验证码条目

在 utilityTools 数组末尾添加:

  {
    id: 16,
    name: "滑块验证码",
    desc: "拼图滑块验证码组件,支持拖拽验证,可集成到登录注册流程",
    icon: <SafetyOutlined />,
    color: "#10b981",
    category: "实用",
    usageCount: 2890,
  },
  • Step 9: 在 routeMap 中添加滑块验证码路由映射

在 handleToolClick 的 routeMap 中添加:

      "滑块验证码": "/utility/slider-captcha",

Task 6: 在 App.tsx 中添加路由配置

Files:

  • Modify: src/App.tsx

  • Step 10: 添加 lazy import

在 App.tsx 的 import 区域添加:

const SliderCaptchaDemo = lazy(() => import("@/pages/SliderCaptchaDemo/SliderCaptchaDemo"));
  • Step 11: 在 Routes 中添加新路由

在 <Routes> 中添加:

<Route path="/utility/slider-captcha" element={<SliderCaptchaDemo />} />

Self-Review

1. Spec coverage

需求 对应 Task
Canvas 生成拼图验证码 Task 1
滑块拖拽交互(鼠标+触摸) Task 2
验证逻辑(容错计算) Task 1 + Task 2
成功/失败状态反馈 Task 2
刷新重置功能 Task 2
组件导出入口 Task 3
演示页面(参数调节+代码示例) Task 4
工具列表注册 Task 5
路由配置 Task 6

无遗漏。

2. Placeholder scan

  • 无 "TBD"、"TODO"、"implement later"
  • 所有步骤包含完整代码
  • 无 "Similar to Task N" 引用
  • 所有文件路径精确

3. Type consistency

  • CaptchaResult 和 GapPosition 接口在 Task 1 中定义,Task 2 中一致使用
  • SliderCaptchaProps 接口在 Task 2 中定义并导出
  • CaptchaStatus 类型在 Task 2 中定义并一致使用
  • useRecordHistory 参数格式与现有代码一致

执行选项

计划已保存到 docs/plans/2026-06-13-slider-captcha-api.md。两个执行选项:

1. Subagent-Driven(推荐) - 每个 Task 分配一个独立子代理执行,逐个审查

2. Inline Execution - 在当前会话中按顺序批量执行

请选择执行方式?