重构前端ui

This commit is contained in:
qaq
2025-07-03 11:12:42 +08:00
parent f1cab0563a
commit 47696e928b
17 changed files with 3388 additions and 85 deletions
+42
View File
@@ -6,6 +6,7 @@ on:
- main
paths:
- 'vue-frontend/**'
- 'vite-frontend/**'
- 'springboot-backend/**'
- '.github/workflows/docker-build.yml'
@@ -51,6 +52,47 @@ jobs:
-t bqlpfy/vue-frontend \
./vue-frontend
build-vite:
if: contains(github.event.head_commit.message, '[skip vite]') == false
name: Build & Push Vite Frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Check for Vite changes
uses: dorny/paths-filter@v2
id: vite-changes
with:
filters: |
vite:
- 'vite-frontend/**'
- name: Set up Node.js
if: steps.vite-changes.outputs.vite == 'true'
uses: actions/setup-node@v4
with:
node-version: 20
- name: Set up Docker Buildx
if: steps.vite-changes.outputs.vite == 'true'
uses: docker/setup-buildx-action@v2
- name: Log in to DockerHub
if: steps.vite-changes.outputs.vite == 'true'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Build and push Vite multi-arch image
if: steps.vite-changes.outputs.vite == 'true'
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--push \
-t bqlpfy/vite-frontend \
./vite-frontend
build-java:
if: contains(github.event.head_commit.message, '[skip java]') == false
name: Build & Push Spring Boot Backend
BIN
View File
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
{
"addr": "127.0.0.1:6365",
"secret": "0fb968b83a234441b290eb17e872b928"
}
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
{
"services": null
}
+21
View File
@@ -0,0 +1,21 @@
# 多阶段构建 - 构建阶段
FROM node:20.19.0 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 生产阶段
FROM nginx:stable-alpine AS production-stage
COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+80
View File
@@ -0,0 +1,80 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# 静态文件缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Vue Router的history模式支持
location / {
try_files $uri $uri/ /index.html;
}
# API代理到后端
location ^~ /api/v1/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://backend:6365/api/v1/;
}
# WebSocket代理 - system-info
location /system-info {
proxy_pass http://backend:6365/system-info;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
+7
View File
@@ -15,20 +15,27 @@
"@heroui/button": "2.2.22",
"@heroui/card": "^2.2.21",
"@heroui/chip": "^2.2.18",
"@heroui/date-picker": "^2.3.22",
"@heroui/divider": "^2.2.15",
"@heroui/dropdown": "^2.3.22",
"@heroui/input": "2.4.22",
"@heroui/link": "2.2.19",
"@heroui/modal": "^2.2.19",
"@heroui/navbar": "2.2.20",
"@heroui/pagination": "^2.2.20",
"@heroui/progress": "^2.2.18",
"@heroui/radio": "^2.3.21",
"@heroui/select": "^2.4.22",
"@heroui/spacer": "^2.2.16",
"@heroui/spinner": "^2.2.19",
"@heroui/switch": "2.2.20",
"@heroui/system": "2.4.18",
"@heroui/table": "^2.2.21",
"@heroui/tabs": "^2.2.19",
"@heroui/theme": "2.4.17",
"@heroui/tooltip": "^2.2.19",
"@heroui/use-theme": "2.1.9",
"@internationalized/date": "^3.8.2",
"@react-aria/visually-hidden": "3.8.25",
"@react-types/shared": "3.30.0",
"axios": "^1.10.0",
+6 -9
View File
@@ -5,6 +5,9 @@ import IndexPage from "@/pages/index";
import ChangePasswordPage from "@/pages/change-password";
import DashboardPage from "@/pages/dashboard";
import ForwardPage from "@/pages/forward";
import TunnelPage from "@/pages/tunnel";
import NodePage from "@/pages/node";
import UserPage from "@/pages/user";
import LimitPage from "@/pages/limit";
import PageWrapper from "@/components/page-wrapper";
import { isLoggedIn } from "@/utils/auth";
@@ -80,9 +83,7 @@ function App() {
path="/tunnel"
element={
<ProtectedRoute>
<PageWrapper title="隧道管理" description="此页面正在开发中...">
<div></div>
</PageWrapper>
<TunnelPage />
</ProtectedRoute>
}
/>
@@ -90,9 +91,7 @@ function App() {
path="/node"
element={
<ProtectedRoute>
<PageWrapper title="节点监控" description="此页面正在开发中...">
<div></div>
</PageWrapper>
<NodePage />
</ProtectedRoute>
}
/>
@@ -100,9 +99,7 @@ function App() {
path="/user"
element={
<ProtectedRoute>
<PageWrapper title="用户管理" description="此页面正在开发中...">
<div></div>
</PageWrapper>
<UserPage />
</ProtectedRoute>
}
/>
+193
View File
@@ -66,3 +66,196 @@ export const SunFilledIcon = ({
</g>
</svg>
);
export const SearchIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M11.5 21C16.7467 21 21 16.7467 21 11.5C21 6.25329 16.7467 2 11.5 2C6.25329 2 2 6.25329 2 11.5C2 16.7467 6.25329 21 11.5 21Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
fill="none"
/>
<path
d="M22 22L20.5 20.5"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
);
export const PlusIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M12 5V19M5 12H19"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
);
export const EditIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M11 4H4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6V20C2 20.5304 2.21071 21.0391 2.58579 21.4142C2.96086 21.7893 3.46957 22 4 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V13"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
fill="none"
/>
<path
d="M18.5 2.49998C18.8978 2.10216 19.4374 1.87866 20 1.87866C20.5626 1.87866 21.1022 2.10216 21.5 2.49998C21.8978 2.89781 22.1213 3.43737 22.1213 3.99998C22.1213 4.56259 21.8978 5.10216 21.5 5.49998L12 15L8 16L9 12L18.5 2.49998Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
fill="none"
/>
</svg>
);
export const DeleteIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M3 6H5H21"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
<path
d="M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
fill="none"
/>
</svg>
);
export const UserIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
fill="none"
/>
<path
d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
fill="none"
/>
</svg>
);
export const SettingsIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M12.22 2H11.78C11.2496 2 10.7409 2.21071 10.3658 2.58579C9.99072 2.96086 9.78 3.46957 9.78 4C9.78 4.53043 9.99072 5.03914 10.3658 5.41421C10.7409 5.78929 11.2496 6 11.78 6H12.22C12.7504 6 13.2591 5.78929 13.6342 5.41421C14.0093 5.03914 14.22 4.53043 14.22 4C14.22 3.46957 14.0093 2.96086 13.6342 2.58579C13.2591 2.21071 12.7504 2 12.22 2V2Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
fill="none"
/>
<path
d="M19.5 8.5C19.8978 8.10218 20.2374 7.63877 20.5 7.12132C20.7626 6.60387 20.8478 6.03677 20.8478 5.464C20.8478 4.89123 20.7626 4.32413 20.5 3.80668C20.2374 3.28923 19.8978 2.82582 19.5 2.428C19.1022 2.03018 18.6388 1.69063 18.1213 1.42801C17.6039 1.16539 17.0368 1.08023 16.464 1.08023C15.8912 1.08023 15.3241 1.16539 14.8067 1.42801C14.2892 1.69063 13.8258 2.03018 13.428 2.428L12 3.856L10.572 2.428C9.78889 1.64486 8.73968 1.21408 7.64800 1.21408C6.55632 1.21408 5.50711 1.64486 4.72400 2.428C3.94086 3.21111 3.51008 4.26032 3.51008 5.352C3.51008 6.44368 3.94086 7.49289 4.72400 8.276L6.15200 9.704L12 15.552L17.848 9.704L19.276 8.276C19.5 8.052 19.5 8.276 19.5 8.5Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
fill="none"
/>
</svg>
);
+78 -43
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Textarea } from "@heroui/input";
import { Select, SelectItem } from "@heroui/select";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal";
import { Chip } from "@heroui/chip";
@@ -36,6 +37,7 @@ interface Forward {
serviceRunning: boolean;
createdTime: string;
userName?: string;
userId?: number;
}
interface Tunnel {
@@ -47,6 +49,7 @@ interface Tunnel {
interface ForwardForm {
id?: number;
userId?: number;
name: string;
tunnelId: number | null;
inPort: number | null;
@@ -190,6 +193,7 @@ export default function ForwardPage() {
setIsEdit(true);
setForm({
id: forward.id,
userId: forward.userId,
name: forward.name,
tunnelId: forward.tunnelId,
inPort: forward.inPort,
@@ -264,18 +268,27 @@ export default function ForwardPage() {
let res;
if (isEdit) {
res = await updateForward({
...form,
// 更新时确保包含必要字段
const updateData = {
id: form.id,
userId: form.userId,
name: form.name,
tunnelId: form.tunnelId,
inPort: form.inPort,
remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : 'fifo'
});
};
res = await updateForward(updateData);
} else {
const { id, ...createData } = form;
res = await createForward({
...createData,
// 创建时不需要id和userId(后端会自动设置)
const createData = {
name: form.name,
tunnelId: form.tunnelId,
inPort: form.inPort,
remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : 'fifo'
});
};
res = await createForward(createData);
}
if (res.code === 0) {
@@ -545,14 +558,14 @@ export default function ForwardPage() {
const strategyDisplay = getStrategyDisplay(forward.strategy);
return (
<Card key={forward.id} className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardHeader className="pb-3">
<Card key={forward.id} className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200">
<CardHeader className="pb-2">
<div className="flex justify-between items-start w-full">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground truncate">{forward.name}</h3>
<p className="text-sm text-default-500 truncate mt-1">{forward.tunnelName}</p>
<h3 className="font-semibold text-foreground truncate text-sm">{forward.name}</h3>
<p className="text-xs text-default-500 truncate">{forward.tunnelName}</p>
</div>
<div className="flex items-center gap-2 ml-2">
<div className="flex items-center gap-1.5 ml-2">
<Switch
size="sm"
isSelected={forward.serviceRunning}
@@ -563,71 +576,91 @@ export default function ForwardPage() {
color={statusDisplay.color as any}
variant="flat"
size="sm"
className="text-xs"
>
{statusDisplay.text}
</Chip>
</div>
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="space-y-3">
<CardBody className="pt-0 pb-3">
<div className="space-y-2">
{/* 地址信息 */}
<div className="space-y-2">
<div className="space-y-1.5">
<div
className={`cursor-pointer p-2 bg-green-50 dark:bg-green-500/10 rounded border border-green-200 dark:border-green-500/20 ${
hasMultipleAddresses(forward.inIp) ? 'hover:bg-green-100 dark:hover:bg-green-500/20' : ''
className={`cursor-pointer p-2 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
hasMultipleAddresses(forward.inIp) ? 'hover:bg-default-100 dark:hover:bg-default-200/50' : ''
}`}
onClick={() => showAddressModal(forward.inIp, forward.inPort, '入口地址')}
title={formatInAddress(forward.inIp, forward.inPort)}
>
<div className="text-xs text-green-600 dark:text-green-400 mb-1">入口地址</div>
<code className="text-xs font-mono text-green-700 dark:text-green-300 truncate block">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-default-600">入口</span>
{hasMultipleAddresses(forward.inIp) && (
<svg className="w-3 h-3 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</div>
<code className="text-xs font-mono text-foreground block truncate">
{formatInAddress(forward.inIp, forward.inPort)}
</code>
</div>
<div className="text-center text-default-400 text-xs">↓</div>
<div className="text-center py-0.5">
<svg className="w-3 h-3 text-default-400 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
<div
className={`cursor-pointer p-2 bg-blue-50 dark:bg-blue-500/10 rounded border border-blue-200 dark:border-blue-500/20 ${
hasMultipleAddresses(forward.remoteAddr) ? 'hover:bg-blue-100 dark:hover:bg-blue-500/20' : ''
className={`cursor-pointer p-2 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
hasMultipleAddresses(forward.remoteAddr) ? 'hover:bg-default-100 dark:hover:bg-default-200/50' : ''
}`}
onClick={() => showAddressModal(forward.remoteAddr, null, '目标地址')}
title={formatRemoteAddress(forward.remoteAddr)}
>
<div className="text-xs text-blue-600 dark:text-blue-400 mb-1">目标地址</div>
<code className="text-xs font-mono text-blue-700 dark:text-blue-300 truncate block">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-default-600">目标</span>
{hasMultipleAddresses(forward.remoteAddr) && (
<svg className="w-3 h-3 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</div>
<code className="text-xs font-mono text-foreground block truncate">
{formatRemoteAddress(forward.remoteAddr)}
</code>
</div>
</div>
{/* 策略和流量 */}
<div className="flex justify-between items-center pt-2 border-t border-gray-200 dark:border-default-200">
<div className="text-center flex-1">
<div className="text-xs text-default-500 mb-1">策略</div>
<Chip color={strategyDisplay.color as any} variant="flat" size="sm">
{/* 统计信息 */}
<div className="flex justify-between items-center pt-2 border-t border-divider">
<div className="flex items-center gap-1">
<span className="text-xs text-default-500">策略:</span>
<Chip color={strategyDisplay.color as any} variant="flat" size="sm" className="text-xs">
{strategyDisplay.text}
</Chip>
</div>
<div className="text-center flex-1">
<div className="text-xs text-default-500 mb-1">总流量</div>
<div className="text-xs font-medium text-primary">
<div className="text-right">
<div className="text-xs text-default-500">流量</div>
<div className="text-xs font-medium text-foreground">
{formatFlow((forward.inFlow || 0) + (forward.outFlow || 0))}
</div>
</div>
</div>
</div>
<div className="flex gap-2 mt-4">
<div className="flex gap-1.5 mt-3">
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleEdit(forward)}
className="flex-1"
className="flex-1 min-h-8"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
}
@@ -639,9 +672,9 @@ export default function ForwardPage() {
variant="flat"
color="danger"
onPress={() => handleDelete(forward)}
className="flex-1"
className="flex-1 min-h-8"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clipRule="evenodd" />
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" clipRule="evenodd" />
</svg>
@@ -719,7 +752,7 @@ export default function ForwardPage() {
variant="bordered"
>
{tunnels.map((tunnel) => (
<SelectItem key={tunnel.id} value={tunnel.id}>
<SelectItem key={tunnel.id} >
{tunnel.name}
</SelectItem>
))}
@@ -744,15 +777,17 @@ export default function ForwardPage() {
}
/>
<Input
<Textarea
label="远程地址"
placeholder="请输入远程地址,多个地址用换行分隔"
placeholder="请输入远程地址,多个地址用换行分隔&#10;例如:&#10;192.168.1.100:8080&#10;example.com:3000"
value={form.remoteAddr}
onChange={(e) => setForm(prev => ({ ...prev, remoteAddr: e.target.value }))}
isInvalid={!!errors.remoteAddr}
errorMessage={errors.remoteAddr}
variant="bordered"
description="格式: IP:端口 或 域名:端口,支持多个地址(每行一个)"
minRows={3}
maxRows={6}
/>
{getAddressCount(form.remoteAddr) > 1 && (
@@ -767,9 +802,9 @@ export default function ForwardPage() {
variant="bordered"
description="多个目标地址的负载均衡策略"
>
<SelectItem key="fifo" value="fifo">主备模式 - 自上而下</SelectItem>
<SelectItem key="round" value="round">轮询模式 - 依次轮换</SelectItem>
<SelectItem key="rand" value="rand">随机模式 - 随机选择</SelectItem>
<SelectItem key="fifo" >主备模式 - 自上而下</SelectItem>
<SelectItem key="round" >轮询模式 - 依次轮换</SelectItem>
<SelectItem key="rand" >随机模式 - 随机选择</SelectItem>
</Select>
)}
</div>
+800
View File
@@ -0,0 +1,800 @@
import { useState, useEffect, useRef } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Textarea } from "@heroui/input";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal";
import { Chip } from "@heroui/chip";
import { Spinner } from "@heroui/spinner";
import { Progress } from "@heroui/progress";
import toast from 'react-hot-toast';
import AdminLayout from "@/layouts/admin";
import {
createNode,
getNodeList,
updateNode,
deleteNode,
getNodeInstallCommand
} from "@/api";
interface Node {
id: number;
name: string;
ip: string;
serverIp: string;
portSta: number;
portEnd: number;
version?: string;
status: number; // 1: 在线, 0: 离线
connectionStatus: 'online' | 'offline';
systemInfo?: {
cpuUsage: number;
memoryUsage: number;
uploadTraffic: number;
downloadTraffic: number;
uploadSpeed: number;
downloadSpeed: number;
uptime: number;
} | null;
copyLoading?: boolean;
}
interface NodeForm {
id: number | null;
name: string;
ipString: string;
serverIp: string;
portSta: number;
portEnd: number;
}
export default function NodePage() {
const [nodeList, setNodeList] = useState<Node[]>([]);
const [loading, setLoading] = useState(false);
const [dialogVisible, setDialogVisible] = useState(false);
const [dialogTitle, setDialogTitle] = useState('');
const [isEdit, setIsEdit] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const [form, setForm] = useState<NodeForm>({
id: null,
name: '',
ipString: '',
serverIp: '',
portSta: 1000,
portEnd: 65535
});
const [errors, setErrors] = useState<Record<string, string>>({});
const websocketRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const maxReconnectAttempts = 5;
useEffect(() => {
loadNodes();
initWebSocket();
return () => {
closeWebSocket();
};
}, []);
// 加载节点列表
const loadNodes = async () => {
setLoading(true);
try {
const res = await getNodeList();
if (res.code === 0) {
setNodeList(res.data.map((node: any) => ({
...node,
connectionStatus: node.status === 1 ? 'online' : 'offline',
systemInfo: null,
copyLoading: false
})));
} else {
toast.error(res.msg || '加载节点列表失败');
}
} catch (error) {
toast.error('网络错误,请重试');
} finally {
setLoading(false);
}
};
// 初始化WebSocket连接
const initWebSocket = () => {
if (websocketRef.current &&
(websocketRef.current.readyState === WebSocket.OPEN ||
websocketRef.current.readyState === WebSocket.CONNECTING)) {
return;
}
if (websocketRef.current) {
closeWebSocket();
}
// 构建WebSocket URL,将HTTP协议转换为WS协议
let baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8080';
const wsUrl = baseUrl.replace(/^http/, 'ws') + `/system-info?type=0&secret=${localStorage.getItem('token')}`;
try {
websocketRef.current = new WebSocket(wsUrl);
websocketRef.current.onopen = () => {
reconnectAttemptsRef.current = 0;
};
websocketRef.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
} catch (error) {
// 解析失败时不输出错误信息
}
};
websocketRef.current.onerror = () => {
// WebSocket错误时不输出错误信息
};
websocketRef.current.onclose = () => {
websocketRef.current = null;
attemptReconnect();
};
} catch (error) {
attemptReconnect();
}
};
// 处理WebSocket消息
const handleWebSocketMessage = (data: any) => {
const { id, type, data: messageData } = data;
if (type === 'status') {
setNodeList(prev => prev.map(node => {
if (node.id == id) {
return {
...node,
connectionStatus: messageData === 1 ? 'online' : 'offline',
systemInfo: messageData === 0 ? null : node.systemInfo
};
}
return node;
}));
} else if (type === 'info') {
setNodeList(prev => prev.map(node => {
if (node.id == id) {
try {
let systemInfo;
if (typeof messageData === 'string') {
systemInfo = JSON.parse(messageData);
} else {
systemInfo = messageData;
}
const currentUpload = parseInt(systemInfo.bytes_transmitted) || 0;
const currentDownload = parseInt(systemInfo.bytes_received) || 0;
const currentUptime = parseInt(systemInfo.uptime) || 0;
let uploadSpeed = 0;
let downloadSpeed = 0;
if (node.systemInfo && node.systemInfo.uptime) {
const timeDiff = currentUptime - node.systemInfo.uptime;
if (timeDiff > 0 && timeDiff <= 10) {
const lastUpload = node.systemInfo.uploadTraffic || 0;
const lastDownload = node.systemInfo.downloadTraffic || 0;
const uploadDiff = currentUpload - lastUpload;
const downloadDiff = currentDownload - lastDownload;
const uploadReset = currentUpload < lastUpload;
const downloadReset = currentDownload < lastDownload;
if (!uploadReset && uploadDiff >= 0) {
uploadSpeed = uploadDiff / timeDiff;
}
if (!downloadReset && downloadDiff >= 0) {
downloadSpeed = downloadDiff / timeDiff;
}
}
}
return {
...node,
connectionStatus: 'online',
systemInfo: {
cpuUsage: parseFloat(systemInfo.cpu_usage) || 0,
memoryUsage: parseFloat(systemInfo.memory_usage) || 0,
uploadTraffic: currentUpload,
downloadTraffic: currentDownload,
uploadSpeed: uploadSpeed,
downloadSpeed: downloadSpeed,
uptime: currentUptime
}
};
} catch (error) {
return node;
}
}
return node;
}));
}
};
// 尝试重新连接
const attemptReconnect = () => {
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectAttemptsRef.current++;
reconnectTimerRef.current = setTimeout(() => {
initWebSocket();
}, 3000 * reconnectAttemptsRef.current);
}
};
// 关闭WebSocket连接
const closeWebSocket = () => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
reconnectAttemptsRef.current = 0;
if (websocketRef.current) {
websocketRef.current.onopen = null;
websocketRef.current.onmessage = null;
websocketRef.current.onerror = null;
websocketRef.current.onclose = null;
if (websocketRef.current.readyState === WebSocket.OPEN ||
websocketRef.current.readyState === WebSocket.CONNECTING) {
websocketRef.current.close();
}
websocketRef.current = null;
}
setNodeList(prev => prev.map(node => ({
...node,
connectionStatus: 'offline',
systemInfo: null
})));
};
// 格式化字节数
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// 格式化速度
const formatSpeed = (bytesPerSecond: number): string => {
if (bytesPerSecond === 0) return '0 B/s';
const k = 1024;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s'];
const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));
return parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// 获取进度条颜色
const getProgressColor = (value: number, offline = false): "default" | "primary" | "secondary" | "success" | "warning" | "danger" => {
if (offline) return "default";
if (value <= 50) return "success";
if (value <= 80) return "warning";
return "danger";
};
// 验证IP地址格式
const validateIp = (ip: string): boolean => {
if (!ip || !ip.trim()) return false;
const trimmedIp = ip.trim();
// IPv4格式验证
const ipv4Regex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
// IPv6格式验证
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
if (ipv4Regex.test(trimmedIp) || ipv6Regex.test(trimmedIp) || trimmedIp === 'localhost') {
return true;
}
// 验证域名格式
if (/^\d+$/.test(trimmedIp)) return false;
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)+$/;
const singleLabelDomain = /^[a-zA-Z][a-zA-Z0-9\-]{0,62}$/;
return domainRegex.test(trimmedIp) || singleLabelDomain.test(trimmedIp);
};
// 表单验证
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!form.name.trim()) {
newErrors.name = '请输入节点名称';
} else if (form.name.trim().length < 2) {
newErrors.name = '节点名称长度至少2位';
} else if (form.name.trim().length > 50) {
newErrors.name = '节点名称长度不能超过50位';
}
if (!form.ipString.trim()) {
newErrors.ipString = '请输入入口IP地址';
} else {
const ips = form.ipString.split('\n').map(ip => ip.trim()).filter(ip => ip);
if (ips.length === 0) {
newErrors.ipString = '请输入至少一个有效IP地址';
} else {
for (let i = 0; i < ips.length; i++) {
if (!validateIp(ips[i])) {
newErrors.ipString = `第${i + 1}行IP地址格式错误: ${ips[i]}`;
break;
}
}
}
}
if (!form.serverIp.trim()) {
newErrors.serverIp = '请输入服务器IP地址';
} else if (!validateIp(form.serverIp.trim())) {
newErrors.serverIp = '请输入有效的IPv4、IPv6地址或域名';
}
if (!form.portSta || form.portSta < 1 || form.portSta > 65535) {
newErrors.portSta = '端口范围必须在1-65535之间';
}
if (!form.portEnd || form.portEnd < 1 || form.portEnd > 65535) {
newErrors.portEnd = '端口范围必须在1-65535之间';
} else if (form.portEnd < form.portSta) {
newErrors.portEnd = '结束端口不能小于起始端口';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 新增节点
const handleAdd = () => {
setDialogTitle('新增节点');
setIsEdit(false);
setDialogVisible(true);
resetForm();
};
// 编辑节点
const handleEdit = (node: Node) => {
setDialogTitle('编辑节点');
setIsEdit(true);
setForm({
id: node.id,
name: node.name,
ipString: node.ip ? node.ip.split(',').map(ip => ip.trim()).join('\n') : '',
serverIp: node.serverIp || '',
portSta: node.portSta,
portEnd: node.portEnd
});
setDialogVisible(true);
};
// 删除节点
const handleDelete = async (node: Node) => {
if (window.confirm(`确定要删除节点 "${node.name}" 吗?`)) {
try {
const res = await deleteNode(node.id);
if (res.code === 0) {
toast.success('删除成功');
setNodeList(prev => prev.filter(n => n.id !== node.id));
} else {
toast.error(res.msg || '删除失败');
}
} catch (error) {
toast.error('网络错误,请重试');
}
}
};
// 复制安装命令
const handleCopyInstallCommand = async (node: Node) => {
setNodeList(prev => prev.map(n =>
n.id === node.id ? { ...n, copyLoading: true } : n
));
try {
const res = await getNodeInstallCommand(node.id);
if (res.code === 0 && res.data) {
await navigator.clipboard.writeText(res.data);
toast.success('安装命令已复制到剪贴板');
} else {
toast.error(res.msg || '获取安装命令失败');
}
} catch (error) {
toast.error('复制失败,请手动复制');
} finally {
setNodeList(prev => prev.map(n =>
n.id === node.id ? { ...n, copyLoading: false } : n
));
}
};
// 提交表单
const handleSubmit = async () => {
if (!validateForm()) return;
setSubmitLoading(true);
try {
const ipString = form.ipString
.split('\n')
.map(ip => ip.trim())
.filter(ip => ip)
.join(',');
const submitData = {
...form,
ip: ipString
};
delete (submitData as any).ipString;
const apiCall = isEdit ? updateNode : createNode;
const data = isEdit ? submitData : {
name: form.name,
ip: ipString,
serverIp: form.serverIp,
portSta: form.portSta,
portEnd: form.portEnd
};
const res = await apiCall(data);
if (res.code === 0) {
toast.success(isEdit ? '更新成功' : '创建成功');
setDialogVisible(false);
if (isEdit) {
setNodeList(prev => prev.map(n =>
n.id === form.id ? {
...n,
name: form.name,
ip: ipString,
serverIp: form.serverIp,
portSta: form.portSta,
portEnd: form.portEnd
} : n
));
} else {
loadNodes();
}
} else {
toast.error(res.msg || (isEdit ? '更新失败' : '创建失败'));
}
} catch (error) {
toast.error('网络错误,请重试');
} finally {
setSubmitLoading(false);
}
};
// 重置表单
const resetForm = () => {
setForm({
id: null,
name: '',
ipString: '',
serverIp: '',
portSta: 1000,
portEnd: 65535
});
setErrors({});
};
return (
<AdminLayout>
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-foreground">节点管理</h1>
<Button
color="primary"
onPress={handleAdd}
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
</svg>
}
>
新增节点
</Button>
</div>
{/* 节点列表 */}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">正在加载...</span>
</div>
</div>
) : nodeList.length === 0 ? (
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardBody className="text-center py-16">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 12h14M5 12l4-4m-4 4l4 4" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">暂无节点配置</h3>
<p className="text-default-500 text-sm mt-1">还没有创建任何节点配置,点击上方按钮开始创建</p>
</div>
</div>
</CardBody>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{nodeList.map((node) => (
<Card
key={node.id}
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
>
<CardHeader className="pb-2">
<div className="flex justify-between items-start w-full">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground truncate text-sm">{node.name}</h3>
<p className="text-xs text-default-500 truncate">{node.serverIp}</p>
</div>
<div className="flex items-center gap-1.5 ml-2">
<Chip
color={node.connectionStatus === 'online' ? 'success' : 'danger'}
variant="flat"
size="sm"
className="text-xs"
>
{node.connectionStatus === 'online' ? '在线' : '离线'}
</Chip>
</div>
</div>
</CardHeader>
<CardBody className="pt-0 pb-3">
{/* 基础信息 */}
<div className="space-y-2 mb-4">
<div className="flex justify-between text-sm">
<span className="text-default-600">服务器</span>
<span className="font-mono text-xs">{node.serverIp}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">入口IP</span>
<div className="text-right text-xs">
{node.ip ? (
node.ip.split(',').length > 1 ? (
<span className="font-mono">{node.ip.split(',')[0].trim()} +{node.ip.split(',').length - 1}个</span>
) : (
<span className="font-mono">{node.ip.trim()}</span>
)
) : '-'}
</div>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">端口</span>
<span className="text-xs">{node.portSta}-{node.portEnd}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">版本</span>
<span className="text-xs">{node.version || '未知'}</span>
</div>
</div>
{/* 系统监控 */}
<div className="space-y-3 mb-4">
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex justify-between text-xs mb-1">
<span>CPU</span>
<span className="font-mono">
{node.connectionStatus === 'online' && node.systemInfo
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
: '-'
}
</span>
</div>
<Progress
value={node.connectionStatus === 'online' && node.systemInfo ? node.systemInfo.cpuUsage : 0}
color={getProgressColor(
node.connectionStatus === 'online' && node.systemInfo ? node.systemInfo.cpuUsage : 0,
node.connectionStatus !== 'online'
)}
size="sm"
aria-label="CPU使用率"
/>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span>内存</span>
<span className="font-mono">
{node.connectionStatus === 'online' && node.systemInfo
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
: '-'
}
</span>
</div>
<Progress
value={node.connectionStatus === 'online' && node.systemInfo ? node.systemInfo.memoryUsage : 0}
color={getProgressColor(
node.connectionStatus === 'online' && node.systemInfo ? node.systemInfo.memoryUsage : 0,
node.connectionStatus !== 'online'
)}
size="sm"
aria-label="内存使用率"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">上传</div>
<div className="font-mono">
{node.connectionStatus === 'online' && node.systemInfo
? formatSpeed(node.systemInfo.uploadSpeed)
: '-'
}
</div>
</div>
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">下载</div>
<div className="font-mono">
{node.connectionStatus === 'online' && node.systemInfo
? formatSpeed(node.systemInfo.downloadSpeed)
: '-'
}
</div>
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="flex gap-1.5">
<Button
size="sm"
variant="flat"
color="success"
onPress={() => handleCopyInstallCommand(node)}
isLoading={node.copyLoading}
className="flex-1 min-h-8"
>
复制
</Button>
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleEdit(node)}
className="flex-1 min-h-8"
>
编辑
</Button>
<Button
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(node)}
className="flex-1 min-h-8"
>
删除
</Button>
</div>
</CardBody>
</Card>
))}
</div>
)}
{/* 新增/编辑节点对话框 */}
<Modal
isOpen={dialogVisible}
onClose={() => setDialogVisible(false)}
size="2xl"
scrollBehavior="inside"
>
<ModalContent>
<ModalHeader>{dialogTitle}</ModalHeader>
<ModalBody>
<div className="space-y-4">
<Input
label="节点名称"
placeholder="请输入节点名称"
value={form.name}
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
isInvalid={!!errors.name}
errorMessage={errors.name}
variant="bordered"
/>
<Input
label="服务器IP"
placeholder="请输入服务器IP地址,如: 192.168.1.100 或 example.com"
value={form.serverIp}
onChange={(e) => setForm(prev => ({ ...prev, serverIp: e.target.value }))}
isInvalid={!!errors.serverIp}
errorMessage={errors.serverIp}
variant="bordered"
/>
<Textarea
label="入口IP"
placeholder="一行一个IP地址或域名,例如:&#10;192.168.1.100&#10;example.com"
value={form.ipString}
onChange={(e) => setForm(prev => ({ ...prev, ipString: e.target.value }))}
isInvalid={!!errors.ipString}
errorMessage={errors.ipString}
variant="bordered"
minRows={3}
maxRows={5}
description="支持多个IP,每行一个地址"
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="起始端口"
type="number"
placeholder="1000"
value={form.portSta.toString()}
onChange={(e) => setForm(prev => ({ ...prev, portSta: parseInt(e.target.value) || 1000 }))}
isInvalid={!!errors.portSta}
errorMessage={errors.portSta}
variant="bordered"
min={1}
max={65535}
/>
<Input
label="结束端口"
type="number"
placeholder="65535"
value={form.portEnd.toString()}
onChange={(e) => setForm(prev => ({ ...prev, portEnd: parseInt(e.target.value) || 65535 }))}
isInvalid={!!errors.portEnd}
errorMessage={errors.portEnd}
variant="bordered"
min={1}
max={65535}
/>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant="flat"
onPress={() => setDialogVisible(false)}
>
取消
</Button>
<Button
color="primary"
onPress={handleSubmit}
isLoading={submitLoading}
>
{submitLoading ? '提交中...' : '确定'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
</AdminLayout>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+29 -26
View File
@@ -5,6 +5,7 @@ import { HeroUIProvider } from "@heroui/system";
import { useHref, useNavigate } from "react-router-dom";
import { Toaster } from 'react-hot-toast';
import { ThemeProvider } from '@/components/theme-provider';
import { I18nProvider } from "@react-aria/i18n";
declare module "@react-types/shared" {
interface RouterConfig {
@@ -20,34 +21,36 @@ export function Provider({ children }: ProvidersProps) {
const navigate = useNavigate();
return (
<HeroUIProvider navigate={navigate} useHref={useHref}>
<ThemeProvider>
{children}
<Toaster
position="top-center"
toastOptions={{
duration: 3000,
className: 'dark:bg-gray-800 dark:text-white',
style: {
background: 'var(--toaster-bg, #ffffff)',
color: 'var(--toaster-color, #000000)',
border: '1px solid var(--toaster-border, #e5e7eb)',
},
success: {
<I18nProvider locale="zh-CN">
<HeroUIProvider navigate={navigate} useHref={useHref}>
<ThemeProvider>
{children}
<Toaster
position="top-center"
toastOptions={{
duration: 3000,
className: 'dark:bg-gray-800 dark:text-white',
style: {
background: '#10b981',
color: '#ffffff',
background: 'var(--toaster-bg, #ffffff)',
color: 'var(--toaster-color, #000000)',
border: '1px solid var(--toaster-border, #e5e7eb)',
},
},
error: {
style: {
background: '#ef4444',
color: '#ffffff',
success: {
style: {
background: '#10b981',
color: '#ffffff',
},
},
},
}}
/>
</ThemeProvider>
</HeroUIProvider>
error: {
style: {
background: '#ef4444',
color: '#ffffff',
},
},
}}
/>
</ThemeProvider>
</HeroUIProvider>
</I18nProvider>
);
}
+79
View File
@@ -3,3 +3,82 @@ import { SVGProps } from "react";
export type IconSvgProps = SVGProps<SVGSVGElement> & {
size?: number;
};
// 用户管理相关类型
export interface User {
id: number;
name?: string;
user: string;
pwd?: string;
status: number; // 1-正常, 0-禁用
flow: number; // 流量限制(GB)
num: number; // 转发数量
expTime?: number; // 过期时间戳
flowResetTime?: number; // 流量重置日期(1-31号)
createdTime?: number; // 创建时间戳
inFlow?: number; // 下载流量(字节)
outFlow?: number; // 上传流量(字节)
}
export interface UserForm {
id?: number;
name?: string;
user: string;
pwd?: string;
status: number;
flow: number;
num: number;
expTime: Date | null;
flowResetTime: number;
}
export interface UserTunnel {
id: number;
userId: number;
tunnelId: number;
tunnelName: string;
status: number; // 1-正常, 0-禁用
flow: number; // 流量限制(GB)
num: number; // 转发数量
expTime: number; // 过期时间戳
flowResetTime: number; // 流量重置日期
speedId?: number | null; // 限速规则ID
speedLimitName?: string; // 限速规则名称
inFlow?: number; // 下载流量(字节)
outFlow?: number; // 上传流量(字节)
tunnelFlow?: number; // 隧道流量计算类型(1-单向, 2-双向)
}
export interface UserTunnelForm {
tunnelId: number | null;
flow: number;
num: number;
expTime: Date | null;
flowResetTime: number;
speedId: number | null;
}
export interface Tunnel {
id: number;
name: string;
entryNodeId: number;
exitNodeId: number;
entryNodeName?: string;
exitNodeName?: string;
status?: number;
flow?: number; // 流量计算类型
}
export interface SpeedLimit {
id: number;
name: string;
tunnelId: number;
uploadSpeed: number;
downloadSpeed: number;
}
export interface Pagination {
current: number;
size: number;
total: number;
}
-7
View File
@@ -76,12 +76,5 @@ http {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# config.js 文件已经在容器启动时生成,这里只需要设置缓存策略
location /config.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
}
}