增加登陆验证码,24小时流量统计
@@ -36,7 +36,6 @@ services:
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LOG_DIR: /app/logs
|
||||
SERVER_ADDR: ${SERVER_HOST}
|
||||
JAVA_OPTS: "-Xms256m -Xmx512m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
|
||||
ports:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
|
||||
@@ -36,7 +36,6 @@ services:
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LOG_DIR: /app/logs
|
||||
SERVER_ADDR: ${SERVER_HOST}
|
||||
JAVA_OPTS: "-Xms256m -Xmx512m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
|
||||
ports:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
|
||||
@@ -21,6 +21,21 @@ SET time_zone = "+00:00";
|
||||
-- 数据库: `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`
|
||||
--
|
||||
@@ -228,6 +250,12 @@ ALTER TABLE `vite_config`
|
||||
-- 在导出的表使用AUTO_INCREMENT
|
||||
--
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `statistics_flow`
|
||||
--
|
||||
ALTER TABLE `statistics_flow`
|
||||
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `forward`
|
||||
--
|
||||
|
||||
@@ -16,6 +16,15 @@ show_menu() {
|
||||
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
|
||||
check_and_install_tcpkill() {
|
||||
# 检查 tcpkill 是否已安装
|
||||
@@ -304,6 +313,7 @@ main() {
|
||||
# 如果提供了命令行参数,直接执行安装
|
||||
if [[ -n "$SERVER_ADDR" && -n "$SECRET" ]]; then
|
||||
install_gost
|
||||
delete_self
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -315,18 +325,22 @@ main() {
|
||||
case $choice in
|
||||
1)
|
||||
install_gost
|
||||
break
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
2)
|
||||
update_gost
|
||||
break
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
3)
|
||||
uninstall_gost
|
||||
break
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
4)
|
||||
echo "👋 退出脚本"
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -5,24 +5,7 @@ set -e
|
||||
export LANG=en_US.UTF-8
|
||||
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"
|
||||
@@ -156,7 +139,7 @@ show_menu() {
|
||||
echo "1. 安装面板"
|
||||
echo "2. 更新面板"
|
||||
echo "3. 卸载面板"
|
||||
echo "4. 导出数据库备份"
|
||||
echo "4. 导出备份"
|
||||
echo "5. 退出"
|
||||
echo "==============================================="
|
||||
}
|
||||
@@ -165,23 +148,22 @@ generate_random() {
|
||||
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() {
|
||||
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
|
||||
FRONTEND_PORT=${FRONTEND_PORT:-6366}
|
||||
@@ -193,7 +175,6 @@ get_config_params() {
|
||||
DB_USER=$(generate_random)
|
||||
DB_PASSWORD=$(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_PASSWORD=$DB_PASSWORD
|
||||
JWT_SECRET=$JWT_SECRET
|
||||
SERVER_HOST=$SERVER_HOST_PORT
|
||||
FRONTEND_PORT=$FRONTEND_PORT
|
||||
BACKEND_PORT=$BACKEND_PORT
|
||||
EOF
|
||||
@@ -236,10 +216,7 @@ EOF
|
||||
$DOCKER_CMD up -d
|
||||
|
||||
echo "🎉 部署完成"
|
||||
echo "🌐 访问地址:"
|
||||
echo "前端地址: http://$SERVER_HOST:$FRONTEND_PORT"
|
||||
echo "后端地址: http://$SERVER_HOST:$BACKEND_PORT"
|
||||
echo ""
|
||||
echo "🌐 访问地址: http://服务器IP:$FRONTEND_PORT"
|
||||
echo "📖 部署完成后请阅读下使用文档,求求了啊,不要上去就是一顿操作"
|
||||
echo "📚 文档地址: https://tes.cc/guide.html"
|
||||
echo "💡 默认管理员账号: admin_user / admin_user"
|
||||
@@ -825,12 +802,17 @@ CREATE TABLE IF NOT EXISTS \`vite_config\` (
|
||||
UNIQUE KEY \`unique_name\` (\`name\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 插入默认应用名称配置(如果不存在)
|
||||
INSERT INTO \`vite_config\` (\`name\`, \`value\`, \`time\`)
|
||||
SELECT 'app_name', '哆啦A梦', 1753344708000
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM \`vite_config\` WHERE \`name\` = 'app_name'
|
||||
);
|
||||
-- 创建 statistics_flow 表(如果不存在)
|
||||
CREATE TABLE IF NOT EXISTS \`statistics_flow\` (
|
||||
\`id\` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
\`user_id\` int(10) NOT NULL,
|
||||
\`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
|
||||
@@ -1002,9 +984,7 @@ uninstall_panel() {
|
||||
|
||||
# 主逻辑
|
||||
main() {
|
||||
# 显示更新说明
|
||||
show_update_notice
|
||||
|
||||
|
||||
# 显示交互式菜单
|
||||
while true; do
|
||||
show_menu
|
||||
@@ -1013,22 +993,27 @@ main() {
|
||||
case $choice in
|
||||
1)
|
||||
install_panel
|
||||
break
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
2)
|
||||
update_panel
|
||||
break
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
3)
|
||||
uninstall_panel
|
||||
break
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
4)
|
||||
export_migration_sql
|
||||
break
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
5)
|
||||
echo "👋 退出脚本"
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -120,6 +120,13 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</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>
|
||||
<dependencyManagement>
|
||||
<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 = "密码不能为空")
|
||||
private String password;
|
||||
|
||||
private String captchaId;
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.admin.common.dto;
|
||||
|
||||
import com.admin.entity.StatisticsFlow;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
@@ -24,6 +25,11 @@ public class UserPackageDto {
|
||||
* 用户转发列表
|
||||
*/
|
||||
private List<UserForwardDetailDto> forwards;
|
||||
|
||||
/**
|
||||
* 用户转发列表
|
||||
*/
|
||||
private List<StatisticsFlow> statisticsFlows;
|
||||
|
||||
/**
|
||||
* 用户基本信息
|
||||
|
||||
@@ -48,7 +48,7 @@ public class ResetFlowAsync {
|
||||
* - 不会影响DelayQueueManager的到期任务对status等其他字段的更新
|
||||
* - 避免了并发修改导致的数据覆盖问题
|
||||
*/
|
||||
@Scheduled(cron = "0 0 0 * * ?")
|
||||
@Scheduled(cron = "5 0 0 * * ?")
|
||||
public void reset_flow(){
|
||||
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("/api/v1/open_api/**")
|
||||
.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.entity.Node;
|
||||
import com.admin.entity.Tunnel;
|
||||
import com.admin.entity.ViteConfig;
|
||||
import com.admin.mapper.NodeMapper;
|
||||
import com.admin.mapper.TunnelMapper;
|
||||
import com.admin.service.NodeService;
|
||||
import com.admin.service.TunnelService;
|
||||
import com.admin.service.ViteConfigService;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
@@ -69,8 +71,9 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
||||
@Lazy
|
||||
private TunnelService tunnelService;
|
||||
|
||||
@Value("${server-addr}")
|
||||
private String serverAddr;
|
||||
@Resource
|
||||
ViteConfigService viteConfigService;
|
||||
|
||||
|
||||
// ========== 公共接口实现 ==========
|
||||
|
||||
@@ -321,9 +324,7 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
||||
}
|
||||
|
||||
// 2. 构建安装命令
|
||||
String installCommand = buildInstallCommand(node);
|
||||
|
||||
return R.ok(installCommand);
|
||||
return buildInstallCommand(node);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -332,7 +333,10 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
||||
* @param node 节点对象
|
||||
* @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();
|
||||
|
||||
// 第一部分:下载安装脚本
|
||||
@@ -340,14 +344,14 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
||||
.append(" -o ./install.sh && chmod +x ./install.sh && ");
|
||||
|
||||
// 处理服务器地址,如果是IPv6需要添加方括号
|
||||
String processedServerAddr = processServerAddress(serverAddr);
|
||||
String processedServerAddr = processServerAddress(viteConfig.getValue());
|
||||
|
||||
// 第二部分:执行安装脚本(去掉-u参数)
|
||||
command.append("./install.sh")
|
||||
.append(" -a ").append(processedServerAddr) // 服务器地址
|
||||
.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;
|
||||
|
||||
import cloud.tianai.captcha.application.ImageCaptchaApplication;
|
||||
import cloud.tianai.captcha.spring.plugins.secondary.SecondaryVerificationApplication;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
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.JwtUtil;
|
||||
import com.admin.common.utils.Md5Util;
|
||||
import com.admin.entity.Forward;
|
||||
import com.admin.entity.Node;
|
||||
import com.admin.entity.Tunnel;
|
||||
import com.admin.entity.User;
|
||||
import com.admin.entity.UserTunnel;
|
||||
import com.admin.entity.*;
|
||||
import com.admin.mapper.ForwardMapper;
|
||||
import com.admin.mapper.UserMapper;
|
||||
import com.admin.mapper.UserTunnelMapper;
|
||||
import com.admin.service.NodeService;
|
||||
import com.admin.service.TunnelService;
|
||||
import com.admin.service.UserService;
|
||||
import com.admin.service.UserTunnelService;
|
||||
import com.admin.service.*;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* <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_CURRENT_PASSWORD_WRONG = "当前密码错误";
|
||||
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_PASSWORD = "admin_user";
|
||||
@@ -112,28 +109,48 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
@Resource
|
||||
UserTunnelService userTunnelService;
|
||||
|
||||
@Resource
|
||||
ViteConfigService viteConfigService;
|
||||
|
||||
@Resource
|
||||
StatisticsFlowService statisticsFlowService;
|
||||
|
||||
@Resource
|
||||
private ImageCaptchaApplication application;
|
||||
|
||||
// ========== 公共接口实现 ==========
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* 验证用户名密码,检查账户状态,生成JWT令牌
|
||||
* 验证验证码、用户名密码,检查账户状态,生成JWT令牌
|
||||
*
|
||||
* @param loginDto 登录数据传输对象
|
||||
* @return 登录结果响应,包含令牌和用户信息
|
||||
*/
|
||||
@Override
|
||||
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);
|
||||
if (validationResult.isHasError()) {
|
||||
return R.err(validationResult.getErrorMessage());
|
||||
}
|
||||
|
||||
// 2. 生成令牌并返回用户信息
|
||||
// 3. 生成令牌并返回用户信息
|
||||
User user = validationResult.getUser();
|
||||
String token = JwtUtil.generateToken(user);
|
||||
|
||||
// 3. 检查是否使用默认账号密码
|
||||
// 4. 检查是否使用默认账号密码
|
||||
boolean requirePasswordChange = isDefaultCredentials(loginDto.getUsername(), loginDto.getPassword());
|
||||
|
||||
return R.ok(MapUtil.builder()
|
||||
@@ -238,6 +255,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
try {
|
||||
// 2. 级联删除用户相关数据
|
||||
deleteUserRelatedData(id);
|
||||
statisticsFlowService.remove(new QueryWrapper<StatisticsFlow>().eq("user_id", id));
|
||||
// 3. 删除用户
|
||||
boolean result = this.removeById(id);
|
||||
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. 获取转发详情
|
||||
List<UserPackageDto.UserForwardDetailDto> forwards = userMapper.getUserForwardDetails(user.getId().intValue());
|
||||
|
||||
// 4. 查询最近24小时流量信息,没有的补0
|
||||
List<StatisticsFlow> statisticsFlows = getLast24HoursFlowStatistics(user.getId());
|
||||
|
||||
// 4. 构造返回结果
|
||||
// 5. 构造返回结果
|
||||
UserPackageDto packageDto = new UserPackageDto();
|
||||
packageDto.setUserInfo(userInfo);
|
||||
packageDto.setTunnelPermissions(tunnelPermissions);
|
||||
packageDto.setForwards(forwards);
|
||||
packageDto.setStatisticsFlows(statisticsFlows);
|
||||
|
||||
return packageDto;
|
||||
}
|
||||
@@ -697,6 +720,104 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
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}
|
||||
log-dir: ${LOG_DIR}
|
||||
server-addr: ${SERVER_ADDR}
|
||||
|
||||
# 验证码配置
|
||||
captcha:
|
||||
init-default-resource: true
|
||||
secondary:
|
||||
enabled: true
|
||||
expire: 120000
|
||||
|
||||
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
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>
|
||||
@@ -4,6 +4,7 @@ import Network from './network';
|
||||
export interface LoginData {
|
||||
username: string;
|
||||
password: string;
|
||||
captchaId: string;
|
||||
}
|
||||
|
||||
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 getConfigByName = (name: string) => Network.post("/config/get", { name });
|
||||
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);
|
||||
@@ -4,7 +4,7 @@ export type SiteConfig = typeof siteConfig;
|
||||
|
||||
// 缓存相关常量
|
||||
const CACHE_PREFIX = 'vite_config_';
|
||||
const VERSION = "1.2.6";
|
||||
const VERSION = "1.2.8";
|
||||
|
||||
|
||||
const getInitialConfig = () => {
|
||||
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 42 B |
@@ -4,6 +4,8 @@ import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import { Divider } from "@heroui/divider";
|
||||
import { Switch } from "@heroui/switch";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import toast from 'react-hot-toast';
|
||||
import { updateConfigs } from '@/api';
|
||||
import { SettingsIcon } from '@/components/icons';
|
||||
@@ -31,17 +33,70 @@ const SaveIcon = ({ className }: { className?: string }) => (
|
||||
interface ConfigItem {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
type: 'input' | 'switch' | 'select';
|
||||
options?: { label: string; value: string; description?: string }[];
|
||||
dependsOn?: string; // 依赖的配置项key
|
||||
dependsValue?: string; // 依赖的配置项值
|
||||
}
|
||||
|
||||
// 网站配置项定义
|
||||
const CONFIG_ITEMS: ConfigItem[] = [
|
||||
{
|
||||
key: 'ip',
|
||||
label: '面板后端地址',
|
||||
placeholder: '请输入面板后端IP:PORT',
|
||||
description: '格式“ip:port”,用于对接节点时使用,ip是你安装面板服务器的公网ip,端口是安装脚本内输入的后端端口。不要套CDN,不支持https,通讯数据有加密',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'app_name',
|
||||
label: '应用名称',
|
||||
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> => {
|
||||
if (typeof window === 'undefined') return {};
|
||||
|
||||
const configKeys = ['app_name'];
|
||||
const configKeys = ['app_name', 'captcha_enabled', 'captcha_type'];
|
||||
const initialConfigs: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
@@ -124,7 +179,15 @@ export default function ConfigPage() {
|
||||
|
||||
// 处理配置项变更
|
||||
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);
|
||||
|
||||
// 检查是否有变更
|
||||
@@ -174,11 +237,88 @@ export default function ConfigPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 重置配置
|
||||
const handleReset = () => {
|
||||
setConfigs({ ...originalConfigs });
|
||||
setHasChanges(false);
|
||||
toast.success('已重置为保存前的状态');
|
||||
|
||||
|
||||
// 检查配置项是否应该显示(依赖检查)
|
||||
const shouldShowItem = (item: ConfigItem): boolean => {
|
||||
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) {
|
||||
@@ -215,17 +355,7 @@ export default function ConfigPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{hasChanges && (
|
||||
<Button
|
||||
color="default"
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
startContent={<SaveIcon className="w-4 h-4" />}
|
||||
@@ -242,36 +372,39 @@ export default function ConfigPage() {
|
||||
<Divider />
|
||||
|
||||
<CardBody className="space-y-6 pt-6">
|
||||
{CONFIG_ITEMS.map((item, index) => (
|
||||
<div key={item.key} className="space-y-2">
|
||||
<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>
|
||||
{CONFIG_ITEMS.map((item, index) => {
|
||||
// 检查配置项是否应该显示
|
||||
if (!shouldShowItem(item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算是否是最后一个显示的项目(用于决定是否显示分隔线)
|
||||
const remainingItems = CONFIG_ITEMS.slice(index + 1).filter(shouldShowItem);
|
||||
const isLastItem = remainingItems.length === 0;
|
||||
|
||||
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>
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from "@heroui/button";
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody } from "@heroui/modal";
|
||||
import { useState, useEffect } from "react";
|
||||
import toast from 'react-hot-toast';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
import { getUserPackageInfo } from "@/api";
|
||||
@@ -48,11 +49,20 @@ interface AddressItem {
|
||||
copying: boolean;
|
||||
}
|
||||
|
||||
interface StatisticsFlow {
|
||||
id: number;
|
||||
userId: number;
|
||||
flow: number;
|
||||
totalFlow: number;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userInfo, setUserInfo] = useState<UserInfo>({} as UserInfo);
|
||||
const [userTunnels, setUserTunnels] = useState<UserTunnel[]>([]);
|
||||
const [forwardList, setForwardList] = useState<Forward[]>([]);
|
||||
const [statisticsFlows, setStatisticsFlows] = useState<StatisticsFlow[]>([]);
|
||||
|
||||
const [addressModalOpen, setAddressModalOpen] = useState(false);
|
||||
const [addressModalTitle, setAddressModalTitle] = useState('');
|
||||
@@ -154,6 +164,7 @@ export default function DashboardPage() {
|
||||
setUserInfo({} as UserInfo);
|
||||
setUserTunnels([]);
|
||||
setForwardList([]);
|
||||
setStatisticsFlows([]);
|
||||
|
||||
loadPackageData();
|
||||
localStorage.setItem('e', '/dashboard');
|
||||
@@ -168,6 +179,7 @@ export default function DashboardPage() {
|
||||
setUserInfo(data.userInfo || {});
|
||||
setUserTunnels(data.tunnelPermissions || []);
|
||||
setForwardList(data.forwards || []);
|
||||
setStatisticsFlows(data.statisticsFlows || []);
|
||||
|
||||
// 检查有效期并显示通知
|
||||
checkExpirationNotifications(data.userInfo, data.tunnelPermissions || []);
|
||||
@@ -207,6 +219,33 @@ export default function DashboardPage() {
|
||||
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) => {
|
||||
if (!expTime) return {
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
@@ -636,6 +675,82 @@ export default function DashboardPage() {
|
||||
</Card>
|
||||
</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">
|
||||
<CardHeader className="pb-3">
|
||||
|
||||
@@ -1,27 +1,63 @@
|
||||
import { Button } from "@heroui/button";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { title } from "@/components/primitives";
|
||||
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 {
|
||||
username: 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() {
|
||||
const [form, setForm] = useState<LoginForm>({
|
||||
username: "",
|
||||
password: ""
|
||||
password: "",
|
||||
captchaId: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Partial<LoginForm>>({});
|
||||
const [showCaptcha, setShowCaptcha] = useState(false);
|
||||
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 => {
|
||||
@@ -37,6 +73,7 @@ export default function IndexPage() {
|
||||
newErrors.password = '密码长度至少6位';
|
||||
}
|
||||
|
||||
|
||||
setErrors(newErrors);
|
||||
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 {
|
||||
const loginData: LoginData = {
|
||||
username: form.username.trim(),
|
||||
password: form.password
|
||||
password: form.password,
|
||||
captchaId: form.captchaId,
|
||||
};
|
||||
|
||||
const response = await login(loginData);
|
||||
|
||||
if (response.code !== 0) {
|
||||
toast.error(response.msg || "登陆失败");
|
||||
toast.error(response.msg || "登录失败");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,33 +170,65 @@ export default function IndexPage() {
|
||||
localStorage.setItem('token', response.data.token);
|
||||
localStorage.setItem("role_id", response.data.role_id.toString());
|
||||
localStorage.setItem("name", response.data.name);
|
||||
// 设置admin字段 - role_id为0的是管理员
|
||||
localStorage.setItem("admin", (response.data.role_id === 0).toString());
|
||||
toast.success('检测到默认密码,即将跳转到修改密码页面');
|
||||
// 直接跳转到修改密码页面
|
||||
navigate("/change-password");
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存登陆信息
|
||||
// 保存登录信息
|
||||
localStorage.setItem('token', response.data.token);
|
||||
localStorage.setItem("role_id", response.data.role_id.toString());
|
||||
localStorage.setItem("name", response.data.name);
|
||||
// 设置admin字段 - role_id为0的是管理员
|
||||
localStorage.setItem("admin", (response.data.role_id === 0).toString());
|
||||
|
||||
// 登陆成功
|
||||
toast.success('登陆成功');
|
||||
// 登录成功
|
||||
toast.success('登录成功');
|
||||
navigate("/dashboard");
|
||||
|
||||
} catch (error) {
|
||||
console.error('登陆错误:', error);
|
||||
console.error('登录错误:', error);
|
||||
toast.error("网络错误,请稍后重试");
|
||||
} finally {
|
||||
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) => {
|
||||
if (e.key === 'Enter' && !loading) {
|
||||
handleLogin();
|
||||
@@ -139,8 +268,8 @@ export default function IndexPage() {
|
||||
variant="bordered"
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.password}
|
||||
errorMessage={errors.password}
|
||||
/>
|
||||
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
@@ -150,12 +279,28 @@ export default function IndexPage() {
|
||||
disabled={loading}
|
||||
className="mt-2"
|
||||
>
|
||||
{loading ? "登陆中..." : "登陆"}
|
||||
{loading ? (showCaptcha ? "验证中..." : "登录中...") : "登录"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</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>
|
||||
</DefaultLayout>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
@@ -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 {};
|
||||
@@ -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}
|
||||