mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-06 18:06:36 +08:00
style: 美化用户隧道权限分配表单UI并统一代码格式
- 使用HeroUI Checkbox组件替换原生checkbox - 重构隧道列表为card-based tile风格 - 添加选中/未选中/已分配状态视觉区分 - 应用eslint --fix代码格式统一 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -3,7 +3,7 @@ import { Input } from "@heroui/input";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from 'react-hot-toast';
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { title } from "@/components/primitives";
|
||||
import { updatePassword } from "@/api";
|
||||
@@ -19,10 +19,10 @@ interface PasswordForm {
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const [form, setForm] = useState<PasswordForm>({
|
||||
newUsername: '',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
newUsername: "",
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Partial<PasswordForm>>({});
|
||||
@@ -32,39 +32,40 @@ export default function ChangePasswordPage() {
|
||||
const newErrors: Partial<PasswordForm> = {};
|
||||
|
||||
if (!form.newUsername.trim()) {
|
||||
newErrors.newUsername = '请输入新用户名';
|
||||
newErrors.newUsername = "请输入新用户名";
|
||||
} else if (form.newUsername.length < 3) {
|
||||
newErrors.newUsername = '用户名长度至少3位';
|
||||
newErrors.newUsername = "用户名长度至少3位";
|
||||
} else if (form.newUsername.length > 20) {
|
||||
newErrors.newUsername = '用户名长度不能超过20位';
|
||||
newErrors.newUsername = "用户名长度不能超过20位";
|
||||
}
|
||||
|
||||
if (!form.currentPassword.trim()) {
|
||||
newErrors.currentPassword = '请输入当前密码';
|
||||
newErrors.currentPassword = "请输入当前密码";
|
||||
}
|
||||
|
||||
if (!form.newPassword.trim()) {
|
||||
newErrors.newPassword = '请输入新密码';
|
||||
newErrors.newPassword = "请输入新密码";
|
||||
} else if (form.newPassword.length < 6) {
|
||||
newErrors.newPassword = '新密码长度不能少于6位';
|
||||
newErrors.newPassword = "新密码长度不能少于6位";
|
||||
} else if (form.newPassword.length > 20) {
|
||||
newErrors.newPassword = '新密码长度不能超过20位';
|
||||
newErrors.newPassword = "新密码长度不能超过20位";
|
||||
}
|
||||
|
||||
if (!form.confirmPassword.trim()) {
|
||||
newErrors.confirmPassword = '请再次输入新密码';
|
||||
newErrors.confirmPassword = "请再次输入新密码";
|
||||
} else if (form.confirmPassword !== form.newPassword) {
|
||||
newErrors.confirmPassword = '两次输入密码不一致';
|
||||
newErrors.confirmPassword = "两次输入密码不一致";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof PasswordForm, value: string) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,23 +75,23 @@ export default function ChangePasswordPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await updatePassword(form);
|
||||
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success(response.msg || '账号密码修改成功');
|
||||
|
||||
toast.success(response.msg || "账号密码修改成功");
|
||||
|
||||
// 使用 toast 确认对话框的替代方案
|
||||
setTimeout(() => {
|
||||
toast.success('即将跳转到登陆页面,请重新登录');
|
||||
toast.success("即将跳转到登陆页面,请重新登录");
|
||||
setTimeout(() => {
|
||||
logout();
|
||||
}, 1000);
|
||||
}, 1000);
|
||||
} else {
|
||||
toast.error(response.msg || '账号密码修改失败');
|
||||
toast.error(response.msg || "账号密码修改失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('修改账号密码错误:', error);
|
||||
toast.error('修改账号密码时发生错误');
|
||||
console.error("修改账号密码错误:", error);
|
||||
toast.error("修改账号密码时发生错误");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -98,11 +99,11 @@ export default function ChangePasswordPage() {
|
||||
|
||||
const logout = () => {
|
||||
safeLogout();
|
||||
navigate('/');
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !loading) {
|
||||
if (e.key === "Enter" && !loading) {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
@@ -114,79 +115,97 @@ export default function ChangePasswordPage() {
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
|
||||
<div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center mb-3">
|
||||
<svg className="w-6 h-6 text-warning-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
<svg
|
||||
className="w-6 h-6 text-warning-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
|
||||
<h1 className={title({ size: "sm" })}>安全提醒</h1>
|
||||
<p className="text-small text-default-500 mt-2 text-center">检测到您使用的是默认账号密码,为了您的账户安全,请立即修改</p>
|
||||
<p className="text-small text-default-500 mt-2 text-center">
|
||||
检测到您使用的是默认账号密码,为了您的账户安全,请立即修改
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
<CardBody className="px-6 py-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
errorMessage={errors.newUsername}
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.newUsername}
|
||||
label="新用户名"
|
||||
placeholder="请输入新用户名(至少3位)"
|
||||
value={form.newUsername}
|
||||
onChange={(e) => handleInputChange('newUsername', e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
variant="bordered"
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.newUsername}
|
||||
errorMessage={errors.newUsername}
|
||||
onChange={(e) =>
|
||||
handleInputChange("newUsername", e.target.value)
|
||||
}
|
||||
onKeyDown={handleKeyPress}
|
||||
/>
|
||||
|
||||
|
||||
<Input
|
||||
errorMessage={errors.currentPassword}
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.currentPassword}
|
||||
label="当前密码"
|
||||
placeholder="请输入当前密码"
|
||||
type="password"
|
||||
value={form.currentPassword}
|
||||
onChange={(e) => handleInputChange('currentPassword', e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
variant="bordered"
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.currentPassword}
|
||||
errorMessage={errors.currentPassword}
|
||||
onChange={(e) =>
|
||||
handleInputChange("currentPassword", e.target.value)
|
||||
}
|
||||
onKeyDown={handleKeyPress}
|
||||
/>
|
||||
|
||||
|
||||
<Input
|
||||
errorMessage={errors.newPassword}
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.newPassword}
|
||||
label="新密码"
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
type="password"
|
||||
value={form.newPassword}
|
||||
onChange={(e) => handleInputChange('newPassword', e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
variant="bordered"
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.newPassword}
|
||||
errorMessage={errors.newPassword}
|
||||
onChange={(e) =>
|
||||
handleInputChange("newPassword", e.target.value)
|
||||
}
|
||||
onKeyDown={handleKeyPress}
|
||||
/>
|
||||
|
||||
|
||||
<Input
|
||||
errorMessage={errors.confirmPassword}
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.confirmPassword}
|
||||
label="确认新密码"
|
||||
placeholder="请再次输入新密码"
|
||||
type="password"
|
||||
value={form.confirmPassword}
|
||||
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
variant="bordered"
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.confirmPassword}
|
||||
errorMessage={errors.confirmPassword}
|
||||
onChange={(e) =>
|
||||
handleInputChange("confirmPassword", e.target.value)
|
||||
}
|
||||
onKeyDown={handleKeyPress}
|
||||
/>
|
||||
|
||||
|
||||
<Button
|
||||
className="mt-2"
|
||||
color="warning"
|
||||
disabled={loading}
|
||||
isLoading={loading}
|
||||
size="lg"
|
||||
onClick={handleSubmit}
|
||||
isLoading={loading}
|
||||
disabled={loading}
|
||||
className="mt-2"
|
||||
>
|
||||
{loading ? "修改中..." : "立即修改账号密码"}
|
||||
</Button>
|
||||
|
||||
|
||||
<div className="bg-warning-50 border border-warning-200 text-warning-700 px-3 py-2 rounded-lg text-sm text-center">
|
||||
⚠️ 注意:修改账号密码后需要重新登录
|
||||
</div>
|
||||
@@ -197,4 +216,4 @@ export default function ChangePasswordPage() {
|
||||
</section>
|
||||
</DefaultLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+227
-214
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Input } from "@heroui/input";
|
||||
@@ -7,23 +7,27 @@ 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';
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { isAdmin } from '@/utils/auth';
|
||||
import { getCachedConfigs, clearConfigCache, updateSiteConfig } from '@/config/site';
|
||||
import { updateConfigs } from "@/api";
|
||||
import { SettingsIcon } from "@/components/icons";
|
||||
import { isAdmin } from "@/utils/auth";
|
||||
import {
|
||||
getCachedConfigs,
|
||||
clearConfigCache,
|
||||
updateSiteConfig,
|
||||
} from "@/config/site";
|
||||
|
||||
// 简单的保存图标组件
|
||||
const SaveIcon = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||
<polyline points="17,21 17,13 7,13 7,21" />
|
||||
@@ -36,7 +40,7 @@ interface ConfigItem {
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
type: 'input' | 'switch' | 'select';
|
||||
type: "input" | "switch" | "select";
|
||||
options?: { label: string; value: string; description?: string }[];
|
||||
dependsOn?: string; // 依赖的配置项key
|
||||
dependsValue?: string; // 依赖的配置项值
|
||||
@@ -45,96 +49,102 @@ interface ConfigItem {
|
||||
// 网站配置项定义
|
||||
const CONFIG_ITEMS: ConfigItem[] = [
|
||||
{
|
||||
key: 'ip',
|
||||
label: '面板后端地址',
|
||||
placeholder: '请输入面板后端IP:PORT',
|
||||
description: '格式“ip:port”,用于对接节点时使用,ip是你安装面板服务器的公网ip,端口是安装脚本内输入的后端端口。不要套CDN,不支持https,通讯数据有加密',
|
||||
type: 'input'
|
||||
key: "ip",
|
||||
label: "面板后端地址",
|
||||
placeholder: "请输入面板后端IP:PORT",
|
||||
description:
|
||||
"格式“ip:port”,用于对接节点时使用,ip是你安装面板服务器的公网ip,端口是安装脚本内输入的后端端口。不要套CDN,不支持https,通讯数据有加密",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
key: 'app_name',
|
||||
label: '应用名称',
|
||||
placeholder: '请输入应用名称',
|
||||
description: '在浏览器标签页和导航栏显示的应用名称',
|
||||
type: 'input'
|
||||
key: "app_name",
|
||||
label: "应用名称",
|
||||
placeholder: "请输入应用名称",
|
||||
description: "在浏览器标签页和导航栏显示的应用名称",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
key: 'captcha_enabled',
|
||||
label: '启用验证码',
|
||||
description: '开启后,用户登录时需要完成验证码验证',
|
||||
type: 'switch'
|
||||
key: "captcha_enabled",
|
||||
label: "启用验证码",
|
||||
description: "开启后,用户登录时需要完成验证码验证",
|
||||
type: "switch",
|
||||
},
|
||||
{
|
||||
key: 'captcha_type',
|
||||
label: '验证码类型',
|
||||
description: '选择验证码的显示类型,不同类型有不同的安全级别',
|
||||
type: 'select',
|
||||
dependsOn: 'captcha_enabled',
|
||||
dependsValue: 'true',
|
||||
key: "captcha_type",
|
||||
label: "验证码类型",
|
||||
description: "选择验证码的显示类型,不同类型有不同的安全级别",
|
||||
type: "select",
|
||||
dependsOn: "captcha_enabled",
|
||||
dependsValue: "true",
|
||||
options: [
|
||||
{
|
||||
label: '随机类型',
|
||||
value: 'RANDOM',
|
||||
description: '系统随机选择验证码类型'
|
||||
{
|
||||
label: "随机类型",
|
||||
value: "RANDOM",
|
||||
description: "系统随机选择验证码类型",
|
||||
},
|
||||
{
|
||||
label: '滑块验证码',
|
||||
value: 'SLIDER',
|
||||
description: '拖动滑块完成拼图验证'
|
||||
{
|
||||
label: "滑块验证码",
|
||||
value: "SLIDER",
|
||||
description: "拖动滑块完成拼图验证",
|
||||
},
|
||||
{
|
||||
label: '文字点选验证码',
|
||||
value: 'WORD_IMAGE_CLICK',
|
||||
description: '按顺序点击指定文字'
|
||||
{
|
||||
label: "文字点选验证码",
|
||||
value: "WORD_IMAGE_CLICK",
|
||||
description: "按顺序点击指定文字",
|
||||
},
|
||||
{
|
||||
label: '旋转验证码',
|
||||
value: 'ROTATE',
|
||||
description: '旋转图片到正确角度'
|
||||
{
|
||||
label: "旋转验证码",
|
||||
value: "ROTATE",
|
||||
description: "旋转图片到正确角度",
|
||||
},
|
||||
{
|
||||
label: '拼图验证码',
|
||||
value: 'CONCAT',
|
||||
description: '拖动滑块完成图片拼接'
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
label: "拼图验证码",
|
||||
value: "CONCAT",
|
||||
description: "拖动滑块完成图片拼接",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 初始化时从缓存读取配置,避免闪烁
|
||||
const getInitialConfigs = (): Record<string, string> => {
|
||||
if (typeof window === 'undefined') return {};
|
||||
|
||||
const configKeys = ['app_name', 'captcha_enabled', 'captcha_type', 'ip'];
|
||||
if (typeof window === "undefined") return {};
|
||||
|
||||
const configKeys = ["app_name", "captcha_enabled", "captcha_type", "ip"];
|
||||
const initialConfigs: Record<string, string> = {};
|
||||
|
||||
|
||||
try {
|
||||
configKeys.forEach(key => {
|
||||
const cachedValue = localStorage.getItem('vite_config_' + key);
|
||||
configKeys.forEach((key) => {
|
||||
const cachedValue = localStorage.getItem("vite_config_" + key);
|
||||
|
||||
if (cachedValue) {
|
||||
initialConfigs[key] = cachedValue;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
}
|
||||
|
||||
} catch (error) {}
|
||||
|
||||
return initialConfigs;
|
||||
};
|
||||
|
||||
export default function ConfigPage() {
|
||||
const navigate = useNavigate();
|
||||
const initialConfigs = getInitialConfigs();
|
||||
const [configs, setConfigs] = useState<Record<string, string>>(initialConfigs);
|
||||
const [loading, setLoading] = useState(Object.keys(initialConfigs).length === 0); // 如果有缓存数据,不显示loading
|
||||
const [configs, setConfigs] =
|
||||
useState<Record<string, string>>(initialConfigs);
|
||||
const [loading, setLoading] = useState(
|
||||
Object.keys(initialConfigs).length === 0,
|
||||
); // 如果有缓存数据,不显示loading
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [originalConfigs, setOriginalConfigs] = useState<Record<string, string>>(initialConfigs);
|
||||
const [originalConfigs, setOriginalConfigs] =
|
||||
useState<Record<string, string>>(initialConfigs);
|
||||
|
||||
// 权限检查
|
||||
useEffect(() => {
|
||||
if (!isAdmin()) {
|
||||
toast.error('权限不足,只有管理员可以访问此页面');
|
||||
navigate('/dashboard', { replace: true });
|
||||
toast.error("权限不足,只有管理员可以访问此页面");
|
||||
navigate("/dashboard", { replace: true });
|
||||
|
||||
return;
|
||||
}
|
||||
}, [navigate]);
|
||||
@@ -143,17 +153,19 @@ export default function ConfigPage() {
|
||||
const loadConfigs = async (currentConfigs?: Record<string, string>) => {
|
||||
const configsToCompare = currentConfigs || configs;
|
||||
const hasInitialData = Object.keys(configsToCompare).length > 0;
|
||||
|
||||
|
||||
// 如果已有缓存数据,不显示loading,静默更新
|
||||
if (!hasInitialData) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const configData = await getCachedConfigs();
|
||||
|
||||
|
||||
// 只有在数据有变化时才更新
|
||||
const hasDataChanged = JSON.stringify(configData) !== JSON.stringify(configsToCompare);
|
||||
const hasDataChanged =
|
||||
JSON.stringify(configData) !== JSON.stringify(configsToCompare);
|
||||
|
||||
if (hasDataChanged) {
|
||||
setConfigs(configData);
|
||||
setOriginalConfigs({ ...configData });
|
||||
@@ -163,7 +175,7 @@ export default function ConfigPage() {
|
||||
} catch (error) {
|
||||
// 只有在没有缓存数据时才显示错误
|
||||
if (!hasInitialData) {
|
||||
toast.error('加载配置出错,请重试');
|
||||
toast.error("加载配置出错,请重试");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -182,22 +194,25 @@ export default function ConfigPage() {
|
||||
// 处理配置项变更
|
||||
const handleConfigChange = (key: string, value: string) => {
|
||||
let newConfigs = { ...configs, [key]: value };
|
||||
|
||||
|
||||
// 特殊处理:启用验证码时,如果验证码类型未设置,默认为随机
|
||||
if (key === 'captcha_enabled' && value === 'true') {
|
||||
if (key === "captcha_enabled" && value === "true") {
|
||||
if (!newConfigs.captcha_type) {
|
||||
newConfigs.captcha_type = 'RANDOM';
|
||||
newConfigs.captcha_type = "RANDOM";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setConfigs(newConfigs);
|
||||
|
||||
|
||||
// 检查是否有变更
|
||||
const hasChangesNow = Object.keys(newConfigs).some(
|
||||
k => newConfigs[k] !== originalConfigs[k]
|
||||
) || Object.keys(originalConfigs).some(
|
||||
k => originalConfigs[k] !== newConfigs[k]
|
||||
);
|
||||
const hasChangesNow =
|
||||
Object.keys(newConfigs).some(
|
||||
(k) => newConfigs[k] !== originalConfigs[k],
|
||||
) ||
|
||||
Object.keys(originalConfigs).some(
|
||||
(k) => originalConfigs[k] !== newConfigs[k],
|
||||
);
|
||||
|
||||
setHasChanges(hasChangesNow);
|
||||
};
|
||||
|
||||
@@ -206,112 +221,115 @@ export default function ConfigPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await updateConfigs(configs);
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success('配置保存成功');
|
||||
|
||||
toast.success("配置保存成功");
|
||||
|
||||
// 清除所有配置缓存,强制下次重新获取
|
||||
clearConfigCache();
|
||||
|
||||
|
||||
// 获取变更的配置项
|
||||
const changedKeys = Object.keys(configs).filter(
|
||||
key => configs[key] !== originalConfigs[key]
|
||||
(key) => configs[key] !== originalConfigs[key],
|
||||
);
|
||||
|
||||
|
||||
setOriginalConfigs({ ...configs });
|
||||
setHasChanges(false);
|
||||
|
||||
|
||||
// 如果应用名称发生变化,立即更新网站配置
|
||||
if (changedKeys.includes('app_name')) {
|
||||
if (changedKeys.includes("app_name")) {
|
||||
await updateSiteConfig();
|
||||
}
|
||||
|
||||
|
||||
// 触发配置更新事件,通知其他组件
|
||||
window.dispatchEvent(new CustomEvent('configUpdated', {
|
||||
detail: { changedKeys }
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("configUpdated", {
|
||||
detail: { changedKeys },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error('保存配置失败: ' + response.msg);
|
||||
toast.error("保存配置失败: " + response.msg);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('保存配置出错,请重试');
|
||||
toast.error("保存配置出错,请重试");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 检查配置项是否应该显示(依赖检查)
|
||||
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];
|
||||
|
||||
const isChanged =
|
||||
hasChanges && configs[item.key] !== originalConfigs[item.key];
|
||||
|
||||
switch (item.type) {
|
||||
case 'input':
|
||||
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"
|
||||
: ""
|
||||
inputWrapper: isChanged
|
||||
? "border-warning-300 data-[hover=true]:border-warning-400"
|
||||
: "",
|
||||
}}
|
||||
placeholder={item.placeholder}
|
||||
size="md"
|
||||
value={configs[item.key] || ""}
|
||||
variant="bordered"
|
||||
onChange={(e) => handleConfigChange(item.key, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'switch':
|
||||
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" : ""
|
||||
wrapper: isChanged ? "border-warning-300" : "",
|
||||
}}
|
||||
color="primary"
|
||||
isSelected={configs[item.key] === "true"}
|
||||
size="md"
|
||||
onValueChange={(checked) =>
|
||||
handleConfigChange(item.key, checked ? "true" : "false")
|
||||
}
|
||||
>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{configs[item.key] === 'true' ? '已启用' : '已禁用'}
|
||||
{configs[item.key] === "true" ? "已启用" : "已禁用"}
|
||||
</span>
|
||||
</Switch>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
classNames={{
|
||||
trigger: isChanged
|
||||
? "border-warning-300 data-[hover=true]:border-warning-400"
|
||||
: "",
|
||||
}}
|
||||
placeholder="请选择验证码类型"
|
||||
selectedKeys={configs[item.key] ? [configs[item.key]] : []}
|
||||
size="md"
|
||||
variant="bordered"
|
||||
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}
|
||||
>
|
||||
<SelectItem key={option.value} description={option.description}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
)) || []}
|
||||
@@ -325,105 +343,100 @@ export default function ConfigPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Spinner size="lg" label="加载配置中..." />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Spinner label="加载配置中..." size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<SettingsIcon className="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">网站配置</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
管理网站的基本信息和显示设置
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<SettingsIcon className="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">网站配置</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
管理网站的基本信息和显示设置
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-md">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">基本设置</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
配置网站的基本信息,这些设置会影响网站的显示效果
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
startContent={<SaveIcon className="w-4 h-4" />}
|
||||
onClick={handleSave}
|
||||
isLoading={saving}
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
{saving ? '保存中...' : '保存配置'}
|
||||
</Button>
|
||||
</div>
|
||||
<Card className="shadow-md">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">基本设置</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
配置网站的基本信息,这些设置会影响网站的显示效果
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={!hasChanges}
|
||||
isLoading={saving}
|
||||
startContent={<SaveIcon className="w-4 h-4" />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{saving ? "保存中..." : "保存配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<Divider />
|
||||
<Divider />
|
||||
|
||||
<CardBody className="space-y-6 pt-6">
|
||||
{CONFIG_ITEMS.map((item, index) => {
|
||||
// 检查配置项是否应该显示
|
||||
if (!shouldShowItem(item)) {
|
||||
return null;
|
||||
}
|
||||
<CardBody className="space-y-6 pt-6">
|
||||
{CONFIG_ITEMS.map((item, index) => {
|
||||
// 检查配置项是否应该显示
|
||||
if (!shouldShowItem(item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算是否是最后一个显示的项目(用于决定是否显示分隔线)
|
||||
const remainingItems = CONFIG_ITEMS.slice(index + 1).filter(shouldShowItem);
|
||||
const isLastItem = remainingItems.length === 0;
|
||||
// 计算是否是最后一个显示的项目(用于决定是否显示分隔线)
|
||||
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" />
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 操作提示 */}
|
||||
{hasChanges && (
|
||||
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
|
||||
<CardBody className="py-3">
|
||||
<div className="flex items-center gap-2 text-warning-700 dark:text-warning-300">
|
||||
<div className="w-2 h-2 bg-warning-500 rounded-full animate-pulse" />
|
||||
<span className="text-sm">
|
||||
检测到配置变更,请记得保存您的修改
|
||||
</span>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 操作提示 */}
|
||||
{hasChanges && (
|
||||
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
|
||||
<CardBody className="py-3">
|
||||
<div className="flex items-center gap-2 text-warning-700 dark:text-warning-300">
|
||||
<div className="w-2 h-2 bg-warning-500 rounded-full animate-pulse" />
|
||||
<span className="text-sm">
|
||||
检测到配置变更,请记得保存您的修改
|
||||
</span>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1809
-1325
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,11 @@ import { Input } from "@heroui/input";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from 'react-hot-toast';
|
||||
import axios from 'axios';
|
||||
import { isWebViewFunc } from '@/utils/panel';
|
||||
import { siteConfig } from '@/config/site';
|
||||
import toast from "react-hot-toast";
|
||||
import axios from "axios";
|
||||
|
||||
import { isWebViewFunc } from "@/utils/panel";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { title } from "@/components/primitives";
|
||||
import DefaultLayout from "@/layouts/default";
|
||||
import { login, LoginData, checkCaptcha } from "@/api";
|
||||
@@ -14,15 +15,12 @@ import "@/utils/tac.css";
|
||||
import "@/utils/tac.min.js";
|
||||
import bgImage from "@/images/bg.jpg";
|
||||
|
||||
|
||||
interface LoginForm {
|
||||
username: string;
|
||||
password: string;
|
||||
captchaId: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface CaptchaConfig {
|
||||
requestCaptchaDataUrl: string;
|
||||
validCaptchaUrl: string;
|
||||
@@ -54,6 +52,7 @@ export default function IndexPage() {
|
||||
const tacInstanceRef = useRef<any>(null);
|
||||
const captchaContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isWebView, setIsWebView] = useState(false);
|
||||
|
||||
// 清理验证码实例
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -72,26 +71,26 @@ export default function IndexPage() {
|
||||
const newErrors: Partial<LoginForm> = {};
|
||||
|
||||
if (!form.username.trim()) {
|
||||
newErrors.username = '请输入用户名';
|
||||
newErrors.username = "请输入用户名";
|
||||
}
|
||||
|
||||
if (!form.password.trim()) {
|
||||
newErrors.password = '请输入密码';
|
||||
newErrors.password = "请输入密码";
|
||||
} else if (form.password.length < 6) {
|
||||
newErrors.password = '密码长度至少6位';
|
||||
newErrors.password = "密码长度至少6位";
|
||||
}
|
||||
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// 处理输入变化
|
||||
const handleInputChange = (field: keyof LoginForm, value: string) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
// 清除该字段的错误
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -109,16 +108,18 @@ export default function IndexPage() {
|
||||
}
|
||||
|
||||
// 使用axios的baseURL,确保在WebView中使用正确的面板地址
|
||||
const baseURL = axios.defaults.baseURL || (import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/');
|
||||
|
||||
const baseURL =
|
||||
axios.defaults.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`,
|
||||
validCaptchaUrl: `${baseURL}captcha/verify`,
|
||||
bindEl: "#captcha-container",
|
||||
validSuccess: (res: any, _: any, tac: any) => {
|
||||
|
||||
|
||||
form.captchaId = res.data.validToken
|
||||
form.captchaId = res.data.validToken;
|
||||
|
||||
setShowCaptcha(false);
|
||||
tac.destroyWindow();
|
||||
@@ -134,30 +135,30 @@ export default function IndexPage() {
|
||||
},
|
||||
btnRefreshFun: (_event: any, tac: any) => {
|
||||
tac.reloadCaptcha();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 检测暗黑模式
|
||||
const isDarkMode = document.documentElement.classList.contains('dark') ||
|
||||
document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
const isDarkMode =
|
||||
document.documentElement.classList.contains("dark") ||
|
||||
document.documentElement.getAttribute("data-theme") === "dark" ||
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
// 根据主题调整颜色
|
||||
const trackColor = isDarkMode ? "#4a5568" : "#7db0be"; // 暗黑模式使用更深的灰蓝色
|
||||
|
||||
|
||||
const style: CaptchaStyle = {
|
||||
bgUrl: bgImage,
|
||||
logoUrl: null,
|
||||
moveTrackMaskBgColor: trackColor,
|
||||
moveTrackMaskBorderColor: trackColor
|
||||
moveTrackMaskBorderColor: trackColor,
|
||||
};
|
||||
|
||||
tacInstanceRef.current = new window.TAC(config, style);
|
||||
tacInstanceRef.current.init();
|
||||
|
||||
} catch (error) {
|
||||
console.error('初始化验证码失败:', error);
|
||||
toast.error('验证码初始化失败,请刷新页面重试');
|
||||
console.error("初始化验证码失败:", error);
|
||||
toast.error("验证码初始化失败,请刷新页面重试");
|
||||
setShowCaptcha(false);
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -165,8 +166,6 @@ export default function IndexPage() {
|
||||
|
||||
// 执行登录请求
|
||||
const performLogin = async () => {
|
||||
|
||||
|
||||
try {
|
||||
const loginData: LoginData = {
|
||||
username: form.username.trim(),
|
||||
@@ -175,35 +174,36 @@ export default function IndexPage() {
|
||||
};
|
||||
|
||||
const response = await login(loginData);
|
||||
|
||||
|
||||
if (response.code !== 0) {
|
||||
toast.error(response.msg || "登录失败");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否需要强制修改密码
|
||||
if (response.data.requirePasswordChange) {
|
||||
localStorage.setItem('token', response.data.token);
|
||||
localStorage.setItem("token", response.data.token);
|
||||
localStorage.setItem("role_id", response.data.role_id.toString());
|
||||
localStorage.setItem("name", response.data.name);
|
||||
localStorage.setItem("admin", (response.data.role_id === 0).toString());
|
||||
toast.success('检测到默认密码,即将跳转到修改密码页面');
|
||||
toast.success("检测到默认密码,即将跳转到修改密码页面");
|
||||
navigate("/change-password");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存登录信息
|
||||
localStorage.setItem('token', response.data.token);
|
||||
localStorage.setItem("token", response.data.token);
|
||||
localStorage.setItem("role_id", response.data.role_id.toString());
|
||||
localStorage.setItem("name", response.data.name);
|
||||
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);
|
||||
@@ -218,10 +218,11 @@ export default function IndexPage() {
|
||||
try {
|
||||
// 先检查是否需要验证码
|
||||
const checkResponse = await checkCaptcha();
|
||||
|
||||
|
||||
if (checkResponse.code !== 0) {
|
||||
toast.error("检查验证码状态失败,请重试" + checkResponse.msg);
|
||||
setLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -238,15 +239,14 @@ export default function IndexPage() {
|
||||
}, 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查验证码状态错误:', error);
|
||||
console.error("检查验证码状态错误:", error);
|
||||
toast.error("网络错误,请稍后重试" + error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !loading) {
|
||||
if (e.key === "Enter" && !loading) {
|
||||
handleLogin();
|
||||
}
|
||||
};
|
||||
@@ -258,42 +258,47 @@ export default function IndexPage() {
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
|
||||
<h1 className={title({ size: "sm" })}>登陆</h1>
|
||||
<p className="text-small text-default-500 mt-2">请输入您的账号信息</p>
|
||||
<p className="text-small text-default-500 mt-2">
|
||||
请输入您的账号信息
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardBody className="px-6 py-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
errorMessage={errors.username}
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.username}
|
||||
label="用户名"
|
||||
placeholder="请输入用户名"
|
||||
value={form.username}
|
||||
onChange={(e) => handleInputChange('username', e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
variant="bordered"
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.username}
|
||||
errorMessage={errors.username}
|
||||
onChange={(e) =>
|
||||
handleInputChange("username", e.target.value)
|
||||
}
|
||||
onKeyDown={handleKeyPress}
|
||||
/>
|
||||
|
||||
|
||||
<Input
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.password}
|
||||
label="密码"
|
||||
placeholder="请输入密码"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
variant="bordered"
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.password}
|
||||
onChange={(e) =>
|
||||
handleInputChange("password", e.target.value)
|
||||
}
|
||||
onKeyDown={handleKeyPress}
|
||||
/>
|
||||
|
||||
|
||||
<Button
|
||||
className="mt-2"
|
||||
color="primary"
|
||||
disabled={loading}
|
||||
isLoading={loading}
|
||||
size="lg"
|
||||
onClick={handleLogin}
|
||||
isLoading={loading}
|
||||
disabled={loading}
|
||||
className="mt-2"
|
||||
>
|
||||
{loading ? (showCaptcha ? "验证中..." : "登录中...") : "登录"}
|
||||
</Button>
|
||||
@@ -302,47 +307,47 @@ export default function IndexPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 版权信息 - 固定在底部,不占据布局空间 */}
|
||||
|
||||
{/* 版权信息 - 固定在底部,不占据布局空间 */}
|
||||
|
||||
<div className="fixed inset-x-0 bottom-4 text-center py-4">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Powered by{' '}
|
||||
<a
|
||||
href={siteConfig.github_repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
flux-panel
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
v{ isWebView ? siteConfig.app_version : siteConfig.version}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="fixed inset-x-0 bottom-4 text-center py-4">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Powered by{" "}
|
||||
<a
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
href={siteConfig.github_repo}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
flux-panel
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
v{isWebView ? siteConfig.app_version : siteConfig.version}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 验证码弹层 */}
|
||||
{showCaptcha && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* 背景遮罩层 - 模糊效果,暗黑模式下更深 */}
|
||||
<div className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter" />
|
||||
{/* 验证码容器 */}
|
||||
<div className="mb-4">
|
||||
<div
|
||||
id="captcha-container"
|
||||
ref={captchaContainerRef}
|
||||
className="w-full flex justify-center"
|
||||
style={{
|
||||
filter: document.documentElement.classList.contains('dark') ||
|
||||
document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'brightness(0.8) contrast(0.9)' : 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* 验证码容器 */}
|
||||
<div className="mb-4">
|
||||
<div
|
||||
ref={captchaContainerRef}
|
||||
className="w-full flex justify-center"
|
||||
id="captcha-container"
|
||||
style={{
|
||||
filter:
|
||||
document.documentElement.classList.contains("dark") ||
|
||||
document.documentElement.getAttribute("data-theme") ===
|
||||
"dark" ||
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "brightness(0.8) contrast(0.9)"
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
+320
-275
@@ -3,18 +3,23 @@ import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "@heroui/modal";
|
||||
import { Chip } from "@heroui/chip";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import toast from 'react-hot-toast';
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
|
||||
import {
|
||||
createSpeedLimit,
|
||||
getSpeedLimitList,
|
||||
updateSpeedLimit,
|
||||
deleteSpeedLimit,
|
||||
getTunnelList
|
||||
import {
|
||||
createSpeedLimit,
|
||||
getSpeedLimitList,
|
||||
updateSpeedLimit,
|
||||
deleteSpeedLimit,
|
||||
getTunnelList,
|
||||
} from "@/api";
|
||||
|
||||
interface SpeedLimitRule {
|
||||
@@ -46,7 +51,7 @@ export default function LimitPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
|
||||
|
||||
// 模态框状态
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
@@ -54,18 +59,18 @@ export default function LimitPage() {
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [ruleToDelete, setRuleToDelete] = useState<SpeedLimitRule | null>(null);
|
||||
|
||||
|
||||
// 表单状态
|
||||
const [form, setForm] = useState<SpeedLimitForm>({
|
||||
name: '',
|
||||
name: "",
|
||||
speed: 100,
|
||||
tunnelId: null,
|
||||
tunnelName: '',
|
||||
status: 1
|
||||
tunnelName: "",
|
||||
status: 1,
|
||||
});
|
||||
|
||||
|
||||
// 表单验证错误
|
||||
const [errors, setErrors] = useState<{[key: string]: string}>({});
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -77,23 +82,23 @@ export default function LimitPage() {
|
||||
try {
|
||||
const [rulesRes, tunnelsRes] = await Promise.all([
|
||||
getSpeedLimitList(),
|
||||
getTunnelList()
|
||||
getTunnelList(),
|
||||
]);
|
||||
|
||||
|
||||
if (rulesRes.code === 0) {
|
||||
setRules(rulesRes.data || []);
|
||||
} else {
|
||||
toast.error(rulesRes.msg || '获取限速规则失败');
|
||||
toast.error(rulesRes.msg || "获取限速规则失败");
|
||||
}
|
||||
|
||||
|
||||
if (tunnelsRes.code === 0) {
|
||||
setTunnels(tunnelsRes.data || []);
|
||||
} else {
|
||||
console.warn('获取隧道列表失败:', tunnelsRes.msg);
|
||||
console.warn("获取隧道列表失败:", tunnelsRes.msg);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
toast.error('加载数据失败');
|
||||
console.error("加载数据失败:", error);
|
||||
toast.error("加载数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -101,23 +106,24 @@ export default function LimitPage() {
|
||||
|
||||
// 表单验证
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: {[key: string]: string} = {};
|
||||
|
||||
const newErrors: { [key: string]: string } = {};
|
||||
|
||||
if (!form.name.trim()) {
|
||||
newErrors.name = '请输入规则名称';
|
||||
newErrors.name = "请输入规则名称";
|
||||
} else if (form.name.length < 2 || form.name.length > 50) {
|
||||
newErrors.name = '规则名称长度应在2-50个字符之间';
|
||||
newErrors.name = "规则名称长度应在2-50个字符之间";
|
||||
}
|
||||
|
||||
|
||||
if (!form.speed || form.speed < 1) {
|
||||
newErrors.speed = '请输入有效的速度限制(≥1 Mbps)';
|
||||
newErrors.speed = "请输入有效的速度限制(≥1 Mbps)";
|
||||
}
|
||||
|
||||
|
||||
if (!form.tunnelId) {
|
||||
newErrors.tunnelId = '请选择要绑定的隧道';
|
||||
newErrors.tunnelId = "请选择要绑定的隧道";
|
||||
}
|
||||
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
@@ -125,11 +131,11 @@ export default function LimitPage() {
|
||||
const handleAdd = () => {
|
||||
setIsEdit(false);
|
||||
setForm({
|
||||
name: '',
|
||||
name: "",
|
||||
speed: 100,
|
||||
tunnelId: null,
|
||||
tunnelName: '',
|
||||
status: 1
|
||||
tunnelName: "",
|
||||
status: 1,
|
||||
});
|
||||
setErrors({});
|
||||
setModalOpen(true);
|
||||
@@ -144,7 +150,7 @@ export default function LimitPage() {
|
||||
speed: rule.speed,
|
||||
tunnelId: rule.tunnelId,
|
||||
tunnelName: rule.tunnelName,
|
||||
status: rule.status
|
||||
status: rule.status,
|
||||
});
|
||||
setErrors({});
|
||||
setModalOpen(true);
|
||||
@@ -159,20 +165,21 @@ export default function LimitPage() {
|
||||
// 确认删除规则
|
||||
const confirmDelete = async () => {
|
||||
if (!ruleToDelete) return;
|
||||
|
||||
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
const res = await deleteSpeedLimit(ruleToDelete.id);
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success('删除成功');
|
||||
toast.success("删除成功");
|
||||
setDeleteModalOpen(false);
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(res.msg || '删除失败');
|
||||
toast.error(res.msg || "删除失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
toast.error('删除失败');
|
||||
console.error("删除失败:", error);
|
||||
toast.error("删除失败");
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
@@ -181,27 +188,29 @@ export default function LimitPage() {
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
|
||||
setSubmitLoading(true);
|
||||
try {
|
||||
let res;
|
||||
|
||||
if (isEdit) {
|
||||
res = await updateSpeedLimit(form);
|
||||
} else {
|
||||
const { id, ...createData } = form;
|
||||
|
||||
res = await createSpeedLimit(createData);
|
||||
}
|
||||
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success(isEdit ? '修改成功' : '创建成功');
|
||||
toast.success(isEdit ? "修改成功" : "创建成功");
|
||||
setModalOpen(false);
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(res.msg || '操作失败');
|
||||
toast.error(res.msg || "操作失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error);
|
||||
toast.error('操作失败');
|
||||
console.error("提交失败:", error);
|
||||
toast.error("操作失败");
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
@@ -209,269 +218,305 @@ export default function LimitPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在加载...</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在加载...</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex-1">
|
||||
</div>
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
onPress={handleAdd}
|
||||
|
||||
<Button size="sm" variant="flat" color="primary" onPress={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 统一卡片网格 */}
|
||||
{rules.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{rules.map((rule) => (
|
||||
<Card
|
||||
key={rule.id}
|
||||
className="shadow-sm border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 统一卡片网格 */}
|
||||
{rules.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{rules.map((rule) => (
|
||||
<Card key={rule.id} className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{rule.name}</h3>
|
||||
</div>
|
||||
<Chip
|
||||
color={rule.status === 1 ? "success" : "danger"}
|
||||
variant="flat"
|
||||
size="sm"
|
||||
>
|
||||
{rule.status === 1 ? '运行' : '异常'}
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{rule.name}
|
||||
</h3>
|
||||
</div>
|
||||
<Chip
|
||||
color={rule.status === 1 ? "success" : "danger"}
|
||||
variant="flat"
|
||||
size="sm"
|
||||
>
|
||||
{rule.status === 1 ? "运行" : "异常"}
|
||||
</Chip>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">
|
||||
速度限制
|
||||
</span>
|
||||
<Chip color="secondary" size="sm" variant="flat">
|
||||
{rule.speed} Mbps
|
||||
</Chip>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">速度限制</span>
|
||||
<Chip color="secondary" variant="flat" size="sm">
|
||||
{rule.speed} Mbps
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">
|
||||
绑定隧道
|
||||
</span>
|
||||
{rule.tunnelName ? (
|
||||
<Chip color="primary" size="sm" variant="flat">
|
||||
{rule.tunnelName}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">绑定隧道</span>
|
||||
{rule.tunnelName ? (
|
||||
<Chip color="primary" variant="flat" size="sm">
|
||||
{rule.tunnelName}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className="text-default-400 text-small">未绑定</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-default-400 text-small">
|
||||
未绑定
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="primary"
|
||||
onPress={() => handleEdit(rule)}
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="danger"
|
||||
onPress={() => handleDelete(rule)}
|
||||
className="flex-1"
|
||||
startContent={
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clipRule="evenodd" />
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" clipRule="evenodd" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="text-center py-16">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6l4 2m6-6a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">暂无限速规则</h3>
|
||||
<p className="text-default-500 text-sm mt-1">还没有创建任何限速规则,点击上方按钮开始创建</p>
|
||||
onPress={() => handleEdit(rule)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="danger"
|
||||
onPress={() => handleDelete(rule)}
|
||||
className="flex-1"
|
||||
startContent={
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="text-center py-16">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 6v6l4 2m6-6a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
暂无限速规则
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm mt-1">
|
||||
还没有创建任何限速规则,点击上方按钮开始创建
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 新增/编辑模态框 */}
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
size="2xl"
|
||||
{/* 新增/编辑模态框 */}
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
size="2xl"
|
||||
scrollBehavior="outside"
|
||||
backdrop="blur"
|
||||
placement="center"
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-bold">
|
||||
{isEdit ? '编辑限速规则' : '新增限速规则'}
|
||||
</h2>
|
||||
<p className="text-small text-default-500">
|
||||
{isEdit ? '修改现有限速规则的配置信息' : '创建新的限速规则并绑定到隧道'}
|
||||
</p>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-bold">
|
||||
{isEdit ? "编辑限速规则" : "新增限速规则"}
|
||||
</h2>
|
||||
<p className="text-small text-default-500">
|
||||
{isEdit
|
||||
? "修改现有限速规则的配置信息"
|
||||
: "创建新的限速规则并绑定到隧道"}
|
||||
</p>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
errorMessage={errors.name}
|
||||
isInvalid={!!errors.name}
|
||||
label="规则名称"
|
||||
placeholder="请输入限速规则名称"
|
||||
value={form.name}
|
||||
variant="bordered"
|
||||
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
isInvalid={!!errors.name}
|
||||
errorMessage={errors.name}
|
||||
variant="bordered"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="速度限制"
|
||||
placeholder="请输入速度限制"
|
||||
type="number"
|
||||
value={form.speed.toString()}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, speed: parseInt(e.target.value) || 0 }))}
|
||||
isInvalid={!!errors.speed}
|
||||
errorMessage={errors.speed}
|
||||
variant="bordered"
|
||||
endContent={
|
||||
/>
|
||||
|
||||
<Input
|
||||
endContent={
|
||||
<div className="pointer-events-none flex items-center">
|
||||
<span className="text-default-400 text-small">Mbps</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="绑定隧道"
|
||||
placeholder="请选择要绑定的隧道"
|
||||
selectedKeys={form.tunnelId ? [form.tunnelId.toString()] : []}
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
if (selectedKey) {
|
||||
const selectedTunnel = tunnels.find(tunnel => tunnel.id === parseInt(selectedKey));
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
tunnelId: parseInt(selectedKey),
|
||||
tunnelName: selectedTunnel?.name || ''
|
||||
}));
|
||||
} else {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
tunnelId: null,
|
||||
tunnelName: ''
|
||||
}));
|
||||
}
|
||||
}}
|
||||
isInvalid={!!errors.tunnelId}
|
||||
errorMessage={errors.tunnelId}
|
||||
errorMessage={errors.speed}
|
||||
isInvalid={!!errors.speed}
|
||||
label="速度限制"
|
||||
placeholder="请输入速度限制"
|
||||
type="number"
|
||||
value={form.speed.toString()}
|
||||
variant="bordered"
|
||||
isDisabled={isEdit}
|
||||
description={isEdit ? "编辑时无法修改绑定隧道" : undefined}
|
||||
>
|
||||
{tunnels.map((tunnel) => (
|
||||
<SelectItem key={tunnel.id}>
|
||||
{tunnel.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleSubmit}
|
||||
isLoading={submitLoading}
|
||||
>
|
||||
{isEdit ? '保存修改' : '创建规则'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
onChange={(e) => setForm(prev => ({ ...prev, speed: parseInt(e.target.value) || 0 }))}
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 删除确认模态框 */}
|
||||
<Modal
|
||||
isOpen={deleteModalOpen}
|
||||
onOpenChange={setDeleteModalOpen}
|
||||
size="2xl"
|
||||
<Select
|
||||
label="绑定隧道"
|
||||
placeholder="请选择要绑定的隧道"
|
||||
selectedKeys={
|
||||
form.tunnelId ? [form.tunnelId.toString()] : []
|
||||
}
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
|
||||
if (selectedKey) {
|
||||
const selectedTunnel = tunnels.find(
|
||||
(tunnel) => tunnel.id === parseInt(selectedKey),
|
||||
);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
tunnelId: parseInt(selectedKey),
|
||||
tunnelName: selectedTunnel?.name || "",
|
||||
}));
|
||||
} else {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
tunnelId: null,
|
||||
tunnelName: "",
|
||||
}));
|
||||
}
|
||||
}}
|
||||
isInvalid={!!errors.tunnelId}
|
||||
errorMessage={errors.tunnelId}
|
||||
variant="bordered"
|
||||
isDisabled={isEdit}
|
||||
description={isEdit ? "编辑时无法修改绑定隧道" : undefined}
|
||||
>
|
||||
{tunnels.map((tunnel) => (
|
||||
<SelectItem key={tunnel.id}>{tunnel.name}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleSubmit}
|
||||
isLoading={submitLoading}
|
||||
>
|
||||
{isEdit ? "保存修改" : "创建规则"}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 删除确认模态框 */}
|
||||
<Modal
|
||||
isOpen={deleteModalOpen}
|
||||
onOpenChange={setDeleteModalOpen}
|
||||
size="2xl"
|
||||
scrollBehavior="outside"
|
||||
backdrop="blur"
|
||||
placement="center"
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-lg font-bold text-danger">确认删除</h2>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className="text-default-600">
|
||||
确定要删除限速规则 <span className="font-semibold text-foreground">"{ruleToDelete?.name}"</span> 吗?
|
||||
</p>
|
||||
<p className="text-small text-default-500 mt-2">
|
||||
此操作无法撤销,删除后该规则将永久消失。
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
onPress={confirmDelete}
|
||||
isLoading={deleteLoading}
|
||||
>
|
||||
确认删除
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-lg font-bold text-danger">确认删除</h2>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className="text-default-600">
|
||||
确定要删除限速规则{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
"{ruleToDelete?.name}"
|
||||
</span>{" "}
|
||||
吗?
|
||||
</p>
|
||||
<p className="text-small text-default-500 mt-2">
|
||||
此操作无法撤销,删除后该规则将永久消失。
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
onPress={confirmDelete}
|
||||
isLoading={deleteLoading}
|
||||
>
|
||||
确认删除
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1078
-820
File diff suppressed because it is too large
Load Diff
+201
-121
@@ -1,14 +1,22 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardBody } from "@heroui/card";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/modal";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
} from "@heroui/modal";
|
||||
import { Input } from "@heroui/input";
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { isWebViewFunc } from '@/utils/panel';
|
||||
import { siteConfig } from '@/config/site';
|
||||
import { updatePassword } from '@/api';
|
||||
import { safeLogout } from '@/utils/logout';
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { isWebViewFunc } from "@/utils/panel";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { updatePassword } from "@/api";
|
||||
import { safeLogout } from "@/utils/logout";
|
||||
interface PasswordForm {
|
||||
newUsername: string;
|
||||
currentPassword: string;
|
||||
@@ -16,7 +24,6 @@ interface PasswordForm {
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
|
||||
interface MenuItem {
|
||||
path: string;
|
||||
label: string;
|
||||
@@ -28,29 +35,31 @@ interface MenuItem {
|
||||
export default function ProfilePage() {
|
||||
const navigate = useNavigate();
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
const [username, setUsername] = useState('');
|
||||
const [username, setUsername] = useState("");
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||
const [passwordForm, setPasswordForm] = useState<PasswordForm>({
|
||||
newUsername: '',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
newUsername: "",
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// 获取用户信息
|
||||
const name = localStorage.getItem('name') || 'Admin';
|
||||
|
||||
const name = localStorage.getItem("name") || "Admin";
|
||||
|
||||
// 兼容处理:如果没有admin字段,根据role_id判断(0为管理员)
|
||||
let adminFlag = localStorage.getItem('admin') === 'true';
|
||||
if (localStorage.getItem('admin') === null) {
|
||||
const roleId = parseInt(localStorage.getItem('role_id') || '1', 10);
|
||||
let adminFlag = localStorage.getItem("admin") === "true";
|
||||
|
||||
if (localStorage.getItem("admin") === null) {
|
||||
const roleId = parseInt(localStorage.getItem("role_id") || "1", 10);
|
||||
|
||||
adminFlag = roleId === 0;
|
||||
// 补充设置admin字段,避免下次再次判断
|
||||
localStorage.setItem('admin', adminFlag.toString());
|
||||
localStorage.setItem("admin", adminFlag.toString());
|
||||
}
|
||||
|
||||
|
||||
setUsername(name);
|
||||
setIsAdmin(adminFlag);
|
||||
}, []);
|
||||
@@ -58,72 +67,89 @@ export default function ProfilePage() {
|
||||
// 管理员菜单项
|
||||
const adminMenuItems: MenuItem[] = [
|
||||
{
|
||||
path: '/limit',
|
||||
label: '限速管理',
|
||||
path: "/limit",
|
||||
label: "限速管理",
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
color: 'bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400',
|
||||
description: '管理用户限速策略'
|
||||
color:
|
||||
"bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400",
|
||||
description: "管理用户限速策略",
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
label: '用户管理',
|
||||
path: "/user",
|
||||
label: "用户管理",
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400',
|
||||
description: '管理系统用户'
|
||||
color: "bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400",
|
||||
description: "管理系统用户",
|
||||
},
|
||||
{
|
||||
path: '/config',
|
||||
label: '网站配置',
|
||||
path: "/config",
|
||||
label: "网站配置",
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
color: 'bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400',
|
||||
description: '配置网站设置'
|
||||
}
|
||||
color:
|
||||
"bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400",
|
||||
description: "配置网站设置",
|
||||
},
|
||||
];
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = () => {
|
||||
safeLogout();
|
||||
navigate('/', { replace: true });
|
||||
navigate("/", { replace: true });
|
||||
};
|
||||
|
||||
// 密码表单验证
|
||||
const validatePasswordForm = (): boolean => {
|
||||
if (!passwordForm.newUsername.trim()) {
|
||||
toast.error('请输入新用户名');
|
||||
toast.error("请输入新用户名");
|
||||
|
||||
return false;
|
||||
}
|
||||
if (passwordForm.newUsername.length < 3) {
|
||||
toast.error('用户名长度至少3位');
|
||||
toast.error("用户名长度至少3位");
|
||||
|
||||
return false;
|
||||
}
|
||||
if (!passwordForm.currentPassword) {
|
||||
toast.error('请输入当前密码');
|
||||
toast.error("请输入当前密码");
|
||||
|
||||
return false;
|
||||
}
|
||||
if (!passwordForm.newPassword) {
|
||||
toast.error('请输入新密码');
|
||||
toast.error("请输入新密码");
|
||||
|
||||
return false;
|
||||
}
|
||||
if (passwordForm.newPassword.length < 6) {
|
||||
toast.error('新密码长度不能少于6位');
|
||||
toast.error("新密码长度不能少于6位");
|
||||
|
||||
return false;
|
||||
}
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
toast.error('两次输入密码不一致');
|
||||
toast.error("两次输入密码不一致");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -134,16 +160,17 @@ export default function ProfilePage() {
|
||||
setPasswordLoading(true);
|
||||
try {
|
||||
const response = await updatePassword(passwordForm);
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success('密码修改成功,请重新登录');
|
||||
toast.success("密码修改成功,请重新登录");
|
||||
onOpenChange();
|
||||
handleLogout();
|
||||
} else {
|
||||
toast.error(response.msg || '密码修改失败');
|
||||
toast.error(response.msg || "密码修改失败");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('修改密码时发生错误');
|
||||
console.error('修改密码错误:', error);
|
||||
toast.error("修改密码时发生错误");
|
||||
console.error("修改密码错误:", error);
|
||||
} finally {
|
||||
setPasswordLoading(false);
|
||||
}
|
||||
@@ -152,38 +179,49 @@ export default function ProfilePage() {
|
||||
// 重置密码表单
|
||||
const resetPasswordForm = () => {
|
||||
setPasswordForm({
|
||||
newUsername: '',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
newUsername: "",
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 lg:px-6 py-8 flex flex-col h-full">
|
||||
|
||||
<div className="space-y-6 flex-1">
|
||||
{/* 用户信息卡片 */}
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
|
||||
<svg
|
||||
className="w-6 h-6 text-primary"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-medium text-foreground">{username}</h3>
|
||||
<h3 className="text-base font-medium text-foreground">
|
||||
{username}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<span className={`px-2 py-1 rounded-md text-xs font-medium ${
|
||||
isAdmin
|
||||
? 'bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300'
|
||||
: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300'
|
||||
}`}>
|
||||
{isAdmin ? '管理员' : '普通用户'}
|
||||
<span
|
||||
className={`px-2 py-1 rounded-md text-xs font-medium ${
|
||||
isAdmin
|
||||
? "bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300"
|
||||
: "bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300"
|
||||
}`}
|
||||
>
|
||||
{isAdmin ? "管理员" : "普通用户"}
|
||||
</span>
|
||||
<span className="text-xs text-default-500">
|
||||
{new Date().toLocaleDateString('zh-CN')}
|
||||
{new Date().toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,119 +234,161 @@ export default function ProfilePage() {
|
||||
<CardBody className="p-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{/* 管理员功能 */}
|
||||
{isAdmin && adminMenuItems.map((item) => (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
className="flex flex-col items-center p-3 rounded-2xl bg-gray-50 dark:bg-default-100 hover:bg-gray-100 dark:hover:bg-default-200 transition-colors duration-200"
|
||||
>
|
||||
<div className={`w-10 h-10 ${item.color} rounded-full flex items-center justify-center mb-2`}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-xs text-foreground text-center">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{isAdmin &&
|
||||
adminMenuItems.map((item) => (
|
||||
<button
|
||||
key={item.path}
|
||||
className="flex flex-col items-center p-3 rounded-2xl bg-gray-50 dark:bg-default-100 hover:bg-gray-100 dark:hover:bg-default-200 transition-colors duration-200"
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
<div
|
||||
className={`w-10 h-10 ${item.color} rounded-full flex items-center justify-center mb-2`}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-xs text-foreground text-center">
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* 修改密码 */}
|
||||
<button
|
||||
onClick={onOpen}
|
||||
className="flex flex-col items-center p-3 rounded-2xl bg-gray-50 dark:bg-default-100 hover:bg-gray-100 dark:hover:bg-default-200 transition-colors duration-200"
|
||||
onClick={onOpen}
|
||||
>
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400 rounded-full flex items-center justify-center mb-2">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z" clipRule="evenodd" />
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xs text-foreground text-center">修改密码</span>
|
||||
<span className="text-xs text-foreground text-center">
|
||||
修改密码
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
{/* 退出登录 */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex flex-col items-center p-3 rounded-2xl bg-gray-50 dark:bg-default-100 hover:bg-gray-100 dark:hover:bg-default-200 transition-colors duration-200"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<div className="w-10 h-10 bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 rounded-full flex items-center justify-center mb-2">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd" />
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xs text-foreground text-center">退出登录</span>
|
||||
<span className="text-xs text-foreground text-center">
|
||||
退出登录
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="fixed inset-x-0 bottom-20 text-center py-4">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Powered by{' '}
|
||||
<a
|
||||
href={siteConfig.github_repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
flux-panel
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
v{ isWebViewFunc() ? siteConfig.app_version : siteConfig.version}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Powered by{" "}
|
||||
<a
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
href={siteConfig.github_repo}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
flux-panel
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
v{isWebViewFunc() ? siteConfig.app_version : siteConfig.version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
{/* 修改密码弹窗 */}
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
isOpen={isOpen}
|
||||
placement="center"
|
||||
scrollBehavior="outside"
|
||||
size="2xl"
|
||||
onOpenChange={() => {
|
||||
onOpenChange();
|
||||
resetPasswordForm();
|
||||
}}
|
||||
size="2xl"
|
||||
scrollBehavior="outside"
|
||||
backdrop="blur"
|
||||
placement="center"
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose: () => void) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">修改密码</ModalHeader>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
修改密码
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="新用户名"
|
||||
placeholder="请输入新用户名(至少3位)"
|
||||
value={passwordForm.newUsername}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, newUsername: e.target.value }))}
|
||||
variant="bordered"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setPasswordForm((prev) => ({
|
||||
...prev,
|
||||
newUsername: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="当前密码"
|
||||
type="password"
|
||||
placeholder="请输入当前密码"
|
||||
type="password"
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, currentPassword: e.target.value }))}
|
||||
variant="bordered"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setPasswordForm((prev) => ({
|
||||
...prev,
|
||||
currentPassword: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="新密码"
|
||||
type="password"
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
type="password"
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, newPassword: e.target.value }))}
|
||||
variant="bordered"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setPasswordForm((prev) => ({
|
||||
...prev,
|
||||
newPassword: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="确认密码"
|
||||
type="password"
|
||||
placeholder="请再次输入新密码"
|
||||
type="password"
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, confirmPassword: e.target.value }))}
|
||||
variant="bordered"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setPasswordForm((prev) => ({
|
||||
...prev,
|
||||
confirmPassword: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
@@ -316,10 +396,10 @@ export default function ProfilePage() {
|
||||
<Button color="default" variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handlePasswordSubmit}
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={passwordLoading}
|
||||
onPress={handlePasswordSubmit}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
|
||||
@@ -1,74 +1,77 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from "react";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody } from "@heroui/card";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import { reinitializeBaseURL } from '@/api/network';
|
||||
import {
|
||||
getPanelAddresses,
|
||||
savePanelAddress,
|
||||
setCurrentPanelAddress,
|
||||
deletePanelAddress,
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { reinitializeBaseURL } from "@/api/network";
|
||||
import {
|
||||
getPanelAddresses,
|
||||
savePanelAddress,
|
||||
setCurrentPanelAddress,
|
||||
deletePanelAddress,
|
||||
validatePanelAddress,
|
||||
} from '@/utils/panel';
|
||||
} from "@/utils/panel";
|
||||
|
||||
interface PanelAddress {
|
||||
name: string;
|
||||
address: string;
|
||||
address: string;
|
||||
inx: boolean;
|
||||
}
|
||||
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newAddress, setNewAddress] = useState('');
|
||||
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newAddress, setNewAddress] = useState("");
|
||||
|
||||
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
|
||||
setPanelAddresses(newAddress);
|
||||
}
|
||||
setPanelAddresses(newAddress);
|
||||
};
|
||||
|
||||
// 加载面板地址列表
|
||||
const loadPanelAddresses = async () => {
|
||||
(window as any).setPanelAddresses = setPanelAddressesFunc
|
||||
(window as any).setPanelAddresses = setPanelAddressesFunc;
|
||||
getPanelAddresses();
|
||||
};
|
||||
|
||||
// 添加新面板地址
|
||||
const addPanelAddress = async () => {
|
||||
if (!newName.trim() || !newAddress.trim()) {
|
||||
toast.error('请输入名称和地址');
|
||||
toast.error("请输入名称和地址");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证地址格式
|
||||
if (!validatePanelAddress(newAddress.trim())) {
|
||||
toast.error('地址格式不正确,请检查:\n• 必须是完整的URL格式\n• 必须以 http:// 或 https:// 开头\n• 支持域名、IPv4、IPv6 地址\n• 端口号范围:1-65535\n• 示例:http://192.168.1.100:3000');
|
||||
toast.error(
|
||||
"地址格式不正确,请检查:\n• 必须是完整的URL格式\n• 必须以 http:// 或 https:// 开头\n• 支持域名、IPv4、IPv6 地址\n• 端口号范围:1-65535\n• 示例:http://192.168.1.100:3000",
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
(window as any).setPanelAddresses = setPanelAddressesFunc
|
||||
(window as any).setPanelAddresses = setPanelAddressesFunc;
|
||||
savePanelAddress(newName.trim(), newAddress.trim());
|
||||
setNewName('');
|
||||
setNewAddress('');
|
||||
toast.success('添加成功');
|
||||
setNewName("");
|
||||
setNewAddress("");
|
||||
toast.success("添加成功");
|
||||
};
|
||||
|
||||
// 设置当前面板地址
|
||||
const setCurrentPanel = async (name: string) => {
|
||||
(window as any).setPanelAddresses = setPanelAddressesFunc
|
||||
(window as any).setPanelAddresses = setPanelAddressesFunc;
|
||||
setCurrentPanelAddress(name);
|
||||
reinitializeBaseURL();
|
||||
};
|
||||
|
||||
// 删除面板地址
|
||||
const handleDeletePanelAddress = async (name: string) => {
|
||||
(window as any).setPanelAddresses = setPanelAddressesFunc
|
||||
(window as any).setPanelAddresses = setPanelAddressesFunc;
|
||||
deletePanelAddress(name);
|
||||
reinitializeBaseURL();
|
||||
toast.success('删除成功');
|
||||
toast.success("删除成功");
|
||||
};
|
||||
|
||||
// 页面加载时获取数据
|
||||
@@ -84,15 +87,27 @@ export const SettingsPage = () => {
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
isIconOnly
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
variant="light"
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M15 19l-7-7 7-7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">面板设置</h1>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
面板设置
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,7 +118,9 @@ export const SettingsPage = () => {
|
||||
{/* 添加新地址 */}
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">添加新面板地址</h2>
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
添加新面板地址
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
@@ -129,30 +146,41 @@ export const SettingsPage = () => {
|
||||
{/* 地址列表 */}
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">已保存的面板地址</h2>
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
已保存的面板地址
|
||||
</h2>
|
||||
{panelAddresses.length === 0 ? (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-center py-8">暂无保存的面板地址</p>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
|
||||
暂无保存的面板地址
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{panelAddresses.map((panel, index) => (
|
||||
<div key={index} className="border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 dark:border-gray-600 rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white">{panel.name}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{panel.name}
|
||||
</span>
|
||||
{panel.inx && (
|
||||
<span className="px-2 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300 text-xs rounded">
|
||||
当前
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{panel.address}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{panel.address}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!panel.inx && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onClick={() => setCurrentPanel(panel.name)}
|
||||
>
|
||||
@@ -160,8 +188,8 @@ export const SettingsPage = () => {
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => handleDeletePanelAddress(panel.name)}
|
||||
>
|
||||
|
||||
+1782
-1239
File diff suppressed because it is too large
Load Diff
+714
-427
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user