import { useState, useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { AnimatePresence, motion } from "framer-motion"; 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, activateLicense, exportBackup, importBackup, getAnnouncement, updateAnnouncement, type AnnouncementData, } from "@/api"; import { BackIcon, SettingsIcon } from "@/components/icons"; import { ThemeSettings } from "@/components/theme-settings"; import { isAdmin } from "@/utils/auth"; import { getCachedConfigs, configCache, updateSiteConfig } from "@/config/site"; import { type UpdateReleaseChannel, getUpdateReleaseChannel, setUpdateReleaseChannel, } from "@/utils/version-update"; import { convertBrandAssetToPngDataURL, isPngDataURL, type BrandAssetKind, } from "@/utils/brand-asset"; // 简单的保存图标组件 const SaveIcon = ({ className }: { className?: string }) => ( ); interface ConfigItem { key: string; label: string; placeholder?: string; description?: string; type: "input" | "switch" | "select" | "bg_image"; options?: { label: string; value: string; description?: string }[]; dependsOn?: string; // 依赖的配置项key dependsValue?: string; // 依赖的配置项值 } const BRAND_PREVIEW_KEYS = ["app_logo", "app_favicon"] as const; type BrandPreviewKey = (typeof BRAND_PREVIEW_KEYS)[number]; const isBrandPreviewKey = (key: string): key is BrandPreviewKey => BRAND_PREVIEW_KEYS.includes(key as BrandPreviewKey); const BRAND_FILE_ACCEPT = "image/png,image/jpeg,image/webp,image/svg+xml"; const toBrandAssetKind = (key: BrandPreviewKey): BrandAssetKind => { return key === "app_logo" ? "logo" : "favicon"; }; // 网站配置项定义 const CONFIG_ITEMS: ConfigItem[] = [ { key: "app_bg_image", label: "自定义背景", description: "上传自定义背景图片(建议使用深色/浅色均可看清的图片,或使用半透明模糊效果)", type: "bg_image", }, { key: "ip", label: "面板后端地址", placeholder: "请输入面板后端IP:PORT", description: '格式"ip:port"或"domain:port",用于对接节点时使用。支持套CDN和HTTPS,通讯数据有加密', type: "input", }, { key: "panel_domain", label: "面板域名", placeholder: "请输入面板域名", description: "当前面板的域名,用于与其他面板进行联邦共享时验证身份", type: "input", }, { key: "app_name", label: "应用名称", placeholder: "请输入应用名称", description: "在浏览器标签页和导航栏显示的应用名称", type: "input", }, { key: "app_logo", label: "网页角标 Logo", description: "用于页面左上角导航角标,上传后会自动转换为 PNG 并持久化保存", type: "input", }, { key: "app_favicon", label: "浏览器缩略图标", description: "用于浏览器标签页图标,上传后会自动转换为 PNG 并持久化保存", type: "input", }, { key: "hide_footer_brand", label: "隐藏页面底部 FLVX 版权信息", description: "需商业版授权才能生效", type: "switch", }, { key: "forward_compact_mode", label: "规则页面精简模式", description: "开启后,规则页面列表使用 2.1.6-alpha8 样式(全局配置)", type: "switch", }, { key: "monitor_tunnel_quality_enabled", label: "实时隧道质量检测", description: "关闭后,前端停止自动刷新,后端停止实时隧道质量探测(全局配置)", type: "switch", }, { 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", }, { key: "github_proxy_enabled", label: "开启 GitHub 加速", description: "用于节点更新和安装脚本下载,解决部分地区 GitHub 访问受限问题", type: "switch", }, { key: "github_proxy_url", label: "加速地址", placeholder: "https://gcode.hostcentral.cc", description: "GitHub 下载加速代理地址,开启加速后生效", type: "input", dependsOn: "github_proxy_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", "forward_compact_mode", "monitor_tunnel_quality_enabled", "ip", "panel_domain", "app_logo", "app_favicon", "github_proxy_enabled", "github_proxy_url", ]; 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 backupFileInputRef = useRef(null); const [activatingLicense, setActivatingLicense] = useState(false); const [licenseKeyInput, setLicenseKeyInput] = useState(""); const logoFileInputRef = useRef(null); const faviconFileInputRef = useRef(null); const bgImageFileInputRef = useRef(null); const [bgImageUploading, setBgImageUploading] = useState(false); const [announcement, setAnnouncement] = useState({ content: "", enabled: 0, }); const [announcementLoading, setAnnouncementLoading] = useState(true); const [announcementSaving, setAnnouncementSaving] = useState(false); const [updateChannel, setUpdateChannel] = useState( getUpdateReleaseChannel(), ); const [previewLoadFailed, setPreviewLoadFailed] = useState< Partial> >({}); const [brandUploading, setBrandUploading] = useState< Partial> >({}); const canGoBack = typeof window !== "undefined" && typeof window.history.state?.idx === "number" && window.history.state.idx > 0; const handleBack = () => { if (canGoBack) { navigate(-1); return; } navigate("/profile", { replace: true }); }; // 权限检查 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 { } 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 handleUpdateChannelChange = (channel: UpdateReleaseChannel) => { setUpdateChannel(channel); setUpdateReleaseChannel(channel); toast.success( `更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`, ); }; const handleActivateLicense = async () => { if (!licenseKeyInput.trim()) { toast.error("请输入有效的商业授权码"); return; } setActivatingLicense(true); try { const res = await activateLicense(licenseKeyInput.trim()); if (res.code === 0) { toast.success("商业版授权激活成功!"); setLicenseKeyInput(""); await loadConfigs(); window.dispatchEvent(new CustomEvent("configUpdated")); } else { toast.error(res.msg || "授权激活失败"); } } catch (e: any) { toast.error(e.message || "授权激活出错"); } finally { setActivatingLicense(false); } }; const handleConfigChange = (key: string, value: string) => { const newConfigs = { ...configs, [key]: value }; setConfigs(newConfigs); if (isBrandPreviewKey(key)) { setPreviewLoadFailed((prev) => ({ ...prev, [key]: false })); } 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 changedKeys = Object.keys(configs).filter( (key) => configs[key] !== originalConfigs[key], ); if (changedKeys.length === 0) { setHasChanges(false); return; } const changedPayload: Record = {}; changedKeys.forEach((key) => { changedPayload[key] = configs[key] || ""; }); const response = await updateConfigs(changedPayload); if (response.code === 0) { toast.success("配置保存成功"); Object.entries(configs).forEach(([key, value]) => { configCache.set(key, value); }); setOriginalConfigs({ ...configs }); setHasChanges(false); if ( changedKeys.some((key) => ["app_name", "app_logo", "app_favicon"].includes(key), ) ) { await updateSiteConfig(configs); } // 触发配置更新事件,通知其他组件 window.dispatchEvent( new CustomEvent("configUpdated", { detail: { changedKeys }, }), ); // 如果隧道质量检测开关变更,通知 tunnel-monitor-view if (changedKeys.includes("monitor_tunnel_quality_enabled")) { window.dispatchEvent( new CustomEvent("monitorTunnelQualityEnabledChanged", { detail: { enabled: configs["monitor_tunnel_quality_enabled"] === "true", }, }), ); } } else { toast.error("保存配置失败: " + response.msg); } } catch { toast.error("保存配置出错,请重试"); } finally { setSaving(false); } }; // 检查配置项是否应该显示(依赖检查) const shouldShowItem = (item: ConfigItem): boolean => { // 隐藏商业版专属的设置项,它们会在专门的卡片中展示 if ( ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes( item.key, ) ) { return false; } if (!item.dependsOn || !item.dependsValue) { return true; } return configs[item.dependsOn] === item.dependsValue; }; const getBrandInputRef = (key: BrandPreviewKey) => { return key === "app_logo" ? logoFileInputRef : faviconFileInputRef; }; const triggerBrandFilePicker = (key: BrandPreviewKey) => { if (brandUploading[key]) { return; } getBrandInputRef(key).current?.click(); }; const clearBrandAsset = (key: BrandPreviewKey) => { handleConfigChange(key, ""); setPreviewLoadFailed((prev) => ({ ...prev, [key]: false })); }; const handleBrandFileChange = async ( key: BrandPreviewKey, event: React.ChangeEvent, ) => { const file = event.target.files?.[0]; if (!file) { return; } setBrandUploading((prev) => ({ ...prev, [key]: true })); try { const pngDataURL = await convertBrandAssetToPngDataURL( file, toBrandAssetKind(key), ); handleConfigChange(key, pngDataURL); toast.success(key === "app_logo" ? "Logo 上传成功" : "Favicon 上传成功"); } catch (error) { const message = error instanceof Error ? error.message : "图片处理失败,请重试"; toast.error(message); } finally { setBrandUploading((prev) => ({ ...prev, [key]: false })); event.target.value = ""; } }; const handleBgImageUpload = async ( e: React.ChangeEvent, ) => { const file = e.target.files?.[0]; if (!file) return; if (!file.type.startsWith("image/")) { toast.error("只能上传图片文件"); return; } setBgImageUploading(true); try { const compressedImage = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (event) => { const img = new Image(); img.onload = () => { const canvas = document.createElement("canvas"); let width = img.width; let height = img.height; const MAX_WIDTH = 1920; const MAX_HEIGHT = 1080; if (width > height) { if (width > MAX_WIDTH) { height = Math.round((height * MAX_WIDTH) / width); width = MAX_WIDTH; } } else { if (height > MAX_HEIGHT) { width = Math.round((width * MAX_HEIGHT) / height); height = MAX_HEIGHT; } } canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); if (!ctx) { reject(new Error("Canvas context is null")); return; } ctx.drawImage(img, 0, 0, width, height); // Output as webp for better compression const dataUrl = canvas.toDataURL("image/webp", 0.8); resolve(dataUrl); }; img.onerror = () => reject(new Error("图片加载失败")); img.src = event.target?.result as string; }; reader.onerror = () => reject(new Error("文件读取失败")); reader.readAsDataURL(file); }); handleConfigChange("app_bg_image", compressedImage); toast.success("自定义背景上传成功"); } catch (error) { toast.error(error instanceof Error ? error.message : "图片处理失败"); } finally { setBgImageUploading(false); e.target.value = ""; } }; const renderBgImageUploader = () => { const bgImage = configs["app_bg_image"] || ""; const isImage = bgImage.startsWith("http") || bgImage.startsWith("data:") || bgImage.startsWith("/") || bgImage.startsWith("blob:"); const isTheme = bgImage === "theme"; const isSolidColor = bgImage && !isImage && !isTheme; return (
{bgImage && ( )}
{bgImage && isImage && (
背景预览
预览效果
)} {bgImage && isTheme && (
当前为自适应纯色背景
)} {bgImage && isSolidColor && (
纯色背景 ({bgImage})
)}
); }; const renderBrandPreview = (key: BrandPreviewKey) => { const previewUrl = (configs[key] || "").trim(); const appNamePreview = (configs.app_name || "").trim() || "应用名称"; const failed = previewLoadFailed[key] === true; const showImage = previewUrl.length > 0 && !failed; return (

实时预览

{key === "app_logo" ? (
{showImage ? ( logo preview setPreviewLoadFailed((prev) => ({ ...prev, [key]: true })) } onLoad={() => setPreviewLoadFailed((prev) => ({ ...prev, [key]: false })) } /> ) : (
LOGO
)} {appNamePreview}
) : (
{showImage ? ( favicon preview setPreviewLoadFailed((prev) => ({ ...prev, [key]: true })) } onLoad={() => setPreviewLoadFailed((prev) => ({ ...prev, [key]: false })) } /> ) : (
)} {appNamePreview}
)}
{previewUrl.length === 0 ? (

上传图片后会实时显示预览

) : null} {previewUrl.length > 0 && failed ? (

图片加载失败,请重新上传

) : null} {previewUrl.length > 0 && !isPngDataURL(previewUrl) ? (

当前是旧版 URL 配置,建议重新上传图片以启用无闪烁加载

) : null}
); }; const renderBrandAssetUploader = ( key: BrandPreviewKey, isChanged: boolean, ) => { const value = (configs[key] || "").trim(); const uploading = brandUploading[key] === true; const isLogo = key === "app_logo"; const isCommercialDisabled = configs.is_commercial !== "true"; return (
{ void handleBrandFileChange(key, event); }} />
仅支持图片文件,自动转换为 PNG

{isLogo ? "建议上传方形图片,系统会统一转换为 96x96 PNG" : "建议上传方形图片,系统会统一转换为 64x64 PNG"}

{renderBrandPreview(key)}
); }; // 渲染不同类型的配置项 const renderConfigItem = (item: ConfigItem) => { const isChanged = hasChanges && configs[item.key] !== originalConfigs[item.key]; const isCommercialDisabled = ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes( item.key, ) && configs.is_commercial !== "true"; switch (item.type) { case "bg_image": return renderBgImageUploader(); case "input": if (isBrandPreviewKey(item.key)) { return renderBrandAssetUploader(item.key, isChanged); } 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(() => backupFileInputRef.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 (backupFileInputRef.current) { backupFileInputRef.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 (
{/* 页面标题 */}

网站配置

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

商业版授权

激活商业版授权以解锁自定义品牌功能(替换 Logo、应用名称,移除底部版权信息等)

setLicenseKeyInput(e.target.value)} />
{configs.is_commercial === "true" && (

白标与品牌设置

{CONFIG_ITEMS.filter((item) => [ "app_name", "app_logo", "app_favicon", "hide_footer_brand", ].includes(item.key), ).map((item, index) => (
{item.description && (

{item.description}

)}
{renderConfigItem(item)}
{index < 3 && }
))}
)}

基本设置

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

{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 && }
); })}

更新通道

稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc 的版本。

{/* 主题设置 */}
{hasChanges && (
检测到配置变更,请记得保存您的修改
)}

公告管理

设置首页显示的公告内容

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

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