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
功能需求
- 生成随机缺口位置:在图片区域内随机生成缺口坐标 (x, y)
- 绘制底图:在 Canvas 上绘制背景图,并在缺口位置绘制半透明遮罩
- 绘制滑块:提取缺口区域的图像作为滑块,绘制到单独的 Canvas
- 验证位置:比对用户滑动的位置与缺口位置,计算偏移量是否在容错范围内
- 生成干扰线:在底图上绘制随机干扰线增加识别难度
- 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
功能需求
- 状态管理:加载中、就绪、拖拽中、验证中、成功、失败 六种状态
- 拖拽交互:鼠标/触摸按下 -> 拖拽移动 -> 释放,滑块跟随手指移动
- 边界限制:滑块只能在轨道范围内移动
- 验证反馈:释放后计算位置,显示成功(绿色)或失败(红色)动画
- 自动刷新:验证失败后自动重置,成功后显示成功状态
- 手动刷新:点击刷新按钮重新生成验证码
- 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
功能需求
- 展示组件:居中展示滑块验证码组件
- 状态反馈:显示验证成功/失败的状态提示
- 参数调节:提供宽度、高度、缺口大小、容错值的调节滑块
- 代码示例:展示组件的使用代码
- 集成说明:说明如何集成到登录/注册流程
- 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 - 在当前会话中按顺序批量执行
请选择执行方式?