增加登陆验证码,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
+181 -48
View File
@@ -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>
+115
View File
@@ -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">
+162 -17
View File
@@ -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>
);