import { useState, useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; import toast from "react-hot-toast"; import { Button } from "@/shadcn-bridge/heroui/button"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Input } from "@/shadcn-bridge/heroui/input"; import { Textarea } from "@/shadcn-bridge/heroui/input"; import { Spinner } from "@/shadcn-bridge/heroui/spinner"; import { Divider } from "@/shadcn-bridge/heroui/divider"; import { Switch } from "@/shadcn-bridge/heroui/switch"; import { Select, SelectItem } from "@/shadcn-bridge/heroui/select"; import { Checkbox } from "@/shadcn-bridge/heroui/checkbox"; import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, } from "@/shadcn-bridge/heroui/modal"; import { updateConfigs, exportBackup, importBackup, getAnnouncement, updateAnnouncement, type AnnouncementData, } from "@/api"; import { SettingsIcon } from "@/components/icons"; import { isAdmin } from "@/utils/auth"; import { getCachedConfigs, clearConfigCache, updateSiteConfig, } from "@/config/site"; // 简单的保存图标组件 const SaveIcon = ({ className }: { className?: string }) => ( ); interface ConfigItem { key: string; label: string; placeholder?: string; description?: string; type: "input" | "switch" | "select"; options?: { label: string; value: string; description?: string }[]; dependsOn?: string; // 依赖的配置项key dependsValue?: string; // 依赖的配置项值 } // 网站配置项定义 const CONFIG_ITEMS: ConfigItem[] = [ { key: "ip", label: "面板后端地址", placeholder: "请输入面板后端IP:PORT", description: "格式“ip:port”,用于对接节点时使用,ip是你安装面板服务器的公网ip,端口是安装脚本内输入的后端端口。不要套CDN,不支持https,通讯数据有加密", type: "input", }, { key: "panel_domain", label: "面板域名", placeholder: "请输入面板域名", description: "当前面板的域名,用于与其他面板进行联邦共享时验证身份", type: "input", }, { key: "app_name", label: "应用名称", placeholder: "请输入应用名称", description: "在浏览器标签页和导航栏显示的应用名称", type: "input", }, { key: "captcha_enabled", label: "启用验证码", description: "开启后,用户登录时需要完成验证码验证", type: "switch", }, { key: "cloudflare_site_key", label: "Cloudflare Site Key", placeholder: "请输入 Cloudflare Site Key", description: "Cloudflare Turnstile 站点密钥", type: "input", dependsOn: "captcha_enabled", dependsValue: "true", }, { key: "cloudflare_secret_key", label: "Cloudflare Secret Key", placeholder: "请输入 Cloudflare Secret Key", description: "Cloudflare Turnstile 密钥", type: "input", dependsOn: "captcha_enabled", dependsValue: "true", }, ]; const BACKUP_TYPE_OPTIONS = [ { value: "users", label: "用户" }, { value: "nodes", label: "节点" }, { value: "tunnels", label: "隧道" }, { value: "forwards", label: "转发" }, { value: "userTunnels", label: "用户隧道权限" }, { value: "speedLimits", label: "限速规则" }, { value: "tunnelGroups", label: "隧道分组" }, { value: "userGroups", label: "用户分组" }, { value: "permissions", label: "分组权限" }, { value: "configs", label: "系统配置" }, ] as const; const BACKUP_TYPE_VALUES = BACKUP_TYPE_OPTIONS.map((option) => option.value); // 初始化时从缓存读取配置,避免闪烁 const getInitialConfigs = (): Record => { if (typeof window === "undefined") return {}; const configKeys = [ "app_name", "captcha_enabled", "cloudflare_site_key", "cloudflare_secret_key", "ip", "panel_domain", ]; const initialConfigs: Record = {}; try { configKeys.forEach((key) => { const cachedValue = localStorage.getItem("vite_config_" + key); if (cachedValue) { initialConfigs[key] = cachedValue; } }); } catch {} return initialConfigs; }; export default function ConfigPage() { const navigate = useNavigate(); const initialConfigs = getInitialConfigs(); const [configs, setConfigs] = useState>(initialConfigs); const [loading, setLoading] = useState( Object.keys(initialConfigs).length === 0, ); const [saving, setSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); const [originalConfigs, setOriginalConfigs] = useState>(initialConfigs); const [exportTypes, setExportTypes] = useState([]); const [importTypes, setImportTypes] = useState([]); const [exporting, setExporting] = useState(false); const [importing, setImporting] = useState(false); const [exportSelectorOpen, setExportSelectorOpen] = useState(false); const [importSelectorOpen, setImportSelectorOpen] = useState(false); const [importFileName, setImportFileName] = useState(""); const fileInputRef = useRef(null); const [announcement, setAnnouncement] = useState({ content: "", enabled: 0, }); const [announcementLoading, setAnnouncementLoading] = useState(true); const [announcementSaving, setAnnouncementSaving] = useState(false); // 权限检查 useEffect(() => { if (!isAdmin()) { toast.error("权限不足,只有管理员可以访问此页面"); navigate("/dashboard", { replace: true }); return; } }, [navigate]); // 加载配置数据(优先从缓存) const loadConfigs = async (currentConfigs?: Record) => { 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 { // 只有在没有缓存数据时才显示错误 if (!hasInitialData) { toast.error("加载配置出错,请重试"); } } finally { setLoading(false); } }; useEffect(() => { const timer = setTimeout(() => { loadConfigs(initialConfigs); loadAnnouncement(); }, 100); return () => clearTimeout(timer); }, []); const loadAnnouncement = async () => { setAnnouncementLoading(true); try { const res = await getAnnouncement(); if (res.code === 0 && res.data) { setAnnouncement(res.data); } } catch (error) { console.error("Failed to load announcement:", error); } finally { setAnnouncementLoading(false); } }; const saveAnnouncement = async () => { setAnnouncementSaving(true); try { const res = await updateAnnouncement(announcement); if (res.code === 0) { toast.success("公告保存成功"); } else { toast.error(res.msg || "保存失败"); } } catch { toast.error("保存公告失败,请重试"); } finally { setAnnouncementSaving(false); } }; 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 { 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]; switch (item.type) { case "input": return ( handleConfigChange(item.key, e.target.value)} /> ); case "switch": return ( handleConfigChange(item.key, checked ? "true" : "false") } > {configs[item.key] === "true" ? "已启用" : "已禁用"} ); case "select": return ( ); default: return null; } }; const handleExport = async () => { if (exportTypes.length === 0) { toast.error("请至少选择一种数据类型"); return; } setExporting(true); try { await exportBackup(exportTypes); toast.success("导出成功"); setExportSelectorOpen(false); } catch { toast.error("导出失败,请重试"); } finally { setExporting(false); } }; const triggerImportFilePicker = () => { if (importTypes.length === 0) { toast.error("请先选择要导入的数据类型"); return; } setImportSelectorOpen(false); requestAnimationFrame(() => fileInputRef.current?.click()); }; const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; if (importTypes.length === 0) { toast.error("请先选择要导入的数据类型"); return; } setImportFileName(file.name); setImporting(true); try { const text = await file.text(); const data = JSON.parse(text); const response = await importBackup({ types: importTypes, ...data, }); if (response.code === 0) { toast.success(`导入成功: ${JSON.stringify(response.data)}`); setImportTypes([]); setImportFileName(""); } else { toast.error("导入失败: " + response.msg); } } catch { toast.error("导入失败,请检查文件格式"); } finally { setImporting(false); if (fileInputRef.current) { fileInputRef.current.value = ""; } } }; const toggleTypeSelection = ( type: string, setTypes: React.Dispatch>, ) => { setTypes((prev) => prev.includes(type) ? prev.filter((item) => item !== type) : [...prev, type], ); }; const isAllTypesSelected = (types: string[]) => BACKUP_TYPE_VALUES.every((type) => types.includes(type)); const renderTypeSelection = ( label: string, selectedTypes: string[], setTypes: React.Dispatch>, ) => { const allSelected = isAllTypesSelected(selectedTypes); return (
{label}
{BACKUP_TYPE_OPTIONS.map((option) => { const isSelected = selectedTypes.includes(option.value); return ( ); })}
); }; if (loading) { return (
); } return (
{/* 页面标题 */}

网站配置

管理网站的基本信息和显示设置

基本设置

配置网站的基本信息,这些设置会影响网站的显示效果

{CONFIG_ITEMS.map((item, index) => { // 检查配置项是否应该显示 if (!shouldShowItem(item)) { return null; } // 计算是否是最后一个显示的项目(用于决定是否显示分隔线) const remainingItems = CONFIG_ITEMS.slice(index + 1).filter( shouldShowItem, ); const isLastItem = remainingItems.length === 0; return (
{item.description && (

{item.description}

)}
{/* 渲染配置项 */} {renderConfigItem(item)} {/* 分隔线 */} {!isLastItem && }
); })}
{hasChanges && (
检测到配置变更,请记得保存您的修改
)}

公告管理

设置首页显示的公告内容

{announcementLoading ? (
) : ( <>
setAnnouncement({ ...announcement, enabled: checked ? 1 : 0, }) } > {announcement.enabled === 1 ? "已启用" : "已禁用"}

启用后,公告将在首页顶部显示