"use client" import React, { useEffect, useState, useCallback, useRef } from "react" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { useToast } from "@/components/ui/use-toast" import { useRouter } from "next/navigation" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" import { Switch } from "@/components/ui/switch" import { Slider } from "@/components/ui/slider" import { Plus, Trash2, Edit, Download, Upload } from "lucide-react" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog" interface ExtRuleConfig { Extensions: string; // 逗号分隔的扩展名 Target: string; // 目标服务器 SizeThreshold: number; // 最小阈值(字节) MaxSize: number; // 最大阈值(字节) RedirectMode?: boolean; // 是否使用302跳转模式 } interface PathMapping { DefaultTarget: string ExtensionMap?: ExtRuleConfig[] // 只支持新格式 SizeThreshold?: number // 保留全局阈值字段(向后兼容) MaxSize?: number // 保留全局阈值字段(向后兼容) RedirectMode?: boolean // 是否使用302跳转模式 } interface CompressionConfig { Enabled: boolean Level: number } interface Config { MAP: Record Compression: { Gzip: CompressionConfig Brotli: CompressionConfig } } export default function ConfigPage() { const [config, setConfig] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const { toast } = useToast() const router = useRouter() // 使用 ref 来保存滚动位置 const scrollPositionRef = useRef(0) // 添加一个ref来跟踪是否是初始加载 const isInitialLoadRef = useRef(true) // 添加一个标志来跟踪配置是否是从API加载的 const isConfigFromApiRef = useRef(true) // 添加一个防抖定时器ref const saveTimeoutRef = useRef(null) // 对话框状态 const [pathDialogOpen, setPathDialogOpen] = useState(false) const [newPathData, setNewPathData] = useState({ path: "", defaultTarget: "", redirectMode: false, extensionMap: {} as Record, sizeThreshold: 0, maxSize: 0, sizeThresholdUnit: 'MB' as 'B' | 'KB' | 'MB' | 'GB', maxSizeUnit: 'MB' as 'B' | 'KB' | 'MB' | 'GB', }) const [editingPath, setEditingPath] = useState(null) const [editingPathData, setEditingPathData] = useState<{ path: string; defaultTarget: string; sizeThreshold: number; maxSize: number; sizeThresholdUnit: 'B' | 'KB' | 'MB' | 'GB'; maxSizeUnit: 'B' | 'KB' | 'MB' | 'GB'; } | null>(null); const [deletingPath, setDeletingPath] = useState(null) // 添加扩展名规则状态 const [newExtensionRule, setNewExtensionRule] = useState<{ extensions: string; target: string; sizeThreshold: number; maxSize: number; sizeThresholdUnit: 'B' | 'KB' | 'MB' | 'GB'; maxSizeUnit: 'B' | 'KB' | 'MB' | 'GB'; }>({ extensions: "", target: "", sizeThreshold: 0, maxSize: 0, sizeThresholdUnit: 'MB', maxSizeUnit: 'MB', }); const [editingExtensionRule, setEditingExtensionRule] = useState<{ index: number, extensions: string; target: string; sizeThreshold: number; maxSize: number; sizeThresholdUnit: 'B' | 'KB' | 'MB' | 'GB'; maxSizeUnit: 'B' | 'KB' | 'MB' | 'GB'; } | null>(null); // 添加扩展名规则对话框状态 const [extensionRuleDialogOpen, setExtensionRuleDialogOpen] = useState(false); const fetchConfig = useCallback(async () => { try { const token = localStorage.getItem("token") if (!token) { router.push("/login") return } const response = await fetch("/admin/api/config/get", { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }) if (response.status === 401) { localStorage.removeItem("token") router.push("/login") return } if (!response.ok) { throw new Error("获取配置失败") } const data = await response.json() isConfigFromApiRef.current = true // 标记配置来自API setConfig(data) } catch (error) { const message = error instanceof Error ? error.message : "获取配置失败" toast({ title: "错误", description: message, variant: "destructive", }) } finally { setLoading(false) } }, [router, toast]) // 创建一个包装的setConfig函数,用于用户修改配置时 const updateConfig = useCallback((newConfig: Config) => { isConfigFromApiRef.current = false // 标记配置来自用户修改 setConfig(newConfig) }, []) useEffect(() => { fetchConfig() }, [fetchConfig]) const handleSave = useCallback(async () => { if (!config) return setSaving(true) try { const token = localStorage.getItem("token") if (!token) { router.push("/login") return } const response = await fetch("/admin/api/config/save", { method: "POST", headers: { "Content-Type": "application/json", 'Authorization': `Bearer ${token}` }, body: JSON.stringify(config), }) if (response.status === 401) { localStorage.removeItem("token") router.push("/login") return } if (!response.ok) { const data = await response.json().catch(() => ({})) throw new Error(data.message || "保存配置失败") } toast({ title: "成功", description: "配置已自动保存", }) } catch (error) { toast({ title: "错误", description: error instanceof Error ? error.message : "保存配置失败", variant: "destructive", }) } finally { setSaving(false) } }, [config, router, toast]) // 添加自动保存的useEffect useEffect(() => { // 如果是初始加载或者配置为空,不触发保存 if (isInitialLoadRef.current || !config || isConfigFromApiRef.current) { isInitialLoadRef.current = false isConfigFromApiRef.current = false // 重置标志 return } // 清除之前的定时器 if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current) } // 保存当前滚动位置 const currentScrollPosition = window.scrollY // 设置新的定时器,延迟1秒后保存 saveTimeoutRef.current = setTimeout(() => { handleSave().then(() => { // 保存完成后恢复滚动位置 window.scrollTo(0, currentScrollPosition) }) }, 1000) // 组件卸载时清除定时器 return () => { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current) } } }, [config, handleSave]) // 监听config变化 // 处理对话框打开和关闭时的滚动位置 const handleDialogOpenChange = useCallback((open: boolean, handler: (open: boolean) => void) => { if (open) { // 对话框打开时,保存当前滚动位置 scrollPositionRef.current = window.scrollY } else { // 对话框关闭时,恢复滚动位置 handler(open) requestAnimationFrame(() => { window.scrollTo(0, scrollPositionRef.current) }) } }, []) const handlePathDialogOpenChange = useCallback((open: boolean) => { handleDialogOpenChange(open, (isOpen) => { setPathDialogOpen(isOpen) if (!isOpen) { setEditingPathData(null) setNewPathData({ path: "", defaultTarget: "", redirectMode: false, extensionMap: {}, sizeThreshold: 0, maxSize: 0, sizeThresholdUnit: 'MB', maxSizeUnit: 'MB', }) } }) }, [handleDialogOpenChange]) const addOrUpdatePath = () => { if (!config) return const data = editingPathData || newPathData const { path, defaultTarget } = data if (!path || !defaultTarget) { toast({ title: "错误", description: "路径和默认目标不能为空", variant: "destructive", }) return } const newConfig = { ...config } const pathConfig: PathMapping = { DefaultTarget: defaultTarget, ExtensionMap: [] } // 如果是编辑现有路径,保留原有的扩展名映射 if (editingPathData && typeof config.MAP[path] === 'object') { const existingConfig = config.MAP[path] as PathMapping pathConfig.ExtensionMap = existingConfig.ExtensionMap } newConfig.MAP[path] = pathConfig updateConfig(newConfig) if (editingPathData) { setEditingPathData(null) } else { setNewPathData({ path: "", defaultTarget: "", redirectMode: false, extensionMap: {}, sizeThreshold: 0, maxSize: 0, sizeThresholdUnit: 'MB', maxSizeUnit: 'MB', }) } setPathDialogOpen(false) } const deletePath = (path: string) => { setDeletingPath(path) } const confirmDeletePath = () => { if (!config || !deletingPath) return const newConfig = { ...config } delete newConfig.MAP[deletingPath] updateConfig(newConfig) setDeletingPath(null) } const updateCompression = (type: 'Gzip' | 'Brotli', field: 'Enabled' | 'Level', value: boolean | number) => { if (!config) return const newConfig = { ...config } if (field === 'Enabled') { newConfig.Compression[type].Enabled = value as boolean } else { newConfig.Compression[type].Level = value as number } updateConfig(newConfig) } const handleExtensionMapEdit = (path: string) => { // 将添加规则的操作重定向到handleExtensionRuleEdit handleExtensionRuleEdit(path); }; const deleteExtensionRule = (path: string, index: number) => { setDeletingExtensionRule({ path, index }); }; const openAddPathDialog = () => { setEditingPathData(null) setNewPathData({ path: "", defaultTarget: "", redirectMode: false, extensionMap: {}, sizeThreshold: 0, maxSize: 0, sizeThresholdUnit: 'MB', maxSizeUnit: 'MB', }) setPathDialogOpen(true) } const exportConfig = () => { if (!config) return const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = 'proxy-config.json' document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } const importConfig = (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (!file) return const reader = new FileReader() reader.onload = (e) => { try { const content = e.target?.result as string const newConfig = JSON.parse(content) // 验证配置结构 if (!newConfig.MAP || typeof newConfig.MAP !== 'object') { throw new Error('配置文件缺少 MAP 字段或格式不正确') } if (!newConfig.Compression || typeof newConfig.Compression !== 'object' || !newConfig.Compression.Gzip || !newConfig.Compression.Brotli) { throw new Error('配置文件压缩设置格式不正确') } // 验证路径映射 for (const [path, target] of Object.entries(newConfig.MAP)) { if (!path.startsWith('/')) { throw new Error(`路径 ${path} 必须以/开头`) } if (typeof target === 'string') { try { new URL(target) } catch { throw new Error(`路径 ${path} 的目标URL格式不正确`) } } else if (target && typeof target === 'object') { const mapping = target as PathMapping if (!mapping.DefaultTarget || typeof mapping.DefaultTarget !== 'string') { throw new Error(`路径 ${path} 的默认目标格式不正确`) } try { new URL(mapping.DefaultTarget) } catch { throw new Error(`路径 ${path} 的默认目标URL格式不正确`) } } else { throw new Error(`路径 ${path} 的目标格式不正确`) } } // 使用setConfig而不是updateConfig,因为导入的配置不应触发自动保存 isConfigFromApiRef.current = true setConfig(newConfig) toast({ title: "成功", description: "配置已导入", }) } catch (error) { toast({ title: "错误", description: error instanceof Error ? error.message : "配置文件格式错误", variant: "destructive", }) } } reader.readAsText(file) } const handleEditPath = (path: string, target: PathMapping | string) => { if (typeof target === 'string') { setEditingPathData({ path, defaultTarget: target, sizeThreshold: 0, maxSize: 0, sizeThresholdUnit: 'MB', maxSizeUnit: 'MB' }) } else { const { value: thresholdValue, unit: thresholdUnit } = convertBytesToUnit(target.SizeThreshold || 0) const { value: maxValue, unit: maxUnit } = convertBytesToUnit(target.MaxSize || 0) setEditingPathData({ path, defaultTarget: target.DefaultTarget, sizeThreshold: thresholdValue, maxSize: maxValue, sizeThresholdUnit: thresholdUnit, maxSizeUnit: maxUnit }) } setPathDialogOpen(true) } // 处理删除对话框的滚动位置 const handleDeleteDialogOpenChange = useCallback((open: boolean, setter: (value: null) => void) => { if (open) { scrollPositionRef.current = window.scrollY } else { setter(null) requestAnimationFrame(() => { window.scrollTo(0, scrollPositionRef.current) }) } }, []) // 为扩展名规则对话框添加处理函数 const handleExtensionRuleDialogOpenChange = useCallback((open: boolean) => { handleDialogOpenChange(open, (isOpen) => { setExtensionRuleDialogOpen(isOpen); if (!isOpen) { setEditingExtensionRule(null); setNewExtensionRule({ extensions: "", target: "", sizeThreshold: 0, maxSize: 0, sizeThresholdUnit: 'MB', maxSizeUnit: 'MB', }); } }); }, [handleDialogOpenChange]); // 处理扩展名规则的编辑 const handleExtensionRuleEdit = (path: string, index?: number, rule?: { Extensions: string; Target: string; SizeThreshold?: number; MaxSize?: number }) => { setEditingPath(path); if (index !== undefined && rule) { // 转换规则的阈值到合适的单位显示 const { value: thresholdValue, unit: thresholdUnit } = convertBytesToUnit(rule.SizeThreshold || 0); const { value: maxValue, unit: maxUnit } = convertBytesToUnit(rule.MaxSize || 0); setEditingExtensionRule({ index, extensions: rule.Extensions, target: rule.Target, sizeThreshold: thresholdValue, maxSize: maxValue, sizeThresholdUnit: thresholdUnit, maxSizeUnit: maxUnit, }); // 同时更新表单显示数据 setNewExtensionRule({ extensions: rule.Extensions, target: rule.Target, sizeThreshold: thresholdValue, maxSize: maxValue, sizeThresholdUnit: thresholdUnit, maxSizeUnit: maxUnit, }); } else { setEditingExtensionRule(null); // 重置表单 setNewExtensionRule({ extensions: "", target: "", sizeThreshold: 0, maxSize: 0, sizeThresholdUnit: 'MB', maxSizeUnit: 'MB', }); } setExtensionRuleDialogOpen(true); }; // 添加或更新扩展名规则 const addOrUpdateExtensionRule = () => { if (!config || !editingPath) return; const { extensions, target, sizeThreshold, maxSize, sizeThresholdUnit, maxSizeUnit } = newExtensionRule; // 验证输入 if (!extensions.trim() || !target.trim()) { toast({ title: "错误", description: "扩展名和目标不能为空", variant: "destructive", }); return; } // 验证扩展名格式 const extensionList = extensions.split(',').map(e => e.trim()); if (extensionList.some(e => !e || (e !== "*" && e.includes('.')))) { toast({ title: "错误", description: "扩展名格式不正确,不需要包含点号", variant: "destructive", }); return; } // 验证URL格式 try { new URL(target); } catch { toast({ title: "错误", description: "目标URL格式不正确", variant: "destructive", }); return; } // 转换大小为字节 const sizeThresholdBytes = convertToBytes(sizeThreshold, sizeThresholdUnit); const maxSizeBytes = convertToBytes(maxSize, maxSizeUnit); // 验证阈值 if (maxSizeBytes > 0 && sizeThresholdBytes >= maxSizeBytes) { toast({ title: "错误", description: "最大阈值必须大于最小阈值", variant: "destructive", }); return; } const newConfig = { ...config }; const mapping = newConfig.MAP[editingPath]; if (typeof mapping === "string") { // 如果映射是字符串,创建新的PathConfig对象 newConfig.MAP[editingPath] = { DefaultTarget: mapping, ExtensionMap: [{ Extensions: extensions, Target: target, SizeThreshold: sizeThresholdBytes, MaxSize: maxSizeBytes }] }; } else { // 确保ExtensionMap是数组 if (!Array.isArray(mapping.ExtensionMap)) { mapping.ExtensionMap = []; } if (editingExtensionRule) { // 更新现有规则 const rules = mapping.ExtensionMap as ExtRuleConfig[]; rules[editingExtensionRule.index] = { Extensions: extensions, Target: target, SizeThreshold: sizeThresholdBytes, MaxSize: maxSizeBytes }; } else { // 添加新规则 mapping.ExtensionMap.push({ Extensions: extensions, Target: target, SizeThreshold: sizeThresholdBytes, MaxSize: maxSizeBytes }); } } updateConfig(newConfig); setExtensionRuleDialogOpen(false); setEditingExtensionRule(null); setNewExtensionRule({ extensions: "", target: "", sizeThreshold: 0, maxSize: 0, sizeThresholdUnit: 'MB', maxSizeUnit: 'MB', }); }; // 删除扩展名规则 const [deletingExtensionRule, setDeletingExtensionRule] = useState<{path: string, index: number} | null>(null); const confirmDeleteExtensionRule = () => { if (!config || !deletingExtensionRule) return; const newConfig = { ...config }; const mapping = newConfig.MAP[deletingExtensionRule.path]; if (typeof mapping !== "string" && Array.isArray(mapping.ExtensionMap)) { // 移除指定索引的规则 const rules = mapping.ExtensionMap as ExtRuleConfig[]; mapping.ExtensionMap = [ ...rules.slice(0, deletingExtensionRule.index), ...rules.slice(deletingExtensionRule.index + 1) ]; } updateConfig(newConfig); setDeletingExtensionRule(null); }; if (loading) { return (
加载中...
正在获取配置数据
) } return (
Proxy Go配置
{saving && (
正在自动保存...
)}
路径映射 压缩设置
{editingPathData ? "编辑路径映射" : "添加路径映射"}
editingPathData ? setEditingPathData({ ...editingPathData, path: e.target.value }) : setNewPathData({ ...newPathData, path: e.target.value }) } placeholder="/example" />

请输入需要代理的路径

editingPathData ? setEditingPathData({ ...editingPathData, defaultTarget: e.target.value }) : setNewPathData({ ...newPathData, defaultTarget: e.target.value }) } placeholder="https://example.com" />

默认的回源地址,所有请求都会转发到这个地址

{config && Object.entries(config.MAP).map(([path, target]) => ( {path}
默认目标: {typeof target === 'string' ? target : target.DefaultTarget}
{typeof target === 'object' && target.ExtensionMap && Array.isArray(target.ExtensionMap) && target.ExtensionMap.length > 0 && (
扩展名映射规则
{target.ExtensionMap.map((rule, index) => (
{rule.Extensions}
目标: {truncateUrl(rule.Target)}
阈值: {formatBytes(rule.SizeThreshold || 0)}
最大: {formatBytes(rule.MaxSize || 0)}
))}
)}
))}
Gzip 压缩
updateCompression('Gzip', 'Enabled', checked)} />
updateCompression('Gzip', 'Level', value[0])} />
Brotli 压缩
updateCompression('Brotli', 'Enabled', checked)} />
updateCompression('Brotli', 'Level', value[0])} />
handleDeleteDialogOpenChange(open, setDeletingPath)} > 确认删除 确定要删除路径 “{deletingPath}” 的映射吗?此操作无法撤销。 取消 删除 {editingExtensionRule ? "编辑扩展名规则" : "添加扩展名规则"}
setNewExtensionRule({ ...newExtensionRule, extensions: e.target.value })} placeholder="jpg,png,webp" />

多个扩展名用逗号分隔,不需要包含点号。使用星号 * 表示匹配所有未指定的扩展名。

setNewExtensionRule({ ...newExtensionRule, target: e.target.value })} placeholder="https://example.com" />
{ setNewExtensionRule({ ...newExtensionRule, sizeThreshold: Number(e.target.value), }); }} />
{ setNewExtensionRule({ ...newExtensionRule, maxSize: Number(e.target.value), }); }} />
handleDeleteDialogOpenChange(open, () => setDeletingExtensionRule(null))} > 确认删除 确定要删除这个扩展名规则吗?此操作无法撤销。 取消 删除
) } // 辅助函数:格式化字节大小 const formatBytes = (bytes: number) => { if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}` } // 辅助函数:截断 URL const truncateUrl = (url: string) => { if (url.length > 30) { return url.substring(0, 27) + '...' } return url } // 辅助函数:单位转换 const convertToBytes = (value: number, unit: 'B' | 'KB' | 'MB' | 'GB'): number => { if (value < 0) return 0 const multipliers = { 'B': 1, 'KB': 1024, 'MB': 1024 * 1024, 'GB': 1024 * 1024 * 1024 } return Math.floor(value * multipliers[unit]) } const convertBytesToUnit = (bytes: number): { value: number, unit: 'B' | 'KB' | 'MB' | 'GB' } => { if (bytes <= 0) return { value: 0, unit: 'MB' } const k = 1024 const sizes: Array<'B' | 'KB' | 'MB' | 'GB'> = ['B', 'KB', 'MB', 'GB'] const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1) return { value: Number((bytes / Math.pow(k, i)).toFixed(2)), unit: sizes[i] } }