增加网站配置

This commit is contained in:
qaq
2025-07-24 16:48:47 +08:00
parent b3135d3b0f
commit d28c04d7ba
21 changed files with 983 additions and 61 deletions
+1 -1
View File
@@ -1033,7 +1033,7 @@ func tcpPingHost(ip string, port int, count int, timeoutMs int) (float64, float6
totalTime += elapsed.Seconds() * 1000 // 转换为毫秒
successCount++
}
// 如果不是最后一次,等待一下再进行下次测试
if i < count-1 {
time.Sleep(100 * time.Millisecond)
+25 -9
View File
@@ -154,9 +154,20 @@ CREATE TABLE `user_tunnel` (
`status` int(10) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- 转储表的索引
--
CREATE TABLE `vite_config` (
`id` int(10) NOT NULL,
`name` varchar(200) NOT NULL,
`value` varchar(200) NOT NULL,
`time` bigint(20) NOT NULL,
UNIQUE KEY `unique_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO `vite_config` (`id`, `name`, `value`, `time`) VALUES
(1, 'app_name', '哆啦A梦', 1753344708000);
ALTER TABLE `vite_config`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `unique_name` (`name`);
--
-- 表的索引 `forward`
@@ -202,37 +213,42 @@ ALTER TABLE `user_tunnel`
-- 使用表AUTO_INCREMENT `forward`
--
ALTER TABLE `forward`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=164;
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT;
--
-- 使用表AUTO_INCREMENT `node`
--
ALTER TABLE `node`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=27;
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT;
--
-- 使用表AUTO_INCREMENT `speed_limit`
--
ALTER TABLE `speed_limit`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=70;
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT;
--
-- 使用表AUTO_INCREMENT `tunnel`
--
ALTER TABLE `tunnel`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=59;
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT;
--
-- 使用表AUTO_INCREMENT `user`
--
ALTER TABLE `user`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=38;
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT;
--
-- 使用表AUTO_INCREMENT `user_tunnel`
--
ALTER TABLE `user_tunnel`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=56;
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT;
COMMIT;
ALTER TABLE `vite_config`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2;
COMMIT;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
+17
View File
@@ -754,6 +754,23 @@ UPDATE \`forward\`
SET \`strategy\` = 'fifo'
WHERE \`strategy\` IS NULL;
-- 创建 vite_config 表(如果不存在)
CREATE TABLE IF NOT EXISTS \`vite_config\` (
\`id\` int(10) NOT NULL AUTO_INCREMENT,
\`name\` varchar(200) NOT NULL,
\`value\` varchar(200) NOT NULL,
\`time\` bigint(20) NOT NULL,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`unique_name\` (\`name\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 插入默认应用名称配置(如果不存在)
INSERT INTO \`vite_config\` (\`name\`, \`value\`, \`time\`)
SELECT 'app_name', '哆啦A梦', 1753344708000
WHERE NOT EXISTS (
SELECT 1 FROM \`vite_config\` WHERE \`name\` = 'app_name'
);
EOF
@@ -57,6 +57,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
registry.addInterceptor(jwtInterceptor())
.addPathPatterns("/api/**")
.excludePathPatterns("/flow/**")
.excludePathPatterns("/api/v1/config/get")
.excludePathPatterns("/api/v1/user/login");
}
}
@@ -20,4 +20,6 @@ public class BaseController {
@Autowired
ForwardService forwardService;
@Autowired
ViteConfigService viteConfigService;
}
@@ -0,0 +1,69 @@
package com.admin.controller;
import com.admin.common.annotation.RequireRole;
import com.admin.common.aop.LogAnnotation;
import com.admin.common.lang.R;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* <p>
* 网站配置控制器
* </p>
*
* @author QAQ
* @since 2025-07-24
*/
@RestController
@CrossOrigin
@RequestMapping("/api/v1/config")
public class ViteConfigController extends BaseController {
/**
* 获取所有网站配置
* 前端无需权限即可访问,用于获取网站基本信息
*/
@LogAnnotation
@PostMapping("/list")
public R getConfigs() {
return viteConfigService.getConfigs();
}
/**
* 根据配置名获取配置值
* 前端无需权限即可访问,用于获取特定配置
*/
@LogAnnotation
@PostMapping("/get")
public R getConfigByName(@RequestBody Map<String, Object> params) {
String name = params.get("name").toString();
return viteConfigService.getConfigByName(name);
}
/**
* 批量更新网站配置
* 需要管理员权限
*/
@LogAnnotation
@RequireRole
@PostMapping("/update")
public R updateConfigs(@RequestBody Map<String, String> configMap) {
return viteConfigService.updateConfigs(configMap);
}
/**
* 更新单个配置项
* 需要管理员权限
*/
@LogAnnotation
@RequireRole
@PostMapping("/update-single")
public R updateConfig(@RequestBody Map<String, Object> params) {
String name = params.get("name").toString();
String value = params.get("value").toString();
return viteConfigService.updateConfig(name, value);
}
}
@@ -0,0 +1,31 @@
package com.admin.entity;
import com.admin.entity.BaseEntity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* <p>
*
* </p>
*
* @author QAQ
* @since 2025-07-24
*/
@Data
public class ViteConfig {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String name;
private String value;
private Long time;
}
@@ -0,0 +1,16 @@
package com.admin.mapper;
import com.admin.entity.ViteConfig;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* <p>
* Mapper 接口
* </p>
*
* @author QAQ
* @since 2025-07-24
*/
public interface ViteConfigMapper extends BaseMapper<ViteConfig> {
}
@@ -0,0 +1,47 @@
package com.admin.service;
import com.admin.entity.ViteConfig;
import com.admin.common.lang.R;
import com.baomidou.mybatisplus.extension.service.IService;
import java.util.Map;
/**
* <p>
* 网站配置服务类
* </p>
*
* @author QAQ
* @since 2025-07-24
*/
public interface ViteConfigService extends IService<ViteConfig> {
/**
* 获取所有网站配置
* @return 配置Map,key为配置名,value为配置值
*/
R getConfigs();
/**
* 根据配置名获取配置值
* @param name 配置名
* @return 配置值
*/
R getConfigByName(String name);
/**
* 批量更新网站配置
* @param configMap 配置Map,key为配置名,value为新的配置值
* @return 更新结果
*/
R updateConfigs(Map<String, String> configMap);
/**
* 更新单个配置项
* @param name 配置名
* @param value 配置值
* @return 更新结果
*/
R updateConfig(String name, String value);
}
@@ -0,0 +1,160 @@
package com.admin.service.impl;
import com.admin.entity.ViteConfig;
import com.admin.mapper.ViteConfigMapper;
import com.admin.service.ViteConfigService;
import com.admin.common.lang.R;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* <p>
* 网站配置服务实现类
* </p>
*
* @author QAQ
* @since 2025-07-24
*/
@Service
public class ViteConfigServiceImpl extends ServiceImpl<ViteConfigMapper, ViteConfig> implements ViteConfigService {
// ========== 常量定义 ==========
/** 成功响应消息 */
private static final String SUCCESS_UPDATE_MSG = "配置更新成功";
/** 错误响应消息 */
private static final String ERROR_UPDATE_MSG = "配置更新失败";
private static final String ERROR_CONFIG_NOT_FOUND = "配置不存在";
private static final String ERROR_CONFIG_NAME_REQUIRED = "配置名称不能为空";
private static final String ERROR_CONFIG_VALUE_REQUIRED = "配置值不能为空";
// ========== 公共接口实现 ==========
/**
* 获取所有网站配置
*
* @return 包含所有配置的Map
*/
@Override
public R getConfigs() {
List<ViteConfig> configList = this.list();
Map<String, String> configMap = new HashMap<>();
for (ViteConfig config : configList) {
configMap.put(config.getName(), config.getValue());
}
return R.ok(configMap);
}
/**
* 根据配置名称获取配置值
*
* @param name 配置名称
* @return 配置响应对象
*/
@Override
public R getConfigByName(String name) {
if (!StringUtils.hasText(name)) {
return R.err(ERROR_CONFIG_NAME_REQUIRED);
}
QueryWrapper<ViteConfig> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("name", name);
ViteConfig config = this.getOne(queryWrapper);
if (config == null) {
return R.err(ERROR_CONFIG_NOT_FOUND);
}
return R.ok(config);
}
/**
* 批量更新网站配置
*
* @param configMap 配置Map
* @return 更新结果响应
*/
@Override
public R updateConfigs(Map<String, String> configMap) {
if (configMap == null || configMap.isEmpty()) {
return R.err("配置数据不能为空");
}
try {
for (Map.Entry<String, String> entry : configMap.entrySet()) {
String name = entry.getKey();
String value = entry.getValue();
if (!StringUtils.hasText(name)) {
continue; // 跳过无效的配置名
}
updateOrCreateConfig(name, value);
}
return R.ok(SUCCESS_UPDATE_MSG);
} catch (Exception e) {
return R.err(ERROR_UPDATE_MSG + ": " + e.getMessage());
}
}
/**
* 更新单个配置项
*
* @param name 配置名
* @param value 配置值
* @return 更新结果响应
*/
@Override
public R updateConfig(String name, String value) {
// 1. 验证必填字段
if (!StringUtils.hasText(name)) {
return R.err(ERROR_CONFIG_NAME_REQUIRED);
}
if (!StringUtils.hasText(value)) {
return R.err(ERROR_CONFIG_VALUE_REQUIRED);
}
try {
updateOrCreateConfig(name, value);
return R.ok(SUCCESS_UPDATE_MSG);
} catch (Exception e) {
return R.err(ERROR_UPDATE_MSG + ": " + e.getMessage());
}
}
// ========== 私有辅助方法 ==========
/**
* 更新或创建配置项
* 如果配置存在则更新,不存在则创建
*/
private void updateOrCreateConfig(String name, String value) {
QueryWrapper<ViteConfig> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("name", name);
ViteConfig existingConfig = this.getOne(queryWrapper);
if (existingConfig != null) {
// 更新现有配置
existingConfig.setValue(value);
existingConfig.setTime(System.currentTimeMillis());
this.updateById(existingConfig);
} else {
// 创建新配置
ViteConfig newConfig = new ViteConfig();
newConfig.setName(name);
newConfig.setValue(value);
newConfig.setTime(System.currentTimeMillis());
this.save(newConfig);
}
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.admin.mapper.ViteConfigMapper">
</mapper>
+1 -1
View File
@@ -40,7 +40,7 @@
}
html.dark body {
background-color: #1f2937;
background-color: #000000;
}
#root {
+35
View File
@@ -9,8 +9,10 @@ import TunnelPage from "@/pages/tunnel";
import NodePage from "@/pages/node";
import UserPage from "@/pages/user";
import LimitPage from "@/pages/limit";
import ConfigPage from "@/pages/config";
import { isLoggedIn } from "@/utils/auth";
import { siteConfig } from "@/config/site";
// 简化的路由保护组件 - 使用浏览器重定向避免React循环
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
@@ -34,6 +36,7 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};
// 登录页面路由组件 - 已登录则重定向到dashboard
const LoginRoute = () => {
const authenticated = isLoggedIn();
@@ -57,6 +60,30 @@ const LoginRoute = () => {
};
function App() {
// 立即设置页面标题(使用已从缓存读取的配置)
useEffect(() => {
document.title = siteConfig.name;
// 异步检查是否有配置更新
const checkTitleUpdate = async () => {
try {
// 引入必要的函数
const { getCachedConfig } = await import('@/config/site');
const cachedAppName = await getCachedConfig('app_name');
if (cachedAppName && cachedAppName !== document.title) {
document.title = cachedAppName;
}
} catch (error) {
console.warn('检查标题更新失败:', error);
}
};
// 延迟检查,避免阻塞初始渲染
const timer = setTimeout(checkTitleUpdate, 100);
return () => clearTimeout(timer);
}, []);
return (
<Routes>
<Route path="/" element={<LoginRoute />} />
@@ -116,6 +143,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/config"
element={
<ProtectedRoute>
<ConfigPage />
</ProtectedRoute>
}
/>
</Routes>
);
}
+7 -1
View File
@@ -70,4 +70,10 @@ export const updateSpeedLimit = (data: any) => Network.post("/speed-limit/update
export const deleteSpeedLimit = (id: number) => Network.post("/speed-limit/delete", { id });
// 修改密码接口
export const updatePassword = (data: any) => Network.post("/user/updatePassword", data);
export const updatePassword = (data: any) => Network.post("/user/updatePassword", data);
// 网站配置相关接口
export const getConfigs = () => Network.post("/config/list");
export const getConfigByName = (name: string) => Network.post("/config/get", { name });
export const updateConfigs = (configMap: Record<string, string>) => Network.post("/config/update", configMap);
export const updateConfig = (name: string, value: string) => Network.post("/config/update-single", { name, value });
+49 -5
View File
@@ -1,3 +1,4 @@
import { useState, useEffect } from "react";
import { Link } from "@heroui/link";
import {
Navbar as HeroUINavbar,
@@ -7,19 +8,62 @@ import {
import { ThemeSwitch } from "@/components/theme-switch";
import { Logo } from "@/components/icons";
import { siteConfig, getCachedConfig } from "@/config/site";
export const Navbar = () => {
// 初始状态使用siteConfig中已经从缓存读取的值,避免闪烁
const [appName, setAppName] = useState(siteConfig.name);
useEffect(() => {
// 异步检查是否有更新的配置
const checkForUpdates = async () => {
try {
const cachedAppName = await getCachedConfig('app_name');
if (cachedAppName && cachedAppName !== appName) {
setAppName(cachedAppName);
// 同步更新siteConfig
siteConfig.name = cachedAppName;
}
} catch (error) {
console.warn('检查配置更新失败:', error);
}
};
// 延迟执行,避免阻塞初始渲染
const timer = setTimeout(checkForUpdates, 100);
// 监听配置更新事件
const handleConfigUpdate = async () => {
try {
const cachedAppName = await getCachedConfig('app_name');
if (cachedAppName) {
setAppName(cachedAppName);
siteConfig.name = cachedAppName;
}
} catch (error) {
console.warn('更新配置失败:', error);
}
};
window.addEventListener('configUpdated', handleConfigUpdate);
return () => {
clearTimeout(timer);
window.removeEventListener('configUpdated', handleConfigUpdate);
};
}, [appName]);
return (
<HeroUINavbar maxWidth="xl" position="sticky">
<HeroUINavbar maxWidth="xl" position="sticky" height="60px" className="shrink-0">
<NavbarContent className="basis-1/5 sm:basis-full" justify="start">
<NavbarBrand className="gap-3 max-w-fit">
<NavbarBrand className="gap-2 max-w-fit">
<Link
className="flex justify-start items-center gap-1"
className="flex justify-start items-center gap-2 max-w-[200px] sm:max-w-none"
color="foreground"
href="/"
>
<Logo />
<p className="font-bold text-inherit">哆啦A梦</p>
<Logo size={24} />
<p className="font-bold text-inherit truncate">{appName}</p>
</Link>
</NavbarBrand>
</NavbarContent>
+150 -12
View File
@@ -1,15 +1,153 @@
import { getConfigByName, getConfigs } from '@/api';
export type SiteConfig = typeof siteConfig;
export const siteConfig = {
name: "哆啦A梦",
description: "哆啦A梦",
navItems: [],
navMenuItems: [],
links: {
github: "https://github.com/go-gost/gost",
twitter: "",
docs: "",
discord: "",
sponsor: "",
},
// 缓存相关常量
const CACHE_PREFIX = 'vite_config_';
// 缓存策略:
// - 长期有效,不设置过期时间
// - 只在以下情况清除:1. 更新配置时 2. 退出登录时(safeLogout会清除localStorage)
// 立即从缓存获取配置(同步操作,避免闪烁)
const getInitialConfig = () => {
if (typeof window === 'undefined') {
return {
name: "哆啦A梦",
version: "1.0.9",
};
}
const cachedAppName = localStorage.getItem(CACHE_PREFIX + 'app_name');
if (cachedAppName) {
return {
name: cachedAppName,
version: "1.0.9",
};
}
return {
name: "哆啦A梦",
version: "1.0.9",
};
};
export const siteConfig = getInitialConfig();
// 缓存工具函数
export const configCache = {
// 获取缓存的配置
get: (key: string): string | null => {
const cacheKey = CACHE_PREFIX + key;
return localStorage.getItem(cacheKey);
},
// 设置缓存的配置
set: (key: string, value: string): void => {
const cacheKey = CACHE_PREFIX + key;
localStorage.setItem(cacheKey, value);
},
// 删除指定配置的缓存
remove: (key: string): void => {
const cacheKey = CACHE_PREFIX + key;
localStorage.removeItem(cacheKey);
},
// 清空所有配置缓存
clear: (): void => {
// 获取所有localStorage的key
const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.startsWith(CACHE_PREFIX)) {
localStorage.removeItem(key);
}
});
}
};
// 获取单个配置(优先从缓存)
export const getCachedConfig = async (key: string): Promise<string | null> => {
const cachedValue = configCache.get(key);
if (cachedValue !== null) {
return cachedValue;
}
const response = await getConfigByName(key);
if (response.code === 0 && response.data?.value) {
const value = response.data.value;
configCache.set(key, value);
return value;
}
return null;
};
// 获取所有配置(优先从缓存)
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
// 尝试从缓存获取所有配置
const configKeys = ['app_name', 'site_title', 'site_description', 'contact_email', 'footer_text', 'logo_url'];
const cachedConfigs: Record<string, string> = {};
let hasCachedData = false;
configKeys.forEach(key => {
const cachedValue = configCache.get(key);
if (cachedValue !== null) {
cachedConfigs[key] = cachedValue;
hasCachedData = true;
}
});
// 从API获取最新配置
try {
const response = await getConfigs();
if (response.code === 0 && response.data) {
const configs = response.data;
// 将所有配置存入缓存
Object.entries(configs).forEach(([key, value]) => {
configCache.set(key, value as string);
});
return configs;
}
} catch (error) {
// API失败时返回缓存的数据
if (hasCachedData) {
return cachedConfigs;
}
}
return {};
};
// 动态更新网站配置
export const updateSiteConfig = async () => {
const appName = await getCachedConfig('app_name');
if (appName && appName !== siteConfig.name) {
siteConfig.name = appName;
// 更新页面标题
document.title = appName;
}
};
// 清除配置缓存的工具函数
// 缓存清除时机:
// 1. 配置更新时:调用此函数清除所有缓存
// 2. 退出登录时:safeLogout()中的localStorage.clear()会自动清除
export const clearConfigCache = (keys?: string[]) => {
if (keys && keys.length > 0) {
// 删除指定的配置缓存
keys.forEach(key => configCache.remove(key));
} else {
// 清空所有配置缓存
configCache.clear();
}
};
// 在页面加载时异步更新配置(如果有更新的话)
if (typeof window !== 'undefined') {
// 延迟执行,避免阻塞初始渲染
setTimeout(() => {
updateSiteConfig();
}, 200);
}
+18 -7
View File
@@ -10,6 +10,7 @@ import { Logo } from '@/components/icons';
import { ThemeSwitch } from '@/components/theme-switch';
import { updatePassword } from '@/api';
import { safeLogout } from '@/utils/logout';
import { siteConfig } from '@/config/site';
interface MenuItem {
path: string;
@@ -105,6 +106,16 @@ export default function AdminLayout({
</svg>
),
adminOnly: true
},
{
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" />
</svg>
),
adminOnly: true
}
];
@@ -245,7 +256,7 @@ export default function AdminLayout({
<aside className={`
${isMobile ? 'fixed' : 'relative'}
${isMobile && !mobileMenuVisible ? '-translate-x-full' : 'translate-x-0'}
${isMobile ? 'w-64' : 'w-64'}
${isMobile ? 'w-64' : 'w-72'}
bg-white dark:bg-black
shadow-lg
border-r border-gray-200 dark:border-gray-600
@@ -255,12 +266,12 @@ export default function AdminLayout({
h-full
`}>
{/* Logo 区域 */}
<div className="px-4 py-3 h-14 flex items-center">
<div className="flex items-center gap-3">
<Logo size={28} />
<div>
<h1 className="text-base font-bold text-foreground">哆啦A梦</h1>
<p className="text-xs text-default-500">v1.0.8</p>
<div className="px-3 py-3 h-14 flex items-center">
<div className="flex items-center gap-2 w-full">
<Logo size={24} />
<div className="flex-1 min-w-0">
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap">{siteConfig.name}</h1>
<p className="text-xs text-default-500">v{siteConfig.version}</p>
</div>
</div>
</div>
+2 -2
View File
@@ -8,9 +8,9 @@ export default function DefaultLayout({
children: React.ReactNode;
}) {
return (
<div className="relative flex flex-col h-screen">
<div className="relative flex flex-col min-h-screen bg-white dark:bg-black">
<Navbar />
<main className="container mx-auto max-w-7xl px-6 flex-grow pt-16">
<main className="container mx-auto max-w-7xl px-4 sm:px-6 flex-grow pt-4 sm:pt-16">
{children}
</main>
<footer className="w-full flex items-center justify-center py-3">
+294
View File
@@ -0,0 +1,294 @@
import { useState, useEffect } from 'react';
import { Button } from "@heroui/button";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Input } from "@heroui/input";
import { Spinner } from "@heroui/spinner";
import { Divider } from "@heroui/divider";
import toast from 'react-hot-toast';
import { updateConfigs } from '@/api';
import { SettingsIcon } from '@/components/icons';
import AdminLayout from "@/layouts/admin";
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"
>
<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" />
<polyline points="7,3 7,8 15,8" />
</svg>
);
interface ConfigItem {
key: string;
label: string;
placeholder: string;
description?: string;
}
// 网站配置项定义
const CONFIG_ITEMS: ConfigItem[] = [
{
key: 'app_name',
label: '应用名称',
placeholder: '请输入应用名称',
description: '在浏览器标签页和导航栏显示的应用名称'
}
];
// 初始化时从缓存读取配置,避免闪烁
const getInitialConfigs = (): Record<string, string> => {
if (typeof window === 'undefined') return {};
const configKeys = ['app_name', 'site_title', 'site_description', 'contact_email', 'footer_text', 'logo_url'];
const initialConfigs: Record<string, string> = {};
try {
configKeys.forEach(key => {
const cachedValue = localStorage.getItem('vite_config_' + key);
if (cachedValue) {
initialConfigs[key] = cachedValue;
}
});
} catch (error) {
}
return initialConfigs;
};
export default function ConfigPage() {
const initialConfigs = getInitialConfigs();
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);
// 权限检查
useEffect(() => {
if (!isAdmin()) {
toast.error('权限不足,只有管理员可以访问此页面');
window.location.href = '/dashboard';
return;
}
}, []);
// 加载配置数据(优先从缓存)
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);
if (hasDataChanged) {
setConfigs(configData);
setOriginalConfigs({ ...configData });
setHasChanges(false);
} else {
}
} catch (error) {
// 只有在没有缓存数据时才显示错误
if (!hasInitialData) {
toast.error('加载配置出错,请重试');
}
} finally {
setLoading(false);
}
};
useEffect(() => {
// 延迟加载,避免阻塞初始渲染
const timer = setTimeout(() => {
loadConfigs(initialConfigs);
}, 100);
return () => clearTimeout(timer);
}, []); // 只在组件挂载时执行一次
// 处理配置项变更
const handleConfigChange = (key: string, value: string) => {
const newConfigs = { ...configs, [key]: value };
setConfigs(newConfigs);
// 检查是否有变更
const hasChangesNow = Object.keys(newConfigs).some(
k => newConfigs[k] !== originalConfigs[k]
) || Object.keys(originalConfigs).some(
k => originalConfigs[k] !== newConfigs[k]
);
setHasChanges(hasChangesNow);
};
// 保存配置
const handleSave = async () => {
setSaving(true);
try {
const response = await updateConfigs(configs);
if (response.code === 0) {
toast.success('配置保存成功');
// 清除所有配置缓存,强制下次重新获取
clearConfigCache();
// 获取变更的配置项
const changedKeys = Object.keys(configs).filter(
key => configs[key] !== originalConfigs[key]
);
setOriginalConfigs({ ...configs });
setHasChanges(false);
// 如果应用名称发生变化,立即更新网站配置
if (changedKeys.includes('app_name')) {
await updateSiteConfig();
}
// 触发配置更新事件,通知其他组件
window.dispatchEvent(new CustomEvent('configUpdated', {
detail: { changedKeys }
}));
} else {
toast.error('保存配置失败: ' + response.msg);
}
} catch (error) {
toast.error('保存配置出错,请重试');
} finally {
setSaving(false);
}
};
// 重置配置
const handleReset = () => {
setConfigs({ ...originalConfigs });
setHasChanges(false);
toast.success('已重置为保存前的状态');
};
if (loading) {
return (
<AdminLayout>
<div className="flex items-center justify-center min-h-[400px]">
<Spinner size="lg" label="加载配置中..." />
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<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">
{hasChanges && (
<Button
color="default"
variant="bordered"
size="sm"
onClick={handleReset}
disabled={saving}
>
重置
</Button>
)}
<Button
color="primary"
startContent={<SaveIcon className="w-4 h-4" />}
onClick={handleSave}
isLoading={saving}
disabled={!hasChanges}
>
{saving ? '保存中...' : '保存配置'}
</Button>
</div>
</div>
</CardHeader>
<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>
)}
</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>
{/* 操作提示 */}
{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>
</AdminLayout>
);
}
+2 -2
View File
@@ -108,8 +108,8 @@ export default function IndexPage() {
return (
<DefaultLayout>
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10 min-h-[calc(100vh-200px)]">
<div className="w-full max-w-md">
<section className="flex flex-col items-center justify-center gap-4 py-4 sm:py-8 md:py-10 min-h-[calc(100vh-120px)] sm:min-h-[calc(100vh-200px)]">
<div className="w-full max-w-md px-4 sm:px-0">
<Card className="w-full">
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
<h1 className={title({ size: "sm" })}>登陆</h1>
+51 -21
View File
@@ -698,14 +698,24 @@ export default function UserPage() {
label="流量限制(GB)"
type="number"
value={userForm.flow.toString()}
onChange={(e) => setUserForm(prev => ({ ...prev, flow: Number(e.target.value) || 0 }))}
onChange={(e) => {
const value = Math.min(Math.max(Number(e.target.value) || 0, 1), 99999);
setUserForm(prev => ({ ...prev, flow: value }));
}}
min="1"
max="99999"
isRequired
/>
<Input
label="转发数量"
type="number"
value={userForm.num.toString()}
onChange={(e) => setUserForm(prev => ({ ...prev, num: Number(e.target.value) || 0 }))}
onChange={(e) => {
const value = Math.min(Math.max(Number(e.target.value) || 0, 1), 99999);
setUserForm(prev => ({ ...prev, num: value }));
}}
min="1"
max="99999"
isRequired
/>
<Select
@@ -720,11 +730,11 @@ export default function UserPage() {
<SelectItem key="0" textValue="不重置">
不重置
</SelectItem>
{Array.from({ length: 31 }, (_, i) => i + 1).map(day => (
<SelectItem key={day.toString()} textValue={`每月${day}号(0点重置)`}>
每月{day}号(0点重置)
</SelectItem>
))}
{Array.from({ length: 31 }, (_, i) => i + 1).map(day => (
<SelectItem key={day.toString()} textValue={`每月${day}号(0点重置)`}>
每月{day}号(0点重置)
</SelectItem>
))}
</>
</Select>
<DatePicker
@@ -823,14 +833,24 @@ export default function UserPage() {
label="流量限制(GB)"
type="number"
value={tunnelForm.flow.toString()}
onChange={(e) => setTunnelForm(prev => ({ ...prev, flow: Number(e.target.value) || 0 }))}
onChange={(e) => {
const value = Math.min(Math.max(Number(e.target.value) || 0, 1), 99999);
setTunnelForm(prev => ({ ...prev, flow: value }));
}}
min="1"
max="99999"
/>
<Input
label="转发数量"
type="number"
value={tunnelForm.num.toString()}
onChange={(e) => setTunnelForm(prev => ({ ...prev, num: Number(e.target.value) || 0 }))}
onChange={(e) => {
const value = Math.min(Math.max(Number(e.target.value) || 0, 1), 99999);
setTunnelForm(prev => ({ ...prev, num: value }));
}}
min="1"
max="99999"
/>
<Select
@@ -845,11 +865,11 @@ export default function UserPage() {
<SelectItem key="0" textValue="不重置">
不重置
</SelectItem>
{Array.from({ length: 31 }, (_, i) => i + 1).map(day => (
<SelectItem key={day.toString()} textValue={`每月${day}号(0点重置)`}>
每月{day}号(0点重置)
</SelectItem>
))}
{Array.from({ length: 31 }, (_, i) => i + 1).map(day => (
<SelectItem key={day.toString()} textValue={`每月${day}号(0点重置)`}>
每月{day}号(0点重置)
</SelectItem>
))}
</>
</Select>
@@ -986,14 +1006,24 @@ export default function UserPage() {
label="流量限制(GB)"
type="number"
value={editTunnelForm.flow.toString()}
onChange={(e) => setEditTunnelForm(prev => prev ? { ...prev, flow: Number(e.target.value) || 0 } : null)}
onChange={(e) => {
const value = Math.min(Math.max(Number(e.target.value) || 0, 1), 99999);
setEditTunnelForm(prev => prev ? { ...prev, flow: value } : null);
}}
min="1"
max="99999"
/>
<Input
label="转发数量"
type="number"
value={editTunnelForm.num.toString()}
onChange={(e) => setEditTunnelForm(prev => prev ? { ...prev, num: Number(e.target.value) || 0 } : null)}
onChange={(e) => {
const value = Math.min(Math.max(Number(e.target.value) || 0, 1), 99999);
setEditTunnelForm(prev => prev ? { ...prev, num: value } : null);
}}
min="1"
max="99999"
/>
<Select
@@ -1026,11 +1056,11 @@ export default function UserPage() {
<SelectItem key="0" textValue="不重置">
不重置
</SelectItem>
{Array.from({ length: 31 }, (_, i) => i + 1).map(day => (
<SelectItem key={day.toString()} textValue={`每月${day}号(0点重置)`}>
每月{day}号(0点重置)
</SelectItem>
))}
{Array.from({ length: 31 }, (_, i) => i + 1).map(day => (
<SelectItem key={day.toString()} textValue={`每月${day}号(0点重置)`}>
每月{day}号(0点重置)
</SelectItem>
))}
</>
</Select>