1426 lines
36 KiB
Markdown
1426 lines
36 KiB
Markdown
# 滑块验证码 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 自定义宽度
|
|
|
|
---
|
|
|
|
## 组件接口设计
|
|
|
|
```ts
|
|
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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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 区域添加:
|
|
|
|
```tsx
|
|
import {
|
|
ToolOutlined,
|
|
SearchOutlined,
|
|
CalculatorOutlined,
|
|
CalendarOutlined,
|
|
QrcodeOutlined,
|
|
LinkOutlined,
|
|
ClockCircleOutlined,
|
|
TranslationOutlined,
|
|
CompressOutlined,
|
|
FileTextOutlined,
|
|
CodeOutlined,
|
|
PictureOutlined,
|
|
ThunderboltOutlined,
|
|
EditOutlined,
|
|
SafetyOutlined, // 新增
|
|
} from "@ant-design/icons";
|
|
```
|
|
|
|
- [ ] **Step 8: 在 utilityTools 数组中添加滑块验证码条目**
|
|
|
|
在 `utilityTools` 数组末尾添加:
|
|
|
|
```tsx
|
|
{
|
|
id: 16,
|
|
name: "滑块验证码",
|
|
desc: "拼图滑块验证码组件,支持拖拽验证,可集成到登录注册流程",
|
|
icon: <SafetyOutlined />,
|
|
color: "#10b981",
|
|
category: "实用",
|
|
usageCount: 2890,
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 9: 在 routeMap 中添加滑块验证码路由映射**
|
|
|
|
在 `handleToolClick` 的 `routeMap` 中添加:
|
|
|
|
```tsx
|
|
"滑块验证码": "/utility/slider-captcha",
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: 在 App.tsx 中添加路由配置
|
|
|
|
**Files:**
|
|
- Modify: `src/App.tsx`
|
|
|
|
- [ ] **Step 10: 添加 lazy import**
|
|
|
|
在 `App.tsx` 的 import 区域添加:
|
|
|
|
```tsx
|
|
const SliderCaptchaDemo = lazy(() => import("@/pages/SliderCaptchaDemo/SliderCaptchaDemo"));
|
|
```
|
|
|
|
- [ ] **Step 11: 在 Routes 中添加新路由**
|
|
|
|
在 `<Routes>` 中添加:
|
|
|
|
```tsx
|
|
<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** - 在当前会话中按顺序批量执行
|
|
|
|
**请选择执行方式?**
|