增加登陆验证码,24小时流量统计

This commit is contained in:
qaq
2025-08-14 15:07:53 +08:00
parent 24dcd58d80
commit 6b65a7dd2d
60 changed files with 1108 additions and 148 deletions
-1
View File
@@ -36,7 +36,6 @@ services:
DB_PASSWORD: ${DB_PASSWORD} DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
LOG_DIR: /app/logs LOG_DIR: /app/logs
SERVER_ADDR: ${SERVER_HOST}
JAVA_OPTS: "-Xms256m -Xmx512m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai" JAVA_OPTS: "-Xms256m -Xmx512m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
ports: ports:
- "${BACKEND_PORT}:6365" - "${BACKEND_PORT}:6365"
-1
View File
@@ -36,7 +36,6 @@ services:
DB_PASSWORD: ${DB_PASSWORD} DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
LOG_DIR: /app/logs LOG_DIR: /app/logs
SERVER_ADDR: ${SERVER_HOST}
JAVA_OPTS: "-Xms256m -Xmx512m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai" JAVA_OPTS: "-Xms256m -Xmx512m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
ports: ports:
- "${BACKEND_PORT}:6365" - "${BACKEND_PORT}:6365"
+28
View File
@@ -21,6 +21,21 @@ SET time_zone = "+00:00";
-- 数据库: `gost` -- 数据库: `gost`
-- --
-- --------------------------------------------------------
--
-- 表的结构 `statistics_flow`
--
CREATE TABLE `statistics_flow` (
`id` bigint(10) NOT NULL,
`user_id` int(10) NOT NULL,
`flow` bigint(20) NOT NULL,
`total_flow` bigint(20) NOT NULL,
`time` varchar(100) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- -------------------------------------------------------- -- --------------------------------------------------------
-- --
@@ -181,6 +196,13 @@ INSERT INTO `vite_config` (`id`, `name`, `value`, `time`) VALUES
-- 转储表的索引 -- 转储表的索引
-- --
--
-- 表的索引 `statistics_flow`
--
ALTER TABLE `statistics_flow`
ADD PRIMARY KEY (`id`);
-- --
-- 表的索引 `forward` -- 表的索引 `forward`
-- --
@@ -228,6 +250,12 @@ ALTER TABLE `vite_config`
-- 在导出的表使用AUTO_INCREMENT -- 在导出的表使用AUTO_INCREMENT
-- --
--
-- 使用表AUTO_INCREMENT `statistics_flow`
--
ALTER TABLE `statistics_flow`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
-- --
-- 使用表AUTO_INCREMENT `forward` -- 使用表AUTO_INCREMENT `forward`
-- --
+17 -3
View File
@@ -16,6 +16,15 @@ show_menu() {
echo "===============================================" echo "==============================================="
} }
# 删除脚本自身
delete_self() {
echo ""
echo "🗑️ 操作已完成,正在清理脚本文件..."
SCRIPT_PATH="$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || echo "$0")"
sleep 1
rm -f "$SCRIPT_PATH" && echo "✅ 脚本文件已删除" || echo "❌ 删除脚本文件失败"
}
# 检查并安装 tcpkill # 检查并安装 tcpkill
check_and_install_tcpkill() { check_and_install_tcpkill() {
# 检查 tcpkill 是否已安装 # 检查 tcpkill 是否已安装
@@ -304,6 +313,7 @@ main() {
# 如果提供了命令行参数,直接执行安装 # 如果提供了命令行参数,直接执行安装
if [[ -n "$SERVER_ADDR" && -n "$SECRET" ]]; then if [[ -n "$SERVER_ADDR" && -n "$SECRET" ]]; then
install_gost install_gost
delete_self
exit 0 exit 0
fi fi
@@ -315,18 +325,22 @@ main() {
case $choice in case $choice in
1) 1)
install_gost install_gost
break delete_self
exit 0
;; ;;
2) 2)
update_gost update_gost
break delete_self
exit 0
;; ;;
3) 3)
uninstall_gost uninstall_gost
break delete_self
exit 0
;; ;;
4) 4)
echo "👋 退出脚本" echo "👋 退出脚本"
delete_self
exit 0 exit 0
;; ;;
*) *)
+34 -49
View File
@@ -5,24 +5,7 @@ set -e
export LANG=en_US.UTF-8 export LANG=en_US.UTF-8
export LC_ALL=C export LC_ALL=C
# 显示重要更新说明
show_update_notice() {
echo "==============================================="
echo "🚨🚨🚨 重要更新说明 🚨🚨🚨"
echo "==============================================="
echo "面板版本大于等于1.0.6可以直接更新,无需查看"
echo "⚠️ 此次更新包含重要的数据库结构变更!"
echo "⚠️ 使用前务必前往群组查看更新说明!"
echo "⚠️ 否则可能导致数据灰飞烟灭!"
echo "⚠️ 有任何疑问可在群组询问,不要盲目操作!"
echo "==============================================="
echo "📱 请先前往群组查看详细更新说明"
echo "📱 确认了解更新内容后再进行操作"
echo "==============================================="
echo ""
echo "按回车键继续..."
read -r
}
# 全局下载地址配置 # 全局下载地址配置
DOCKER_COMPOSEV4_URL="https://raw.githubusercontent.com/bqlpfy/forward-panel/refs/heads/main/docker-compose-v4.yml" DOCKER_COMPOSEV4_URL="https://raw.githubusercontent.com/bqlpfy/forward-panel/refs/heads/main/docker-compose-v4.yml"
@@ -156,7 +139,7 @@ show_menu() {
echo "1. 安装面板" echo "1. 安装面板"
echo "2. 更新面板" echo "2. 更新面板"
echo "3. 卸载面板" echo "3. 卸载面板"
echo "4. 导出数据库备份" echo "4. 导出备份"
echo "5. 退出" echo "5. 退出"
echo "===============================================" echo "==============================================="
} }
@@ -165,23 +148,22 @@ generate_random() {
LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c16 LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c16
} }
# 删除脚本自身
delete_self() {
echo ""
echo "🗑️ 操作已完成,正在清理脚本文件..."
SCRIPT_PATH="$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || echo "$0")"
sleep 1
rm -f "$SCRIPT_PATH" && echo "✅ 脚本文件已删除" || echo "❌ 删除脚本文件失败"
}
# 获取用户输入的配置参数 # 获取用户输入的配置参数
get_config_params() { get_config_params() {
echo "🔧 请输入配置参数:" echo "🔧 请输入配置参数:"
echo "📡 节点端服务器和面板通信的地址,需要能正常访问(IPv6不需要加[])"
echo "不要套CDN,不支持HTTPS,直接输入面板服务器的ip"
echo "用于节点和面板通讯的地址,节点和面板通讯内容有加密!!!!!"
while true; do
read -p "当前面板服务器地址: " SERVER_HOST
if [ -n "$SERVER_HOST" ]; then
break
else
echo "面板服务器地址不能为空,请输入。"
fi
done
read -p "前端端口(默认 6366): " FRONTEND_PORT read -p "前端端口(默认 6366): " FRONTEND_PORT
FRONTEND_PORT=${FRONTEND_PORT:-6366} FRONTEND_PORT=${FRONTEND_PORT:-6366}
@@ -193,7 +175,6 @@ get_config_params() {
DB_USER=$(generate_random) DB_USER=$(generate_random)
DB_PASSWORD=$(generate_random) DB_PASSWORD=$(generate_random)
JWT_SECRET=$(generate_random) JWT_SECRET=$(generate_random)
SERVER_HOST_PORT="${SERVER_HOST}:${BACKEND_PORT}"
} }
# 安装功能 # 安装功能
@@ -227,7 +208,6 @@ DB_NAME=$DB_NAME
DB_USER=$DB_USER DB_USER=$DB_USER
DB_PASSWORD=$DB_PASSWORD DB_PASSWORD=$DB_PASSWORD
JWT_SECRET=$JWT_SECRET JWT_SECRET=$JWT_SECRET
SERVER_HOST=$SERVER_HOST_PORT
FRONTEND_PORT=$FRONTEND_PORT FRONTEND_PORT=$FRONTEND_PORT
BACKEND_PORT=$BACKEND_PORT BACKEND_PORT=$BACKEND_PORT
EOF EOF
@@ -236,10 +216,7 @@ EOF
$DOCKER_CMD up -d $DOCKER_CMD up -d
echo "🎉 部署完成" echo "🎉 部署完成"
echo "🌐 访问地址:" echo "🌐 访问地址: http://服务器IP:$FRONTEND_PORT"
echo "前端地址: http://$SERVER_HOST:$FRONTEND_PORT"
echo "后端地址: http://$SERVER_HOST:$BACKEND_PORT"
echo ""
echo "📖 部署完成后请阅读下使用文档,求求了啊,不要上去就是一顿操作" echo "📖 部署完成后请阅读下使用文档,求求了啊,不要上去就是一顿操作"
echo "📚 文档地址: https://tes.cc/guide.html" echo "📚 文档地址: https://tes.cc/guide.html"
echo "💡 默认管理员账号: admin_user / admin_user" echo "💡 默认管理员账号: admin_user / admin_user"
@@ -825,12 +802,17 @@ CREATE TABLE IF NOT EXISTS \`vite_config\` (
UNIQUE KEY \`unique_name\` (\`name\`) UNIQUE KEY \`unique_name\` (\`name\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 插入默认应用名称配置(如果不存在) -- 创建 statistics_flow 表(如果不存在)
INSERT INTO \`vite_config\` (\`name\`, \`value\`, \`time\`) CREATE TABLE IF NOT EXISTS \`statistics_flow\` (
SELECT 'app_name', '哆啦A梦', 1753344708000 \`id\` bigint(20) NOT NULL AUTO_INCREMENT,
WHERE NOT EXISTS ( \`user_id\` int(10) NOT NULL,
SELECT 1 FROM \`vite_config\` WHERE \`name\` = 'app_name' \`flow\` bigint(20) NOT NULL,
); \`total_flow\` bigint(20) NOT NULL,
\`time\` varchar(100) NOT NULL,
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
EOF EOF
@@ -1002,9 +984,7 @@ uninstall_panel() {
# 主逻辑 # 主逻辑
main() { main() {
# 显示更新说明
show_update_notice
# 显示交互式菜单 # 显示交互式菜单
while true; do while true; do
show_menu show_menu
@@ -1013,22 +993,27 @@ main() {
case $choice in case $choice in
1) 1)
install_panel install_panel
break delete_self
exit 0
;; ;;
2) 2)
update_panel update_panel
break delete_self
exit 0
;; ;;
3) 3)
uninstall_panel uninstall_panel
break delete_self
exit 0
;; ;;
4) 4)
export_migration_sql export_migration_sql
break delete_self
exit 0
;; ;;
5) 5)
echo "👋 退出脚本" echo "👋 退出脚本"
delete_self
exit 0 exit 0
;; ;;
*) *)
+7
View File
@@ -120,6 +120,13 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- tianai-captcha Spring Boot Starter -->
<dependency>
<groupId>cloud.tianai.captcha</groupId>
<artifactId>tianai-captcha-springboot-starter</artifactId>
<version>1.5.2</version>
</dependency>
</dependencies> </dependencies>
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
@@ -0,0 +1,18 @@
package com.admin.common.dto;
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 验证码验证请求DTO
*/
@Data
public class CaptchaVerifyDto {
private String id;
private ImageCaptchaTrack data;
}
@@ -14,4 +14,7 @@ public class LoginDto {
@NotBlank(message = "密码不能为空") @NotBlank(message = "密码不能为空")
private String password; private String password;
private String captchaId;
} }
@@ -1,5 +1,6 @@
package com.admin.common.dto; package com.admin.common.dto;
import com.admin.entity.StatisticsFlow;
import lombok.Data; import lombok.Data;
import java.util.List; import java.util.List;
@@ -24,6 +25,11 @@ public class UserPackageDto {
* 用户转发列表 * 用户转发列表
*/ */
private List<UserForwardDetailDto> forwards; private List<UserForwardDetailDto> forwards;
/**
* 用户转发列表
*/
private List<StatisticsFlow> statisticsFlows;
/** /**
* 用户基本信息 * 用户基本信息
@@ -48,7 +48,7 @@ public class ResetFlowAsync {
* - 不会影响DelayQueueManager的到期任务对status等其他字段的更新 * - 不会影响DelayQueueManager的到期任务对status等其他字段的更新
* - 避免了并发修改导致的数据覆盖问题 * - 避免了并发修改导致的数据覆盖问题
*/ */
@Scheduled(cron = "0 0 0 * * ?") @Scheduled(cron = "5 0 0 * * ?")
public void reset_flow(){ public void reset_flow(){
log.info("开始执行流量重置任务"); log.info("开始执行流量重置任务");
@@ -0,0 +1,75 @@
package com.admin.common.task;
import com.admin.entity.StatisticsFlow;
import com.admin.entity.User;
import com.admin.service.StatisticsFlowService;
import com.admin.service.UserService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Configuration
@EnableScheduling
public class StatisticsFlowAsync {
@Resource
UserService userService;
@Resource
StatisticsFlowService statisticsFlowService;
@Scheduled(cron = "0 0 * * * ?")
public void statistics_flow() {
LocalDateTime currentHour = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0);
String hourString = currentHour.format(DateTimeFormatter.ofPattern("HH:mm"));
List<User> list = userService.list();
List<StatisticsFlow> statisticsFlowList = new ArrayList<>();
for (User user : list) {
long currentFlow = user.getInFlow() + user.getOutFlow();
// 从数据库获取上一次记录
StatisticsFlow lastFlowRecord = statisticsFlowService.getOne(
new LambdaQueryWrapper<StatisticsFlow>()
.eq(StatisticsFlow::getUserId, user.getId())
.orderByDesc(StatisticsFlow::getId)
.last("LIMIT 1")
);
long currentTotalFlow = currentFlow;
long incrementFlow = currentTotalFlow;
if (lastFlowRecord != null) {
long lastTotalFlow = lastFlowRecord.getTotalFlow();
incrementFlow = currentTotalFlow - lastTotalFlow;
if (incrementFlow < 0) {
incrementFlow = currentTotalFlow;
}
}
StatisticsFlow statisticsFlow = new StatisticsFlow();
statisticsFlow.setUserId(user.getId());
statisticsFlow.setFlow(incrementFlow);
statisticsFlow.setTotalFlow(currentTotalFlow);
statisticsFlow.setTime(hourString);
statisticsFlowList.add(statisticsFlow);
}
statisticsFlowService.saveBatch(statisticsFlowList);
}
}
@@ -0,0 +1,37 @@
package com.admin.config;
import cloud.tianai.captcha.common.constant.CaptchaTypeConstant;
import cloud.tianai.captcha.resource.FontCache;
import cloud.tianai.captcha.resource.ResourceStore;
import cloud.tianai.captcha.resource.common.model.dto.Resource;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
@Component
@RequiredArgsConstructor
public class CaptchaResourceConfiguration {
private final ResourceStore resourceStore;
private static final String[] OPTIONS = {
"SLIDER", "WORD_IMAGE_CLICK", "ROTATE", "CONCAT"
};
@PostConstruct
public void init() {
// 添加自定义背景图片
for (String option : OPTIONS) {
for (int i = 1; i <= 26; i++) {
resourceStore.addResource(option, new Resource("classpath", "bgimages/"+i + ".jpg", "default"));
}
}
//添加自定义字体
resourceStore.addResource(FontCache.FONT_TYPE,new Resource("classpath", "fonts/SIMSUN.TTC", "default"));
}
}
@@ -59,6 +59,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
.excludePathPatterns("/flow/**") .excludePathPatterns("/flow/**")
.excludePathPatterns("/api/v1/open_api/**") .excludePathPatterns("/api/v1/open_api/**")
.excludePathPatterns("/api/v1/config/get") .excludePathPatterns("/api/v1/config/get")
.excludePathPatterns("/api/v1/user/login"); .excludePathPatterns("/api/v1/user/login")
.excludePathPatterns("/api/v1/captcha/**");
} }
} }
@@ -0,0 +1,77 @@
package com.admin.controller;
import cloud.tianai.captcha.application.ImageCaptchaApplication;
import cloud.tianai.captcha.common.constant.CaptchaTypeConstant;
import cloud.tianai.captcha.common.response.ApiResponse;
import cloud.tianai.captcha.application.vo.ImageCaptchaVO;
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
import cloud.tianai.captcha.validator.common.model.dto.MatchParam;
import com.admin.common.dto.CaptchaVerifyDto;
import com.admin.common.lang.R;
import com.admin.entity.ViteConfig;
import com.admin.service.ViteConfigService;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import cloud.tianai.captcha.application.vo.CaptchaResponse;
import javax.annotation.Resource;
import javax.validation.Valid;
import java.util.Collections;
import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
/**
* 验证码控制器
*/
@Slf4j
@RestController
@CrossOrigin
@RequestMapping("/api/v1/captcha")
public class CaptchaController extends BaseController {
@Resource
ImageCaptchaApplication application;
private static final String[] OPTIONS = {
"SLIDER", "WORD_IMAGE_CLICK", "ROTATE", "CONCAT"
};
@PostMapping("/check")
public R check() {
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "captcha_enabled"));
if (viteConfig == null) return R.ok(0);
if (!Objects.equals(viteConfig.getValue(), "true")) return R.ok(0);
return R.ok(1);
}
@PostMapping("/generate")
public CaptchaResponse<ImageCaptchaVO> genCaptcha() {
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "captcha_type"));
String captchaType;
if (viteConfig == null || Objects.equals(viteConfig.getValue(), "RANDOM")) {
captchaType = getRandomOption();
}else {
captchaType = viteConfig.getValue();
}
return application.generateCaptcha(captchaType);
}
@PostMapping("/verify")
public ApiResponse<?> verify(@Valid @RequestBody CaptchaVerifyDto verifyDto) {
ApiResponse<?> response = application.matching(verifyDto.getId(), verifyDto.getData());
if (response.isSuccess()) {
return ApiResponse.ofSuccess(Collections.singletonMap("validToken", verifyDto.getId()));
}
return response;
}
public static String getRandomOption() {
int index = ThreadLocalRandom.current().nextInt(OPTIONS.length);
return OPTIONS[index];
}
}
@@ -0,0 +1,32 @@
package com.admin.entity;
import com.admin.entity.BaseEntity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* <p>
*
* </p>
*
* @author QAQ
* @since 2025-08-14
*/
@Data
public class StatisticsFlow {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long userId;
private Long flow;
private Long totalFlow;
private String time;
}
@@ -0,0 +1,16 @@
package com.admin.mapper;
import com.admin.entity.StatisticsFlow;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* <p>
* Mapper 接口
* </p>
*
* @author QAQ
* @since 2025-08-14
*/
public interface StatisticsFlowMapper extends BaseMapper<StatisticsFlow> {
}
@@ -0,0 +1,16 @@
package com.admin.service;
import com.admin.entity.StatisticsFlow;
import com.baomidou.mybatisplus.extension.service.IService;
/**
* <p>
* 服务类
* </p>
*
* @author QAQ
* @since 2025-08-14
*/
public interface StatisticsFlowService extends IService<StatisticsFlow> {
}
@@ -7,10 +7,12 @@ import com.admin.common.dto.NodeUpdateDto;
import com.admin.common.lang.R; import com.admin.common.lang.R;
import com.admin.entity.Node; import com.admin.entity.Node;
import com.admin.entity.Tunnel; import com.admin.entity.Tunnel;
import com.admin.entity.ViteConfig;
import com.admin.mapper.NodeMapper; import com.admin.mapper.NodeMapper;
import com.admin.mapper.TunnelMapper; import com.admin.mapper.TunnelMapper;
import com.admin.service.NodeService; import com.admin.service.NodeService;
import com.admin.service.TunnelService; import com.admin.service.TunnelService;
import com.admin.service.ViteConfigService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -69,8 +71,9 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
@Lazy @Lazy
private TunnelService tunnelService; private TunnelService tunnelService;
@Value("${server-addr}") @Resource
private String serverAddr; ViteConfigService viteConfigService;
// ========== 公共接口实现 ========== // ========== 公共接口实现 ==========
@@ -321,9 +324,7 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
} }
// 2. 构建安装命令 // 2. 构建安装命令
String installCommand = buildInstallCommand(node); return buildInstallCommand(node);
return R.ok(installCommand);
} }
/** /**
@@ -332,7 +333,10 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
* @param node 节点对象 * @param node 节点对象
* @return 格式化的安装命令 * @return 格式化的安装命令
*/ */
private String buildInstallCommand(Node node) { private R buildInstallCommand(Node node) {
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "ip"));
if (viteConfig == null) return R.err("请先前往网站配置中设置ip");
StringBuilder command = new StringBuilder(); StringBuilder command = new StringBuilder();
// 第一部分:下载安装脚本 // 第一部分:下载安装脚本
@@ -340,14 +344,14 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
.append(" -o ./install.sh && chmod +x ./install.sh && "); .append(" -o ./install.sh && chmod +x ./install.sh && ");
// 处理服务器地址,如果是IPv6需要添加方括号 // 处理服务器地址,如果是IPv6需要添加方括号
String processedServerAddr = processServerAddress(serverAddr); String processedServerAddr = processServerAddress(viteConfig.getValue());
// 第二部分:执行安装脚本(去掉-u参数) // 第二部分:执行安装脚本(去掉-u参数)
command.append("./install.sh") command.append("./install.sh")
.append(" -a ").append(processedServerAddr) // 服务器地址 .append(" -a ").append(processedServerAddr) // 服务器地址
.append(" -s ").append(node.getSecret()); // 节点密钥 .append(" -s ").append(node.getSecret()); // 节点密钥
return command.toString(); return R.ok(command.toString());
} }
/** /**
@@ -0,0 +1,20 @@
package com.admin.service.impl;
import com.admin.entity.StatisticsFlow;
import com.admin.mapper.StatisticsFlowMapper;
import com.admin.service.StatisticsFlowService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
/**
* <p>
* 服务实现类
* </p>
*
* @author QAQ
* @since 2025-08-14
*/
@Service
public class StatisticsFlowServiceImpl extends ServiceImpl<StatisticsFlowMapper, StatisticsFlow> implements StatisticsFlowService {
}
@@ -1,5 +1,7 @@
package com.admin.service.impl; package com.admin.service.impl;
import cloud.tianai.captcha.application.ImageCaptchaApplication;
import cloud.tianai.captcha.spring.plugins.secondary.SecondaryVerificationApplication;
import cn.hutool.core.map.MapUtil; import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.admin.common.dto.*; import com.admin.common.dto.*;
@@ -7,28 +9,24 @@ import com.admin.common.lang.R;
import com.admin.common.utils.GostUtil; import com.admin.common.utils.GostUtil;
import com.admin.common.utils.JwtUtil; import com.admin.common.utils.JwtUtil;
import com.admin.common.utils.Md5Util; import com.admin.common.utils.Md5Util;
import com.admin.entity.Forward; import com.admin.entity.*;
import com.admin.entity.Node;
import com.admin.entity.Tunnel;
import com.admin.entity.User;
import com.admin.entity.UserTunnel;
import com.admin.mapper.ForwardMapper; import com.admin.mapper.ForwardMapper;
import com.admin.mapper.UserMapper; import com.admin.mapper.UserMapper;
import com.admin.mapper.UserTunnelMapper; import com.admin.mapper.UserTunnelMapper;
import com.admin.service.NodeService; import com.admin.service.*;
import com.admin.service.TunnelService;
import com.admin.service.UserService;
import com.admin.service.UserTunnelService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.Data; import lombok.Data;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects;
/** /**
* <p> * <p>
@@ -77,8 +75,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
private static final String ERROR_GET_PACKAGE_INFO_FAILED = "获取套餐信息失败"; private static final String ERROR_GET_PACKAGE_INFO_FAILED = "获取套餐信息失败";
private static final String ERROR_CURRENT_PASSWORD_WRONG = "当前密码错误"; private static final String ERROR_CURRENT_PASSWORD_WRONG = "当前密码错误";
private static final String ERROR_PASSWORD_NOT_MATCH = "新密码和确认密码不匹配"; private static final String ERROR_PASSWORD_NOT_MATCH = "新密码和确认密码不匹配";
private static final String SUCCESS_PASSWORD_UPDATE = "密码修改成功";
/** 默认账号密码 */ /** 默认账号密码 */
private static final String DEFAULT_USERNAME = "admin_user"; private static final String DEFAULT_USERNAME = "admin_user";
private static final String DEFAULT_PASSWORD = "admin_user"; private static final String DEFAULT_PASSWORD = "admin_user";
@@ -112,28 +109,48 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
@Resource @Resource
UserTunnelService userTunnelService; UserTunnelService userTunnelService;
@Resource
ViteConfigService viteConfigService;
@Resource
StatisticsFlowService statisticsFlowService;
@Resource
private ImageCaptchaApplication application;
// ========== 公共接口实现 ========== // ========== 公共接口实现 ==========
/** /**
* 用户登录 * 用户登录
* 验证用户名密码,检查账户状态,生成JWT令牌 * 验证验证码、用户名密码,检查账户状态,生成JWT令牌
* *
* @param loginDto 登录数据传输对象 * @param loginDto 登录数据传输对象
* @return 登录结果响应,包含令牌和用户信息 * @return 登录结果响应,包含令牌和用户信息
*/ */
@Override @Override
public R login(LoginDto loginDto) { public R login(LoginDto loginDto) {
// 1. 验证用户凭据
// 1. 验证验证码
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "captcha_enabled"));
if (viteConfig != null && Objects.equals(viteConfig.getValue(), "true")) {
if (StringUtils.isBlank(loginDto.getCaptchaId())) return R.err("验证码校验失败");
boolean valid = ((SecondaryVerificationApplication) application).secondaryVerification(loginDto.getCaptchaId());
if (!valid) return R.err("验证码校验失败");
}
// 2. 验证用户凭据
LoginValidationResult validationResult = validateUserCredentials(loginDto); LoginValidationResult validationResult = validateUserCredentials(loginDto);
if (validationResult.isHasError()) { if (validationResult.isHasError()) {
return R.err(validationResult.getErrorMessage()); return R.err(validationResult.getErrorMessage());
} }
// 2. 生成令牌并返回用户信息 // 3. 生成令牌并返回用户信息
User user = validationResult.getUser(); User user = validationResult.getUser();
String token = JwtUtil.generateToken(user); String token = JwtUtil.generateToken(user);
// 3. 检查是否使用默认账号密码 // 4. 检查是否使用默认账号密码
boolean requirePasswordChange = isDefaultCredentials(loginDto.getUsername(), loginDto.getPassword()); boolean requirePasswordChange = isDefaultCredentials(loginDto.getUsername(), loginDto.getPassword());
return R.ok(MapUtil.builder() return R.ok(MapUtil.builder()
@@ -238,6 +255,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
try { try {
// 2. 级联删除用户相关数据 // 2. 级联删除用户相关数据
deleteUserRelatedData(id); deleteUserRelatedData(id);
statisticsFlowService.remove(new QueryWrapper<StatisticsFlow>().eq("user_id", id));
// 3. 删除用户 // 3. 删除用户
boolean result = this.removeById(id); boolean result = this.removeById(id);
return result ? R.ok(SUCCESS_DELETE_MSG) : R.err(ERROR_DELETE_FAILED); return result ? R.ok(SUCCESS_DELETE_MSG) : R.err(ERROR_DELETE_FAILED);
@@ -345,6 +363,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
// ========== 私有辅助方法 ========== // ========== 私有辅助方法 ==========
/** /**
* 验证用户登录凭据 * 验证用户登录凭据
* *
@@ -655,12 +674,16 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
// 3. 获取转发详情 // 3. 获取转发详情
List<UserPackageDto.UserForwardDetailDto> forwards = userMapper.getUserForwardDetails(user.getId().intValue()); List<UserPackageDto.UserForwardDetailDto> forwards = userMapper.getUserForwardDetails(user.getId().intValue());
// 4. 查询最近24小时流量信息,没有的补0
List<StatisticsFlow> statisticsFlows = getLast24HoursFlowStatistics(user.getId());
// 4. 构造返回结果 // 5. 构造返回结果
UserPackageDto packageDto = new UserPackageDto(); UserPackageDto packageDto = new UserPackageDto();
packageDto.setUserInfo(userInfo); packageDto.setUserInfo(userInfo);
packageDto.setTunnelPermissions(tunnelPermissions); packageDto.setTunnelPermissions(tunnelPermissions);
packageDto.setForwards(forwards); packageDto.setForwards(forwards);
packageDto.setStatisticsFlows(statisticsFlows);
return packageDto; return packageDto;
} }
@@ -697,6 +720,104 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
return userMapper.getUserTunnelDetails(userId.intValue()); return userMapper.getUserTunnelDetails(userId.intValue());
} }
/**
* 获取用户最近24小时的流量统计数据,没有数据的时间点补0
*
* @param userId 用户ID
* @return 最近24小时流量统计列表
*/
private List<StatisticsFlow> getLast24HoursFlowStatistics(Long userId) {
try {
// 按ID倒序查最近24条记录(ID越大越新,时间就是23:00, 22:00, 21:00...这样倒序)
List<StatisticsFlow> recentFlows = statisticsFlowService.list(
new QueryWrapper<StatisticsFlow>()
.eq("user_id", userId)
.orderByDesc("id")
.last("LIMIT 24")
);
List<StatisticsFlow> result = new ArrayList<>(recentFlows);
// 如果查出来的记录不足24条,需要补0和对应的时间
if (result.size() < 24) {
// 获取最早记录的时间,继续往前推
int startHour = getCurrentHour();
if (!result.isEmpty()) {
// 从最后一条记录的时间继续往前推
String lastTime = result.get(result.size() - 1).getTime();
startHour = parseHour(lastTime) - 1;
}
// 补0到24条
while (result.size() < 24) {
if (startHour < 0) startHour = 23; // 跨天处理
StatisticsFlow emptyFlow = new StatisticsFlow();
emptyFlow.setUserId(userId);
emptyFlow.setFlow(0L);
emptyFlow.setTotalFlow(0L);
emptyFlow.setTime(String.format("%02d:00", startHour));
result.add(emptyFlow);
startHour--;
}
}
log.debug("用户 {} 获取到 {} 条实际记录,补齐为 {} 条24小时记录", userId, recentFlows.size(), result.size());
return result;
} catch (Exception e) {
log.error("获取用户 {} 最近24小时流量统计失败", userId, e);
// 返回24条全0数据,时间从当前小时往前推
return generateEmpty24HourData(userId);
}
}
/**
* 获取当前小时(0-23)
*/
private int getCurrentHour() {
return java.time.LocalDateTime.now().getHour();
}
/**
* 解析时间字符串获取小时数
*/
private int parseHour(String timeStr) {
try {
if (timeStr != null && timeStr.contains(":")) {
return Integer.parseInt(timeStr.split(":")[0]);
}
} catch (Exception e) {
// 解析失败,返回当前小时
}
return getCurrentHour();
}
/**
* 生成24小时全0数据(异常情况使用)
*/
private List<StatisticsFlow> generateEmpty24HourData(Long userId) {
List<StatisticsFlow> result = new ArrayList<>();
int currentHour = getCurrentHour();
for (int i = 0; i < 24; i++) {
StatisticsFlow emptyFlow = new StatisticsFlow();
emptyFlow.setUserId(userId);
emptyFlow.setFlow(0L);
emptyFlow.setTotalFlow(0L);
emptyFlow.setTime(String.format("%02d:00", currentHour));
result.add(emptyFlow);
currentHour--;
if (currentHour < 0) currentHour = 23;
}
return result;
}
@@ -40,4 +40,10 @@ mybatis-plus:
jwt-secret: ${JWT_SECRET} jwt-secret: ${JWT_SECRET}
log-dir: ${LOG_DIR} log-dir: ${LOG_DIR}
server-addr: ${SERVER_ADDR}
# 验证码配置
captcha:
init-default-resource: true
secondary:
enabled: true
expire: 120000
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.admin.mapper.StatisticsFlowMapper">
</mapper>
+8 -1
View File
@@ -4,6 +4,7 @@ import Network from './network';
export interface LoginData { export interface LoginData {
username: string; username: string;
password: string; password: string;
captchaId: string;
} }
export interface LoginResponse { export interface LoginResponse {
@@ -81,4 +82,10 @@ export const resetUserFlow = (data: { id: number; type: number }) => Network.pos
export const getConfigs = () => Network.post("/config/list"); export const getConfigs = () => Network.post("/config/list");
export const getConfigByName = (name: string) => Network.post("/config/get", { name }); export const getConfigByName = (name: string) => Network.post("/config/get", { name });
export const updateConfigs = (configMap: Record<string, string>) => Network.post("/config/update", configMap); export const updateConfigs = (configMap: Record<string, string>) => Network.post("/config/update", configMap);
export const updateConfig = (name: string, value: string) => Network.post("/config/update-single", { name, value }); export const updateConfig = (name: string, value: string) => Network.post("/config/update-single", { name, value });
// 验证码相关接口
export const checkCaptcha = () => Network.post("/captcha/check");
export const generateCaptcha = () => Network.post(`/captcha/generate`);
export const verifyCaptcha = (data: { captchaId: string; trackData: string }) => Network.post("/captcha/verify", data);
+1 -1
View File
@@ -4,7 +4,7 @@ export type SiteConfig = typeof siteConfig;
// 缓存相关常量 // 缓存相关常量
const CACHE_PREFIX = 'vite_config_'; const CACHE_PREFIX = 'vite_config_';
const VERSION = "1.2.6"; const VERSION = "1.2.8";
const getInitialConfig = () => { const getInitialConfig = () => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 B

+181 -48
View File
@@ -4,6 +4,8 @@ import { Card, CardBody, CardHeader } from "@heroui/card";
import { Input } from "@heroui/input"; import { Input } from "@heroui/input";
import { Spinner } from "@heroui/spinner"; import { Spinner } from "@heroui/spinner";
import { Divider } from "@heroui/divider"; import { Divider } from "@heroui/divider";
import { Switch } from "@heroui/switch";
import { Select, SelectItem } from "@heroui/select";
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { updateConfigs } from '@/api'; import { updateConfigs } from '@/api';
import { SettingsIcon } from '@/components/icons'; import { SettingsIcon } from '@/components/icons';
@@ -31,17 +33,70 @@ const SaveIcon = ({ className }: { className?: string }) => (
interface ConfigItem { interface ConfigItem {
key: string; key: string;
label: string; label: string;
placeholder: string; placeholder?: string;
description?: string; description?: string;
type: 'input' | 'switch' | 'select';
options?: { label: string; value: string; description?: string }[];
dependsOn?: string; // 依赖的配置项key
dependsValue?: string; // 依赖的配置项值
} }
// 网站配置项定义 // 网站配置项定义
const CONFIG_ITEMS: ConfigItem[] = [ const CONFIG_ITEMS: ConfigItem[] = [
{
key: 'ip',
label: '面板后端地址',
placeholder: '请输入面板后端IP:PORT',
description: '格式“ip:port”,用于对接节点时使用,ip是你安装面板服务器的公网ip,端口是安装脚本内输入的后端端口。不要套CDN,不支持https,通讯数据有加密',
type: 'input'
},
{ {
key: 'app_name', key: 'app_name',
label: '应用名称', label: '应用名称',
placeholder: '请输入应用名称', placeholder: '请输入应用名称',
description: '在浏览器标签页和导航栏显示的应用名称' description: '在浏览器标签页和导航栏显示的应用名称',
type: 'input'
},
{
key: 'captcha_enabled',
label: '启用验证码',
description: '开启后,用户登录时需要完成验证码验证',
type: 'switch'
},
{
key: 'captcha_type',
label: '验证码类型',
description: '选择验证码的显示类型,不同类型有不同的安全级别',
type: 'select',
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: '拖动滑块完成图片拼接'
}
]
} }
]; ];
@@ -49,7 +104,7 @@ const CONFIG_ITEMS: ConfigItem[] = [
const getInitialConfigs = (): Record<string, string> => { const getInitialConfigs = (): Record<string, string> => {
if (typeof window === 'undefined') return {}; if (typeof window === 'undefined') return {};
const configKeys = ['app_name']; const configKeys = ['app_name', 'captcha_enabled', 'captcha_type'];
const initialConfigs: Record<string, string> = {}; const initialConfigs: Record<string, string> = {};
try { try {
@@ -124,7 +179,15 @@ export default function ConfigPage() {
// 处理配置项变更 // 处理配置项变更
const handleConfigChange = (key: string, value: string) => { const handleConfigChange = (key: string, value: string) => {
const newConfigs = { ...configs, [key]: value }; let newConfigs = { ...configs, [key]: value };
// 特殊处理:启用验证码时,如果验证码类型未设置,默认为随机
if (key === 'captcha_enabled' && value === 'true') {
if (!newConfigs.captcha_type) {
newConfigs.captcha_type = 'RANDOM';
}
}
setConfigs(newConfigs); setConfigs(newConfigs);
// 检查是否有变更 // 检查是否有变更
@@ -174,11 +237,88 @@ export default function ConfigPage() {
} }
}; };
// 重置配置
const handleReset = () => {
setConfigs({ ...originalConfigs }); // 检查配置项是否应该显示(依赖检查)
setHasChanges(false); const shouldShowItem = (item: ConfigItem): boolean => {
toast.success('已重置为保存前的状态'); if (!item.dependsOn || !item.dependsValue) {
return true;
}
return configs[item.dependsOn] === item.dependsValue;
};
// 渲染不同类型的配置项
const renderConfigItem = (item: ConfigItem) => {
const isChanged = hasChanges && configs[item.key] !== originalConfigs[item.key];
switch (item.type) {
case 'input':
return (
<Input
value={configs[item.key] || ''}
onChange={(e) => handleConfigChange(item.key, e.target.value)}
placeholder={item.placeholder}
variant="bordered"
size="md"
classNames={{
input: "text-sm",
inputWrapper: isChanged
? "border-warning-300 data-[hover=true]:border-warning-400"
: ""
}}
/>
);
case 'switch':
return (
<Switch
isSelected={configs[item.key] === 'true'}
onValueChange={(checked) => handleConfigChange(item.key, checked ? 'true' : 'false')}
color="primary"
size="md"
classNames={{
wrapper: isChanged ? "border-warning-300" : ""
}}
>
<span className="text-sm text-gray-700 dark:text-gray-300">
{configs[item.key] === 'true' ? '已启用' : '已禁用'}
</span>
</Switch>
);
case 'select':
return (
<Select
selectedKeys={configs[item.key] ? [configs[item.key]] : []}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
if (selectedKey) {
handleConfigChange(item.key, selectedKey);
}
}}
placeholder="请选择验证码类型"
variant="bordered"
size="md"
classNames={{
trigger: isChanged
? "border-warning-300 data-[hover=true]:border-warning-400"
: ""
}}
>
{item.options?.map((option) => (
<SelectItem
key={option.value}
description={option.description}
>
{option.label}
</SelectItem>
)) || []}
</Select>
);
default:
return null;
}
}; };
if (loading) { if (loading) {
@@ -215,17 +355,7 @@ export default function ConfigPage() {
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{hasChanges && (
<Button
color="default"
variant="bordered"
size="sm"
onClick={handleReset}
disabled={saving}
>
重置
</Button>
)}
<Button <Button
color="primary" color="primary"
startContent={<SaveIcon className="w-4 h-4" />} startContent={<SaveIcon className="w-4 h-4" />}
@@ -242,36 +372,39 @@ export default function ConfigPage() {
<Divider /> <Divider />
<CardBody className="space-y-6 pt-6"> <CardBody className="space-y-6 pt-6">
{CONFIG_ITEMS.map((item, index) => ( {CONFIG_ITEMS.map((item, index) => {
<div key={item.key} className="space-y-2"> // 检查配置项是否应该显示
<div className="flex flex-col gap-1"> if (!shouldShowItem(item)) {
<label className="text-sm font-medium text-gray-700 dark:text-gray-300"> return null;
{item.label} }
</label>
{item.description && ( // 计算是否是最后一个显示的项目(用于决定是否显示分隔线)
<p className="text-xs text-gray-500 dark:text-gray-400"> const remainingItems = CONFIG_ITEMS.slice(index + 1).filter(shouldShowItem);
{item.description} const isLastItem = remainingItems.length === 0;
</p>
return (
<div key={item.key} className="space-y-3">
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{item.label}
</label>
{item.description && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{item.description}
</p>
)}
</div>
{/* 渲染配置项 */}
{renderConfigItem(item)}
{/* 分隔线 */}
{!isLastItem && (
<Divider className="mt-6" />
)} )}
</div> </div>
<Input );
value={configs[item.key] || ''} })}
onChange={(e) => handleConfigChange(item.key, e.target.value)}
placeholder={item.placeholder}
variant="bordered"
size="md"
classNames={{
input: "text-sm",
inputWrapper: hasChanges && configs[item.key] !== originalConfigs[item.key]
? "border-warning-300 data-[hover=true]:border-warning-400"
: ""
}}
/>
{index < CONFIG_ITEMS.length - 1 && (
<Divider className="mt-4" />
)}
</div>
))}
</CardBody> </CardBody>
</Card> </Card>
+115
View File
@@ -3,6 +3,7 @@ import { Button } from "@heroui/button";
import { Modal, ModalContent, ModalHeader, ModalBody } from "@heroui/modal"; import { Modal, ModalContent, ModalHeader, ModalBody } from "@heroui/modal";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import AdminLayout from "@/layouts/admin"; import AdminLayout from "@/layouts/admin";
import { getUserPackageInfo } from "@/api"; import { getUserPackageInfo } from "@/api";
@@ -48,11 +49,20 @@ interface AddressItem {
copying: boolean; copying: boolean;
} }
interface StatisticsFlow {
id: number;
userId: number;
flow: number;
totalFlow: number;
time: string;
}
export default function DashboardPage() { export default function DashboardPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [userInfo, setUserInfo] = useState<UserInfo>({} as UserInfo); const [userInfo, setUserInfo] = useState<UserInfo>({} as UserInfo);
const [userTunnels, setUserTunnels] = useState<UserTunnel[]>([]); const [userTunnels, setUserTunnels] = useState<UserTunnel[]>([]);
const [forwardList, setForwardList] = useState<Forward[]>([]); const [forwardList, setForwardList] = useState<Forward[]>([]);
const [statisticsFlows, setStatisticsFlows] = useState<StatisticsFlow[]>([]);
const [addressModalOpen, setAddressModalOpen] = useState(false); const [addressModalOpen, setAddressModalOpen] = useState(false);
const [addressModalTitle, setAddressModalTitle] = useState(''); const [addressModalTitle, setAddressModalTitle] = useState('');
@@ -154,6 +164,7 @@ export default function DashboardPage() {
setUserInfo({} as UserInfo); setUserInfo({} as UserInfo);
setUserTunnels([]); setUserTunnels([]);
setForwardList([]); setForwardList([]);
setStatisticsFlows([]);
loadPackageData(); loadPackageData();
localStorage.setItem('e', '/dashboard'); localStorage.setItem('e', '/dashboard');
@@ -168,6 +179,7 @@ export default function DashboardPage() {
setUserInfo(data.userInfo || {}); setUserInfo(data.userInfo || {});
setUserTunnels(data.tunnelPermissions || []); setUserTunnels(data.tunnelPermissions || []);
setForwardList(data.forwards || []); setForwardList(data.forwards || []);
setStatisticsFlows(data.statisticsFlows || []);
// 检查有效期并显示通知 // 检查有效期并显示通知
checkExpirationNotifications(data.userInfo, data.tunnelPermissions || []); checkExpirationNotifications(data.userInfo, data.tunnelPermissions || []);
@@ -207,6 +219,33 @@ export default function DashboardPage() {
return value.toString(); return value.toString();
}; };
// 处理24小时流量统计数据
const processFlowChartData = () => {
// 生成最近24小时的时间数组(从当前小时往前推24小时)
const now = new Date();
const hours: string[] = [];
for (let i = 23; i >= 0; i--) {
const time = new Date(now.getTime() - i * 60 * 60 * 1000);
const hourString = time.getHours().toString().padStart(2, '0') + ':00';
hours.push(hourString);
}
// 创建数据映射
const flowMap = new Map<string, number>();
statisticsFlows.forEach(item => {
flowMap.set(item.time, item.flow || 0);
});
// 生成图表数据,没有数据的小时显示为0
return hours.map(hour => ({
time: hour,
flow: flowMap.get(hour) || 0,
// 格式化显示用的流量值
formattedFlow: formatFlow(flowMap.get(hour) || 0)
}));
};
const getExpStatus = (expTime?: string) => { const getExpStatus = (expTime?: string) => {
if (!expTime) return { if (!expTime) return {
color: 'text-green-600 dark:text-green-400', color: 'text-green-600 dark:text-green-400',
@@ -636,6 +675,82 @@ export default function DashboardPage() {
</Card> </Card>
</div> </div>
{/* 24小时流量统计图表 */}
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z" />
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z" />
</svg>
<h2 className="text-lg lg:text-xl font-semibold text-foreground">24小时流量统计</h2>
</div>
</CardHeader>
<CardBody className="pt-0">
{statisticsFlows.length === 0 ? (
<div className="text-center py-12">
<svg className="w-12 h-12 text-default-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p className="text-default-500">暂无流量统计数据</p>
</div>
) : (
<div className="space-y-4">
{/* 流量趋势图 */}
<div className="h-64 lg:h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={processFlowChartData()}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis
dataKey="time"
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }}
/>
<YAxis
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }}
tickFormatter={(value) => {
if (value === 0) return '0';
if (value < 1024) return `${value}B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}K`;
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)}M`;
return `${(value / (1024 * 1024 * 1024)).toFixed(1)}G`;
}}
/>
<Tooltip
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3">
<p className="font-medium text-foreground">{`时间: ${label}`}</p>
<p className="text-primary">
{`流量: ${formatFlow(payload[0]?.value as number || 0)}`}
</p>
</div>
);
}
return null;
}}
/>
<Line
type="monotone"
dataKey="flow"
stroke="#8b5cf6"
strokeWidth={3}
dot={false}
activeDot={{ r: 4, stroke: '#8b5cf6', strokeWidth: 2, fill: '#fff' }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
)}
</CardBody>
</Card>
{/* 隧道权限 */} {/* 隧道权限 */}
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md"> <Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
+162 -17
View File
@@ -1,27 +1,63 @@
import { Button } from "@heroui/button"; import { Button } from "@heroui/button";
import { Input } from "@heroui/input"; import { Input } from "@heroui/input";
import { Card, CardBody, CardHeader } from "@heroui/card"; import { Card, CardBody, CardHeader } from "@heroui/card";
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { title } from "@/components/primitives"; import { title } from "@/components/primitives";
import DefaultLayout from "@/layouts/default"; import DefaultLayout from "@/layouts/default";
import { login, LoginData } from "@/api"; import { login, LoginData, checkCaptcha } from "@/api";
import "@/utils/tac.css";
import "@/utils/tac.min.js";
interface LoginForm { interface LoginForm {
username: string; username: string;
password: string; password: string;
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 {
logoUrl?: string | null;
moveTrackMaskBgColor?: string;
moveTrackMaskBorderColor?: string;
} }
export default function IndexPage() { export default function IndexPage() {
const [form, setForm] = useState<LoginForm>({ const [form, setForm] = useState<LoginForm>({
username: "", username: "",
password: "" password: "",
captchaId: "",
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Partial<LoginForm>>({}); const [errors, setErrors] = useState<Partial<LoginForm>>({});
const [showCaptcha, setShowCaptcha] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const tacInstanceRef = useRef<any>(null);
const captchaContainerRef = useRef<HTMLDivElement>(null);
// 清理验证码实例
useEffect(() => {
return () => {
if (tacInstanceRef.current) {
tacInstanceRef.current.destroyWindow();
tacInstanceRef.current = null;
}
};
}, []);
// 验证表单 // 验证表单
const validateForm = (): boolean => { const validateForm = (): boolean => {
@@ -37,6 +73,7 @@ export default function IndexPage() {
newErrors.password = '密码长度至少6位'; newErrors.password = '密码长度至少6位';
} }
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
}; };
@@ -50,21 +87,81 @@ export default function IndexPage() {
} }
}; };
const handleLogin = async () => { // 初始化验证码
if (!validateForm()) return; const initCaptcha = async () => {
if (!window.TAC || !captchaContainerRef.current) {
return;
}
try {
// 清理之前的验证码实例
if (tacInstanceRef.current) {
tacInstanceRef.current.destroyWindow();
tacInstanceRef.current = null;
}
// 获取API基础URL
const baseURL = import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1` : '/api/v1';
const config: CaptchaConfig = {
requestCaptchaDataUrl: `${baseURL}/captcha/generate`,
validCaptchaUrl: `${baseURL}/captcha/verify`,
bindEl: "#captcha-container",
validSuccess: (res: any, _: any, tac: any) => {
form.captchaId = res.data.validToken
setShowCaptcha(false);
tac.destroyWindow();
performLogin();
},
validFail: (_: any, _captcha: any, tac: any) => {
toast.error("验证码验证失败,请重试");
tac.reloadCaptcha();
},
btnCloseFun: (_event: any, tac: any) => {
setShowCaptcha(false);
tac.destroyWindow();
setLoading(false);
},
btnRefreshFun: (_event: any, tac: any) => {
tac.reloadCaptcha();
}
};
const style: CaptchaStyle = {
logoUrl: null, // 去除默认logo
moveTrackMaskBgColor: "#0298f8",
moveTrackMaskBorderColor: "#0298f8"
};
tacInstanceRef.current = new window.TAC(config, style);
tacInstanceRef.current.init();
} catch (error) {
console.error('初始化验证码失败:', error);
toast.error('验证码初始化失败,请刷新页面重试');
setShowCaptcha(false);
setLoading(false);
}
};
// 执行登录请求
const performLogin = async () => {
setLoading(true);
try { try {
const loginData: LoginData = { const loginData: LoginData = {
username: form.username.trim(), username: form.username.trim(),
password: form.password password: form.password,
captchaId: form.captchaId,
}; };
const response = await login(loginData); const response = await login(loginData);
if (response.code !== 0) { if (response.code !== 0) {
toast.error(response.msg || "登陆失败"); toast.error(response.msg || "登录失败");
return; return;
} }
@@ -73,33 +170,65 @@ export default function IndexPage() {
localStorage.setItem('token', response.data.token); localStorage.setItem('token', response.data.token);
localStorage.setItem("role_id", response.data.role_id.toString()); localStorage.setItem("role_id", response.data.role_id.toString());
localStorage.setItem("name", response.data.name); localStorage.setItem("name", response.data.name);
// 设置admin字段 - role_id为0的是管理员
localStorage.setItem("admin", (response.data.role_id === 0).toString()); localStorage.setItem("admin", (response.data.role_id === 0).toString());
toast.success('检测到默认密码,即将跳转到修改密码页面'); toast.success('检测到默认密码,即将跳转到修改密码页面');
// 直接跳转到修改密码页面
navigate("/change-password"); navigate("/change-password");
return; return;
} }
// 保存登陆信息 // 保存登录信息
localStorage.setItem('token', response.data.token); localStorage.setItem('token', response.data.token);
localStorage.setItem("role_id", response.data.role_id.toString()); localStorage.setItem("role_id", response.data.role_id.toString());
localStorage.setItem("name", response.data.name); localStorage.setItem("name", response.data.name);
// 设置admin字段 - role_id为0的是管理员
localStorage.setItem("admin", (response.data.role_id === 0).toString()); localStorage.setItem("admin", (response.data.role_id === 0).toString());
// 登陆成功 // 登录成功
toast.success('登陆成功'); toast.success('登录成功');
navigate("/dashboard"); navigate("/dashboard");
} catch (error) { } catch (error) {
console.error('登陆错误:', error); console.error('登录错误:', error);
toast.error("网络错误,请稍后重试"); toast.error("网络错误,请稍后重试");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleLogin = async () => {
if (!validateForm()) return;
setLoading(true);
try {
// 先检查是否需要验证码
const checkResponse = await checkCaptcha();
if (checkResponse.code !== 0) {
toast.error("检查验证码状态失败,请重试");
setLoading(false);
return;
}
// 根据返回值决定是否显示验证码
if (checkResponse.data === 0) {
// 不需要验证码,直接登录
await performLogin();
} else {
// 需要验证码,显示验证码弹层
setShowCaptcha(true);
// 延时初始化验证码,确保DOM已渲染
setTimeout(() => {
initCaptcha();
}, 100);
}
} catch (error) {
console.error('检查验证码状态错误:', error);
toast.error("网络错误,请稍后重试");
setLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => { const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !loading) { if (e.key === 'Enter' && !loading) {
handleLogin(); handleLogin();
@@ -139,8 +268,8 @@ export default function IndexPage() {
variant="bordered" variant="bordered"
isDisabled={loading} isDisabled={loading}
isInvalid={!!errors.password} isInvalid={!!errors.password}
errorMessage={errors.password}
/> />
<Button <Button
color="primary" color="primary"
@@ -150,12 +279,28 @@ export default function IndexPage() {
disabled={loading} disabled={loading}
className="mt-2" className="mt-2"
> >
{loading ? "登陆中..." : "登陆"} {loading ? (showCaptcha ? "验证中..." : "登录中...") : "登录"}
</Button> </Button>
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
</div> </div>
{/* 验证码弹层 */}
{showCaptcha && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 背景遮罩层 - 模糊效果 */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm captcha-backdrop-enter" />
{/* 验证码容器 */}
<div className="mb-4">
<div
id="captcha-container"
ref={captchaContainerRef}
className="w-full flex justify-center"
/>
</div>
</div>
)}
</section> </section>
</DefaultLayout> </DefaultLayout>
); );
+30
View File
@@ -30,4 +30,34 @@ html, body {
} }
/* 验证码弹层动画 */
.captcha-modal-enter {
animation: captchaModalIn 0.3s ease-out forwards;
}
@keyframes captchaModalIn {
from {
opacity: 0;
transform: scale(0.9) translateY(-20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* 验证码弹层背景渐入 */
.captcha-backdrop-enter {
animation: backdropFadeIn 0.3s ease-out forwards;
}
@keyframes backdropFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@config "../../tailwind.config.js" @config "../../tailwind.config.js"
+54
View File
@@ -0,0 +1,54 @@
// TAC验证码SDK的TypeScript类型声明
declare global {
interface Window {
TAC: typeof TAC;
currentCaptcha?: any;
currentCaptchaRes?: any;
}
}
interface CaptchaConfig {
// 生成接口 (必选项,必须配置)
requestCaptchaDataUrl: string;
// 验证接口 (必选项,必须配置)
validCaptchaUrl: string;
// 验证码绑定的div块 (必选项,必须配置)
bindEl: string;
// 验证成功回调函数(必选项,必须配置)
validSuccess: (res: any, captcha: any, tac: any) => void;
// 验证失败的回调函数(可选)
validFail?: (res: any, captcha: any, tac: any) => void;
// 请求头配置(可选)
requestHeaders?: Record<string, string>;
// 关闭按钮回调(可选)
btnCloseFun?: (event: any, tac: any) => void;
// 刷新按钮回调(可选)
btnRefreshFun?: (event: any, tac: any) => void;
// 时间转时间戳(可选)
timeToTimestamp?: boolean;
}
interface CaptchaStyle {
// 按钮样式URL(可选)
btnUrl?: string;
// 背景样式URL(可选)
bgUrl?: string;
// logo地址URL(可选)
logoUrl?: string | null;
// 滑动边框背景色(可选)
moveTrackMaskBgColor?: string;
// 滑动边框颜色(可选)
moveTrackMaskBorderColor?: string;
}
declare class TAC {
constructor(config: CaptchaConfig, style?: CaptchaStyle);
init(): TAC;
reloadCaptcha(): void;
destroyWindow(): void;
openCaptcha(): void;
}
export {};
+6
View File
@@ -0,0 +1,6 @@
#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