Commit 3e6314c1 authored by 郑磊's avatar 郑磊

首次提交

parents
Pipeline #828 canceled with stages
# 基于开源项目搭建的行为验证码服务
HELP.md
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
/*.yaml
!/docker-compose.example.yaml
/public
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar
FROM maven:3.9.6-eclipse-temurin-17-alpine
RUN sed -i 's/<mirrors>/<mirrors><mirror><id>aliyunmaven<\/id><mirrorOf>*<\/mirrorOf><name>阿里云公共仓库<\/name><url>https:\/\/maven.aliyun.com\/repository\/public<\/url><\/mirror>/' /usr/share/maven/conf/settings.xml
EXPOSE 8080
VOLUME /application
WORKDIR /application
CMD ["mvn", "spring-boot:run"]
\ No newline at end of file
version: '3.7'
services:
server:
container_name: 'fj-captcha-server'
build:
context: './'
ports:
- '8080:8080'
volumes:
- '.:/application'
restart: 'always'
extra_hosts:
- 'host.docker.internal:docker-gateway'
This diff is collapsed.
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM https://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.2.0
@REM
@REM Required ENV vars:
@REM JAVA_HOME - location of a JDK home dir
@REM
@REM Optional ENV vars
@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending
@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
@REM e.g. to debug Maven itself, use
@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
@REM ----------------------------------------------------------------------------
@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
@echo off
@REM set title of command window
title %0
@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO%
@REM set %HOME% to equivalent of $HOME
if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
@REM Execute a user defined script before this one
if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
@REM check for pre script, once with legacy .bat ending and once with .cmd ending
if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %*
if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %*
:skipRcPre
@setlocal
set ERROR_CODE=0
@REM To isolate internal variables from possible post scripts, we use another setlocal
@setlocal
@REM ==== START VALIDATION ====
if not "%JAVA_HOME%" == "" goto OkJHome
echo.
echo Error: JAVA_HOME not found in your environment. >&2
echo Please set the JAVA_HOME variable in your environment to match the >&2
echo location of your Java installation. >&2
echo.
goto error
:OkJHome
if exist "%JAVA_HOME%\bin\java.exe" goto init
echo.
echo Error: JAVA_HOME is set to an invalid directory. >&2
echo JAVA_HOME = "%JAVA_HOME%" >&2
echo Please set the JAVA_HOME variable in your environment to match the >&2
echo location of your Java installation. >&2
echo.
goto error
@REM ==== END VALIDATION ====
:init
@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
@REM Fallback to current working directory if not found.
set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
set EXEC_DIR=%CD%
set WDIR=%EXEC_DIR%
:findBaseDir
IF EXIST "%WDIR%"\.mvn goto baseDirFound
cd ..
IF "%WDIR%"=="%CD%" goto baseDirNotFound
set WDIR=%CD%
goto findBaseDir
:baseDirFound
set MAVEN_PROJECTBASEDIR=%WDIR%
cd "%EXEC_DIR%"
goto endDetectBaseDir
:baseDirNotFound
set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
cd "%EXEC_DIR%"
:endDetectBaseDir
IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
@setlocal EnableExtensions EnableDelayedExpansion
for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
:endReadAdditionalConfig
SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B
)
@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
if exist %WRAPPER_JAR% (
if "%MVNW_VERBOSE%" == "true" (
echo Found %WRAPPER_JAR%
)
) else (
if not "%MVNW_REPOURL%" == "" (
SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
)
if "%MVNW_VERBOSE%" == "true" (
echo Couldn't find %WRAPPER_JAR%, downloading it ...
echo Downloading from: %WRAPPER_URL%
)
powershell -Command "&{"^
"$webclient = new-object System.Net.WebClient;"^
"if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^
"$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^
"}"^
"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^
"}"
if "%MVNW_VERBOSE%" == "true" (
echo Finished downloading %WRAPPER_JAR%
)
)
@REM End of extension
@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file
SET WRAPPER_SHA_256_SUM=""
FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B
)
IF NOT %WRAPPER_SHA_256_SUM%=="" (
powershell -Command "&{"^
"$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^
"If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^
" Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^
" Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^
" Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^
" exit 1;"^
"}"^
"}"
if ERRORLEVEL 1 goto error
)
@REM Provide a "standardized" way to retrieve the CLI args that will
@REM work with both Windows and non-Windows executions.
set MAVEN_CMD_LINE_ARGS=%*
%MAVEN_JAVA_EXE% ^
%JVM_CONFIG_MAVEN_PROPS% ^
%MAVEN_OPTS% ^
%MAVEN_DEBUG_OPTS% ^
-classpath %WRAPPER_JAR% ^
"-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^
%WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
if ERRORLEVEL 1 goto error
goto end
:error
set ERROR_CODE=1
:end
@endlocal & set ERROR_CODE=%ERROR_CODE%
if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost
@REM check for post script, once with legacy .bat ending and once with .cmd ending
if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat"
if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd"
:skipRcPost
@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
if "%MAVEN_BATCH_PAUSE%"=="on" pause
if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE%
cmd /C exit /B %ERROR_CODE%
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.fanjiao.captcha</groupId>
<artifactId>server</artifactId>
<version>1.0.0</version>
<name>server</name>
<description>server</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>cloud.tianai.captcha</groupId>
<artifactId>tianai-captcha-springboot-starter</artifactId>
<version>1.4.1</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
package com.fanjiao.captcha.server;
import cloud.tianai.captcha.common.response.ApiResponse;
import cloud.tianai.captcha.common.response.ApiResponseStatusConstant;
import cloud.tianai.captcha.generator.ImageCaptchaGenerator;
import cloud.tianai.captcha.generator.common.model.dto.GenerateParam;
import cloud.tianai.captcha.spring.application.DefaultImageCaptchaApplication;
import cloud.tianai.captcha.spring.autoconfiguration.ImageCaptchaProperties;
import cloud.tianai.captcha.spring.exception.CaptchaValidException;
import cloud.tianai.captcha.spring.store.CacheStore;
import cloud.tianai.captcha.spring.vo.CaptchaResponse;
import cloud.tianai.captcha.spring.vo.ImageCaptchaVO;
import cloud.tianai.captcha.validator.ImageCaptchaValidator;
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
import com.fanjiao.captcha.server.dto.ExtraGenerateParam;
import com.fanjiao.captcha.server.dto.UseCaptchaData;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component
@EnableConfigurationProperties({ImageCaptchaProperties.class})
public class CaptchaApplication extends DefaultImageCaptchaApplication {
private final ImageCaptchaProperties prop;
public CaptchaApplication(
ImageCaptchaGenerator template,
ImageCaptchaValidator imageCaptchaValidator,
CacheStore cacheStore,
ImageCaptchaProperties prop
) {
super(template, imageCaptchaValidator, cacheStore, prop);
this.prop = prop;
log.info("自定义验证码应用初始化完成");
}
@Override
public CaptchaResponse<ImageCaptchaVO> generateCaptcha(GenerateParam param) {
//扩展一下这个生成验证码方法,如果传入的参数是带有校验数据的,那么在生成验证码成功后,把校验数据也写入缓存
var result = super.generateCaptcha(param);
if (param instanceof ExtraGenerateParam extraParam) {
Long expire = prop.getExpire().getOrDefault(param.getType(), prop.getExpire().getOrDefault("default", 20000L));
String cacheKey = getKey(result.getId()).concat(":verification");
if (!getCacheStore().setCache(cacheKey, extraParam.getVerification(), expire, TimeUnit.MILLISECONDS)) {
log.error("缓存验证码校验数据失败, id={}, validData={}", result.getId(), extraParam.getVerification());
throw new CaptchaValidException(param.getType(), "缓存验证码校验数据失败");
}
}
return result;
}
public ApiResponse<?> matching(String id, ImageCaptchaTrack imageCaptchaTrack, @NonNull String ip) {
var result = super.matching(id, imageCaptchaTrack);
//不管校验是否成功,都读取并删除额外的校验数据
var verification = getCacheStore().getAndRemoveCache(getKey(id).concat(":verification"));
//如果验证结果是失败的就不做后续的校验了
if (!result.isSuccess()) {
return result;
}
//没有校验数据视为失败
if (verification == null) {
return ApiResponse.ofMessage(ApiResponseStatusConstant.BASIC_CHECK_FAIL);
}
//IP不匹配视为失败
if (!ip.equals(verification.get("ip"))) {
return ApiResponse.ofMessage(ApiResponseStatusConstant.BASIC_CHECK_FAIL);
}
//到这就是校验成功了,那么把校验数据写入到二次验证缓存中
if (!saveSecondaryData(id, verification)) {
log.error("缓存验证码二次校验数据失败, id={}, validData={}", id, verification);
throw new CaptchaValidException("", "缓存验证码二次校验数据失败");
}
return result;
}
/**
* 写入二次验证校验数据
*
* @param key 验证码id
* @param data 校验数据
* @return 是否写入成功
*/
private boolean saveSecondaryData(String key, Map<String, Object> data) {
var secondary = prop.getSecondary();
return getCacheStore().setCache(
secondary.getKeyPrefix().concat(":").concat(key),
data,
secondary.getExpire(),
TimeUnit.MILLISECONDS
);
}
/**
* 二次校验(使用)验证码
*
* @param data 验证数据
* @return 是否校验成功
*/
public boolean secondaryVerification(UseCaptchaData data) {
if (data == null) return false;
//读取二次校验数据
var secondary = prop.getSecondary();
var verification = getCacheStore().getAndRemoveCache(secondary.getKeyPrefix().concat(":").concat(data.getKey()));
if (verification == null) {
//如果没有校验数据,那么视为校验失败
return false;
}
//如果传入了ip,那就校验ip
String ip = data.getIp();
if (ip != null && !ip.isEmpty()) {
if (!ip.equals(verification.get("ip"))) {
return false;
}
}
Map<String, Object> extra = data.getExtra();
if (extra == null) {
extra = new HashMap<>();
}
Object orgExtra = verification.get("extra");
if (orgExtra instanceof Map<?, ?> orgMap) {
//确定原始校验数据中的每个数据都与新传入的数据匹配
for (Map.Entry<?, ?> entry : orgMap.entrySet()) {
Object key = entry.getKey();
if (!(key instanceof String)) {
continue;
}
if (!Objects.equals(entry.getValue(), extra.get(key))) {
return false;
}
}
}
return true;
}
}
package com.fanjiao.captcha.server;
import cloud.tianai.captcha.common.constant.CaptchaTypeConstant;
import com.fanjiao.captcha.server.dto.*;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
@Slf4j
@RestController
@RequestMapping("/api/captcha")
public class CaptchaController {
private final static List<String> usableCaptchaTypes;
static {
usableCaptchaTypes = Arrays.asList(
CaptchaTypeConstant.SLIDER,
CaptchaTypeConstant.ROTATE,
CaptchaTypeConstant.CONCAT
);
}
@Autowired
private CaptchaApplication imageCaptchaApplication;
@RequestMapping("/generate")
@ResponseBody
public Response<?> generateCaptcha(
HttpServletRequest request,
@RequestBody(required = false) GenerateCaptchaData data
) {
var type = getRandomType();
var ip = request.getRemoteAddr();
Map<String, Object> extra = null;
if (data != null) {
extra = data.getExtra();
}
if (extra == null) {
extra = new HashMap<>();
}
//带校验数据的生成参数
ExtraGenerateParam extraGenerateParam = new ExtraGenerateParam();
extraGenerateParam.setType(type);
//填充额外的校验数据
var validation = extraGenerateParam.getVerification();
validation.put("ip", ip);
validation.put("extra", extra);
//生成验证码
var result = imageCaptchaApplication.generateCaptcha(extraGenerateParam);
return Response.success(GenerateCaptchaResult.from(result));
}
/**
* 获取随机的验证码类型
*
* @return 验证码类型
*/
private String getRandomType() {
int index = ThreadLocalRandom.current().nextInt(0, usableCaptchaTypes.size());
return usableCaptchaTypes.get(index);
}
/**
* 校验验证码
*
* @param request 请求对象
* @param data 请求体
*/
@PostMapping("/check")
@ResponseBody
public Response<?> checkCaptcha(
HttpServletRequest request,
@RequestBody CheckCaptchaData data
) {
String ip = request.getRemoteAddr();
//校验验证码
var result = imageCaptchaApplication.matching(
data.getKey(),
data.getData(),
ip
);
//检查实际的验证码校验结果
if (!result.isSuccess()) {
//校验失败
return Response.from(result);
}
//校验成功
return Response.success();
}
/**
* 二次校验(使用)验证码
*
* @param data 校验数据
*/
@PostMapping("/use")
@ResponseBody
public Response<?> useCaptcha(
@RequestBody UseCaptchaData data
) {
if (data == null) {
return Response.fail("校验失败");
}
if (!imageCaptchaApplication.secondaryVerification(data)) {
return Response.fail("校验失败");
}
return Response.success();
}
}
package com.fanjiao.captcha.server;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 扩展后的图片验证码配置项
*/
@Data
@ConfigurationProperties(prefix = "captcha")
public class ResourceProperties {
/**
* 资源文件根目录
*/
private String resourceRoot;
}
package com.fanjiao.captcha.server;
import cloud.tianai.captcha.common.constant.CaptchaTypeConstant;
import cloud.tianai.captcha.generator.common.constant.SliderCaptchaConstant;
import cloud.tianai.captcha.generator.impl.StandardSliderImageCaptchaGenerator;
import cloud.tianai.captcha.resource.common.model.dto.Resource;
import cloud.tianai.captcha.resource.common.model.dto.ResourceMap;
import cloud.tianai.captcha.resource.impl.DefaultResourceStore;
import cloud.tianai.captcha.resource.impl.provider.ClassPathResourceProvider;
import cloud.tianai.captcha.resource.impl.provider.FileResourceProvider;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Component;
import java.io.File;
import static cloud.tianai.captcha.generator.impl.StandardSliderImageCaptchaGenerator.DEFAULT_SLIDER_IMAGE_TEMPLATE_PATH;
@Slf4j
@Component
@EnableConfigurationProperties({ResourceProperties.class})
public class ResourceStore extends DefaultResourceStore {
private final ResourceProperties prop;
public ResourceStore(ResourceProperties prop) {
super();
this.prop = prop;
// 滑块验证码 模板 (系统内置)
ResourceMap template1 = new ResourceMap("default", 4);
template1.put(SliderCaptchaConstant.TEMPLATE_ACTIVE_IMAGE_NAME, new Resource(ClassPathResourceProvider.NAME, DEFAULT_SLIDER_IMAGE_TEMPLATE_PATH.concat("/1/active.png")));
template1.put(SliderCaptchaConstant.TEMPLATE_FIXED_IMAGE_NAME, new Resource(ClassPathResourceProvider.NAME, DEFAULT_SLIDER_IMAGE_TEMPLATE_PATH.concat("/1/fixed.png")));
ResourceMap template2 = new ResourceMap("default", 4);
template2.put(SliderCaptchaConstant.TEMPLATE_ACTIVE_IMAGE_NAME, new Resource(ClassPathResourceProvider.NAME, DEFAULT_SLIDER_IMAGE_TEMPLATE_PATH.concat("/2/active.png")));
template2.put(SliderCaptchaConstant.TEMPLATE_FIXED_IMAGE_NAME, new Resource(ClassPathResourceProvider.NAME, DEFAULT_SLIDER_IMAGE_TEMPLATE_PATH.concat("/2/fixed.png")));
// 旋转验证码 模板 (系统内置)
ResourceMap template3 = new ResourceMap("default", 4);
template3.put(SliderCaptchaConstant.TEMPLATE_ACTIVE_IMAGE_NAME, new Resource(ClassPathResourceProvider.NAME, StandardSliderImageCaptchaGenerator.DEFAULT_SLIDER_IMAGE_TEMPLATE_PATH.concat("/3/active.png")));
template3.put(SliderCaptchaConstant.TEMPLATE_FIXED_IMAGE_NAME, new Resource(ClassPathResourceProvider.NAME, StandardSliderImageCaptchaGenerator.DEFAULT_SLIDER_IMAGE_TEMPLATE_PATH.concat("/3/fixed.png")));
//添加模板
addTemplate(CaptchaTypeConstant.SLIDER, template1);
addTemplate(CaptchaTypeConstant.SLIDER, template2);
addTemplate(CaptchaTypeConstant.ROTATE, template3);
//添加本地资源
load();
log.info("本地资源存储器初始化完成 资源总数 {} 个", getAllResourceCount());
}
/**
* 加载资源
*/
private void load() {
String root = getResourceRoot();
log.info("正在初始化本地资源存储器 {}", root);
//加载公共资源
addResourcesFromDir(
root + File.separator + "common",
CaptchaTypeConstant.CONCAT,
CaptchaTypeConstant.ROTATE,
CaptchaTypeConstant.SLIDER
);
//加载各个类型特有的资源
addResourcesFromDir(
root + File.separator + CaptchaTypeConstant.CONCAT.toLowerCase(),
CaptchaTypeConstant.CONCAT
);
addResourcesFromDir(
root + File.separator + CaptchaTypeConstant.ROTATE.toLowerCase(),
CaptchaTypeConstant.ROTATE
);
addResourcesFromDir(
root + File.separator + CaptchaTypeConstant.SLIDER.toLowerCase(),
CaptchaTypeConstant.SLIDER
);
}
private void addResourcesFromDir(String dirPath, String... types) {
if (types.length == 0) {
return;
}
if (!isValidDir(dirPath)) return;
File dir = new File(dirPath);
File[] files = dir.listFiles(file -> {
if (!file.isFile()) return false;
return file.getName().toLowerCase().matches("^.*\\.(?:jpg|jpeg|png)$");
});
if (files == null) return;
for (File file : files) {
for (String type : types) {
addResource(type, new Resource(FileResourceProvider.NAME, file.getAbsolutePath(), "default"));
}
}
}
private boolean isValidDir(String dirPath) {
if (dirPath == null || dirPath.isEmpty()) return false;
File dir = new File(dirPath);
return dir.exists() && dir.isDirectory() && dir.canRead();
}
/**
* 获取资源文件根目录路径
*/
private String getResourceRoot() {
String root = prop.getResourceRoot();
if (root != null && !root.isBlank()) {
return root;
}
return System.getProperty("user.dir") + File.separator + "resources";
}
}
package com.fanjiao.captcha.server;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
public class ServerApplication {
public static void main(String[] args) {
SpringApplication.run(ServerApplication.class, args);
}
}
package com.fanjiao.captcha.server.dto;
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
import lombok.Data;
@Data
public class CheckCaptchaData {
private String key;
private ImageCaptchaTrack data = new ImageCaptchaTrack();
}
package com.fanjiao.captcha.server.dto;
import cloud.tianai.captcha.generator.common.model.dto.GenerateParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.HashMap;
import java.util.Map;
/**
* 带有扩展校验数据的验证码生成数据
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ExtraGenerateParam extends GenerateParam {
/**
* 扩展校验数据
*/
private Map<String, Object> verification = new HashMap<>();
}
package com.fanjiao.captcha.server.dto;
import lombok.Data;
import java.util.HashMap;
import java.util.Map;
@Data
public class GenerateCaptchaData {
private Map<String, Object> extra = new HashMap<>();
}
package com.fanjiao.captcha.server.dto;
import cloud.tianai.captcha.spring.vo.CaptchaResponse;
import cloud.tianai.captcha.spring.vo.ImageCaptchaVO;
import lombok.Data;
@Data
public class GenerateCaptchaResult {
private String key;
private ImageCaptchaVO captcha;
public static GenerateCaptchaResult from(CaptchaResponse<ImageCaptchaVO> data) {
var result = new GenerateCaptchaResult();
result.setKey(data.getId());
result.setCaptcha(data.getCaptcha());
return result;
}
}
package com.fanjiao.captcha.server.dto;
import cloud.tianai.captcha.common.response.ApiResponse;
import lombok.Data;
/**
* 简洁处理后的接口响应体
*
* @param <T>
*/
@Data
public class Response<T> {
private int code = 0;
private String msg = "success";
private T data;
/**
* 从原始接口响应数据中生成新的响应体
*
* @param source 原始接口响应数据
*/
public static Response<?> from(ApiResponse<?> source) {
if (source.isSuccess()) {
return success(source.getData());
} else {
return fail(source.getMsg(), source.getCode());
}
}
public static <T> Response<T> success(T data) {
Response<T> resp = new Response<>();
resp.setCode(0);
resp.setMsg("success");
resp.setData(data);
return resp;
}
public static Response<?> success() {
return success(null);
}
public static Response<?> fail(String msg, int code) {
Response<?> resp = new Response<>();
resp.setCode(code);
resp.setMsg(msg);
return resp;
}
public static Response<?> fail(String msg) {
return fail(msg, 500);
}
}
package com.fanjiao.captcha.server.dto;
import lombok.Data;
import java.util.HashMap;
import java.util.Map;
@Data
public class UseCaptchaData {
private String key;
private String ip;
private Map<String, Object> extra = new HashMap<>();
}
package com.fanjiao.captcha.server.dto;
import lombok.Data;
import java.util.HashMap;
import java.util.Map;
/**
* 放置到缓存的,验证码扩展校验数据
*/
@Data
public class ValidateCaptchaData {
private String ip;
private Map<String, Object> extra = new HashMap<>();
}
server:
port: 8080
forward-headers-strategy: native
spring:
application:
name: 'fj-captcha-server'
# data:
# redis:
# host: 'localhost'
# port: 6379
# username: ''
# password: ''
# database: 0
# jedis:
# pool:
# enabled: true
# min-idle: 1
# max-idle: 1
# max-active: 8
captcha:
expire:
# 验证码的过期时间,1分钟
default: 60000
secondary:
# 二次校验缓存前缀
key-prefix: 'captcha:secondary'
# 二次校验数据过期时间
expire: 60000
# 不初始化默认的资源文件
init-default-resource: false
# 验证码缓存数据前缀
prefix: 'captcha'
\ No newline at end of file
node_modules/
# 行为验证码H5库
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
.captcha--slider-bar[data-v-fcfe5ea2]{height:55px;position:relative;margin-top:15px}.captcha--slider-bar--bar[data-v-fcfe5ea2]{height:35px;position:relative;top:5px;background-color:#eee;border-radius:3px;overflow:hidden}.captcha--slider-bar--bar .captcha--slider-bar--active[data-v-fcfe5ea2]{position:absolute;left:0;top:0;height:100%;background-color:#6495ed}.captcha--slider-bar--track[data-v-fcfe5ea2]{background-color:#fff;position:absolute;left:0;top:0;width:55px;height:45px;border-radius:3px;box-shadow:0 0 5px #999;display:flex;justify-content:center;align-items:center}.captcha--slider-bar--track .captcha--slider-bar--track-icon[data-v-fcfe5ea2]{fill:#333;display:block;width:25px;height:25px}.captcha--status-tip[data-v-4b782e10]{position:absolute;left:0;bottom:0;width:100%;display:flex;justify-content:center;align-items:center;color:#fff;font-size:14px;height:30px}.captcha--status-tip .captcha--status-tip--icon[data-v-4b782e10]{fill:#fff;display:block;width:18px;height:18px;margin-right:5px}.captcha--status-tip.captcha--status-tip--success[data-v-4b782e10]{background-color:#39c522}.captcha--status-tip.captcha--status-tip--fail[data-v-4b782e10]{background-color:#ff5d39}.captcha--view[data-v-ef209593]{padding:15px 10px}.captcha--view--content[data-v-ef209593]{position:relative;height:210px;margin-top:15px;overflow:hidden}.captcha--view--tip[data-v-ef209593]{font-size:14px;color:#333;line-height:24px;text-align:center}.captcha--status-tip-enter-active[data-v-ef209593],.captcha--status-tip-appear-active[data-v-ef209593]{transition:transform ease-out .4s}.captcha--status-tip-enter-from[data-v-ef209593],.captcha--status-tip-appear-from[data-v-ef209593]{transform:translateY(100%)}.captcha--status-tip-enter-to[data-v-ef209593],.captcha--status-tip-appear-to[data-v-ef209593]{transform:translateY(0)}.captcha--view--concat--bg[data-v-fd603259]{position:absolute;display:block;width:100%;height:100%}.captcha--view--concat--track[data-v-fd603259]{position:absolute;display:block;left:0;top:0;width:100%;overflow:hidden;background-size:100% auto;background-position:0px 0px}.captcha--view--rotate--bg[data-v-76bb4af9]{position:absolute;display:block;width:100%;height:100%}.captcha--view--rotate--track[data-v-76bb4af9]{position:absolute;display:block;width:100%;height:100%;object-fit:contain}.captcha--view--slider--bg[data-v-8fb0f971]{position:absolute;display:block;width:100%;height:100%}.captcha--view--slider--track[data-v-8fb0f971]{position:absolute;display:block;width:auto;height:100%;left:0;top:0}.captcha--error[data-v-6ba70e16]{position:absolute;left:0;top:0;width:100%;height:100%;display:flex;flex-direction:column;justify-content:center;align-items:center;background-color:rgba(255,255,255,.7)}.captcha--error .captcha--error--icon[data-v-6ba70e16]{fill:#1c4c5b;width:80px;height:80px}.captcha--error .captcha--error--text[data-v-6ba70e16]{color:#1c4c5b;font-size:14px;margin-top:12px}.captcha--loading[data-v-edb749e2]{position:absolute;left:0;top:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background-color:rgba(255,255,255,.8)}.captcha--loading .lds-ellipsis[data-v-edb749e2],.captcha--loading .lds-ellipsis div[data-v-edb749e2]{box-sizing:border-box}.captcha--loading .lds-ellipsis[data-v-edb749e2]{color:#1c4c5b;display:inline-block;position:relative;width:80px;height:80px}.captcha--loading .lds-ellipsis div[data-v-edb749e2]{position:absolute;top:33.33333px;width:13.33333px;height:13.33333px;border-radius:50%;background:currentColor;animation-timing-function:cubic-bezier(0,1,1,0)}.captcha--loading .lds-ellipsis div[data-v-edb749e2]:nth-child(1){left:8px;animation:lds-ellipsis1-edb749e2 .6s infinite}.captcha--loading .lds-ellipsis div[data-v-edb749e2]:nth-child(2){left:8px;animation:lds-ellipsis2-edb749e2 .6s infinite}.captcha--loading .lds-ellipsis div[data-v-edb749e2]:nth-child(3){left:32px;animation:lds-ellipsis2-edb749e2 .6s infinite}.captcha--loading .lds-ellipsis div[data-v-edb749e2]:nth-child(4){left:56px;animation:lds-ellipsis3-edb749e2 .6s infinite}@keyframes lds-ellipsis1-edb749e2{0%{transform:scale(0)}to{transform:scale(1)}}@keyframes lds-ellipsis3-edb749e2{0%{transform:scale(1)}to{transform:scale(0)}}@keyframes lds-ellipsis2-edb749e2{0%{transform:translate(0)}to{transform:translate(24px)}}@keyframes captcha--refreshing-3b07deff{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.captcha--container[data-v-3b07deff]{position:relative;width:300px;height:400px;background:#fff;display:flex;flex-direction:column;overflow:hidden}.captcha--container.captcha--container--radius[data-v-3b07deff]{border-radius:6px}.captcha--container.captcha--container--shadow[data-v-3b07deff]{box-shadow:0 0 11px #999}.captcha--box[data-v-3b07deff]{position:relative;flex:1;overflow:hidden}.captcha--content[data-v-3b07deff]{position:absolute;left:0;top:0;width:100%;height:100%;overflow:hidden;box-sizing:border-box}.captcha--bottom[data-v-3b07deff]{display:flex;justify-content:space-between;align-items:center;flex-shrink:0;padding:15px 10px}.captcha--bottom .captcha--svg-icon[data-v-3b07deff]{width:28px;height:28px;display:block;cursor:pointer;fill:#1c4c5b}.captcha--bottom .captcha--svg-icon+.captcha--svg-icon[data-v-3b07deff]{margin-left:12px}.captcha--bottom .captcha--btn-refresh.captcha--btn-refresh--active[data-v-3b07deff]{animation:captcha--refreshing-3b07deff linear .8s infinite}
import { PropType } from 'vue';
declare const _default: import("vue").DefineComponent<{
requestCaptchaDataUrl: {
type: StringConstructor;
required: true;
};
validCaptchaUrl: {
type: StringConstructor;
required: true;
};
showClose: {
type: BooleanConstructor;
default: boolean;
};
radius: {
type: BooleanConstructor;
default: boolean;
};
shadow: {
type: BooleanConstructor;
default: boolean;
};
locale: {
type: StringConstructor;
};
extra: {
type: PropType<Record<string, any>>;
default: () => {};
};
}, {
containerRef: import("vue").Ref<HTMLDivElement | undefined>;
refresh: () => void;
}, unknown, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
close: () => void;
success: (id: string) => void;
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
requestCaptchaDataUrl: {
type: StringConstructor;
required: true;
};
validCaptchaUrl: {
type: StringConstructor;
required: true;
};
showClose: {
type: BooleanConstructor;
default: boolean;
};
radius: {
type: BooleanConstructor;
default: boolean;
};
shadow: {
type: BooleanConstructor;
default: boolean;
};
locale: {
type: StringConstructor;
};
extra: {
type: PropType<Record<string, any>>;
default: () => {};
};
}>> & {
onSuccess?: ((id: string) => any) | undefined;
onClose?: (() => any) | undefined;
}, {
showClose: boolean;
radius: boolean;
shadow: boolean;
extra: Record<string, any>;
}, {}>;
export default _default;
import { PropType } from 'vue';
import { CaptchaDisplayData } from '../core';
import { VerifyStatus } from '../types';
declare const _default: import("vue").DefineComponent<{
captcha: {
type: PropType<CaptchaDisplayData>;
required: true;
};
verifyStatus: {
type: PropType<VerifyStatus>;
};
}, {}, unknown, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
verify: (data: object) => void;
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
captcha: {
type: PropType<CaptchaDisplayData>;
required: true;
};
verifyStatus: {
type: PropType<VerifyStatus>;
};
}>> & {
onVerify?: ((data: object) => any) | undefined;
}, {}, {}>;
export default _default;
import { PropType } from 'vue';
import { CaptchaDisplayData } from '../core';
import { VerifyStatus } from '../types';
declare const _default: import("vue").DefineComponent<{
captcha: {
type: PropType<CaptchaDisplayData>;
required: true;
};
verifyStatus: {
type: PropType<VerifyStatus>;
};
}, {}, unknown, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
verify: (data: object) => void;
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
captcha: {
type: PropType<CaptchaDisplayData>;
required: true;
};
verifyStatus: {
type: PropType<VerifyStatus>;
};
}>> & {
onVerify?: ((data: object) => any) | undefined;
}, {}, {}>;
export default _default;
import { PropType } from 'vue';
import { CaptchaDisplayData } from '../core';
import { VerifyStatus } from '../types';
declare const _default: import("vue").DefineComponent<{
captcha: {
type: PropType<CaptchaDisplayData>;
required: true;
};
verifyStatus: {
type: PropType<VerifyStatus>;
};
}, {}, unknown, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
verify: (data: object) => void;
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
captcha: {
type: PropType<CaptchaDisplayData>;
required: true;
};
verifyStatus: {
type: PropType<VerifyStatus>;
};
}>> & {
onVerify?: ((data: object) => any) | undefined;
}, {}, {}>;
export default _default;
import { PropType } from 'vue';
import { VerifyStatus } from '../types';
declare const _default: __VLS_WithTemplateSlots<import("vue").DefineComponent<{
verifyStatus: {
type: PropType<VerifyStatus>;
};
x: {
type: NumberConstructor;
default: number;
};
}, {}, unknown, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
verifyStatus: {
type: PropType<VerifyStatus>;
};
x: {
type: NumberConstructor;
default: number;
};
}>>, {
x: number;
}, {}>, {
default?(_: {}): any;
}>;
export default _default;
type __VLS_WithTemplateSlots<T, S> = T & {
new (): {
$slots: S;
};
};
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
click: (event: MouseEvent) => void;
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{}>> & {
onClick?: ((event: MouseEvent) => any) | undefined;
}, {}, {}>;
export default _default;
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{}>>, {}, {}>;
export default _default;
declare const _default: import("vue").DefineComponent<{
x: {
type: NumberConstructor;
default: number;
};
}, {}, unknown, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
x: {
type: NumberConstructor;
default: number;
};
}>>, {
x: number;
}, {}>;
export default _default;
declare const _default: import("vue").DefineComponent<{
status: {
type: BooleanConstructor;
required: true;
};
message: {
type: StringConstructor;
};
}, {}, unknown, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
status: {
type: BooleanConstructor;
required: true;
};
message: {
type: StringConstructor;
};
}>>, {}, {}>;
export default _default;
import { CaptchaDisplayData } from './types';
/**
* 创建验证码实例的配置项
*/
interface CreateCaptchaOptions {
/**
* 自动绑定开始拖动事件的元素
*/
triggers?: HTMLElement[];
/**
* 验证码显示数据
*/
captcha: CaptchaDisplayData;
/**
* 允许拖动的最大距离
*/
max: number;
/**
* 当进行拖动时触发的回调
* @param x 拖动的距离
*/
onDrag: (x: number) => void;
/**
* 当拖动完成需要校验数据时触发的回调
* @param data 待校验的数据
* @returns
*/
onVerify: (data: object) => void;
}
/**
* 验证码实例
*/
export interface CaptchaInstance {
/**
* 当开始拖动时调用的方法
* @param event
* @returns
*/
onDragStart: (event: MouseEvent | TouchEvent) => void;
/**
* 销毁验证码实例,解除所有的事件绑定
*/
destroy(): void;
}
/**
* 创建拖动填充型验证码的配置项
*/
export interface CreateSliderCaptchaOptions extends Omit<CreateCaptchaOptions, 'max'> {
/**
* 实际显示尺寸与原始图片的比例
*/
ratio: number;
}
/**
* 创建拖动填充型验证码实例
* @param options
*/
export declare function createSliderCaptcha(options: CreateSliderCaptchaOptions): CaptchaInstance;
/**
* 创建旋转型验证码的配置项
*/
export interface CreateRotateCaptchaOptions extends Omit<CreateCaptchaOptions, 'onDrag'> {
/**
* 实际显示尺寸与原始图片的比例
*/
ratio: number;
/**
* 当进行拖动时触发的回调
* @param x 拖动的距离
* @param deg 旋转的角度
*/
onDrag: (x: number, deg: number) => void;
}
/**
* 创建旋转型验证码实例
*/
export declare function createRotateCaptcha(options: CreateRotateCaptchaOptions): CaptchaInstance;
/**
* 创建上下拼接型验证码的配置项
*/
export interface CreateConcatCaptchaOptions extends CreateCaptchaOptions {
/**
* 实际显示尺寸与原始图片的比例
*/
ratio: number;
}
/**
* 获取上下拼接型验证码的滑块高度
*/
export declare function getConcatTrackHeight(captcha: CaptchaDisplayData, ratio: number): number;
/**
* 创建上下拼接型验证码实例
*/
export declare function createConcatCaptcha(options: CreateConcatCaptchaOptions): CaptchaInstance;
export {};
import { ApiResp, CaptchaData } from './types';
/**
* HTTP请求配置项
*/
export interface HttpRequestOptions {
/**
* 请求地址
*/
url: string;
/**
* 基地址
*/
baseURL?: string;
/**
* 请求方法
*/
method?: string;
/**
* 请求参数
*/
params?: Record<string, string>;
/**
* 请求头
*/
headers?: Record<string, string>;
/**
* 请求体
*/
data?: any;
/**
* 请求超时时间
*/
timeout?: number;
/**
* 跨域时是否包含身份信息
*/
withCredentials?: boolean;
}
export interface HttpResponse<T = any> {
/**
* 响应码
*/
status: number;
/**
* 响应头
*/
headers: Record<string, string>;
/**
* 响应体
*/
data: T;
}
/**
* 加载验证码数据
* @param options 获取验证码数据的配置项
*/
export declare function loadCaptcha(options: HttpRequestOptions): Promise<ApiResp<CaptchaData>>;
export interface ValidCaptchaOptions extends Omit<HttpRequestOptions, 'method' | 'data'> {
data: {
key: string;
data: any;
};
}
/**
* 校验验证码数据,校验成功返回undefined,校验失败返回失败文字
* @param options
*/
export declare function validCaptcha(options: ValidCaptchaOptions): Promise<string | void>;
export * from './locales';
export * from './http';
export * from './types';
export * from './captcha';
/**
* 根据输入值,返回可用的语言
* @param language
*/
export declare function getLanguage(language?: string): string;
/**
* 获取翻译字典
* @param language
*/
export declare function getTranslations(language?: string): Record<string, string>;
/**
* 获取翻译函数
* @param language
*/
export declare function getTranslator(language?: string): (id: string) => string;
/**
* 验证码类型
*/
export type CaptchaType = 'SLIDER' | 'ROTATE' | 'CONCAT';
/**
* 接口响应数据格式
*/
export interface ApiResp<T = any> {
code: number;
msg: string;
data: T;
}
/**
* 验证码显示数据
*/
export interface CaptchaDisplayData {
/**
* 验证码类型
*/
type: CaptchaType;
templateImage: string;
templateImageHeight: number;
templateImageTag: string;
templateImageWidth: number;
backgroundImage: string;
backgroundImageHeight: number;
backgroundImageTag: string;
backgroundImageWidth: number;
data: {
randomY: number;
};
}
/**
* 验证码数据
*/
export interface CaptchaData {
key: string;
captcha: CaptchaDisplayData;
}
export { default as Captcha } from './Captcha.vue';
This diff is collapsed.
import { Ref } from 'vue';
export declare function provideLocale(ref: Ref<string | undefined>): void;
export declare function useTranslator(locale?: Ref<string | undefined>): import("vue").ComputedRef<(id: string) => string>;
.captcha--slider-bar[data-v-fcfe5ea2]{height:55px;position:relative;margin-top:15px}.captcha--slider-bar--bar[data-v-fcfe5ea2]{height:35px;position:relative;top:5px;background-color:#eee;border-radius:3px;overflow:hidden}.captcha--slider-bar--bar .captcha--slider-bar--active[data-v-fcfe5ea2]{position:absolute;left:0;top:0;height:100%;background-color:#6495ed}.captcha--slider-bar--track[data-v-fcfe5ea2]{background-color:#fff;position:absolute;left:0;top:0;width:55px;height:45px;border-radius:3px;box-shadow:0 0 5px #999;display:flex;justify-content:center;align-items:center}.captcha--slider-bar--track .captcha--slider-bar--track-icon[data-v-fcfe5ea2]{fill:#333;display:block;width:25px;height:25px}.captcha--status-tip[data-v-4b782e10]{position:absolute;left:0;bottom:0;width:100%;display:flex;justify-content:center;align-items:center;color:#fff;font-size:14px;height:30px}.captcha--status-tip .captcha--status-tip--icon[data-v-4b782e10]{fill:#fff;display:block;width:18px;height:18px;margin-right:5px}.captcha--status-tip.captcha--status-tip--success[data-v-4b782e10]{background-color:#39c522}.captcha--status-tip.captcha--status-tip--fail[data-v-4b782e10]{background-color:#ff5d39}.captcha--view[data-v-ef209593]{padding:15px 10px}.captcha--view--content[data-v-ef209593]{position:relative;height:210px;margin-top:15px;overflow:hidden}.captcha--view--tip[data-v-ef209593]{font-size:14px;color:#333;line-height:24px;text-align:center}.captcha--status-tip-enter-active[data-v-ef209593],.captcha--status-tip-appear-active[data-v-ef209593]{transition:transform ease-out .4s}.captcha--status-tip-enter-from[data-v-ef209593],.captcha--status-tip-appear-from[data-v-ef209593]{transform:translateY(100%)}.captcha--status-tip-enter-to[data-v-ef209593],.captcha--status-tip-appear-to[data-v-ef209593]{transform:translateY(0)}.captcha--view--concat--bg[data-v-fd603259]{position:absolute;display:block;width:100%;height:100%}.captcha--view--concat--track[data-v-fd603259]{position:absolute;display:block;left:0;top:0;width:100%;overflow:hidden;background-size:100% auto;background-position:0px 0px}.captcha--view--rotate--bg[data-v-76bb4af9]{position:absolute;display:block;width:100%;height:100%}.captcha--view--rotate--track[data-v-76bb4af9]{position:absolute;display:block;width:100%;height:100%;object-fit:contain}.captcha--view--slider--bg[data-v-8fb0f971]{position:absolute;display:block;width:100%;height:100%}.captcha--view--slider--track[data-v-8fb0f971]{position:absolute;display:block;width:auto;height:100%;left:0;top:0}.captcha--error[data-v-6ba70e16]{position:absolute;left:0;top:0;width:100%;height:100%;display:flex;flex-direction:column;justify-content:center;align-items:center;background-color:rgba(255,255,255,.7)}.captcha--error .captcha--error--icon[data-v-6ba70e16]{fill:#1c4c5b;width:80px;height:80px}.captcha--error .captcha--error--text[data-v-6ba70e16]{color:#1c4c5b;font-size:14px;margin-top:12px}.captcha--loading[data-v-edb749e2]{position:absolute;left:0;top:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background-color:rgba(255,255,255,.8)}.captcha--loading .lds-ellipsis[data-v-edb749e2],.captcha--loading .lds-ellipsis div[data-v-edb749e2]{box-sizing:border-box}.captcha--loading .lds-ellipsis[data-v-edb749e2]{color:#1c4c5b;display:inline-block;position:relative;width:80px;height:80px}.captcha--loading .lds-ellipsis div[data-v-edb749e2]{position:absolute;top:33.33333px;width:13.33333px;height:13.33333px;border-radius:50%;background:currentColor;animation-timing-function:cubic-bezier(0,1,1,0)}.captcha--loading .lds-ellipsis div[data-v-edb749e2]:nth-child(1){left:8px;animation:lds-ellipsis1-edb749e2 .6s infinite}.captcha--loading .lds-ellipsis div[data-v-edb749e2]:nth-child(2){left:8px;animation:lds-ellipsis2-edb749e2 .6s infinite}.captcha--loading .lds-ellipsis div[data-v-edb749e2]:nth-child(3){left:32px;animation:lds-ellipsis2-edb749e2 .6s infinite}.captcha--loading .lds-ellipsis div[data-v-edb749e2]:nth-child(4){left:56px;animation:lds-ellipsis3-edb749e2 .6s infinite}@keyframes lds-ellipsis1-edb749e2{0%{transform:scale(0)}to{transform:scale(1)}}@keyframes lds-ellipsis3-edb749e2{0%{transform:scale(1)}to{transform:scale(0)}}@keyframes lds-ellipsis2-edb749e2{0%{transform:translate(0)}to{transform:translate(24px)}}@keyframes captcha--refreshing-3b07deff{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.captcha--container[data-v-3b07deff]{position:relative;width:300px;height:400px;background:#fff;display:flex;flex-direction:column;overflow:hidden}.captcha--container.captcha--container--radius[data-v-3b07deff]{border-radius:6px}.captcha--container.captcha--container--shadow[data-v-3b07deff]{box-shadow:0 0 11px #999}.captcha--box[data-v-3b07deff]{position:relative;flex:1;overflow:hidden}.captcha--content[data-v-3b07deff]{position:absolute;left:0;top:0;width:100%;height:100%;overflow:hidden;box-sizing:border-box}.captcha--bottom[data-v-3b07deff]{display:flex;justify-content:space-between;align-items:center;flex-shrink:0;padding:15px 10px}.captcha--bottom .captcha--svg-icon[data-v-3b07deff]{width:28px;height:28px;display:block;cursor:pointer;fill:#1c4c5b}.captcha--bottom .captcha--svg-icon+.captcha--svg-icon[data-v-3b07deff]{margin-left:12px}.captcha--bottom .captcha--btn-refresh.captcha--btn-refresh--active[data-v-3b07deff]{animation:captcha--refreshing-3b07deff linear .8s infinite}
import { CaptchaData } from './core';
/**
* 验证码的校验状态
*/
export interface VerifyStatus {
/**
* 是否校验成功
*/
status: boolean;
/**
* 失败的消息
*/
message?: string;
}
/**
* 包含校验状态数据的验证码数据
*/
export interface CurrentCaptchaData extends CaptchaData {
/**
* 校验状态
*/
verifyStatus?: VerifyStatus;
}
This diff is collapsed.
{
"name": "fj-captcha-web",
"version": "1.0.0",
"type": "module",
"main": "./esm/index.js",
"typings": "./esm/index.d.ts",
"files": [
"browser",
"esm"
],
"scripts": {
"build": "npm run build:esm && npm run build:declaration && npm run build:browser",
"build:esm": "vite build -c vite.esm.config.ts",
"build:browser": "vite build -c vite.browser.config.ts",
"build:declaration": "vue-tsc -p tsconfig.dts.json"
},
"peerDependencies": {
"vue": "^3.0.0"
},
"devDependencies": {
"@types/node": "^20.12.12",
"@vitejs/plugin-vue": "^5.0.4",
"less": "^4.2.0",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vue": "^3.4.27",
"vue-tsc": "^2.0.17"
}
}
<script setup lang="ts">
import { PropType, onMounted, ref, toRef } from 'vue'
import ConcatCaptcha from './captcha-views/ConcatCaptcha.vue'
import RotateCaptcha from './captcha-views/RotateCaptcha.vue'
import SliderCaptcha from './captcha-views/SliderCaptcha.vue'
import ErrorView from './components/ErrorView.vue'
import Loading from './components/Loading.vue'
import { CaptchaData, loadCaptcha, validCaptcha } from './core'
import { provideLocale } from './locales'
import { CurrentCaptchaData } from './types'
const captchaViews = {
CONCAT: ConcatCaptcha,
SLIDER: SliderCaptcha,
ROTATE: RotateCaptcha,
}
const emit = defineEmits<{
close: []
success: [id: string]
}>()
const props = defineProps({
requestCaptchaDataUrl: {
type: String,
required: true,
},
validCaptchaUrl: {
type: String,
required: true,
},
showClose: {
type: Boolean,
default: true,
},
radius: {
type: Boolean,
default: true,
},
shadow: {
type: Boolean,
default: true,
},
locale: {
type: String,
},
extra: {
type: Object as PropType<Record<string, any>>,
default: () => ({}),
},
})
const locale = toRef(props, 'locale')
provideLocale(locale)
const loading = ref(false)
const refreshing = ref(false)
const verifying = ref(false)
const currentCaptcha = ref<CurrentCaptchaData>()
const error = ref(false)
const containerRef = ref<HTMLDivElement>()
const loadId = ref(0)
const _loadCaptcha = () => {
loading.value = true
const id = ++loadId.value
return new Promise<CaptchaData>((resolve, reject) => {
loadCaptcha({
url: props.requestCaptchaDataUrl,
data: {
extra: props.extra,
},
})
.then((resp) => {
if (id !== loadId.value) return
if (resp.code === 0) {
resolve(resp.data)
} else {
reject(resp)
}
loading.value = false
})
.catch((err) => {
if (id !== loadId.value) return
reject(err)
loading.value = false
})
})
}
/**
* 执行加载操作
*/
const load = async () => {
try {
currentCaptcha.value = await _loadCaptcha()
} catch {
//加载失败
error.value = true
}
}
const onRefresh = async () => {
if (refreshing.value || verifying.value) return
error.value = false
refreshing.value = true
await load()
refreshing.value = false
}
//初始化时自动刷新
onMounted(onRefresh)
/**
* 子组件触发的校验回调
*/
const onVerify = async (data: object) => {
if (!currentCaptcha.value) return
const captcha = currentCaptcha.value
verifying.value = true
const message = await validCaptcha({
url: props.validCaptchaUrl,
data: {
key: captcha.key,
data,
},
})
if (!message) {
//没有错误信息就表示验证成功
captcha.verifyStatus = { status: true }
//1.5秒后触发成功回调
setTimeout(() => emit('success', captcha.key), 1500)
return
}
//设置校验失败数据
captcha.verifyStatus = {
status: false,
message,
}
//1.5秒后重新加载
setTimeout(() => {
verifying.value = false
load()
}, 1500)
}
defineExpose({
containerRef,
refresh: () => {
onRefresh()
},
})
</script>
<template>
<div
ref="containerRef"
class="captcha--container"
:class="{
'captcha--container--radius': radius,
'captcha--container--shadow': shadow,
}"
@touchmove.prevent
@mousemove.prevent
>
<div class="captcha--box">
<component
v-if="currentCaptcha"
:is="captchaViews[currentCaptcha.captcha.type]"
:key="currentCaptcha.key"
:captcha="currentCaptcha.captcha"
:verifyStatus="currentCaptcha.verifyStatus"
@verify="onVerify"
/>
<template v-if="currentCaptcha"> </template>
<Loading v-if="loading" />
<ErrorView v-if="error" />
</div>
<div className="captcha--bottom">
<svg
class="captcha--svg-icon captcha--btn-refresh"
:class="{
'captcha--btn-refresh--active': refreshing,
}"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="4399"
width="64"
height="64"
@click="onRefresh"
>
<path
d="M715.7 794.7c-1.9-4.7-7.4-6.5-11.8-4-54.3 31.1-115.8 50.3-177.2 50.3-192.2 0-345.2-148.3-345.2-335.7 0-37.7 5.5-72.6 18.3-107.1l65.2 17.5c6.3 1.7 11.9-4.1 10-10.5l-55.5-183.1c-1.8-5.9-9.1-7.9-13.5-3.6l-139.4 131c-4.8 4.5-2.8 12.3 3.5 14.1l58.2 15.6c-15.7 42.6-25.4 86.5-25.4 135 0 226.6 187.9 409.8 419.5 409.8 76.7 0 149.6-18.6 215.5-55.9 3.6-2.1 5.2-6.5 3.6-10.4l-25.8-63zM308.2 229.2c1.9 4.7 7.4 6.5 11.8 4 54.3-31.2 115.8-50.3 177.2-50.3 192.2 0 345.2 148.3 345.2 335.7 0 37.7-5.5 72.6-18.3 107.1l-65.2-17.5c-6.3-1.7-11.9 4.1-10 10.5l55.5 183.1c1.8 5.9 9.1 7.9 13.5 3.6l139.5-130.8c4.8-4.5 2.8-12.3-3.5-14.1l-58.2-15.6c15.7-42.6 25.4-86.5 25.4-135 0-226.6-187.9-409.8-419.5-409.8-76.7 0-149.6 18.6-215.5 55.9-3.6 2.1-5.2 6.5-3.6 10.4l25.7 62.8z"
p-id="4400"
></path>
</svg>
<svg
v-if="showClose"
class="captcha--svg-icon captcha--btn-close"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="5377"
width="64"
height="64"
@click="
() => {
if (verifying) return
emit('close')
}
"
>
<path
d="M509.866667 32C245.333333 32 32 247.466667 32 512s213.333333 480 477.866667 480S987.733333 776.533333 987.733333 512 774.4 32 509.866667 32z m0 896C281.6 928 96 742.4 96 512S281.6 96 509.866667 96 923.733333 281.6 923.733333 512s-185.6 416-413.866666 416z"
p-id="5378"
></path>
<path
d="M693.333333 330.666667c-12.8-12.8-32-12.8-44.8 0L512 467.2l-136.533333-136.533333c-12.8-12.8-32-12.8-44.8 0-12.8 12.8-12.8 32 0 44.8l136.533333 136.533333-136.533333 136.533333c-12.8 12.8-12.8 32 0 44.8 6.4 6.4 14.933333 8.533333 23.466666 8.533334s17.066667-2.133333 23.466667-8.533334l136.533333-136.533333 136.533334 136.533333c6.4 6.4 14.933333 8.533333 23.466666 8.533334s17.066667-2.133333 23.466667-8.533334c12.8-12.8 12.8-32 0-44.8L556.8 512l136.533333-136.533333c12.8-12.8 12.8-32 0-44.8z"
p-id="5379"
></path>
</svg>
</div>
</div>
</template>
<style lang="less" scoped>
@keyframes captcha--refreshing {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.captcha--container {
position: relative;
width: 300px;
height: 400px;
background: #fff;
display: flex;
flex-direction: column;
overflow: hidden;
&.captcha--container--radius {
border-radius: 6px;
}
&.captcha--container--shadow {
box-shadow: 0 0 11px 0 #999999;
}
}
.captcha--box {
position: relative;
flex: 1;
overflow: hidden;
}
.captcha--content {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: hidden;
box-sizing: border-box;
}
.captcha--bottom {
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
padding: 15px 10px;
.captcha--svg-icon {
width: 28px;
height: 28px;
display: block;
cursor: pointer;
fill: #1c4c5b;
& + .captcha--svg-icon {
margin-left: 12px;
}
}
.captcha--btn-refresh {
&.captcha--btn-refresh--active {
animation: captcha--refreshing linear 800ms infinite;
}
}
}
</style>
import { createApp } from 'vue'
import Captcha from './Captcha.vue'
export interface CaptchaProps {
/**
* 获取验证码数据的接口地址
*/
requestCaptchaDataUrl: string
/**
* 校验验证码数据的接口地址
*/
validCaptchaUrl: string
/**
* 是否显示关闭按钮
*/
showClose?: boolean
/**
* 是否显示为圆角
*/
radius?: boolean
/**
* 是否显示阴影
*/
shadow?: boolean
/**
* 显示语言设置,支持zh-CN,zh-TW,en
*/
locale?: string
/**
* 额外的CSS类名
*/
className?: string
/**
* 额外的样式
*/
style?: Record<string, string | number> | string
/**
* 当验证码校验成功时触发的回调
* @param id 验证码id
*/
onSuccess?: (id: string) => void
/**
* 当用户点击关闭按钮时触发的回调
*/
onClose?: () => void
}
export interface CaptchaInstance {
refresh(): void
}
export function createCaptcha(
container: string | Element,
options: CaptchaProps,
) {
const app = createApp(Captcha, {
...options,
})
const refresh = () => {
console.log(app)
}
app.mount(container)
return {
refresh,
}
}
<script setup lang="ts">
import { PropType, computed, onBeforeUnmount, ref } from 'vue'
import CaptchaViewContainer from '../components/CaptchaViewContainer.vue'
import {
CaptchaDisplayData,
createConcatCaptcha,
getConcatTrackHeight,
} from '../core'
import { VerifyStatus } from '../types'
const emit = defineEmits<{
verify: [data: object]
}>()
const props = defineProps({
captcha: {
type: Object as PropType<CaptchaDisplayData>,
required: true,
},
verifyStatus: {
type: Object as PropType<VerifyStatus>,
},
})
const ratio = 280 / props.captcha.backgroundImageWidth
const x = ref(0)
const height = computed(() => getConcatTrackHeight(props.captcha, ratio))
const instance = createConcatCaptcha({
captcha: props.captcha,
ratio: ratio,
max: 280 - 55,
onDrag: (v) => (x.value = v),
onVerify: (data) => emit('verify', data),
})
onBeforeUnmount(() => instance.destroy())
</script>
<template>
<CaptchaViewContainer
class="captcha--view--concat"
:verifyStatus="verifyStatus"
:x="x"
@touchstart="instance.onDragStart"
@mousedown="instance.onDragStart"
>
<img :src="captcha.backgroundImage" class="captcha--view--concat--bg" />
<div
class="captcha--view--concat--track"
:style="{
height: `${height}px`,
backgroundImage: `url('${captcha.backgroundImage}')`,
backgroundPositionX: `${x}px`,
}"
></div>
</CaptchaViewContainer>
</template>
<style lang="less" scoped>
.captcha--view--concat--bg {
position: absolute;
display: block;
width: 100%;
height: 100%;
}
.captcha--view--concat--track {
position: absolute;
display: block;
left: 0;
top: 0;
width: 100%;
overflow: hidden;
background-size: 100% auto;
background-position: 0px 0px;
}
</style>
<script setup lang="ts">
import { PropType, onBeforeUnmount, ref } from 'vue'
import CaptchaViewContainer from '../components/CaptchaViewContainer.vue'
import { CaptchaDisplayData, createRotateCaptcha } from '../core'
import { VerifyStatus } from '../types'
const emit = defineEmits<{
verify: [data: object]
}>()
const props = defineProps({
captcha: {
type: Object as PropType<CaptchaDisplayData>,
required: true,
},
verifyStatus: {
type: Object as PropType<VerifyStatus>,
},
})
const x = ref(0)
const rotate = ref(0)
const ratio = 280 / props.captcha.backgroundImageWidth
const instance = createRotateCaptcha({
captcha: props.captcha,
ratio,
max: 280 - 55,
onDrag: (dx, drotate) => {
x.value = dx
rotate.value = drotate
},
onVerify: (data) => emit('verify', data),
})
onBeforeUnmount(() => instance.destroy())
</script>
<template>
<CaptchaViewContainer
class="captcha--view--rotate"
:verifyStatus="verifyStatus"
:x="x"
@touchstart="instance.onDragStart"
@mousedown="instance.onDragStart"
>
<img :src="captcha.backgroundImage" class="captcha--view--rotate--bg" />
<img
:src="captcha.templateImage"
class="captcha--view--rotate--track"
:style="{ transform: `rotate(${rotate}deg)` }"
/>
</CaptchaViewContainer>
</template>
<style lang="less" scoped>
.captcha--view--rotate--bg {
position: absolute;
display: block;
width: 100%;
height: 100%;
}
.captcha--view--rotate--track {
position: absolute;
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
</style>
<script setup lang="ts">
import { PropType, onBeforeUnmount, ref } from 'vue'
import CaptchaViewContainer from '../components/CaptchaViewContainer.vue'
import { CaptchaDisplayData, createSliderCaptcha } from '../core'
import { VerifyStatus } from '../types'
const emit = defineEmits<{
verify: [data: object]
}>()
const props = defineProps({
captcha: {
type: Object as PropType<CaptchaDisplayData>,
required: true,
},
verifyStatus: {
type: Object as PropType<VerifyStatus>,
},
})
const ratio = 280 / props.captcha.backgroundImageWidth
const x = ref(0)
const instance = createSliderCaptcha({
captcha: props.captcha,
ratio,
onDrag: (dx) => {
x.value = dx
},
onVerify: (data) => emit('verify', data),
})
onBeforeUnmount(() => instance.destroy())
</script>
<template>
<CaptchaViewContainer
class="captcha--view--slider"
:verifyStatus="verifyStatus"
:x="x"
@touchstart="instance.onDragStart"
@mousedown="instance.onDragStart"
>
<img :src="captcha.backgroundImage" class="captcha--view--slider--bg" />
<img
:src="captcha.templateImage"
class="captcha--view--slider--track"
:style="{ transform: `translateX(${x}px)` }"
/>
</CaptchaViewContainer>
</template>
<style lang="less" scoped>
.captcha--view--slider--bg {
position: absolute;
display: block;
width: 100%;
height: 100%;
}
.captcha--view--slider--track {
position: absolute;
display: block;
width: auto;
height: 100%;
left: 0;
top: 0;
}
</style>
<script setup lang="ts">
import { PropType } from 'vue'
import SliderBar from '../components/SliderBar.vue'
import StatusTip from '../components/StatusTip.vue'
import { useTranslator } from '../locales'
import { VerifyStatus } from '../types'
defineProps({
verifyStatus: {
type: Object as PropType<VerifyStatus>,
},
x: {
type: Number,
default: 0,
},
})
const t = useTranslator()
</script>
<template>
<div class="captcha--view">
<div class="captcha--view--tip">{{ t('拖动下方滑块完成拼图') }}</div>
<div class="captcha--view--content">
<slot></slot>
<Transition name="captcha--status-tip">
<StatusTip v-if="verifyStatus" v-bind="verifyStatus" />
</Transition>
</div>
<SliderBar :x="x" />
</div>
</template>
<style lang="less" scoped>
.captcha--view {
padding: 15px 10px;
}
.captcha--view--content {
position: relative;
height: 210px;
margin-top: 15px;
overflow: hidden;
}
.captcha--view--tip {
font-size: 14px;
color: #333;
line-height: 24px;
text-align: center;
}
.captcha--status-tip-enter-active,
.captcha--status-tip-appear-active {
transition: transform ease-out 400ms;
}
.captcha--status-tip-enter-from,
.captcha--status-tip-appear-from {
transform: translateY(100%);
}
.captcha--status-tip-enter-to,
.captcha--status-tip-appear-to {
transform: translateY(0%);
}
</style>
<script setup lang="ts">
import { useTranslator } from '../locales'
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const t = useTranslator()
</script>
<template>
<div className="captcha--error" @click="(evt) => emit('click', evt)">
<svg
class="captcha--error--icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="6383"
width="64"
height="64"
>
<path
d="M575.986694 832.039919C575.986694 867.356032 547.316113 896.026613 512 896.026613 476.683887 896.026613 448.013306 867.356032 448.013306 832.039919 448.013306 796.723806 476.683887 768.048156 512 768.048156 547.316113 768.048156 575.986694 796.64777 575.986694 832.039919ZM512 255.997465C476.683887 255.997465 448.013306 284.668046 448.013306 319.984159L448.013306 639.998733C448.013306 675.314846 476.683887 703.990496 512 703.990496 547.316113 703.990496 575.986694 675.314846 575.986694 639.998733L575.986694 319.984159C575.986694 284.668046 547.316113 255.997465 512 255.997465ZM1023.979724 896.026613C1023.979724 966.729805 966.709529 1024.005069 896.006336 1024.005069L127.993664 1024.005069C57.290471 1024.005069 0.020276 966.658839 0.020276 896.026613 0.020276 874.868373 5.216059 854.931776 14.39105 837.311737L14.320083 837.24077 398.250384 69.304133 398.392317 69.304133C419.626593 28.133261 462.455047 0.040552 512 0.040552 561.544953 0.040552 604.373407 28.209297 625.67865 69.3751L1008.311272 834.711311C1018.348003 852.838256 1023.979724 873.783595 1023.979724 896.026613ZM959.99303 896.026613C959.99303 885.123073 957.392604 874.868373 952.191753 865.404445L951.613881 864.319667 951.112044 863.239958 568.621355 98.405584C557.499847 77.171308 535.834701 64.027246 512 64.027246 488.023365 64.027246 466.282184 77.318311 455.160675 98.765487L452.12938 104.544211 92.175714 824.527578 92.677551 825.034483 71.154339 866.778159C66.389426 875.948082 64.00697 885.842879 64.00697 896.097579 64.00697 931.413692 92.748517 960.089342 127.993664 960.089342L896.006336 960.089342C931.322449 960.013306 959.99303 931.342726 959.99303 896.026613Z"
p-id="6384"
></path>
</svg>
<div class="captcha--error--text">{{ t('网络异常,请点击重试') }}</div>
</div>
</template>
<style lang="less" scoped>
.captcha--error {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: rgba(#fff, 0.7);
.captcha--error--icon {
fill: #1c4c5b;
width: 80px;
height: 80px;
}
.captcha--error--text {
color: #1c4c5b;
font-size: 14px;
margin-top: 12px;
}
}
</style>
<template>
<div class="captcha--loading">
<div class="lds-ellipsis">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</div>
</template>
<style lang="less" scoped>
.captcha--loading {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(#fff, 0.8);
.lds-ellipsis,
.lds-ellipsis div {
box-sizing: border-box;
}
.lds-ellipsis {
color: #1c4c5b;
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ellipsis div {
position: absolute;
top: 33.33333px;
width: 13.33333px;
height: 13.33333px;
border-radius: 50%;
background: currentColor;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.lds-ellipsis div:nth-child(1) {
left: 8px;
animation: lds-ellipsis1 0.6s infinite;
}
.lds-ellipsis div:nth-child(2) {
left: 8px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(3) {
left: 32px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(4) {
left: 56px;
animation: lds-ellipsis3 0.6s infinite;
}
}
@keyframes lds-ellipsis1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes lds-ellipsis3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes lds-ellipsis2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}
</style>
<script setup lang="ts">
defineProps({
x: {
type: Number,
default: 0,
},
})
</script>
<template>
<div class="captcha--slider-bar">
<div class="captcha--slider-bar--bar">
<div
class="captcha--slider-bar--active"
:style="{ width: `${x}px` }"
></div>
</div>
<div
class="captcha--slider-bar--track"
:style="{
transform: `translateX(${x}px)`,
}"
>
<svg
class="captcha--slider-bar--track-icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="4239"
width="64"
height="64"
>
<path
d="M576 192v256H0v128h576v256l448-320z"
p-id="4240"
></path>
</svg>
</div>
</div>
</template>
<style lang="less" scoped>
.captcha--slider-bar {
height: 55px;
position: relative;
margin-top: 15px;
}
.captcha--slider-bar--bar {
height: 35px;
position: relative;
top: 5px;
background-color: #eee;
border-radius: 3px;
overflow: hidden;
.captcha--slider-bar--active {
position: absolute;
left: 0;
top: 0;
height: 100%;
background-color: cornflowerblue;
}
}
.captcha--slider-bar--track {
background-color: #fff;
position: absolute;
left: 0;
top: 0;
width: 55px;
height: 45px;
border-radius: 3px;
box-shadow: 0 0 5px 0 #999999;
display: flex;
justify-content: center;
align-items: center;
.captcha--slider-bar--track-icon {
fill: #333;
display: block;
width: 25px;
height: 25px;
}
}
</style>
<script setup lang="ts">
import { useTranslator } from '../locales'
defineProps({
status: {
type: Boolean,
required: true,
},
message: {
type: String,
},
})
const t = useTranslator()
</script>
<template>
<div
:class="`captcha--status-tip captcha--status-tip--${
status ? 'success' : 'fail'
}`"
>
<template v-if="status">
<svg
class="captcha--status-tip--icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="5407"
width="64"
height="64"
>
<path
d="M896 512a384 384 0 1 0-768 0 384 384 0 0 0 768 0zM42.666667 512C42.666667 252.8 252.8 42.666667 512 42.666667s469.333333 210.133333 469.333333 469.333333-210.133333 469.333333-469.333333 469.333333S42.666667 771.2 42.666667 512z m652.501333-158.165333l60.330667 60.330666L469.333333 700.330667l-200.832-200.832 60.330667-60.330667L469.333333 579.669333l225.834667-225.834666z"
p-id="5408"
></path>
</svg>
<div>{{ t('验证成功') }}</div>
</template>
<template v-else>
<svg
class="captcha--status-tip--icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="6383"
width="64"
height="64"
>
<path
d="M575.986694 832.039919C575.986694 867.356032 547.316113 896.026613 512 896.026613 476.683887 896.026613 448.013306 867.356032 448.013306 832.039919 448.013306 796.723806 476.683887 768.048156 512 768.048156 547.316113 768.048156 575.986694 796.64777 575.986694 832.039919ZM512 255.997465C476.683887 255.997465 448.013306 284.668046 448.013306 319.984159L448.013306 639.998733C448.013306 675.314846 476.683887 703.990496 512 703.990496 547.316113 703.990496 575.986694 675.314846 575.986694 639.998733L575.986694 319.984159C575.986694 284.668046 547.316113 255.997465 512 255.997465ZM1023.979724 896.026613C1023.979724 966.729805 966.709529 1024.005069 896.006336 1024.005069L127.993664 1024.005069C57.290471 1024.005069 0.020276 966.658839 0.020276 896.026613 0.020276 874.868373 5.216059 854.931776 14.39105 837.311737L14.320083 837.24077 398.250384 69.304133 398.392317 69.304133C419.626593 28.133261 462.455047 0.040552 512 0.040552 561.544953 0.040552 604.373407 28.209297 625.67865 69.3751L1008.311272 834.711311C1018.348003 852.838256 1023.979724 873.783595 1023.979724 896.026613ZM959.99303 896.026613C959.99303 885.123073 957.392604 874.868373 952.191753 865.404445L951.613881 864.319667 951.112044 863.239958 568.621355 98.405584C557.499847 77.171308 535.834701 64.027246 512 64.027246 488.023365 64.027246 466.282184 77.318311 455.160675 98.765487L452.12938 104.544211 92.175714 824.527578 92.677551 825.034483 71.154339 866.778159C66.389426 875.948082 64.00697 885.842879 64.00697 896.097579 64.00697 931.413692 92.748517 960.089342 127.993664 960.089342L896.006336 960.089342C931.322449 960.013306 959.99303 931.342726 959.99303 896.026613Z"
p-id="6384"
></path>
</svg>
<div>{{ t(message ?? '验证失败,请再试一次') }}</div>
</template>
</div>
</template>
<style lang="less" scoped>
.captcha--status-tip {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
color: #fff;
font-size: 14px;
height: 30px;
.captcha--status-tip--icon {
fill: #fff;
display: block;
width: 18px;
height: 18px;
margin-right: 5px;
}
&.captcha--status-tip--success {
background-color: #39c522;
}
&.captcha--status-tip--fail {
background-color: #ff5d39;
}
}
</style>
This diff is collapsed.
This diff is collapsed.
export * from './locales'
export * from './http'
export * from './types'
export * from './captcha'
{
"验证成功": "Success",
"验证失败,请再试一次": "Verification failed",
"验证码已过期": "Expired",
"拖动下方滑块完成拼图": "Drag the slider to complete the puzzle",
"网络异常,请点击重试": "Network error, click to retry"
}
import en from './en.json'
import zhCn from './zh-cn.json'
import zhTw from './zh-tw.json'
/**
* 可用的翻译数据
*/
const translations: Record<string, Record<string, string>> = {
en,
'zh-cn': zhCn,
'zh-tw': zhTw,
}
/**
* 可用的语言标识
*/
const availableLanguages = Object.keys(translations)
/**
* 默认的语言标识
*/
const DEFAULT_LANGUAGE = 'zh-cn'
/**
* 标准化语言标识
* @param language
*/
function normalizeLanguage(language: string): string {
const parts = language.split(/_|-/)
if (parts.length > 2) {
parts.splice(1, parts.length - 2)
}
return parts.map((t) => t.toLowerCase()).join('-')
}
/**
* 根据输入值,返回可用的语言
* @param language
*/
export function getLanguage(language?: string): string {
if (typeof language === 'string') {
language = normalizeLanguage(language)
if (availableLanguages.includes(language)) {
return language
}
}
//尝试通过navigator.languages获取
if (navigator && navigator.languages) {
for (let language of navigator.languages) {
language = normalizeLanguage(language)
if (availableLanguages.includes(language)) {
return language
}
}
}
return DEFAULT_LANGUAGE
}
/**
* 获取翻译字典
* @param language
*/
export function getTranslations(language?: string): Record<string, string> {
return translations[getLanguage(language)] ?? {}
}
/**
* 获取翻译函数
* @param language
*/
export function getTranslator(language?: string): (id: string) => string {
const translation = getTranslations(language)
return (id: string) => translation[id] ?? id
}
{
"验证成功": "验证成功",
"验证失败,请再试一次": "验证失败,请再试一次",
"验证码已过期": "验证码已过期",
"拖动下方滑块完成拼图": "拖动下方滑块完成拼图",
"网络异常,请点击重试": "网络异常,请点击重试"
}
{
"验证成功": "驗證成功",
"验证失败,请再试一次": "驗證失敗,請再試一次",
"验证码已过期": "驗證碼已過期",
"拖动下方滑块完成拼图": "拖動下方滑塊完成拼圖",
"网络异常,请点击重试": "網路異常,請點擊重試"
}
/**
* 验证码类型
*/
export type CaptchaType = 'SLIDER' | 'ROTATE' | 'CONCAT'
/**
* 接口响应数据格式
*/
export interface ApiResp<T = any> {
code: number
msg: string
data: T
}
/**
* 验证码显示数据
*/
export interface CaptchaDisplayData {
/**
* 验证码类型
*/
type: CaptchaType
templateImage: string
templateImageHeight: number
templateImageTag: string
templateImageWidth: number
backgroundImage: string
backgroundImageHeight: number
backgroundImageTag: string
backgroundImageWidth: number
data: {
randomY: number
}
}
/**
* 验证码数据
*/
export interface CaptchaData {
key: string
captcha: CaptchaDisplayData
}
export { default as Captcha } from './Captcha.vue'
import { InjectionKey, Ref, computed, inject, provide } from 'vue'
import { getTranslator } from './core'
const KEY_LOCALE: InjectionKey<Ref<string | undefined>> = Symbol()
export function provideLocale(ref: Ref<string | undefined>) {
provide(KEY_LOCALE, ref)
}
export function useTranslator(locale?: Ref<string | undefined>) {
if (!locale) {
locale = inject(KEY_LOCALE, () => computed(() => undefined), true)
}
return computed(() => getTranslator(locale.value))
}
import { CaptchaData } from './core'
/**
* 验证码的校验状态
*/
export interface VerifyStatus {
/**
* 是否校验成功
*/
status: boolean
/**
* 失败的消息
*/
message?: string
}
/**
* 包含校验状态数据的验证码数据
*/
export interface CurrentCaptchaData extends CaptchaData {
/**
* 校验状态
*/
verifyStatus?: VerifyStatus
}
/// <reference types="vite/client" />
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
# 获取验证码数据的地址
VITE_REQUEST_CAPTCHA_URL=/api/captcha/generate
# 校验验证码数据的地址
VITE_VALID_CAPTCHA_URL=/api/captcha/check
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment