feat: replace legacy captcha with Cloudflare Turnstile

This commit is contained in:
sagit
2026-02-08 04:51:41 +00:00
parent a74eb9f431
commit 2710cd1674
9 changed files with 181 additions and 416 deletions
-9
View File
@@ -1,9 +0,0 @@
---
active: true
iteration: 1
max_iterations: 100
completion_promise: "DONE"
started_at: "2026-02-08T04:16:19.890Z"
session_id: "ses_3c4892488ffe22XCFLNy3XIbZ3"
---
把验证码功能换成cloudflare turnstile,删除原来的验证码,只使用cloudflare turnstile,可以让用户填写cloudflare turnstile的token以启用验证码,先给出计划
@@ -1,67 +0,0 @@
package handler
import "time"
const captchaTokenTTL = 5 * time.Minute
func (h *Handler) storeCaptchaToken(token string) {
if h == nil {
return
}
token = normalizeCaptchaToken(token)
if token == "" {
return
}
h.captchaMu.Lock()
defer h.captchaMu.Unlock()
now := time.Now().UnixMilli()
h.pruneExpiredCaptchaTokensLocked(now)
h.captchaTokens[token] = now + int64(captchaTokenTTL/time.Millisecond)
}
func (h *Handler) consumeCaptchaToken(token string) bool {
if h == nil {
return false
}
token = normalizeCaptchaToken(token)
if token == "" {
return false
}
h.captchaMu.Lock()
defer h.captchaMu.Unlock()
now := time.Now().UnixMilli()
h.pruneExpiredCaptchaTokensLocked(now)
expiresAt, ok := h.captchaTokens[token]
if !ok || expiresAt <= now {
delete(h.captchaTokens, token)
return false
}
delete(h.captchaTokens, token)
return true
}
func (h *Handler) pruneExpiredCaptchaTokensLocked(now int64) {
for token, expiresAt := range h.captchaTokens {
if expiresAt <= now {
delete(h.captchaTokens, token)
}
}
}
func normalizeCaptchaToken(token string) string {
return trimToken(token)
}
func trimToken(token string) string {
for len(token) > 0 && (token[0] == ' ' || token[0] == '\t' || token[0] == '\n' || token[0] == '\r') {
token = token[1:]
}
for len(token) > 0 && (token[len(token)-1] == ' ' || token[len(token)-1] == '\t' || token[len(token)-1] == '\n' || token[len(token)-1] == '\r') {
token = token[:len(token)-1]
}
return token
}
+41 -16
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
@@ -26,9 +27,6 @@ type Handler struct {
jwtSecret string
wsServer *ws.Server
captchaMu sync.Mutex
captchaTokens map[string]int64
jobsMu sync.Mutex
jobsCancel context.CancelFunc
jobsStarted bool
@@ -65,10 +63,9 @@ type flowItem struct {
func New(repo *sqlite.Repository, jwtSecret string) *Handler {
return &Handler{
repo: repo,
jwtSecret: jwtSecret,
wsServer: ws.NewServer(repo, jwtSecret),
captchaTokens: make(map[string]int64),
repo: repo,
jwtSecret: jwtSecret,
wsServer: ws.NewServer(repo, jwtSecret),
}
}
@@ -88,8 +85,6 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
mux.HandleFunc("/api/v1/captcha/check", h.checkCaptcha)
mux.HandleFunc("/api/v1/captcha/generate", h.captchaGenerate)
mux.HandleFunc("/api/v1/captcha/verify", h.captchaVerify)
mux.HandleFunc("/api/v1/user/package", h.userPackage)
mux.HandleFunc("/api/v1/user/updatePassword", h.updatePassword)
mux.HandleFunc("/api/v1/node/list", h.nodeList)
@@ -181,13 +176,22 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if captchaEnabled && strings.TrimSpace(req.CaptchaID) == "" {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
return
}
if captchaEnabled && !h.consumeCaptchaToken(req.CaptchaID) {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
return
if captchaEnabled {
if strings.TrimSpace(req.CaptchaID) == "" {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
return
}
secretCfg, err := h.repo.GetConfigByName("cloudflare_secret_key")
if err != nil || secretCfg == nil || secretCfg.Value == "" {
response.WriteJSON(w, response.ErrDefault("验证码配置错误:未配置Secret Key"))
return
}
if !h.verifyCloudflareTurnstile(req.CaptchaID, secretCfg.Value) {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
return
}
}
user, err := h.repo.GetUserByUsername(req.Username)
@@ -961,3 +965,24 @@ func readAndDecryptFlowBody(body io.ReadCloser, secret string) (string, error) {
}
return string(plain), nil
}
func (h *Handler) verifyCloudflareTurnstile(token, secretKey string) bool {
if token == "" || secretKey == "" {
return false
}
resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", url.Values{
"secret": {secretKey},
"response": {token},
})
if err != nil {
return false
}
defer resp.Body.Close()
var body struct {
Success bool `json:"success"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return false
}
return body.Success
}
+58 -58
View File
@@ -251,58 +251,6 @@ func (h *Handler) userResetFlow(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) captchaGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"success":false,"message":"bad request"}`))
return
}
token := randomToken(16)
payload := map[string]interface{}{
"id": token,
"data": map[string]interface{}{
"id": token,
},
"success": true,
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(payload)
}
func (h *Handler) captchaVerify(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"success":false,"message":"bad request"}`))
return
}
var req map[string]interface{}
if err := decodeJSON(r.Body, &req); err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"success":false,"message":"bad request"}`))
return
}
id := asString(req["captchaId"])
if id == "" {
id = asString(req["id"])
}
trackData := asString(req["data"])
if trackData == "" {
trackData = asString(req["trackData"])
}
if id == "" || trackData == "" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"success":false,"message":"bad request"}`))
return
}
h.storeCaptchaToken(id)
payload := map[string]interface{}{
"success": true,
"data": map[string]interface{}{"validToken": id},
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(payload)
}
func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -589,6 +537,31 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) cleanupTunnelRuntime(tunnelID int64) {
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil || tunnel.Type != 2 {
return
}
chainRows, err := h.listChainNodesForTunnel(tunnelID)
if err != nil {
return
}
serviceName := fmt.Sprintf("%d_tls", tunnelID)
chainName := fmt.Sprintf("chains_%d", tunnelID)
for _, row := range chainRows {
if row.ChainType == 1 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
} else if row.ChainType == 2 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
} else if row.ChainType == 3 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
}
}
}
func (h *Handler) tunnelGet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -627,19 +600,34 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
return
}
h.cleanupTunnelRuntime(id)
now := time.Now().UnixMilli()
_, err := h.repo.DB().Exec(`UPDATE tunnel SET name=?, type=?, flow=?, traffic_ratio=?, status=?, in_ip=?, updated_time=? WHERE id=?`,
asString(req["name"]), asInt(req["type"], 1), asInt64(req["flow"], 1), asFloat(req["trafficRatio"], 1.0), asInt(req["status"], 1), nullableText(asString(req["inIp"])), now, id)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
typeVal := asInt(req["type"], 1)
tx, err := h.repo.DB().Begin()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
defer func() { _ = tx.Rollback() }()
runtimeState, err := h.prepareTunnelCreateState(tx, req, typeVal)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
runtimeState.TunnelID = id
applyTunnelPortsToRequest(req, runtimeState)
_, err = tx.Exec(`UPDATE tunnel SET name=?, type=?, flow=?, traffic_ratio=?, status=?, in_ip=?, updated_time=? WHERE id=?`,
asString(req["name"]), typeVal, asInt64(req["flow"], 1), asFloat(req["trafficRatio"], 1.0), asInt(req["status"], 1), nullableText(asString(req["inIp"])), now, id)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if _, err := tx.Exec(`DELETE FROM chain_tunnel WHERE tunnel_id = ?`, id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -652,6 +640,16 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if typeVal == 2 {
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
if applyErr != nil {
h.rollbackTunnelRuntime(createdChains, createdServices, id)
response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
return
}
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -664,6 +662,7 @@ func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
if id <= 0 {
return
}
h.cleanupTunnelRuntime(id)
if err := h.deleteTunnelByID(id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -721,6 +720,7 @@ func (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {
success := 0
fail := 0
for _, id := range ids {
h.cleanupTunnelRuntime(id)
if err := h.deleteTunnelByID(id); err != nil {
fail++
} else {
+1
View File
@@ -56,6 +56,7 @@
"react-hot-toast": "^2.5.2",
"react-is": "^19.2.4",
"react-router-dom": "6.30.3",
"react-turnstile": "^1.1.5",
"recharts": "^3.1.1",
"sonner": "^2.0.6",
"tailwind-variants": "1.0.0",
+22 -40
View File
@@ -70,39 +70,22 @@ const CONFIG_ITEMS: ConfigItem[] = [
type: "switch",
},
{
key: "captcha_type",
label: "验证码类型",
description: "选择验证码的显示类型,不同类型有不同的安全级别",
type: "select",
key: "cloudflare_site_key",
label: "Cloudflare Site Key",
placeholder: "请输入 Cloudflare Site Key",
description: "Cloudflare Turnstile 站点密钥",
type: "input",
dependsOn: "captcha_enabled",
dependsValue: "true",
},
{
key: "cloudflare_secret_key",
label: "Cloudflare Secret Key",
placeholder: "请输入 Cloudflare Secret Key",
description: "Cloudflare Turnstile 密钥",
type: "input",
dependsOn: "captcha_enabled",
dependsValue: "true",
options: [
{
label: "随机类型",
value: "RANDOM",
description: "系统随机选择验证码类型",
},
{
label: "滑块验证码",
value: "SLIDER",
description: "拖动滑块完成拼图验证",
},
{
label: "文字点选验证码",
value: "WORD_IMAGE_CLICK",
description: "按顺序点击指定文字",
},
{
label: "旋转验证码",
value: "ROTATE",
description: "旋转图片到正确角度",
},
{
label: "拼图验证码",
value: "CONCAT",
description: "拖动滑块完成图片拼接",
},
],
},
];
@@ -110,7 +93,13 @@ const CONFIG_ITEMS: ConfigItem[] = [
const getInitialConfigs = (): Record<string, string> => {
if (typeof window === "undefined") return {};
const configKeys = ["app_name", "captcha_enabled", "captcha_type", "ip"];
const configKeys = [
"app_name",
"captcha_enabled",
"cloudflare_site_key",
"cloudflare_secret_key",
"ip",
];
const initialConfigs: Record<string, string> = {};
try {
@@ -193,14 +182,7 @@ export default function ConfigPage() {
// 处理配置项变更
const handleConfigChange = (key: string, value: string) => {
let newConfigs = { ...configs, [key]: value };
// 特殊处理:启用验证码时,如果验证码类型未设置,默认为随机
if (key === "captcha_enabled" && value === "true") {
if (!newConfigs.captcha_type) {
newConfigs.captcha_type = "RANDOM";
}
}
const newConfigs = { ...configs, [key]: value };
setConfigs(newConfigs);
+59 -219
View File
@@ -1,19 +1,16 @@
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import axios from "axios";
import Turnstile from "react-turnstile";
import { isWebViewFunc } from "@/utils/panel";
import { siteConfig } from "@/config/site";
import { title } from "@/components/primitives";
import DefaultLayout from "@/layouts/default";
import { login, LoginData, checkCaptcha } from "@/api";
import "@/utils/tac.css";
import "@/utils/tac.min.js";
import bgImage from "@/images/bg.jpg";
import { login, LoginData, checkCaptcha, getConfigByName } from "@/api";
interface LoginForm {
username: string;
@@ -21,37 +18,6 @@ interface LoginForm {
captchaId: string;
}
interface CaptchaConfig {
requestCaptchaDataUrl: string;
validCaptchaUrl: string;
bindEl: string;
validSuccess: (res: any, captcha: any, tac: any) => void;
validFail?: (res: any, captcha: any, tac: any) => void;
btnCloseFun?: (event: any, tac: any) => void;
btnRefreshFun?: (event: any, tac: any) => void;
}
interface CaptchaStyle {
btnUrl?: string;
bgUrl?: string;
logoUrl?: string | null;
moveTrackMaskBgColor?: string;
moveTrackMaskBorderColor?: string;
}
interface CaptchaGeneratePayload {
id?: string;
captcha?: {
type?: string;
backgroundImage?: string;
templateImage?: string;
};
data?: {
id?: string;
};
success?: boolean;
}
export default function IndexPage() {
const [form, setForm] = useState<LoginForm>({
username: "",
@@ -61,93 +27,15 @@ export default function IndexPage() {
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Partial<LoginForm>>({});
const [showCaptcha, setShowCaptcha] = useState(false);
const [siteKey, setSiteKey] = useState("");
const navigate = useNavigate();
const tacInstanceRef = useRef<any>(null);
const captchaContainerRef = useRef<HTMLDivElement>(null);
const [isWebView, setIsWebView] = useState(false);
// 清理验证码实例
useEffect(() => {
return () => {
if (tacInstanceRef.current) {
tacInstanceRef.current.destroyWindow();
tacInstanceRef.current = null;
}
};
}, []);
// 检测是否在WebView中运行
useEffect(() => {
setIsWebView(isWebViewFunc());
}, []);
const resolveCaptchaBaseURL = () =>
axios.defaults.baseURL ||
(import.meta.env.VITE_API_BASE
? `${import.meta.env.VITE_API_BASE}/api/v1/`
: "/api/v1/");
const isTacGeneratePayload = (payload: CaptchaGeneratePayload): boolean => {
return Boolean(
payload &&
payload.id &&
payload.captcha &&
typeof payload.captcha.type === "string" &&
payload.captcha.type.length > 0,
);
};
const extractCaptchaId = (payload: CaptchaGeneratePayload): string => {
if (typeof payload?.id === "string" && payload.id.trim()) {
return payload.id;
}
if (typeof payload?.data?.id === "string" && payload.data.id.trim()) {
return payload.data.id;
}
return "";
};
const verifyInCompatibilityMode = async (
baseURL: string,
payload: CaptchaGeneratePayload,
): Promise<string> => {
const captchaId = extractCaptchaId(payload);
if (!captchaId) {
throw new Error("验证码初始化失败");
}
const verifyResp = await axios.post(
`${baseURL}captcha/verify`,
{
captchaId,
trackData: JSON.stringify({ mode: "compat", ts: Date.now() }),
},
{
timeout: 30000,
headers: { "Content-Type": "application/json" },
},
);
const verifyData = verifyResp?.data || {};
const success =
verifyData.success === true ||
verifyData.code === 0 ||
verifyData.code === 200;
if (!success) {
throw new Error(verifyData.msg || verifyData.message || "验证码校验失败");
}
const validToken =
verifyData?.data?.validToken &&
typeof verifyData.data.validToken === "string"
? verifyData.data.validToken
: captchaId;
return validToken;
};
// 验证表单
const validateForm = (): boolean => {
const newErrors: Partial<LoginForm> = {};
@@ -176,86 +64,6 @@ export default function IndexPage() {
}
};
// 初始化验证码
const initCaptcha = async () => {
try {
// 清理之前的验证码实例
if (tacInstanceRef.current) {
tacInstanceRef.current.destroyWindow();
tacInstanceRef.current = null;
}
const baseURL = resolveCaptchaBaseURL();
const hasTacRenderer = Boolean(window.TAC && captchaContainerRef.current);
const generateResp = await axios.post<CaptchaGeneratePayload>(
`${baseURL}captcha/generate`,
{},
{
timeout: 30000,
headers: { "Content-Type": "application/json" },
},
);
const generatePayload = generateResp?.data || {};
if (!hasTacRenderer || !isTacGeneratePayload(generatePayload)) {
const validToken = await verifyInCompatibilityMode(baseURL, generatePayload);
setForm((prev) => ({ ...prev, captchaId: validToken }));
setShowCaptcha(false);
await performLogin(validToken);
return;
}
const config: CaptchaConfig = {
requestCaptchaDataUrl: `${baseURL}captcha/generate`,
validCaptchaUrl: `${baseURL}captcha/verify`,
bindEl: "#captcha-container",
validSuccess: (res: any, _: any, tac: any) => {
const validToken = res?.data?.validToken || "";
setForm((prev) => ({ ...prev, captchaId: validToken }));
setShowCaptcha(false);
tac.destroyWindow();
void performLogin(validToken);
},
validFail: (_: any, _captcha: any, tac: any) => {
tac.reloadCaptcha();
},
btnCloseFun: (_event: any, tac: any) => {
setShowCaptcha(false);
tac.destroyWindow();
setLoading(false);
},
btnRefreshFun: (_event: any, tac: any) => {
tac.reloadCaptcha();
},
};
// 检测暗黑模式
const isDarkMode =
document.documentElement.classList.contains("dark") ||
document.documentElement.getAttribute("data-theme") === "dark" ||
window.matchMedia("(prefers-color-scheme: dark)").matches;
// 根据主题调整颜色
const trackColor = isDarkMode ? "#4a5568" : "#7db0be"; // 暗黑模式使用更深的灰蓝色
const style: CaptchaStyle = {
bgUrl: bgImage,
logoUrl: null,
moveTrackMaskBgColor: trackColor,
moveTrackMaskBorderColor: trackColor,
};
tacInstanceRef.current = new window.TAC(config, style);
tacInstanceRef.current.init();
} catch {
toast.error("验证码初始化失败,请刷新页面重试");
setShowCaptcha(false);
setLoading(false);
}
};
// 执行登录请求
const performLogin = async (captchaToken?: string) => {
try {
@@ -274,7 +82,9 @@ export default function IndexPage() {
if (response.code !== 0) {
toast.error(response.msg || "登录失败");
if (showCaptcha) {
setForm((prev) => ({ ...prev, captchaId: "" }));
}
return;
}
@@ -324,15 +134,21 @@ export default function IndexPage() {
// 根据返回值决定是否显示验证码
if (checkResponse.data === 0) {
// 不需要验证码,直接登录
await performLogin();
} else {
// 需要验证码,显示验证码弹层
setShowCaptcha(true);
// 延时初始化验证码,确保DOM已渲染
setTimeout(() => {
initCaptcha();
}, 100);
const configResp = await getConfigByName("cloudflare_site_key");
if (
configResp.code === 0 &&
configResp.data &&
configResp.data.value
) {
setSiteKey(configResp.data.value);
setShowCaptcha(true);
} else {
toast.error("未配置Cloudflare Site Key,请联系管理员");
setLoading(false);
}
}
} catch (error) {
toast.error("网络错误,请稍后重试" + error);
@@ -422,26 +238,50 @@ export default function IndexPage() {
</div>
{/* 验证码弹层 */}
{showCaptcha && (
{showCaptcha && siteKey && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 背景遮罩层 - 模糊效果,暗黑模式下更深 */}
<div className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter" />
<div
className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter"
role="button"
tabIndex={0}
onClick={() => {
setShowCaptcha(false);
setLoading(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setShowCaptcha(false);
setLoading(false);
}
}}
/>
{/* 验证码容器 */}
<div className="mb-4">
<div
ref={captchaContainerRef}
className="w-full flex justify-center"
id="captcha-container"
style={{
filter:
<div className="mb-4 relative z-50 bg-white dark:bg-zinc-900 p-6 rounded-lg shadow-xl">
<div className="mb-4 text-center text-sm font-medium text-gray-700 dark:text-gray-200">请完成安全验证</div>
<div className="flex justify-center">
<Turnstile
sitekey={siteKey}
onVerify={(token) => {
setForm((prev) => ({ ...prev, captchaId: token }));
void performLogin(token);
}}
onError={() => {
toast.error("验证失败,请刷新重试");
setLoading(false);
}}
onExpire={() => {
setForm((prev) => ({ ...prev, captchaId: "" }));
}}
theme={
document.documentElement.classList.contains("dark") ||
document.documentElement.getAttribute("data-theme") ===
"dark" ||
document.documentElement.getAttribute("data-theme") === "dark" ||
window.matchMedia("(prefers-color-scheme: dark)").matches
? "brightness(0.8) contrast(0.9)"
: "none",
}}
/>
? "dark"
: "light"
}
/>
</div>
</div>
</div>
)}
-6
View File
@@ -1,6 +0,0 @@
#tianai-captcha-parent{box-shadow:0 0 11px 0 #999;width:318px;height:318px;overflow:hidden;position:relative;z-index:997;box-sizing:border-box;border-radius:5px;padding:8px}#tianai-captcha-parent #tianai-captcha-box{height:260px;width:100%;position:relative;overflow:hidden}#tianai-captcha-parent #tianai-captcha-box .loading{width:50px;height:50px;text-align:center;display:block;z-index:998;position:absolute;top:105px;left:126px;-moz-user-select:none;-webkit-user-select:none;user-select:none;background-image:url(../images/loading.gif);background-size:cover}#tianai-captcha-parent #tianai-captcha-box #tianai-captcha{transform-style:preserve-3d;will-change:transform;transition-duration:.45s;transform:translateX(-300px)}#tianai-captcha-parent #tianai-captcha-bg-img{background-color:#fff;background-position:top;background-size:cover;z-index:-1;width:100%;height:100%;top:0;left:0;position:absolute;border-radius:6px}#tianai-captcha-parent .slider-bottom{height:19px;width:100%}#tianai-captcha-parent .slider-bottom .close-btn{width:20px;height:20px;background-image:url(../images/icon.png);background-repeat:no-repeat;background-position:0 -14px;float:right;margin-right:2px;cursor:pointer}#tianai-captcha-parent .slider-bottom .refresh-btn{width:20px;height:20px;background-image:url(../images/icon.png);background-position:0 -167px;background-repeat:no-repeat;float:right;margin-right:10px;cursor:pointer}#tianai-captcha-parent .slider-bottom .logo{height:30px;float:left}#tianai-captcha-parent .slider-move-shadow{animation:myanimation 2s infinite;height:100%;width:5px;background-color:#fff;position:absolute;top:0;left:0;filter:opacity(0.5);box-shadow:1px 1px 1px #fff;border-radius:50%}#tianai-captcha-parent #tianai-captcha-slider-move-track-mask{border-width:1px;border-style:solid;border-color:#00f4ab;width:0;height:32px;background-color:#a9ffe5;opacity:.5;position:absolute;top:-1px;left:-1px;border-radius:5px}
#tianai-captcha{text-align:left;box-sizing:content-box;width:300px;height:260px;z-index:999}#tianai-captcha .slider-bottom .logo{height:30px}#tianai-captcha .slider-bottom{height:19px;width:100%}#tianai-captcha .content .tianai-captcha-tips{height:25px;width:100%;position:absolute;bottom:-25px;left:0;z-index:999;font-size:15px;line-height:25px;color:#fff;text-align:center;transition:bottom .3s ease-in-out}#tianai-captcha .content .tianai-captcha-tips.tianai-captcha-tips-error{background-color:#ff5d39}#tianai-captcha .content .tianai-captcha-tips.tianai-captcha-tips-success{background-color:#39c522}#tianai-captcha .content .tianai-captcha-tips.tianai-captcha-tips-on{bottom:0}#tianai-captcha .content #tianai-captcha-loading{z-index:9999;background-color:#f5f5f5;text-align:center;height:100%;overflow:hidden;position:relative;display:flex;justify-content:center;align-items:center}#tianai-captcha .content #tianai-captcha-loading img{display:block;width:45px;height:45px}#tianai-captcha #tianai-captcha-slider-bg-canvas{position:absolute;left:0;top:0;width:100%;height:100%;border-radius:5px}@keyframes myanimation{from{left:0}to{left:289px}}
#tianai-captcha.tianai-captcha-slider{z-index:999;position:absolute;left:0;top:0;user-select:none}#tianai-captcha.tianai-captcha-slider .content{width:100%;height:180px;position:relative;overflow:hidden}#tianai-captcha.tianai-captcha-slider .bg-img-div{width:100%;height:100%;position:absolute;transform:translate(0px, 0px)}#tianai-captcha.tianai-captcha-slider .bg-img-div img{height:100%;width:100%;border-radius:5px}#tianai-captcha.tianai-captcha-slider .slider-img-div{height:100%;position:absolute;transform:translate(0px, 0px)}#tianai-captcha.tianai-captcha-slider .slider-img-div #tianai-captcha-slider-move-img{height:100%}#tianai-captcha.tianai-captcha-slider .slider-move{height:34px;width:100%;margin:11px 0;position:relative}#tianai-captcha.tianai-captcha-slider .slider-move-track{position:relative;height:32px;line-height:32px;text-align:center;background:#f5f5f5;color:#999;transition:0s;font-size:14px;box-sizing:content-box;border:1px solid #f5f5f5;border-radius:4px}#tianai-captcha.tianai-captcha-slider .refresh-btn,#tianai-captcha.tianai-captcha-slider .close-btn{display:inline-block}#tianai-captcha.tianai-captcha-slider .slider-move{line-height:38px;font-size:14px;text-align:center;white-space:nowrap;color:#88949d;-moz-user-select:none;-webkit-user-select:none;user-select:none;filter:opacity(0.8)}#tianai-captcha.tianai-captcha-slider .slider-move .slider-move-btn{transform:translate(0px, 0px);position:absolute;top:-6px;left:0;width:63px;height:45px;background-color:#fff;background-repeat:no-repeat;background-size:contain;border-radius:5px}#tianai-captcha.tianai-captcha-slider .slider-tip{margin-bottom:5px;font-weight:bold;font-size:15px;line-height:normal;color:#000}#tianai-captcha.tianai-captcha-slider .slider-move-btn:hover{cursor:pointer}
#tianai-captcha.tianai-captcha-rotate .rotate-img-div{height:100%;text-align:center}#tianai-captcha.tianai-captcha-rotate .rotate-img-div img{height:100%;transform:rotate(0deg);display:inline-block}
#tianai-captcha.tianai-captcha-concat .tianai-captcha-slider-concat-img-div{background-size:100% 180px;position:absolute;transform:translate(0px, 0px);z-index:1;width:100%}#tianai-captcha.tianai-captcha-concat .tianai-captcha-slider-concat-bg-img{width:100%;height:100%;position:absolute;transform:translate(0px, 0px);background-size:100% 180px}
#tianai-captcha.tianai-captcha-word-click{position:relative;box-sizing:border-box}#tianai-captcha.tianai-captcha-word-click .click-tip{position:relative;height:40px;width:100%}#tianai-captcha.tianai-captcha-word-click .click-tip .tip-img{width:130px;position:absolute;right:15px}#tianai-captcha.tianai-captcha-word-click .click-tip #tianai-captcha-click-track-font{font-size:18px;display:inline-block;height:40px;line-height:40px;position:absolute}#tianai-captcha.tianai-captcha-word-click .slider-bottom{position:relative;top:6px}#tianai-captcha.tianai-captcha-word-click .content #bg-img-click-mask{width:100%;height:100%;position:absolute;left:0;top:0}#tianai-captcha.tianai-captcha-word-click .content #bg-img-click-mask .click-span{position:absolute;left:0;top:0;border-radius:50px;background-color:#409eff;width:20px;height:20px;text-align:center;line-height:20px;color:#fff;border:2px solid #fff;box-sizing:content-box}
File diff suppressed because one or more lines are too long