"use client" import { useEffect, useState, useCallback } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" 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, Save, Download, Upload } from "lucide-react" interface PathMapping { DefaultTarget: string ExtensionMap?: Record SizeThreshold?: number } interface FixedPath { Path: string TargetHost: string TargetURL: string } interface CompressionConfig { Enabled: boolean Level: number } interface Config { MAP: Record Compression: { Gzip: CompressionConfig Brotli: CompressionConfig } FixedPaths: FixedPath[] } 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() // 对话框状态 const [pathDialogOpen, setPathDialogOpen] = useState(false) const [newPathData, setNewPathData] = useState({ path: "", defaultTarget: "", extensionMap: {} as Record, sizeThreshold: 0, sizeUnit: 'MB' as 'B' | 'KB' | 'MB' | 'GB', }) const [fixedPathDialogOpen, setFixedPathDialogOpen] = useState(false) const [editingFixedPath, setEditingFixedPath] = useState(null) const [newFixedPath, setNewFixedPath] = useState({ Path: "", TargetHost: "", TargetURL: "", }) const [extensionMapDialogOpen, setExtensionMapDialogOpen] = useState(false) const [editingPath, setEditingPath] = useState(null) const [newExtension, setNewExtension] = useState({ ext: "", target: "" }) const [editingPathData, setEditingPathData] = useState<{ path: string; defaultTarget: string; sizeThreshold: number; sizeUnit: 'B' | 'KB' | 'MB' | 'GB'; } | null>(null); 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() setConfig(data) } catch (error) { const message = error instanceof Error ? error.message : "获取配置失败" toast({ title: "错误", description: message, variant: "destructive", }) } finally { setLoading(false) } }, [router, toast]) useEffect(() => { fetchConfig() }, [fetchConfig]) const handleSave = 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) } } const handleEditPath = (path: string, target: PathMapping | string) => { setPathDialogOpen(true) if (typeof target === 'string') { setEditingPathData({ path, defaultTarget: target, sizeThreshold: 0, sizeUnit: 'MB' }) } else { const sizeThreshold = target.SizeThreshold || 0 const { value, unit } = convertBytesToUnit(sizeThreshold) setEditingPathData({ path, defaultTarget: target.DefaultTarget, sizeThreshold: value, sizeUnit: unit }) } } const addOrUpdatePath = () => { if (!config) return const data = editingPathData ? editingPathData : newPathData if (!data.path || !data.defaultTarget) { toast({ title: "错误", description: "路径和默认目标不能为空", variant: "destructive", }) return } const sizeInBytes = convertToBytes(data.sizeThreshold, data.sizeUnit) const newConfig = { ...config } const existingMapping = newConfig.MAP[data.path] if (editingPathData) { if (typeof existingMapping === 'object') { newConfig.MAP[data.path] = { DefaultTarget: data.defaultTarget, SizeThreshold: sizeInBytes, ExtensionMap: existingMapping.ExtensionMap || {} } } else { newConfig.MAP[data.path] = { DefaultTarget: data.defaultTarget, SizeThreshold: sizeInBytes } } } else { newConfig.MAP[data.path] = { DefaultTarget: data.defaultTarget, SizeThreshold: sizeInBytes } } setConfig(newConfig) setPathDialogOpen(false) setEditingPathData(null) setNewPathData({ path: "", defaultTarget: "", extensionMap: {}, sizeThreshold: 0, sizeUnit: 'MB', }) } const deletePath = (path: string) => { if (!config) return const newConfig = { ...config } delete newConfig.MAP[path] setConfig(newConfig) } 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 } setConfig(newConfig) } const handleExtensionMapEdit = (path: string) => { setEditingPath(path) setExtensionMapDialogOpen(true) } const addExtensionMap = () => { if (!config || !editingPath) return const { ext, target } = newExtension if (!ext || !target) { toast({ title: "错误", description: "扩展名和目标不能为空", variant: "destructive", }) return } const newConfig = { ...config } const mapping = newConfig.MAP[editingPath] if (typeof mapping === "string") { newConfig.MAP[editingPath] = { DefaultTarget: mapping, ExtensionMap: { [ext]: target } } } else { mapping.ExtensionMap = { ...mapping.ExtensionMap, [ext]: target } } setConfig(newConfig) setNewExtension({ ext: "", target: "" }) } const deleteExtensionMap = (path: string, ext: string) => { if (!config) return const newConfig = { ...config } const mapping = newConfig.MAP[path] if (typeof mapping !== "string" && mapping.ExtensionMap) { const newExtensionMap = { ...mapping.ExtensionMap } delete newExtensionMap[ext] mapping.ExtensionMap = newExtensionMap } setConfig(newConfig) } const addFixedPath = () => { if (!config) return const { Path, TargetHost, TargetURL } = newFixedPath if (!Path || !TargetHost || !TargetURL) { toast({ title: "错误", description: "所有字段都不能为空", variant: "destructive", }) return } const newConfig = { ...config } if (editingFixedPath) { const index = newConfig.FixedPaths.findIndex(p => p.Path === editingFixedPath.Path) if (index !== -1) { newConfig.FixedPaths[index] = newFixedPath } } else { newConfig.FixedPaths.push(newFixedPath) } setConfig(newConfig) setFixedPathDialogOpen(false) setEditingFixedPath(null) setNewFixedPath({ Path: "", TargetHost: "", TargetURL: "", }) } const editFixedPath = (path: FixedPath) => { setEditingFixedPath(path) setNewFixedPath(path) setFixedPathDialogOpen(true) } const deleteFixedPath = (path: FixedPath) => { if (!config) return const newConfig = { ...config } newConfig.FixedPaths = newConfig.FixedPaths.filter(p => p.Path !== path.Path) setConfig(newConfig) } 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) setConfig(newConfig) toast({ title: "成功", description: "配置已导入", }) } catch { toast({ title: "错误", description: "配置文件格式错误", variant: "destructive", }) } } reader.readAsText(file) } if (loading) { return (
加载中...
正在获取配置数据
) } return (
Proxy Go配置
路径映射 压缩设置 固定路径
{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" />

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

{ const value = parseInt(e.target.value) || 0 if (editingPathData) { setEditingPathData({ ...editingPathData, sizeThreshold: value }) } else { setNewPathData({ ...newPathData, sizeThreshold: value }) } }} min={0} />

文件大小超过此阈值时,将使用扩展名映射中的目标地址(如果存在)

路径 默认目标 大小阈值 扩展名映射 操作 {config && Object.entries(config.MAP).map(([path, target]) => ( {path} {typeof target === 'string' ? target : target.DefaultTarget} {typeof target === 'object' && target.SizeThreshold ? ( {formatBytes(target.SizeThreshold)} ) : '-'} {typeof target === 'object' && target.ExtensionMap ? (
{Object.entries(target.ExtensionMap).map(([ext, url]) => (
{ext}: {truncateUrl(url)}
))}
) : '-'}
))}
添加扩展名映射
setNewExtension({ ...newExtension, ext: e.target.value })} placeholder="jpg,png,webp" />

多个扩展名用逗号分隔,不需要包含点号

setNewExtension({ ...newExtension, target: e.target.value })} placeholder="https://example.com" />

当文件大小超过阈值且扩展名匹配时,将使用此地址

Gzip 压缩
updateCompression('Gzip', 'Enabled', checked)} />
updateCompression('Gzip', 'Level', value[0])} />
Brotli 压缩
updateCompression('Brotli', 'Enabled', checked)} />
updateCompression('Brotli', 'Level', value[0])} />
路径 目标主机 目标 URL 操作 {config?.FixedPaths.map((path, index) => ( {path.Path} {path.TargetHost} {path.TargetURL}
))}
{editingFixedPath ? "编辑固定路径" : "添加固定路径"}
setNewFixedPath({ ...newFixedPath, Path: e.target.value })} placeholder="/example" />
setNewFixedPath({ ...newFixedPath, TargetHost: e.target.value })} placeholder="example.com" />
setNewFixedPath({ ...newFixedPath, TargetURL: e.target.value })} placeholder="https://example.com" />
) } // 辅助函数:格式化字节大小 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 => { const multipliers = { 'B': 1, 'KB': 1024, 'MB': 1024 * 1024, 'GB': 1024 * 1024 * 1024 } return 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.floor(Math.log(bytes) / Math.log(k)) return { value: Number((bytes / Math.pow(k, i)).toFixed(2)), unit: sizes[i] } }